diff --git a/MEMORY.md b/MEMORY.md index 1a73bde..0d89d82 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,6 @@ # xgen CLI와 연계 도구 공동 개발 메모 -갱신: 2026-10-30 (KST) +갱신: 2026-10-31 (KST) ## 사용자가 정한 방향 @@ -154,3 +154,7 @@ - 빠른 TUI resize 잔상(2026-10-30, ADR-0079, 구현·로컬 검증 완료): tmux가 screen을 먼저 바꾸고 PTY 크기 통지를 늦추는 동안 xgen의 periodic redraw가 옛 크기로 다시 그렸다. cursor report 캐시 문제가 아니었다. Unix draw·정리는 cursor 위치를 보존하고 화면 밖 CUP→CPR clipping으로 실제 화면 크기를 조회하며, Windows console size API는 유지한다. sleep·tmux 감지·전체 화면/scrollback 삭제는 넣지 않았다. draft·실행 중 queue의 rapid smoke 5회와 별도 크기 12회 burst, 결과 viewer·승인·입력·history·종료 복원을 통과했다(모델 HTTP 3/5회, 유료 LLM 없음). Rust 773 passed·4 ignored와 Clippy·fmt·문서·배포 workflow 검사를 통과했다. PTY만 73×19·화면은 100×30으로 고정한 fixture에서도 새 입력 redraw를 확인해 지연 상황을 timing과 무관하게 검증했다. 이 강제 불일치 검사는 변경 전 main `6215e93`의 별도 archive 빌드에서 실패하고 수정 빌드에서 통과했다. Unix redraw마다 CPR 왕복 하나가 추가되어 원격 터미널 latency 비용은 남는다. 소유자는 xgen TUI이며 다른 도구 변경은 없다. - 빠른 resize 변경 추적: [PR #136](https://github.com/PlateerLab/xgen-cli/pull/136), 코드 `f1171ba`, [전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41071470733). 강제 PTY 지연 fixture는 후속 PR 검사에 포함한다. + +- 상태 폴더 권한 진단(2026-10-31, ADR-0080, 구현·로컬 검증 완료): Unix mode의 group/other 비트 거절은 유지하고, root·runs·개별 Run 폴더 오류를 `state_directory_permissions`/exit64로 전달한다. CLI run/resume/usage/recover·REPL에서 문제 경로·현재 mode·`chmod go-rwx -- PATH` 안내를 표시하며 자동 chmod·재귀 수정은 없다. 명시적 로컬 진단만 경로를 escape해 표시하고 일반 Debug·오류 Display·journal·모델·progress에서는 가린다. 설계 root0775/runs0755와 별도 run0770·symlink·space·개행/ESC·비UTF8 경로를 검증했다. 사용자가 직접0700으로 고치면 권한 검사 다음 단계로 진행하며 fixture DB는 생성되지 않는다. 전체 Rust 회귀778개(4 ignored)·Clippy·fmt·protocol·공개 문서·배포 workflow 검사를 통과했다. 소유자는 xgen CLI이며 외부 도구 변경은 없다. +- 상태 폴더 진단 변경 추적: [PR #137](https://github.com/PlateerLab/xgen-cli/pull/137), 코드 `7edf540`, [전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41077456833). 실제 외부 모델·다른 OS의 terminal 화면 실측은 하지 않았다. +- 상태 폴더 진단의 [Linux PR 검사](https://github.com/PlateerLab/xgen-cli/actions/runs/41077575969)가 통과했다. 플랫폼 전체 결과는 위 전체 CI run을 참조한다. diff --git a/crates/xgen-cli/src/change_report.rs b/crates/xgen-cli/src/change_report.rs index 6c73c24..2d47c0a 100644 --- a/crates/xgen-cli/src/change_report.rs +++ b/crates/xgen-cli/src/change_report.rs @@ -58,7 +58,8 @@ pub struct TextEdit { /// [`PublicRunError::Integrity`] when its journal or material records do not verify. pub fn inspect_local_changes(run_id: &str) -> Result, PublicRunError> { let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + let layout = + RunLayout::existing(&root, run_id).map_err(crate::composition::map_layout_existing)?; let store = SqliteRunStore::open_existing_read_only(layout.database_path()) .map_err(|_| PublicRunError::Integrity)?; let state = store @@ -170,7 +171,8 @@ pub fn inspect_step_activities( skip: usize, ) -> Result, PublicRunError> { let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + let layout = + RunLayout::existing(&root, run_id).map_err(crate::composition::map_layout_existing)?; let store = SqliteRunStore::open_existing_read_only(layout.database_path()) .map_err(|_| PublicRunError::Integrity)?; let receipts = store @@ -241,7 +243,8 @@ pub fn inspect_step_activities( /// [`PublicRunError::Integrity`] when its journal does not open. pub fn inspect_receipt_count(run_id: &str) -> Result { let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + let layout = + RunLayout::existing(&root, run_id).map_err(crate::composition::map_layout_existing)?; SqliteRunStore::open_existing_read_only(layout.database_path()) .and_then(|store| store.load_execution_receipts()) .map(|receipts| receipts.len()) diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 8e209cb..12e4eab 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -655,6 +655,7 @@ pub enum PublicRunError { field: &'static str, changed_items: Vec, }, + StateDirectoryPermissions(crate::StateDirectoryPermissions), Busy, Integrity, Internal, @@ -724,8 +725,7 @@ fn local_model_call_recovery( expected_head: Option<&str>, ) -> Result { let state_root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = - RunLayout::existing(&state_root, run_id).map_err(|_| PublicRunError::Configuration)?; + let layout = RunLayout::existing(&state_root, run_id).map_err(map_layout_existing)?; let lease = acquire_lease(&layout, run_id)?; let manifest = layout .read_manifest() @@ -807,6 +807,7 @@ impl PublicRunError { pub const fn code(&self) -> &'static str { match self { Self::Configuration | Self::ConfigurationMismatch { .. } => "configuration_mismatch", + Self::StateDirectoryPermissions(_) => "state_directory_permissions", Self::Busy => "run_busy", Self::Integrity => "run_integrity_failure", Self::Internal => "internal_safety_failure", @@ -816,12 +817,23 @@ impl PublicRunError { #[must_use] pub const fn exit_code(&self) -> u8 { match self { - Self::Configuration | Self::ConfigurationMismatch { .. } => 64, + Self::Configuration + | Self::ConfigurationMismatch { .. } + | Self::StateDirectoryPermissions(_) => 64, Self::Busy => 75, Self::Integrity | Self::Internal => 70, } } + /// Explicit local diagnostics; quoted paths escape terminal controls and stay out of Debug. + #[must_use] + pub fn state_directory_details(&self) -> Option { + match self { + Self::StateDirectoryPermissions(details) => Some(details.terminal_details()), + _ => None, + } + } + #[must_use] pub fn configuration_details(&self) -> Option<(&'static str, &[String], &'static str)> { match self { @@ -1241,8 +1253,7 @@ where { validate_max_ticks(request.max_ticks)?; let state_root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = RunLayout::existing(&state_root, &request.run_id) - .map_err(|_| PublicRunError::Configuration)?; + let layout = RunLayout::existing(&state_root, &request.run_id).map_err(map_layout_existing)?; let lease = acquire_lease(&layout, &request.run_id)?; let manifest = layout .read_manifest() @@ -2318,8 +2329,20 @@ fn acquire_lease(layout: &RunLayout, run_id: &str) -> Result PublicRunError { + match error { + crate::run_layout::RunLayoutError::Permissions(details) => { + PublicRunError::StateDirectoryPermissions(details) + } + _ => PublicRunError::Configuration, + } +} + fn map_layout_create(error: crate::run_layout::RunLayoutError) -> PublicRunError { match error { + crate::run_layout::RunLayoutError::Permissions(details) => { + PublicRunError::StateDirectoryPermissions(details) + } crate::run_layout::RunLayoutError::InvalidRunId => PublicRunError::Configuration, crate::run_layout::RunLayoutError::AlreadyExists | crate::run_layout::RunLayoutError::Unavailable diff --git a/crates/xgen-cli/src/lib.rs b/crates/xgen-cli/src/lib.rs index 48b32ec..7fc8008 100644 --- a/crates/xgen-cli/src/lib.rs +++ b/crates/xgen-cli/src/lib.rs @@ -31,6 +31,7 @@ pub use instructions::{ }; pub use manifest::MAX_HOST_MODEL_TURNS; pub use model_profile::*; +pub use run_layout::StateDirectoryPermissions; pub use usage::{CostEstimate, TokenPrices, UsageReport, UsdPerMillion, inspect_local_usage}; #[doc(hidden)] diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index df8166c..7d2170f 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -446,6 +446,7 @@ fn usage_command(args: &UsageArgs) -> ExitCode { } Err(error) => { eprintln!("XGEN_ERROR code={}", error.code()); + print_state_directory_details(&error); ExitCode::from(error.exit_code()) } } @@ -2133,6 +2134,14 @@ fn prompt_model(models: &[String]) -> Result { Ok(models[selected - 1].clone()) } +fn print_state_directory_details(error: &PublicRunError) { + if let Some(details) = error.state_directory_details() { + for line in details.lines() { + eprintln!(" {line}"); + } + } +} + fn present_model_command_error(command: &str, error: ModelCliError) -> ExitCode { eprintln!("XGEN model {command}: FAIL"); eprintln!(" reason={}", error.code()); @@ -2274,6 +2283,7 @@ fn present(result: Result) -> ExitCode { } Err(error) => { eprintln!("XGEN_ERROR code={}", error.code()); + print_state_directory_details(&error); if let Some((field, items, hint)) = error.configuration_details() { eprintln!(" configuration={field}"); if !items.is_empty() { diff --git a/crates/xgen-cli/src/repl.rs b/crates/xgen-cli/src/repl.rs index 0f3185e..dba8905 100644 --- a/crates/xgen-cli/src/repl.rs +++ b/crates/xgen-cli/src/repl.rs @@ -1878,6 +1878,13 @@ fn print_permissions(output: &mut impl Write, settings: PermissionSettings) -> i fn print_failure(output: &mut impl Write, failure: &ReplFailure) -> io::Result<()> { writeln!(output, "error: {}", failure.code())?; + if let Some(details) = failure + .run_error + .as_ref() + .and_then(xgen_cli::PublicRunError::state_directory_details) + { + writeln!(output, "{details}")?; + } if let Some((field, items, hint)) = failure .run_error .as_ref() diff --git a/crates/xgen-cli/src/run_layout.rs b/crates/xgen-cli/src/run_layout.rs index 45b733d..c3273ea 100644 --- a/crates/xgen-cli/src/run_layout.rs +++ b/crates/xgen-cli/src/run_layout.rs @@ -132,7 +132,7 @@ impl fmt::Debug for RunLayout { } } -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Error, Clone, PartialEq, Eq)] pub(crate) enum RunLayoutError { #[error("Run identifier is invalid")] InvalidRunId, @@ -140,10 +140,40 @@ pub(crate) enum RunLayoutError { AlreadyExists, #[error("Run layout is unavailable")] Unavailable, + #[error("State directory permits group or other access")] + Permissions(StateDirectoryPermissions), #[error("Run manifest is invalid")] InvalidManifest, } +/// Permission diagnostics are exposed explicitly to the local terminal, never through Debug. +#[derive(Clone, PartialEq, Eq)] +pub struct StateDirectoryPermissions { + directory: PathBuf, + mode: u32, +} + +impl fmt::Debug for StateDirectoryPermissions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StateDirectoryPermissions") + .field("directory", &"") + .field("mode", &self.mode) + .finish() + } +} + +impl StateDirectoryPermissions { + // Debug quoting is deliberate: paths can contain terminal controls and newlines. + #[allow(clippy::unnecessary_debug_formatting)] + pub(crate) fn terminal_details(&self) -> String { + format!( + "state_directory={:?}\npermissions=0{:03o}; required=no group or other access\nrecovery=Check the quoted directory, then remove group/other access with chmod go-rwx -- PATH. Permissions were not changed automatically.", + self.directory, self.mode + ) + } +} + pub(crate) fn discover_state_root() -> Result { if let Some(value) = crate::compatible_environment("XGEN_STATE_HOME") { let path = PathBuf::from(value); @@ -349,7 +379,10 @@ fn verify_private_directory(path: &Path) -> Result<(), RunLayoutError> { use std::os::unix::fs::PermissionsExt as _; let metadata = fs::symlink_metadata(path).map_err(|_| RunLayoutError::Unavailable)?; if metadata.permissions().mode() & 0o077 != 0 { - return Err(RunLayoutError::Unavailable); + return Err(RunLayoutError::Permissions(StateDirectoryPermissions { + directory: path.to_path_buf(), + mode: metadata.permissions().mode() & 0o7777, + })); } } Ok(()) @@ -428,7 +461,10 @@ mod tests { assert_eq!( RunLayout::create(&root, run_id).unwrap_err(), - RunLayoutError::Unavailable + RunLayoutError::Permissions(StateDirectoryPermissions { + directory: root.clone(), + mode: 0o755 + }) ); assert_eq!( fs::symlink_metadata(&root).unwrap().permissions().mode() & 0o777, @@ -567,6 +603,51 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn permission_diagnostics_keep_guards_and_redact_debug_paths() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = tempdir().unwrap(); + let root = fixture.path().join("STATE-SENTINEL\n\x1b[31m"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o775)).unwrap(); + let error = RunLayout::create(&root, "run-0123456789abcdef0123456789abcdef").unwrap_err(); + assert!(!format!("{error:?}").contains("STATE-SENTINEL")); + assert!(!error.to_string().contains("STATE-SENTINEL")); + let RunLayoutError::Permissions(details) = error else { + panic!("expected permissions") + }; + let public = crate::PublicRunError::StateDirectoryPermissions(details); + assert_eq!(public.code(), "state_directory_permissions"); + assert_eq!(public.exit_code(), 64); + assert!(!format!("{public:?}").contains("STATE-SENTINEL")); + let text = public.state_directory_details().unwrap(); + assert!(text.contains("STATE-SENTINEL\\n\\u{1b}[31m")); + assert!(!text.contains('\x1b')); + assert!(text.contains("permissions=0775")); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o775 + ); + assert!(!root.join(RUNS_DIRECTORY).exists()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_permission_paths_are_printable_and_control_safe() { + use std::os::unix::ffi::OsStringExt as _; + // Formatting must not depend on whether the host filesystem accepts invalid UTF-8. + let details = StateDirectoryPermissions { + directory: PathBuf::from(std::ffi::OsString::from_vec(b"state-\xff-\n-\x1b".to_vec())), + mode: 0o750, + }; + let text = details.terminal_details(); + assert!(text.contains("permissions=0750")); + assert_eq!(text.lines().count(), 3); + assert!(!text.contains('\x1b')); + assert!(!format!("{details:?}").contains("state-")); + } + #[test] fn generated_ids_follow_the_strict_grammar() { let first = generate_run_id().unwrap(); diff --git a/crates/xgen-cli/src/usage.rs b/crates/xgen-cli/src/usage.rs index c55da55..bcf6371 100644 --- a/crates/xgen-cli/src/usage.rs +++ b/crates/xgen-cli/src/usage.rs @@ -242,7 +242,8 @@ fn format_usd(nano_usd: u128) -> String { /// Returns a fixed configuration/integrity failure for missing or unreadable state. pub fn inspect_local_usage(run_id: &str) -> Result { let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; - let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + let layout = + RunLayout::existing(&root, run_id).map_err(crate::composition::map_layout_existing)?; let store = SqliteRunStore::open_existing_read_only(layout.database_path()) .map_err(|_| PublicRunError::Integrity)?; let state = store diff --git a/crates/xgen-cli/tests/interactive_repl.rs b/crates/xgen-cli/tests/interactive_repl.rs index f109156..2a0742b 100644 --- a/crates/xgen-cli/tests/interactive_repl.rs +++ b/crates/xgen-cli/tests/interactive_repl.rs @@ -1258,3 +1258,42 @@ fn extract_run_id(stderr: &str) -> String { fn stderr(output: &Output) -> String { String::from_utf8_lossy(&output.stderr).into_owned() } + +#[cfg(unix)] +#[test] +#[allow(clippy::unnecessary_debug_formatting)] +fn state_permission_failure_is_actionable_in_the_repl() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = tempdir().unwrap(); + let root = fixture.path().join("state"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o775)).unwrap(); + let output = bounded_scripted_output( + Command::new(env!("CARGO_BIN_EXE_xgen")) + .current_dir(fixture.path()) + .env("XGEN_STATE_HOME", &root) + .env("XGEN_CONFIG_HOME", fixture.path().join("config")) + .env("XGEN_OPENAI_BASE_URL", "http://127.0.0.1:9/v1") + .env("XGEN_OPENAI_MODEL", MODEL) + .env("XGEN_OPENAI_TOKENIZER", TOKENIZER) + .env_remove("XGEN_OPENAI_API_KEY"), + b"Inspect fixture.\ny\n/exit\n", + ) + .unwrap(); + let text = String::from_utf8(output.stdout).unwrap(); + assert!( + text.contains("error: state_directory_permissions"), + "{text}" + ); + assert!( + text.contains(&format!("state_directory={root:?}")), + "{text}" + ); + assert!(text.contains("permissions=0775")); + assert!(text.contains("chmod go-rwx -- PATH")); + assert!(!root.join("runs").exists()); + assert_eq!( + fs::metadata(root).unwrap().permissions().mode() & 0o777, + 0o775 + ); +} diff --git a/crates/xgen-cli/tests/state_permissions.rs b/crates/xgen-cli/tests/state_permissions.rs new file mode 100644 index 0000000..3dcfa0f --- /dev/null +++ b/crates/xgen-cli/tests/state_permissions.rs @@ -0,0 +1,93 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::process::Command; +use tempfile::tempdir; + +const RUN_ID: &str = "run-0123456789abcdef0123456789abcdef"; + +#[test] +#[allow(clippy::unnecessary_debug_formatting)] +fn every_state_directory_reports_its_permissions_without_repairing_them() { + for (relative, mode) in [ + ("", 0o775), + ("runs", 0o755), + ("runs/run-0123456789abcdef0123456789abcdef", 0o770), + ] { + let fixture = tempdir().unwrap(); + let root = fixture.path().join("state with spaces"); + let run = root.join("runs").join(RUN_ID); + fs::create_dir_all(&run).unwrap(); + for path in [&root, &root.join("runs"), &run] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + let rejected = if relative.is_empty() { + root.clone() + } else { + root.join(relative) + }; + fs::set_permissions(&rejected, fs::Permissions::from_mode(mode)).unwrap(); + for command in ["usage", "recover"] { + let output = Command::new(env!("CARGO_BIN_EXE_xgen")) + .env("XGEN_STATE_HOME", &root) + .args([command, RUN_ID]) + .output() + .unwrap(); + let error = String::from_utf8(output.stderr).unwrap(); + assert_eq!(output.status.code(), Some(64), "{error}"); + assert!(output.stdout.is_empty()); + assert!( + error.contains("XGEN_ERROR code=state_directory_permissions"), + "{error}" + ); + assert!( + error.contains(&format!("state_directory={rejected:?}")), + "{error}" + ); + assert!( + error.contains(&format!("permissions=0{mode:03o}")), + "{error}" + ); + assert!(error.contains("chmod go-rwx -- PATH")); + assert_eq!( + fs::metadata(&rejected).unwrap().permissions().mode() & 0o777, + mode + ); + assert!(!run.join("run.sqlite3").exists()); + } + fs::set_permissions(&rejected, fs::Permissions::from_mode(0o700)).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_xgen")) + .env("XGEN_STATE_HOME", &root) + .args(["usage", RUN_ID]) + .output() + .unwrap(); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("run_integrity_failure"), "{error}"); + assert!(!error.contains("state_directory_permissions")); + assert!(!run.join("run.sqlite3").exists()); + } +} + +#[test] +fn symlinks_remain_generic_errors_and_never_suggest_permission_repairs() { + let fixture = tempdir().unwrap(); + let target = fixture.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o775)).unwrap(); + let root = fixture.path().join("linked-state"); + symlink(&target, &root).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_xgen")) + .env("XGEN_STATE_HOME", &root) + .args(["usage", RUN_ID]) + .output() + .unwrap(); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("configuration_mismatch"), "{error}"); + assert!(!error.contains("state_directory=")); + assert!(!error.contains("chmod")); + assert_eq!( + fs::metadata(target).unwrap().permissions().mode() & 0o777, + 0o775 + ); +} diff --git a/docs/adr/0023-public-local-run-resume-prototype.md b/docs/adr/0023-public-local-run-resume-prototype.md index 91f7859..d89719b 100644 --- a/docs/adr/0023-public-local-run-resume-prototype.md +++ b/docs/adr/0023-public-local-run-resume-prototype.md @@ -196,7 +196,8 @@ recovery 상태를 반복 반환한다. Effect `Started` 뒤 결과가 없는 | `75` | 다른 process가 Run lease 보유 | stdout은 성공 summary 전용이다. 상태와 fixed reason code는 stderr에만 출력하며 내부 error chain, -endpoint, path와 provider response body를 출력하지 않는다. 새 Run ID는 OS random source로 만들고 +endpoint, path와 provider response body를 출력하지 않는다. 상태 폴더 권한 거절의 명시적 로컬 복구 진단은 +[ADR-0080](0080-state-directory-permission-diagnostics.md)에서 추가했다. 새 Run ID는 OS random source로 만들고 manifest, DB와 `RunCreated`를 durable commit한 뒤 외부 호출 전에 `XGEN_STARTED`로 알린다. ## 검증 기준 diff --git a/docs/adr/0080-state-directory-permission-diagnostics.md b/docs/adr/0080-state-directory-permission-diagnostics.md new file mode 100644 index 0000000..58fb976 --- /dev/null +++ b/docs/adr/0080-state-directory-permission-diagnostics.md @@ -0,0 +1,63 @@ +# ADR-0080: 상태 폴더 권한 거절은 로컬 복구 정보를 제공한다 + +- 상태: Accepted +- 기준일: 2026-10-31 +- 분류: 범용 CLI·REPL 오류 진단. 모델 입력·journal·envelope·파일 접근 승인 계약 변경 없음. +- 후속 결정: ADR-0023의 상태 폴더 검사와 로컬 오류 표시. + +## 문제 + +Unix의 `XGEN_STATE_HOME`이 0775이면 기존 group/other 접근 거절 검사는 작동하지만, +새 Run에서는 `internal_safety_failure`, 기존 Run 조회에서는 `configuration_mismatch`로 바뀌었다. +사용자는 문제 경로나 권한, 복구 방법을 알 수 없었다. 상태 root뿐 아니라 `runs` 폴더와 +개별 Run 폴더의 동일 검사에서도 정보가 사라졌다. + +## 결정 + +1. 기존 `mode & 0o077 != 0` 거절 조건은 그대로 둔다. 해당 경우만 typed permission 오류로 + 경로·관찰한 mode를 전달한다. 파일 생성·읽기·chmod·symlink·경로 경계 검사 순서를 바꾸지 않는다. +2. 공개 code는 `state_directory_permissions`, exit code는 사용자 구성 오류인 64로 한다. + 기존 root/runs 생성 경로의 이 실패는 70에서 64로 바뀐다. 그 밖의 실패 분류는 유지한다. +3. `run`·`resume`·`usage`·`recover`와 REPL에 같은 진단을 제공한다. + 상태 조회·변경 내역 조회도 같은 typed 오류를 보존한다. headless stdout에는 진단을 넣지 않는다. +4. 명시적인 로컬 진단에서만 다음을 보여 준다. + + ```text + XGEN_ERROR code=state_directory_permissions + state_directory="/example/private-state" + permissions=0775; required=no group or other access + recovery=Check the quoted directory, then remove group/other access with chmod go-rwx -- PATH. Permissions were not changed automatically. + ``` + + `PATH`에는 확인한 실제 폴더 경로를 전달한다. 표시된 문자열을 shell 명령으로 자동 실행하지 않는다. + 기존 검사는 0700만 허용하는 검사가 아니므로 안내도 group/other 접근 제거를 요구한다. + `chmod go-rwx`는 소유자 권한을 유지한다. 폴더와 자식의 권한을 자동 변경하거나 재귀적으로 고치지 않는다. +5. 경로는 Rust path Debug의 인용·escape로 표시한다. 개행·ESC·비 UTF-8 경로도 terminal control로 + 실행되지 않는다. 오류 payload의 일반 Debug와 RunLayoutError Display는 경로를 가린다. + ADR-0023의 일반 오류에 경로를 싣지 않는 원칙에 대해 이 로컬 상태 폴더 복구 정보만 명시적으로 추가한다. + 내부 OS error chain, 파일 내용, 모델 응답, endpoint, credential은 표시하지 않는다. + 경로를 journal·모델 context·progress event·telemetry에 넣지 않는다. +6. symlink·없는 폴더·잘못된 state root·일반 OS I/O 오류에는 권한 문제라고 추정하거나 + `chmod`를 권하지 않는다. Windows의 기존 검사·오류 동작도 유지한다. + +## 검증 + +- 설계 사례: 상태 root 0775와 `runs` 0755. CLI `usage`·`recover`는 정확한 폴더·mode와 + exit 64를 보이며 stdout이 비어 있고 기존 권한은 그대로다. DB도 생성하지 않는다. +- 별도 검증 사례: 개별 Run 폴더 0770, space가 있는 경로, symlink 대상 0775. + symlink는 기존 generic 오류를 유지하고 target의 mode와 경로를 노출하거나 고치지 않는다. +- 사용자가 직접 문제 폴더를 0700으로 바꾸면 permission 거절을 통과해 다음 단계인 DB 무결성 검사로 + 진행한다. fixture에는 DB가 없으므로 integrity 오류가 나며 DB는 여전히 생성되지 않는다. +- 실제 REPL child process에서도 새 목표가 typed 오류와 복구 안내를 표시한다. 외부 모델 호출은 없다. +- 개행·ESC·비 UTF-8 경로의 진단은 escape되고, 오류 Debug·Display에는 경로 sentinel이 없다. +- 기존 permissive root 검사도 typed 오류를 기대하도록 바꾸고 자동 chmod 금지 assertion은 유지했다. + +## 범위 + +소유자는 xgen CLI의 RunLayout 오류 전달·표시다. filesystem adapter, 외부 도구, 모델의 문제는 아니다. +Unix mode 비트 검사의 설명을 개선했으며 ACL·소유자 UID 검사나 Windows ACL 정책을 새로 도입하지 않는다. + +로컬 전체 회귀는 Rust 778 passed·4 ignored(47 suites), Clippy·fmt·protocol·공개 문서·배포 workflow 검사 통과다. + +변경 추적: [PR #137](https://github.com/PlateerLab/xgen-cli/pull/137), 코드 `7edf540`, +[5개 플랫폼을 포함한 전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41077456833). diff --git a/docs/development/interactive-repl.md b/docs/development/interactive-repl.md index 237418b..b85e334 100644 --- a/docs/development/interactive-repl.md +++ b/docs/development/interactive-repl.md @@ -152,3 +152,14 @@ printf '/status\n/exit\n' | xgen 사용한다. 일반 자동화는 exit code와 고정 stderr 계약이 더 단순한 기존 `xgen run/resume`을 권장한다. 대화 응답의 저장·검증 계약은 [ADR-0047](../adr/0047-conversation-response-and-bounded-session-context.md)을 따른다. + +## 상태 폴더 권한 오류 + +Unix에서 `error: state_directory_permissions`가 나오면 함께 표시된 `state_directory`와 +`permissions`를 확인한다. 해당 폴더의 group/other 접근을 제거해야 한다. +확인한 경로에 `chmod go-rwx -- PATH`를 직접 실행하면 소유자 권한은 유지된다. +프로그램은 권한을 자동 변경하거나 재귀적으로 고치지 않는다. 경로는 제어 문자를 escape한 인용 표기다. + +headless `run`·`resume`·`usage`·`recover`는 같은 code와 진단을 stderr에 내보내고 exit 64로 끝난다. +symlink나 일반 I/O 실패에는 이 복구 안내를 붙이지 않는다. 자세한 범위는 +[ADR-0080](../adr/0080-state-directory-permission-diagnostics.md)을 따른다.