From d4365508317df46e14b739a8fb5f4076a2931b9c Mon Sep 17 00:00:00 2001 From: unsupportedpastels Date: Mon, 26 Oct 2026 03:01:24 +0000 Subject: [PATCH] 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: 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. --- gateway/platforms/docker_media_paths.py | 28 +++++++++++++++++-- hermes_cli/web_server_files.py | 13 ++++++++- .../test_web_server_fs_session_workspace.py | 28 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/docker_media_paths.py b/gateway/platforms/docker_media_paths.py index 2034a39754..22be9267c4 100644 --- a/gateway/platforms/docker_media_paths.py +++ b/gateway/platforms/docker_media_paths.py @@ -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:`` 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:`` 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) diff --git a/hermes_cli/web_server_files.py b/hermes_cli/web_server_files.py index 06f07f7a68..d61bd65e2b 100644 --- a/hermes_cli/web_server_files.py +++ b/hermes_cli/web_server_files.py @@ -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. diff --git a/tests/hermes_cli/test_web_server_fs_session_workspace.py b/tests/hermes_cli/test_web_server_fs_session_workspace.py index a7567e90c1..cde401b110 100644 --- a/tests/hermes_cli/test_web_server_fs_session_workspace.py +++ b/tests/hermes_cli/test_web_server_fs_session_workspace.py @@ -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:`` 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})