Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# xgen CLI와 연계 도구 공동 개발 메모

갱신: 2026-10-30 (KST)
갱신: 2026-10-31 (KST)

## 사용자가 정한 방향

Expand Down Expand Up @@ -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을 참조한다.
9 changes: 6 additions & 3 deletions crates/xgen-cli/src/change_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<CommittedChange>, 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
Expand Down Expand Up @@ -170,7 +171,8 @@ pub fn inspect_step_activities(
skip: usize,
) -> Result<Vec<StepActivity>, 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
Expand Down Expand Up @@ -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<usize, 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)?;
SqliteRunStore::open_existing_read_only(layout.database_path())
.and_then(|store| store.load_execution_receipts())
.map(|receipts| receipts.len())
Expand Down
33 changes: 28 additions & 5 deletions crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ pub enum PublicRunError {
field: &'static str,
changed_items: Vec<String>,
},
StateDirectoryPermissions(crate::StateDirectoryPermissions),
Busy,
Integrity,
Internal,
Expand Down Expand Up @@ -724,8 +725,7 @@ fn local_model_call_recovery(
expected_head: Option<&str>,
) -> Result<ModelCallRecoveryReport, PublicRunError> {
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()
Expand Down Expand Up @@ -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",
Expand All @@ -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<String> {
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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2318,8 +2329,20 @@ fn acquire_lease(layout: &RunLayout, run_id: &str) -> Result<LocalRunLease, Publ
})
}

pub(crate) fn map_layout_existing(error: crate::run_layout::RunLayoutError) -> 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
Expand Down
1 change: 1 addition & 0 deletions crates/xgen-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
10 changes: 10 additions & 0 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down Expand Up @@ -2133,6 +2134,14 @@ fn prompt_model(models: &[String]) -> Result<String, ModelCliError> {
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());
Expand Down Expand Up @@ -2274,6 +2283,7 @@ fn present(result: Result<LocalCommandResult, PublicRunError>) -> 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() {
Expand Down
7 changes: 7 additions & 0 deletions crates/xgen-cli/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
87 changes: 84 additions & 3 deletions crates/xgen-cli/src/run_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,48 @@ 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,
#[error("Run already exists")]
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", &"<redacted>")
.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<PathBuf, RunLayoutError> {
if let Some(value) = crate::compatible_environment("XGEN_STATE_HOME") {
let path = PathBuf::from(value);
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion crates/xgen-cli/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageReport, 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
Expand Down
39 changes: 39 additions & 0 deletions crates/xgen-cli/tests/interactive_repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Loading