fix(settings): write settings.json through a symlink instead of over it #1250

Merged
coilyco-ops merged 1 commit from aos/claude/sy55 into main 2026-08-26 02:31:48 +00:00
Owner

Closes #1248, with a corrected diagnosis. The issue blamed Path.home() resolving to the session home. That is true but not the cause, and the fix it proposed (refuse to run inside a shadow) would have been wrong.

Actual mechanism

A staged shadow home makes every entry under .claude a symlink back to the host file, so ~/.claude/settings.json inside a session already pointed at /Users/kai/.claude/settings.json. The run should have converged the host.

Both writers ended in os.replace(tmp, path). os.replace swaps the link itself rather than writing through it, so the run severed the symlink, left a real file in the throwaway session home, and printed success while the host kept the settings the run was meant to change.

Reproduced against the pre-fix function:

old code -> link still a symlink: False
old code -> host content: {"theme": "dark"}

Fix

Resolve the path before the atomic write. docs/native-shadow.md already states the contract this restores: an entry that already exists resolves to the host through its symlink, and only a file the session newly creates stays in the session. The writers now also return and report the path that actually took the write, so a caller sees which home it landed in rather than which one it aimed at.

No shadow detection and no new flag. With write-through the hand run does the right thing from inside a session, which is what made the refuse-by-default shape unnecessary.

scripts/install-session-name.py carried the identical bug against the same file and is fixed the same way. Both scripts stay standalone and stdlib-only for the ansible role, so the fix is duplicated rather than factored out, matching how load_settings and write_settings are already duplicated across the pair.

Verification

Four new tests, two per writer: a symlinked path stays a symlink and the target takes the content, and a plain path still gets a plain file. Both fail against the pre-fix code. Full suite at 723 passed, full pre-commit suite green.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

Closes https://forgejo.coilysiren.me/coilyco-flight-deck/agentic-os/issues/1248, with a corrected diagnosis. The issue blamed `Path.home()` resolving to the session home. That is true but not the cause, and the fix it proposed (refuse to run inside a shadow) would have been wrong. ## Actual mechanism A staged shadow home makes every entry under `.claude` a symlink back to the host file, so `~/.claude/settings.json` inside a session already pointed at `/Users/kai/.claude/settings.json`. The run should have converged the host. Both writers ended in `os.replace(tmp, path)`. `os.replace` swaps the link itself rather than writing through it, so the run severed the symlink, left a real file in the throwaway session home, and printed success while the host kept the settings the run was meant to change. Reproduced against the pre-fix function: ``` old code -> link still a symlink: False old code -> host content: {"theme": "dark"} ``` ## Fix Resolve the path before the atomic write. `docs/native-shadow.md` already states the contract this restores: an entry that already exists resolves to the host through its symlink, and only a file the session newly creates stays in the session. The writers now also return and report the path that actually took the write, so a caller sees which home it landed in rather than which one it aimed at. No shadow detection and no new flag. With write-through the hand run does the right thing from inside a session, which is what made the refuse-by-default shape unnecessary. `scripts/install-session-name.py` carried the identical bug against the same file and is fixed the same way. Both scripts stay standalone and stdlib-only for the ansible role, so the fix is duplicated rather than factored out, matching how `load_settings` and `write_settings` are already duplicated across the pair. ## Verification Four new tests, two per writer: a symlinked path stays a symlink and the target takes the content, and a plain path still gets a plain file. Both fail against the pre-fix code. Full suite at 723 passed, full pre-commit suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(settings): write settings.json through a symlink instead of over it
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 7s
ci / ward-doctor (pull_request) Successful in 8s
ci / aos-cli-tests (pull_request) Successful in 24s
ci / gate (pull_request) Successful in 50s
d13c2cd036
Both home-settings writers ended in os.replace against the path they were
given. os.replace swaps the link itself, so a run inside a native session
shadow severed the staged .claude/settings.json symlink, left a real file in
the throwaway session home, and reported success while the host it pointed at
kept the settings the run was meant to change.

A staged shadow home links every host entry precisely so an existing entry
writes through to the host, which docs/native-shadow.md already states as the
contract. Resolving the path before the atomic write restores it, and the
writers now return and report the path that actually took the write, so a
caller can see which home it landed in rather than which one it aimed at.

install-session-name.py carried the identical bug against the same file. The
two scripts stay standalone and stdlib-only for the ansible role, so the fix
is applied in both rather than factored out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
coilyco-ops deleted branch aos/claude/sy55 2026-08-26 02:31:49 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1250
No description provided.