Skip to content

fix(artifacts): resolve a linked state root so a relocated Codewhale home keeps working - #6947

Merged
Hmbown merged 6 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/artifacts-state-root-junction-upstream
Oct 10, 2026
Merged

Hmbown merged 6 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/artifacts-state-root-junction-upstream

Conversation

@SparkofSpike

Copy link
Copy Markdown
Contributor

Summary

On Windows, when the Codewhale state directory is reached through a junction —
the standard way to keep %USERPROFILE%\.codewhale on another volume — every
session-artifact write fails with:

Fleet artifact path must stay within the workspace

/compact fails outright, because it persists a pre-compaction checkpoint as
its first step (compact_messages_safe propagates that write's error with ?).
Spillover saving, background-shell evidence, tool-media storage and the
workflow journal degrade on the same path, and the Runtime API cannot read an
artifact back.

Cause

The strict opener in crates/tui/src/fleet/files.rs (Windows) walks every
component of the root path and refuses any reparse point — including the
components of the state root itself. resolve_state_dir("sessions")
returns %USERPROFILE%\.codewhale\sessions in the original spelling; when
.codewhale is a junction, WindowsDirectory::open_inner refuses that very
component and invalid_path() reports the Fleet-artifact message.

v0.10.0 resolved the root first (workspace.canonicalize()), and the Unix
opener still does; the v0.10.1 Windows rewrite dropped that resolution for
every caller. The state root is the application's own directory, not a
caller-supplied workspace: a user who relocates it behind a link is placing
their state, not widening a trust boundary.

Changes

  • WorkspaceFile::open_resolved_root (new): resolves the root once
    (canonicalize) and then runs the existing link-refusing walk. It exists for
    the application's own state directory, which the user may have relocated
    behind a link; a caller-supplied workspace root must keep using open. The
    doc comment says so on every platform. On Unix it forwards to open, whose
    confined opener already resolves the root — no second resolve and no new
    blocking-call site.
  • crates/tui/src/artifacts.rs: open_session_relative opens through
    open_resolved_root.
  • crates/tui/src/runtime_api/: workspace.rs gains
    open_confined_file_resolved, and the two session-artifact read routes
    (resolve_session_artifact, image_evidence_summary) move to it. Without
    this the writer succeeded and the reader still failed on a relocated root:
    the compact checkpoint was written and could not be read back through
    GET /sessions/<id>/artifacts/<artifact_id> or the turn-ref route that
    shares the same resolver. The two remaining open_confined_file callers
    keep the strict opener on purpose — their roots are
    canonical_workspace(thread_workspace) and the caller's workspace, neither
    of which is the application's state directory.
  • Nothing below the root is relaxed: the walk still refuses any link inside
    the state directory, and open — used by Fleet workspaces, delivery files,
    the LSP root and the Runtime API workspace reads — is unchanged.
  • session_artifact_absolute_path documents what it is: the recorded
    spelling for receipts and display, not a path to open, because it keeps the
    state root as spelled.
  • scripts/check-blocking-calls-budget.json: crates/tui/src/fleet/files.rs
    moves from one path_canonicalize site to two, with the reason below. The
    Unix variant adds no site.

Evidence

Reproduced before the fix on 0.10.1 (Windows). A component-by-component probe
of both spellings shows the exact split:

[ok]      C:\Users\<user>
[REPARSE! C:\Users\<user>\.codewhale        <- refused here
[ok]      C:\Users\<user>\.codewhale\sessions
[ok]      D:\CodeWhaleData\sessions          <- same directory, no link on the chain

New Windows regression test
(fleet::files::windows_root_link_tests::linked_state_root_resolves_once_and_keeps_the_inner_rule)
creates a real junction and pins both directions: the strict opener must keep
refusing a linked root, open_resolved_root must write through to the real
target, and a link below the resolved root must still be refused.

Not run here: the repository's own gates. The change is Windows-only in
effect; the pushed branch is the design under review, and CI is the gate.

Blocking-call budget

The file's recorded path_canonicalize budget is raised from one to two. This
is a synchronous, confined file-I/O layer whose callers already accept the
file's blocking work, and the new site sits beside the five std_fs sites the
budget already records. An adversarial review of the change independently
re-verified the count with the checker's own logic: base has one site, the new
method adds one, and the inline test module contributes none.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Related Issues

No-Issue: observed in an operator session on 0.10.1 (Windows); the state
directory relocated behind a junction makes every session-artifact write fail.

… 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
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}.
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/<id>/artifacts/<artifact_id>, 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.
@SparkofSpike
SparkofSpike requested a review from Hmbown as a code owner October 9, 2026 16:31

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@Hmbown
Hmbown enabled auto-merge (squash) October 9, 2026 21:50
Hmbown and others added 2 commits October 9, 2026 18:57
Resolves the one conflict, scripts/check-blocking-calls-budget.json: main re-keyed
and tightened the budget; this keeps main's version and applies the PR's single
change (crates/tui/src/fleet/files.rs path_canonicalize 1 -> 2). No other file
differs from main apart from the PR's own changes.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@Hmbown
Hmbown merged commit 876a3ed into codewhale-hq:main Oct 10, 2026
27 checks passed
Hmbown pushed a commit that referenced this pull request Oct 11, 2026
…isconnect

- The CHANGELOG 0.10.2 section gains Added (`/connect` and `/disconnect`,
  #6950) and Fixed lines for the slices on this branch:
  - git 2.43 /undo, shell cancel, the 30 s background notice and the
    todo_write receipt;
  - goal usage, pet live rows and crisp sprites, #6931 and /constitution;
  - the fixes that already landed on main for #6953, #6954, #6955, #6923,
    #6947 and #6945, plus the Auto-Review guardian budget, skills, MCP
    reconnect, the pet owner idle exit, tool headers, /jobs and the one-row
    gap.
- Contributors: @LIghtJUNction for #6950, #6947 added to @SparkofSpike, and
  #6952 added to @Lstarsky0.
- crates/tui/CHANGELOG.md was regenerated with scripts/sync-changelog.sh;
  the tui crate include_str!s it.
- docs/features.toml: the plugin-oauth-providers summary names /disconnect
  next to /connect (111 characters, under the 120 limit).

Evidence: ./scripts/release/check-versions.sh --require-dated-release
prints "Version state OK: workspace=0.10.2, npm=0.10.2, npm-binary=0.10.2,
lockfile in sync" and "Feature release-note receipts OK: 8 linked issue
reference(s) checked in v0.10.1..HEAD".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXVvReYca2UxzimQtj7Mj
Hmbown pushed a commit that referenced this pull request Oct 11, 2026
…ibutor page

CI on 054b122 failed "Lint & Type Check": web/lib/public-copy.test.ts:167
compares the 0.10.2 CHANGELOG contributor handles with the website credit
arrays and the docs/CONTRIBUTORS.md band. The previous commit (49bd217)
added @LIghtJUNction (#6950) to the CHANGELOG. The website still listed
them under UNRELEASED_CONTRIBUTORS, which RELEASE_CONTRIBUTORS does not
include.

- web/lib/release-credits.ts moves @LIghtJUNction from
  UNRELEASED_CONTRIBUTORS to RELEASE_CONTRIBUTORS, as its own comment
  describes for the release that carries the work. UNRELEASED_CONTRIBUTORS
  is now empty.
- The docs/CONTRIBUTORS.md v0.10.2 band gains the #6950 line, and adds
  #6947 to SparkofSpike's entry and #6952 to Lstarsky0's, matching the
  CHANGELOG.

Evidence: a python3 re-implementation of the test's three handle
extractions gives the same 8 sorted handles for the CHANGELOG, the website
arrays and the doc band (@BX166 @LIghtJUNction @Lstarsky0 @SparkofSpike
@asto18089 @dajiaohuang @gaord @jayanthvee).
`python3 scripts/check-contributor-credit.py`: "Every contributor in the
window is credited on all three surfaces." The vitest run itself is
CI's job on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXVvReYca2UxzimQtj7Mj
SparkofSpike added a commit to SparkofSpike/CodeWhale that referenced this pull request Oct 27, 2026
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:<id> 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
`<sessions>/<id>/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 (codewhale-hq#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.
Hmbown added a commit that referenced this pull request Oct 27, 2026
…7097)

* 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:<id> 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
`<sessions>/<id>/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.

* fix(tui): keep the strict root rule on the workspace fallback

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 `<home>/.codewhale`, the strict opener otherwise. The normal
path (a home directory exists) keeps the junction fix; the narrow fallback
keeps the boundary it had.

---------

Co-authored-by: Hunter Bown <hmbown@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants