mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
cdf1091ffc
commit
d436550831
3 files changed
+66
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user