From d8b8c331df81d18b86c9f1a83fbf010c23ca55e3 Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:04:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EA=B1=B0=EC=A0=88=EC=97=90=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=C2=B7=ED=98=84=EC=9E=AC=20mode=C2=B7=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=B3=B5=EA=B5=AC=20=EB=B0=A9=EB=B2=95=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 2 + crates/xgen-cli/src/change_report.rs | 9 +- crates/xgen-cli/src/composition.rs | 33 ++++++- crates/xgen-cli/src/lib.rs | 1 + crates/xgen-cli/src/main.rs | 10 ++ crates/xgen-cli/src/repl.rs | 7 ++ crates/xgen-cli/src/run_layout.rs | 93 ++++++++++++++++++- crates/xgen-cli/src/usage.rs | 3 +- crates/xgen-cli/tests/interactive_repl.rs | 39 ++++++++ crates/xgen-cli/tests/state_permissions.rs | 93 +++++++++++++++++++ .../0023-public-local-run-resume-prototype.md | 3 +- ...-state-directory-permission-diagnostics.md | 60 ++++++++++++ docs/development/interactive-repl.md | 11 +++ 13 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 crates/xgen-cli/tests/state_permissions.rs create mode 100644 docs/adr/0080-state-directory-permission-diagnostics.md diff --git a/MEMORY.md b/MEMORY.md index 1a73bde..2310fe1 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -154,3 +154,5 @@ - 빠른 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이며 외부 도구 변경은 없다. 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..fe8cf3c 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,57 @@ 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 _, fs::PermissionsExt as _}; + let fixture = tempdir().unwrap(); + let root = fixture + .path() + .join(std::ffi::OsString::from_vec(b"state-\xff-\n-\x1b".to_vec())); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o750)).unwrap(); + let RunLayoutError::Permissions(details) = + RunLayout::create(&root, "run-0123456789abcdef0123456789abcdef").unwrap_err() + else { + panic!("expected permissions") + }; + 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..fb0c740 --- /dev/null +++ b/docs/adr/0080-state-directory-permission-diagnostics.md @@ -0,0 +1,60 @@ +# 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 검사 통과다. 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)을 따른다. From 7edf5406689dee2945adcc5596b2a8b83b75f1af Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:05:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=B9=84UTF8=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EA=B0=80=20=ED=8C=8C=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=A7=80=EC=9B=90=EC=97=90=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/xgen-cli/src/run_layout.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/xgen-cli/src/run_layout.rs b/crates/xgen-cli/src/run_layout.rs index fe8cf3c..c3273ea 100644 --- a/crates/xgen-cli/src/run_layout.rs +++ b/crates/xgen-cli/src/run_layout.rs @@ -635,17 +635,11 @@ mod tests { #[cfg(unix)] #[test] fn non_utf8_permission_paths_are_printable_and_control_safe() { - use std::os::unix::{ffi::OsStringExt as _, fs::PermissionsExt as _}; - let fixture = tempdir().unwrap(); - let root = fixture - .path() - .join(std::ffi::OsString::from_vec(b"state-\xff-\n-\x1b".to_vec())); - fs::create_dir(&root).unwrap(); - fs::set_permissions(&root, fs::Permissions::from_mode(0o750)).unwrap(); - let RunLayoutError::Permissions(details) = - RunLayout::create(&root, "run-0123456789abcdef0123456789abcdef").unwrap_err() - else { - panic!("expected permissions") + 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")); From c650eb5ba5b5d7e3be66f1da6d88d6fee5c6cf0c Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:06:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=A7=84=EB=8B=A8=20PR=EA=B3=BC=20?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 1 + docs/adr/0080-state-directory-permission-diagnostics.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/MEMORY.md b/MEMORY.md index 2310fe1..4776619 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -156,3 +156,4 @@ - 빠른 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 화면 실측은 하지 않았다. diff --git a/docs/adr/0080-state-directory-permission-diagnostics.md b/docs/adr/0080-state-directory-permission-diagnostics.md index fb0c740..58fb976 100644 --- a/docs/adr/0080-state-directory-permission-diagnostics.md +++ b/docs/adr/0080-state-directory-permission-diagnostics.md @@ -58,3 +58,6 @@ Unix의 `XGEN_STATE_HOME`이 0775이면 기존 group/other 접근 거절 검사 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). From 66ebc7b31e43dc9bbb339c3085eddf799ee3fa54 Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:08:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=EC=9D=98=20Linux=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=EC=99=80=20=EB=A9=94=EB=AA=A8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=EC=9D=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 4776619..0d89d82 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,6 @@ # xgen CLI와 연계 도구 공동 개발 메모 -갱신: 2026-10-30 (KST) +갱신: 2026-10-31 (KST) ## 사용자가 정한 방향 @@ -155,5 +155,6 @@ - 빠른 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이며 외부 도구 변경은 없다. +- 상태 폴더 권한 진단(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을 참조한다.