'.../.ssh/link/' split to an empty basename, so the entry check degenerated
to checking the link's TARGET: get_write_denied_error(entry=True) and
is_protected_path(follow=False) let the delete remove a link inside ~/.ssh,
and _resolve_entry_for_task fell back to full resolution, so a V4A
'*** Delete File: dir/link/' deleted the file the link points to (the
original bug, trailing-slash form).
split_entry() drops trailing separators (keeping a bare '/' or drive root)
before the parent/leaf split and is used by all three entry-mode sites.
is_protected_path(follow=False) now normcases the joined entry, not only
its parent, so a case-variant spelling of the exe/venv entry still matches
on Windows.
patch_tool rewrites every V4A header to the path _resolve_path_for_task
returns, and on a host backend that is Path.resolve(), which follows a
symlink in the last component. For Update/Add that is harmless: the shell
layer reads and writes the target through the link either way. Delete and
Move act on the directory entry, so "*** Delete File: config/local.yaml"
(a link to base.yaml) deleted base.yaml and left the link dangling, and
"*** Move File: current.txt -> previous.txt" renamed the link's target,
both reported as success.
Delete headers and both Move endpoints now resolve their parent directory
only (_resolve_entry_for_task), keeping the final component, so the link
is removed or renamed. The same paths are locked and reported in
files_modified. Update/Add headers are unchanged.
(cherry picked from commit 7b1fe43d30db6015155349b67ca814b340df0e18)
Follow-up to the ssh path fix salvaged from #121686.
- Read the ssh anchor raw (session record, override, TERMINAL_CWD): the
shared workspace-root helper expands ~ on the Hermes host, so
TERMINAL_CWD='~/proj' still resolved into the container home.
- Resolve ~ to the remote home the SSH environment detects at connect,
bringing the environment up through the file tools' own creator
(_get_file_ops, same cwd and cache) when none is live. A failed bring-up
is remembered per container for 30s so one call's several resolutions
don't each retry; a live environment is always used first. SSHEnvironment
now records whether the home was detected, and a guessed /home/<user>
(echo $HOME failed) is not used. Results are
absolute and stable from the first call (read tracking and staleness
checks key on them), and '..' normalizes to the real target: relative
traversal like ../../../etc/x from ~ was refused on main and slipped past
the sensitive-path guard on the PR head.
- If the remote home cannot be detected, an ssh ~-path that climbs above ~
cannot be classified; the write guard refuses it.
- ~user passes through for the remote shell instead of becoming ~/~user.
- coerce_ssh_remote_cwd maps paths under the host subprocess home onto ~/,
except when that home is the OS user's real home.
- The outside-workspace warning compares in the remote namespace (it fired
on every correct relative write when the anchor was ~).
- The backend type is looked up once per resolution again (the PR head did three per local path).
Relative writes and a bare ~ were expanded against the container
subprocess home and then sent to the SSH target, which does not have
that directory.
(cherry picked from commit f4a2549d8276762e53ed2d773dbd4de8c7e74b3b)
With a container terminal backend (docker, singularity, modal, daytona,
vercel_sandbox, container plugins) file-tool paths keep container semantics,
but the checkpoint hook handed them to the host-side CheckpointManager: a path
that does not exist on the host produced a useless snapshot attempt, one that
happens to exist on the host snapshotted the wrong tree, the destructive
terminal branch did the same with the container cwd, and the post-write ledger
hashed the container path on the host so safe restore could trust unrelated
host content. Every failure was swallowed, so a docker user saw "No checkpoints
found for /home/admin" with nothing behind it.
Classify the task's backend the way the file tools do (_uses_container_paths)
and, for container-backed tasks, take no checkpoint and record no ledger entry;
/rollback prints the reason and refuses diff and restore for that session (a
host checkpoint that predates it belongs to another tree), and the
rollback.restore RPC returns the same reason as a failed restore. The refusal
classifies the session's configured backend directly (in the gateway under the
session's own identity and profile scope, as a turn binds them), so it holds
before the first mutation of the session. Local and ssh backends are untouched. This stops the
false protection; it does not add rollback support for containers (translating
bind mounts is a separate contract).
Tests: eight cases in tests/agent/test_tool_executor_checkpoint_paths.py through
the production classifier (a fake docker environment registered for the task, or
the configured backend): missing host path, colliding host tree (POSIX),
destructive terminal command, post-write ledger on a real host file, /rollback
and rollback.restore refusal in a fresh session, the local session still
restoring, and unchanged local behavior. Five fail on main on Windows, where the
collision case is skipped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 97a5709e4980c8f85a5a640e6e5e11fe8f94affa)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.