From ecd248f3fb7a597eb9dd7156a845bda83036cf45 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 9 Oct 2026 18:49:52 +0800 Subject: [PATCH 1/3] fix(artifacts): resolve a linked state root once so session artifacts keep working When the Codewhale state directory is reached through a junction (the standard way to keep ~/.codewhale on another volume on Windows), the Windows WorkspaceFile opener refuses the root's own reparse component, so every session-artifact write failed with "Fleet artifact path must stay within the workspace". Compact fails outright (the pre-compaction checkpoint write propagates its error), and spillover, background-shell evidence and the workflow journal degrade on the same path. Open the state root through a new open_resolved_root: the root is resolved once, and the existing link-refusing walk then runs, so links below the root are still refused. The Unix opener has always resolved the root; this restores the same behaviour on Windows for the application's own state directory. Caller-supplied workspace roots keep the strict opener. Test: fleet::files::windows_root_link_tests::linked_state_root_resolves_once_and_keeps_the_inner_rule --- crates/tui/src/artifacts.rs | 5 +- crates/tui/src/fleet/files.rs | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/artifacts.rs b/crates/tui/src/artifacts.rs index bbbb328afe..723134e726 100644 --- a/crates/tui/src/artifacts.rs +++ b/crates/tui/src/artifacts.rs @@ -207,7 +207,10 @@ pub(crate) fn open_session_relative( if create { std::fs::create_dir_all(&root)?; } - crate::fleet::files::WorkspaceFile::open( + // The sessions root is the application's own state, which a user may have + // relocated behind a link (on Windows, a junction to another volume). + // Resolve the root once; everything below it stays link-refusing. + crate::fleet::files::WorkspaceFile::open_resolved_root( &root, &PathBuf::from(session_id).join(relative_path), create, diff --git a/crates/tui/src/fleet/files.rs b/crates/tui/src/fleet/files.rs index 13198ddfd1..948f4c1d07 100644 --- a/crates/tui/src/fleet/files.rs +++ b/crates/tui/src/fleet/files.rs @@ -81,6 +81,19 @@ impl WorkspaceFile { Self::open_confined(workspace, relative, false, false, false) } + /// Open under a root that may itself be reached through user-owned links: + /// the application's own state directory, which a user may have relocated + /// behind a symlink or junction. The root is resolved once; the path below + /// it is still link-refusing. Not for a caller-supplied workspace root. + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + let resolved = workspace.canonicalize()?; + Self::open(&resolved, relative, create) + } + fn open_confined( workspace: &Path, relative: &Path, @@ -616,6 +629,21 @@ impl WorkspaceFile { }) } + /// Open under a root that may itself be reached through user-owned links: + /// the application's own state directory, which a user may have relocated + /// behind a junction. The root is resolved once to its real directory and + /// the same link-refusing walk then runs, so links below the root are still + /// refused. The caller must have created the root already. Not for a + /// caller-supplied workspace root. + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + let resolved = workspace.canonicalize()?; + Self::open(&resolved, relative, create) + } + pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result { Self::open(workspace, relative, false) } @@ -1067,6 +1095,61 @@ mod windows_publication_tests { } } +#[cfg(all(test, windows))] +mod windows_root_link_tests { + use super::*; + + fn make_junction(link: &Path, target: &Path) { + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .expect("invoke Windows junction creation"); + assert!( + output.status.success(), + "failed to create junction: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// A state root relocated behind a junction: the strict opener refuses the + /// root link, `open_resolved_root` resolves it once and writes through, + /// and a link below the root stays refused either way. + #[test] + fn linked_state_root_resolves_once_and_keeps_the_inner_rule() { + let temp = tempfile::tempdir().unwrap(); + let real = temp.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = temp.path().join("link"); + make_junction(&link, &real); + + let relative = Path::new("state/entry.txt"); + assert!( + WorkspaceFile::open(&link, relative, true).is_err(), + "the strict opener must refuse a linked root" + ); + + WorkspaceFile::open_resolved_root(&link, relative, true) + .unwrap() + .publish(b"payload") + .unwrap(); + assert_eq!( + std::fs::read(real.join("state").join("entry.txt")).unwrap(), + b"payload" + ); + + let elsewhere = temp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + make_junction(&real.join("linked-child"), &elsewhere); + assert!( + WorkspaceFile::open_resolved_root(&link, Path::new("linked-child/x.txt"), true) + .is_err(), + "a link below the resolved root must stay refused" + ); + } +} + #[cfg(all(not(unix), not(windows)))] #[derive(Debug)] pub(crate) struct WorkspaceFile; @@ -1081,6 +1164,13 @@ impl WorkspaceFile { pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result { Self::open(workspace, relative, create) } + pub(crate) fn open_resolved_root( + workspace: &Path, + relative: &Path, + create: bool, + ) -> io::Result { + Self::open(workspace, relative, create) + } pub(crate) fn sibling(&self, _: &str) -> io::Result { unreachable!() } From 8ace61984ab5c701c2c938b77581f2c3ea77133f Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 9 Oct 2026 20:12:13 +0800 Subject: [PATCH 2/3] fix(artifacts): keep the unix opener single-resolve and land the budget The Unix opener has always resolved the root inside open_confined, so open_resolved_root forwards to open there instead of resolving a second time; the Windows variant keeps the one resolve it needs. The added method took crates/tui/src/fleet/files.rs from one path_canonicalize site to two, which the blocking-calls ratchet refuses, so the file's recorded budget is raised to two with that reason: this is a synchronous, confined file-I/O layer whose callers already accept the file's blocking work, and the new site lives beside the five std_fs sites the budget already records. Verified against the checker's own counting logic: files.rs counts {thread_sleep: 1, std_fs: 5, path_canonicalize: 2} against a budget of {1, 6, 2}. --- crates/tui/src/fleet/files.rs | 8 ++++---- scripts/check-blocking-calls-budget.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/fleet/files.rs b/crates/tui/src/fleet/files.rs index 948f4c1d07..77c49988ce 100644 --- a/crates/tui/src/fleet/files.rs +++ b/crates/tui/src/fleet/files.rs @@ -83,15 +83,15 @@ impl WorkspaceFile { /// Open under a root that may itself be reached through user-owned links: /// the application's own state directory, which a user may have relocated - /// behind a symlink or junction. The root is resolved once; the path below - /// it is still link-refusing. Not for a caller-supplied workspace root. + /// behind a symlink. The Unix opener already resolves the root and still + /// refuses every link below it, so this is the same call under a name that + /// says why a caller wants it. Not for a caller-supplied workspace root. pub(crate) fn open_resolved_root( workspace: &Path, relative: &Path, create: bool, ) -> io::Result { - let resolved = workspace.canonicalize()?; - Self::open(&resolved, relative, create) + Self::open(workspace, relative, create) } fn open_confined( diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 7ff3947535..04da909ec4 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -178,7 +178,7 @@ "std_fs": 1 }, "crates/tui/src/fleet/files.rs": { - "path_canonicalize": 1, + "path_canonicalize": 2, "std_fs": 6, "thread_sleep": 1 }, From c0e404cfd03cbfa9c7d10a45161b036007d8ec4e Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Fri, 9 Oct 2026 21:22:43 +0800 Subject: [PATCH 3/3] fix(artifacts): follow the resolved root on the read side too Adversarial review of this PR (SpikeBot 003, finding F1) showed the fix covered writes only. resolve_session_artifact and image_evidence_summary opened through the strict opener, so on a machine whose state root is a junction the writer succeeded and the reader still failed: the compact checkpoint was written and could not be read back through GET /sessions//artifacts/, or through the turn-ref route that shares the same resolver. runtime_api::workspace gains open_confined_file_resolved and the two session artifact reads move to it, so a reader follows its writer. The two remaining open_confined_file callers keep the strict opener on purpose: the root in turn_artifacts.rs is canonical_workspace(thread_workspace) and the root in workspace_file_read is the caller's workspace, neither of which is the application's own state directory. session_artifact_absolute_path also documents what it is (finding F3): the recorded spelling for receipts and display, not a path to open, because it keeps the state root as spelled. --- crates/tui/src/artifacts.rs | 5 +++++ crates/tui/src/runtime_api/sessions.rs | 4 ++-- crates/tui/src/runtime_api/workspace.rs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/artifacts.rs b/crates/tui/src/artifacts.rs index 723134e726..c4c19adabf 100644 --- a/crates/tui/src/artifacts.rs +++ b/crates/tui/src/artifacts.rs @@ -122,6 +122,11 @@ pub(crate) fn set_test_artifact_sessions_root(root: Option) -> Option

Option { if !is_valid_session_id(session_id) { diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index db489907b3..3acc49399b 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -1395,7 +1395,7 @@ pub(super) fn resolve_session_artifact( } }; let relative = PathBuf::from(id).join(&relative_path); - let opened = super::workspace::open_confined_file(sessions_dir, &relative, false) + let opened = super::workspace::open_confined_file_resolved(sessions_dir, &relative, false) .and_then(|file| super::workspace::read_confined_bytes(&file)); let read = match (opened, &authority) { (Ok(read), _) => read, @@ -1481,7 +1481,7 @@ fn image_evidence_summary( > { let relative = PathBuf::from(id) .join(crate::tools::large_output_router::evidence_metadata_relative_path(artifact_id)); - let file = super::workspace::open_confined_file(sessions_dir, &relative, false)?; + let file = super::workspace::open_confined_file_resolved(sessions_dir, &relative, false)?; let evidence = crate::tools::large_output_router::read_evidence_metadata_file(&file) .map_err(|error| super::workspace::map_fs_error(error, "evidence metadata"))?; let expected_path = diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index 19f4a21bc9..ddb4a40870 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -566,6 +566,20 @@ pub(super) fn open_confined_file( .map_err(|error| map_fs_error(error, "file")) } +/// [`open_confined_file`] for the application's own state root, which a user +/// may have relocated behind a link (on Windows, a junction to another +/// volume). The root is resolved once and every component below it is still +/// refused when linked, so a reader follows its writer and a caller-supplied +/// workspace root keeps the stricter [`open_confined_file`]. +pub(super) fn open_confined_file_resolved( + root: &FsPath, + relative: &FsPath, + create: bool, +) -> Result { + crate::fleet::files::WorkspaceFile::open_resolved_root(root, relative, create) + .map_err(|error| map_fs_error(error, "file")) +} + /// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so /// the revision always describes the whole file. pub(super) fn read_confined_bytes(