Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions anton/cloud_turn/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#: ``<workspace>/.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(
Expand Down Expand Up @@ -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 ``<workspace>/.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"

Expand Down Expand Up @@ -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
# `<workspace>/.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
Expand Down
59 changes: 59 additions & 0 deletions tests/test_cloud_turn_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# `<workspace>/.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

Expand Down
Loading