diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py index 07a1dba6..35e16a59 100644 --- a/anton/cloud_turn/session.py +++ b/anton/cloud_turn/session.py @@ -42,6 +42,16 @@ #: Operator/CI override for the mount path (pod-side env var, not request data). _WORKSPACE_PATH_ENV = "ANTON_CLOUD_WORKSPACE_PATH" +#: Pod-side mount of the PROJECT's shared artifacts directory (ENG-2056). The +#: workspace mount is per-CONVERSATION, so the derived +#: ``/.anton/artifacts`` only ever shows a task its own artifacts. +#: The scratchpad controller mounts the project-level artifacts dir as a second +#: mount OUTSIDE the workspace (so it can never land on sys.path) and sets this +#: env var to tell anton where it is. When set, it replaces the derived default +#: so sibling tasks in one project share a single artifacts tree. Same trust +#: posture as _WORKSPACE_PATH_ENV: pod-side config, never from the wire request. +_ARTIFACTS_ROOT_ENV = "ANTON_CLOUD_ARTIFACTS_ROOT" + #: The only tools exposed in a cloud turn: scratchpad + the workspace-scoped #: artifact tools. Everything else core registers is dropped. CLOUD_TOOL_ALLOWLIST = frozenset( @@ -425,6 +435,32 @@ def resolve_trusted_workspace_path() -> Path: return resolved +def resolve_trusted_artifacts_root() -> Path | None: + """Resolve the project-artifacts mount (ENG-2056), or None when not set. + + Reads :data:`_ARTIFACTS_ROOT_ENV`, set by the same scratchpad controller + that sets :data:`_WORKSPACE_PATH_ENV` (pod-side config, never from the wire + request), so it gets the same validation: absolute, no ``..``, then + canonicalised and created. None — desktop, CI, older controllers — leaves + the derived ``/.anton/artifacts`` default untouched. + """ + raw = (os.environ.get(_ARTIFACTS_ROOT_ENV) or "").strip() + if not raw: + return None + if not os.path.isabs(raw): + raise ValueError( + f"trusted artifacts root must be absolute, got {raw!r} " + f"(set {_ARTIFACTS_ROOT_ENV} to an absolute path)" + ) + if ".." in Path(raw).parts: + raise ValueError(f"trusted artifacts root must not contain '..': {raw!r}") + resolved = Path(raw).resolve() + resolved.mkdir(parents=True, exist_ok=True) + if not resolved.is_dir(): + raise ValueError(f"trusted artifacts root is not a directory: {resolved}") + return resolved + + #: Attachments cowork-server stages into the workspace for this conversation. _ATTACHMENTS_DIRNAME = "attachments" @@ -557,6 +593,16 @@ def build_cloud_chat_session(request: TurnRequestV1) -> "ChatSession": if llm.get("coding_model"): settings_kwargs["coding_model"] = llm["coding_model"] settings = AntonSettings(**settings_kwargs) + # ENG-2056: the workspace mount is per-conversation, so deriving + # `/.anton/artifacts` hides sibling tasks' artifacts. When the + # controller mounts the PROJECT's shared artifacts dir (outside the + # workspace, so it can't land on sys.path) and points _ARTIFACTS_ROOT_ENV + # at it, use that instead. Must be set BEFORE resolve_workspace, which only + # derives artifacts_dir when the value is relative and leaves an absolute + # one alone; unset env var keeps today's derivation byte-identical. + artifacts_root = resolve_trusted_artifacts_root() + if artifacts_root is not None: + settings.artifacts_dir = str(artifacts_root) settings.resolve_workspace(str(base)) if request.model: settings.planning_model = request.model diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 42e31e1d..20abcaba 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -19,8 +19,10 @@ from anton.cloud_turn.contract import TurnRequestV1 from anton.cloud_turn.session import ( CLOUD_TOOL_ALLOWLIST, + _ARTIFACTS_ROOT_ENV, _WORKSPACE_PATH_ENV, build_cloud_chat_session, + resolve_trusted_artifacts_root, resolve_trusted_workspace_path, ) from anton.core.backends.local import local_scratchpad_runtime_factory @@ -186,6 +188,63 @@ def test_resolver_rejects_parent_traversal(monkeypatch): resolve_trusted_workspace_path() +# ── project artifacts root (ENG-2056, never from the wire) ────────────────── + +def test_project_artifacts_root_overrides_derived_default(tmp_path, monkeypatch): + # ENG-2056: the workspace mount is per-conversation, so the derived + # `/.anton/artifacts` hides sibling tasks' artifacts. With the + # controller's project-artifacts mount announced via env, the session must + # use it — for the settings AND the workspace the artifact tools read. + root = tmp_path / "project-artifacts" + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, str(root)) + _, cfg = _build(tmp_path, monkeypatch) + assert cfg.settings.artifacts_dir == str(root.resolve()) + assert cfg.workspace.artifacts_dir == root.resolve() + assert cfg.settings.artifacts_dir != str(tmp_path.resolve() / ".anton" / "artifacts") + + +def test_project_artifacts_root_created_when_missing(tmp_path, monkeypatch): + root = tmp_path / "mounts" / "project-artifacts" + assert not root.exists() + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, str(root)) + _build(tmp_path, monkeypatch) + assert root.is_dir() + + +def test_artifacts_default_unchanged_when_env_unset(tmp_path, monkeypatch): + # Desktop and current cloud installs: no env var, behaviour identical to + # before — artifacts derive under the workspace's .anton dir. + monkeypatch.delenv(_ARTIFACTS_ROOT_ENV, raising=False) + _, cfg = _build(tmp_path, monkeypatch) + derived = tmp_path.resolve() / ".anton" / "artifacts" + assert cfg.settings.artifacts_dir == str(derived) + assert cfg.workspace.artifacts_dir == derived + + +def test_artifacts_resolver_unset_or_blank_is_none(monkeypatch): + monkeypatch.delenv(_ARTIFACTS_ROOT_ENV, raising=False) + assert resolve_trusted_artifacts_root() is None + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, " ") + assert resolve_trusted_artifacts_root() is None + + +def test_artifacts_resolver_uses_env(tmp_path, monkeypatch): + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, str(tmp_path)) + assert resolve_trusted_artifacts_root() == tmp_path.resolve() + + +def test_artifacts_resolver_rejects_relative_path(monkeypatch): + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, "not/absolute") + with pytest.raises(ValueError, match="absolute"): + resolve_trusted_artifacts_root() + + +def test_artifacts_resolver_rejects_parent_traversal(monkeypatch): + monkeypatch.setenv(_ARTIFACTS_ROOT_ENV, "/project-artifacts/../etc") + with pytest.raises(ValueError, match=r"\.\."): + resolve_trusted_artifacts_root() + + def test_artifact_tools_cannot_escape_workspace(tmp_path): from anton.core.artifacts.store import ArtifactStore