From 35ea5ca071f464523dbde875b0ce53081470d1dd Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Tue, 27 Oct 2026 02:44:12 +0800 Subject: [PATCH 1/3] fix(tui): resolve the state root before opening session-side state A user who relocates application state to another volume cannot save a session at all. Every save fails with the red "cannot save session" notice and the log records persistence write failed what=session: error_kind=InvalidInput error=Fleet artifact path must stay within the workspace `SessionManager::save_session_inner` hydrates approval receipts before it serializes anything, and `ApprovalReceiptStore::log_file` opened `//approval_receipts.jsonl` through the strict `WorkspaceFile::open`. On Windows that opener walks the root component by component and refuses the junction that is the state root, so the save died before producing a document. Storing session receipt data went from plain file I/O (0.10.2) to the confined opener, which is the right direction, but the state root is a root the user owns and may relocate. `open_resolved_root` exists for exactly that (#6947): it resolves the root once and then runs the same link-refusing walk below it. These call sites now use it: - approval receipt log (the one that blocked saving) - session archive artifact reads and the two directory checks - clipboard image sink, screencapture attachment sink - pet habitat store, descriptor and lifetime lock No boundary is widened: a link below the state root is still refused, and `WorkspaceFile::open` stays the opener for caller-supplied workspace roots. Verified with cargo check -p codewhale-tui (exit 0) and cargo fmt. The 0.10.2 binary with the same junction layout reproduces the failure, so this is not a regression from an unreleased branch. --- crates/tui/src/approval_log.rs | 2 +- crates/tui/src/session_export.rs | 14 +++++++++++--- crates/tui/src/tui/clipboard.rs | 8 ++++++-- crates/tui/src/tui/file_mention.rs | 8 ++++++-- crates/tui/src/tui/pet_watch/owner.rs | 7 ++++--- crates/tui/src/tui/pet_watch/persistence.rs | 4 ++-- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/approval_log.rs b/crates/tui/src/approval_log.rs index 579980151b..bb8456034c 100644 --- a/crates/tui/src/approval_log.rs +++ b/crates/tui/src/approval_log.rs @@ -463,7 +463,7 @@ impl ApprovalReceiptStore { if create { fs::create_dir_all(&self.sessions_dir)?; } - WorkspaceFile::open( + WorkspaceFile::open_resolved_root( &self.sessions_dir, &PathBuf::from(session_id).join(APPROVAL_LOG_FILE), create, diff --git a/crates/tui/src/session_export.rs b/crates/tui/src/session_export.rs index 66cdefc2b2..d98695ff7a 100644 --- a/crates/tui/src/session_export.rs +++ b/crates/tui/src/session_export.rs @@ -215,7 +215,7 @@ pub fn write_session_archive( .expect("artifact collection needs a store"); // Parent directories and the leaf are opened without following links. // Stream this handle directly; never reopen a validated path. - let mut file = WorkspaceFile::open(root, relative, false)?.open_file()?; + let mut file = WorkspaceFile::open_resolved_root(root, relative, false)?.open_file()?; append_member( &mut tar, name, @@ -278,7 +278,11 @@ pub fn session_artifacts_dir(sessions_dir: &Path, session_id: &str) -> io::Resul let relative = Path::new(session_id).join(ARTIFACTS_DIR_NAME); // Opening a confined file reference pins and validates its parent directory; // this checks the artifact root without creating or opening the dummy leaf. - match WorkspaceFile::open(sessions_dir, &relative.join(".export-root-check"), false) { + match WorkspaceFile::open_resolved_root( + sessions_dir, + &relative.join(".export-root-check"), + false, + ) { Ok(_) => Ok(Some(sessions_dir.join(relative))), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), @@ -392,7 +396,11 @@ fn collect_artifact_files_recursive( if file_type.is_dir() { // Reject substituted parent directories before descending. The file // read repeats confinement checks, so directory races cannot leak bytes. - WorkspaceFile::open(sessions_dir, &relative.join(".export-root-check"), false)?; + WorkspaceFile::open_resolved_root( + sessions_dir, + &relative.join(".export-root-check"), + false, + )?; collect_artifact_files_recursive( sessions_dir, &path, diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 410d2444d4..028c175b31 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -962,8 +962,12 @@ fn clipboard_image_target( let name = dir .file_name() .context("clipboard-images dir has no name")?; - crate::fleet::files::WorkspaceFile::open(root, &Path::new(name).join(file_name), true) - .context("open clipboard-images destination (links are not followed)") + crate::fleet::files::WorkspaceFile::open_resolved_root( + root, + &Path::new(name).join(file_name), + true, + ) + .context("open clipboard-images destination (links are not followed)") } /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 03bb605c6c..04145b9438 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -812,8 +812,12 @@ fn stabilize_screencapture_file(path: &Path, artifact_dir: &Path) -> Option io::Result { } pub fn descriptor(root: &Path) -> io::Result { - let file = WorkspaceFile::open(root, Path::new("connection.json"), false)?.open_file()?; + let file = WorkspaceFile::open_resolved_root(root, Path::new("connection.json"), false)? + .open_file()?; let mut text = String::new(); file.take(4097).read_to_string(&mut text)?; if text.len() > 4096 { @@ -312,7 +313,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> { } std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?; } - let lock_path = WorkspaceFile::open(&root, Path::new("owner.lock"), true)?; + let lock_path = WorkspaceFile::open_resolved_root(&root, Path::new("owner.lock"), true)?; let original = lock_path.open_update(true, false)?; let mut lifetime = fd_lock::RwLock::new(original.try_clone()?); let _guard = lifetime @@ -360,7 +361,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> { token: saved.token.clone(), identity: saved.identity.clone(), }; - let connection = WorkspaceFile::open(&root, Path::new("connection.json"), true)?; + let connection = WorkspaceFile::open_resolved_root(&root, Path::new("connection.json"), true)?; let epoch = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::sync_channel(128); let frames = Arc::new(Mutex::new(VecDeque::new())); diff --git a/crates/tui/src/tui/pet_watch/persistence.rs b/crates/tui/src/tui/pet_watch/persistence.rs index 53a0821811..e7e377195e 100644 --- a/crates/tui/src/tui/pet_watch/persistence.rs +++ b/crates/tui/src/tui/pet_watch/persistence.rs @@ -26,8 +26,8 @@ pub struct Store { impl Store { /// Shared presentation-owner state. Legacy session habitats stay in place. pub fn at(root: &Path) -> io::Result { - let data = WorkspaceFile::open(root, Path::new("habitat.json"), true)?; - let lock = WorkspaceFile::open(root, Path::new("habitat.lock"), true)?; + let data = WorkspaceFile::open_resolved_root(root, Path::new("habitat.json"), true)?; + let lock = WorkspaceFile::open_resolved_root(root, Path::new("habitat.lock"), true)?; let original_lock = lock.open_update(true, false)?; Ok(Self { data, From 3c31785dbbd099beda4ebd08b784f365b02f4e1e Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Tue, 27 Oct 2026 03:03:21 +0800 Subject: [PATCH 2/3] fix(tui): keep the strict root rule on the workspace fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of the previous commit found that two of the converted call sites do not always hold the state root. `clipboard_images_dir_for_home` and `screenshot_stabilization_dir` fall back to a directory under the workspace when no home directory resolves, so `dir.parent()` is then a caller-supplied workspace root — and resolving it would have widened the rule that a link at the workspace root is refused. Both sites now pick the opener from the root they actually hold: the resolved opener only for `/.codewhale`, the strict opener otherwise. The normal path (a home directory exists) keeps the junction fix; the narrow fallback keeps the boundary it had. --- crates/tui/src/tui/clipboard.rs | 20 ++++++++++++++------ crates/tui/src/tui/file_mention.rs | 19 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 028c175b31..10ce671a73 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -962,12 +962,20 @@ fn clipboard_image_target( let name = dir .file_name() .context("clipboard-images dir has no name")?; - crate::fleet::files::WorkspaceFile::open_resolved_root( - root, - &Path::new(name).join(file_name), - true, - ) - .context("open clipboard-images destination (links are not followed)") + let relative = Path::new(name).join(file_name); + // `dir` sits under the user's own state root unless no home was available + // and it fell back to the workspace (`clipboard_images_dir_for_home`). Only + // the state root may be a link the user made to relocate `~/.codewhale`; a + // caller-supplied workspace root keeps the strict rule. + let state_root = codewhale_paths::user_home() + .is_some_and(|home| root == home.join(codewhale_paths::CODEWHALE_APP_DIR)); + let open = if state_root { + crate::fleet::files::WorkspaceFile::open_resolved_root + } else { + crate::fleet::files::WorkspaceFile::open + }; + open(root, &relative, true) + .context("open clipboard-images destination (links are not followed)") } /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 04145b9438..2e92d053f2 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -812,12 +812,19 @@ fn stabilize_screencapture_file(path: &Path, artifact_dir: &Path) -> Option Date: Mon, 26 Oct 2026 22:02:21 -0700 Subject: [PATCH 3/3] chore(changelog): sync crates/tui/CHANGELOG.md with root Unreleased 81558024c added three Unreleased entries to CHANGELOG.md without running scripts/sync-changelog.sh, so the Version drift check fails on every PR based on main (seen on #7097 and #7094). Regenerated from origin/main's root CHANGELOG.md; scripts/sync-changelog.sh --check passes on the result. Built with a temporary index so the shared checkout's uncommitted work is untouched. Co-Authored-By: Claude Opus 5.5 --- crates/tui/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 161acd9c59..8f80226bda 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **GitHub Action reviews:** analysis can export a structured Engine review for a separate trusted publisher job. The publisher checks the current PR revision and line anchors, deduplicates its findings, and keeps uncertain or preexisting claims out of inline comments. +- **Telegram recovery:** prompts and their initiating identity are durable before the polling cursor advances. Restart recovery reuses the original Engine operation, questions preserve human ownership, and uncertain final replies remain available through `/status` without an automatic resend. +- **DingTalk bridge:** an optional internal-app Stream adapter reuses the same Engine, shared admission and approval helpers. Tenant/staff allowlists, durable acceptance before acknowledgement and retained uncertain deliveries protect recovery. Managed account linking is separate from this advanced self-hosted bridge. + - **Answerless output-limit recovery:** opt into `[reasoning_only] on_output_limit = true` to request an answer again after a length stop with no answer or tool call, including hidden reasoning and ordinary Fleet workers. Retries share `max_reprompts`, retain existing output and control limits, and record request-scoped nudge receipts without adding them to resumed conversation context. RLM keeps its existing stop protocol. Thanks [@M-Maciej](https://github.com/M-Maciej) for the long-proof Fleet report. (#6990) - **Linux hardening tests:** the NoNewPrivs child matrices now respect a parent that already holds the irreversible flag, keeping the full assertions on unpinned hosts and checking the kernel ratchet on pinned hosts. Thanks [@asto18089](https://github.com/asto18089) for diagnosing the hardened-container failure. (#7021)