fix(webapp): never resolve a shared container session's /workspace in its own cwd

With terminal.docker_shared_container_key set, every session reuses one
shared:<key> container, which mounts the cwd of the session that created
it. The previous commit took a cwd-mounted session's /workspace from that
session's own cwd, so a second session at another folder read that folder,
and Save (which own_sandbox_only allowed) overwrote it while the agent's
file in the container's real mount stayed unchanged.

Nothing records which cwd a shared container mounted: shared containers
carry no mount fingerprint label, and a cross-process attach never inspects
the container's mounts. So a session on a shared container reads from the
profile's cwd again, as before the previous commit, and a session Save that
resolves there answers 409 "File is no longer in this session's workspace".
Per-workspace containers still mount the session's own cwd.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-10-25 23:31:32 -04:00
1 parent cdf1091ffc
commit d436550831
3 files changed
+66 -3

No files matched your search

+26 -2
View File
@@ -104,6 +104,26 @@ def _session_mounted_workspace(workspace_key: str, workspace_cwd: str) -> list[P
return _mounted_workspace_cwd(cwd)
def _shared_cwd_mount_guess(workspace_key: str) -> list[Path]:
"""The profile cwd that stands in for a shared container's ``/workspace``, or nothing.
With ``TERMINAL_DOCKER_SHARED_CONTAINER_KEY`` every session reuses one
``shared:<key>`` container, which mounts whatever cwd the session that
created it brought, so a session's own cwd is not its mount. Nothing records
that first cwd: shared containers carry no mount fingerprint label, and a
cross-process attach never inspects the container's mounts. Reads keep the
profile cwd, as before; a session Save there is a guess the caller refuses.
"""
tenv, truthy, persistent = _scope()
if not persistent() or not _WORKSPACE_KEY_RE.fullmatch(workspace_key or ""):
return []
if tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() not in truthy:
return []
if not tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip():
return []
return _mounted_workspace_cwd()
def _default_docker_workspace_host_roots(
session_key: str = "", workspace_key: str = "", workspace_cwd: str = "",
) -> list[Path]:
@@ -112,7 +132,8 @@ def _default_docker_workspace_host_roots(
A cwd mount is tried first. With no workspace digest that mount is the
profile's cwd and the only candidate, so MEDIA delivery (which has no
session) stays cwd-only. A download that names a digest mounts that
session's own cwd (``workspace_cwd``) instead, then tries that session's
session's own cwd (``workspace_cwd``) instead, unless its container is a
shared one (``_shared_cwd_mount_guess``), then tries that session's
sandbox: the container key can be ``default:ws:<digest>`` while
``/workspace`` is still the sandbox directory, because the host cwd was
not bind-mounted when the container was created.
@@ -125,7 +146,10 @@ def _default_docker_workspace_host_roots(
if mounted:
if not _WORKSPACE_KEY_RE.fullmatch(workspace_key or ""):
return _mounted_workspace_cwd()
roots.extend(_session_mounted_workspace(workspace_key, workspace_cwd))
if tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip():
roots.extend(_shared_cwd_mount_guess(workspace_key))
else:
roots.extend(_session_mounted_workspace(workspace_key, workspace_cwd))
for root in _docker_persistent_sandbox_roots(session_key, "workspace", workspace_key):
if root not in roots:
roots.append(root)
+12 -1
View File
@@ -154,7 +154,9 @@ def _translate_agent_path(
translated = _translate_docker_container_media_path(
candidate, quiet=True, workspace_key=workspace_key, workspace_cwd=workspace_cwd,
)
if own_sandbox_only and translated is not None and _in_fallback_sandbox(translated, workspace_key):
if own_sandbox_only and translated is not None and (
_in_fallback_sandbox(translated, workspace_key) or _in_shared_cwd_mount_guess(translated, workspace_key)
):
raise HTTPException(status_code=409, detail=SANDBOX_COPY_MISSING)
return translated
@@ -176,6 +178,15 @@ def _in_fallback_sandbox(target: Path, workspace_key: str) -> bool:
return any(_path_is_under(sandbox, target) for sandbox in fallbacks)
def _in_shared_cwd_mount_guess(target: Path, workspace_key: str) -> bool:
"""Whether *target* is in the cwd a shared container's ``/workspace`` is only assumed to mount:
that container mounts its first session's cwd, which nothing records, so a Save there could
change a file the agent never sees."""
from gateway.platforms.docker_media_paths import _shared_cwd_mount_guess
return any(_path_is_under(root, target) for root in _shared_cwd_mount_guess(workspace_key))
@contextmanager
def _agent_path_scope(profile_home: Path | None = None) -> Iterator[None]:
"""Bind the profile whose sandbox a container path names: its home and its terminal policy.
@@ -325,6 +325,34 @@ def test_a_cwd_mounted_session_reads_and_saves_its_own_workspace(client, bound,
assert _mount_notes(bound) == {"workspace": "edited", "profile_cwd": "profile note"}
@pytest.mark.parametrize("path", ["/workspace/note.txt", "file:///workspace/note.txt"])
def test_a_session_on_a_shared_container_never_reads_or_saves_its_unmounted_workspace(
client, bound, monkeypatch, path,
):
"""``terminal.docker_shared_container_key``: every session reuses ONE ``shared:<key>`` container,
which mounts the cwd of the session that created it (P), so ``sess-ws``'s own W is not its
``/workspace``. Nothing records P, so reads keep the profile cwd and Save refuses."""
from gateway.session_policy import unbind_turn_workspace
from tools.terminal_tool import _resolve_container_task_id, register_task_env_overrides, workspace_key_for
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team")
first = str(bound["profile_cwd"])
register_task_env_overrides("sess-first", {
"cwd": first, "cwd_source": "session", "workspace_key": workspace_key_for(first),
})
try:
assert _resolve_container_task_id("sess-first") == _resolve_container_task_id("sess-ws") == "shared:team"
read = client.get("/api/fs/read-text", params={"path": path, "session_id": "sess-ws"})
saved = client.post("/api/fs/write-text", json={"path": path, "content": "edited", "session_id": "sess-ws"})
finally:
unbind_turn_workspace("sess-first")
assert (read.status_code, read.json()["text"]) == (200, "profile note"), read.text
assert read.json()["path"] == str(bound["profile_cwd"] / "note.txt")
assert (saved.status_code, saved.json()) == (409, {"detail": "File is no longer in this session's workspace"})
assert _mount_notes(bound) == {"workspace": "workspace note", "profile_cwd": "profile note"}
def test_a_cwd_mounted_session_without_a_workspace_binding_keeps_the_profile_cwd(client, cwd_mount):
for params in ({"session_id": "sess-container"}, {}):
read = client.get("/api/fs/read-text", params={"path": "/workspace/note.txt", **params})