From 5905517cc809ed9e13b1b7dcc84ce2f938ef4a76 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:50:51 +0200 Subject: [PATCH 01/11] Initial entrypoint --- anton/cloud_turn/__init__.py | 38 +++++++ anton/cloud_turn/__main__.py | 207 +++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 anton/cloud_turn/__init__.py create mode 100644 anton/cloud_turn/__main__.py diff --git a/anton/cloud_turn/__init__.py b/anton/cloud_turn/__init__.py new file mode 100644 index 00000000..bb786fbc --- /dev/null +++ b/anton/cloud_turn/__init__.py @@ -0,0 +1,38 @@ +"""Cloud turn runner: run one full anton turn inside a sandbox pod. + +`python -m anton.cloud_turn` reads a :class:`TurnRequestV1` as JSON on stdin, +runs the turn to completion against the mounted workspace, and emits versioned +:class:`TurnEventV1` records as JSONL on stdout (diagnostics go to stderr). It is +the cloud counterpart of the in-process host harness: the same ChatSession, but +built cloud-safe (see :mod:`anton.cloud_turn.session`) and driven headlessly. +""" + +from anton.cloud_turn.protocol import ( + PROTOCOL_VERSION, + CapabilitiesV1, + ErrorCodeV1, + MessageV1, + TurnCompletedV1, + TurnErrorV1, + TurnEventV1, + TurnFailedV1, + TurnRequestV1, + TurnStartedV1, + event_line, + parse_request, +) + +__all__ = [ + "PROTOCOL_VERSION", + "CapabilitiesV1", + "TurnRequestV1", + "MessageV1", + "TurnEventV1", + "TurnStartedV1", + "TurnCompletedV1", + "TurnFailedV1", + "TurnErrorV1", + "ErrorCodeV1", + "parse_request", + "event_line", +] diff --git a/anton/cloud_turn/__main__.py b/anton/cloud_turn/__main__.py new file mode 100644 index 00000000..a625bf8b --- /dev/null +++ b/anton/cloud_turn/__main__.py @@ -0,0 +1,207 @@ +"""`python -m anton.cloud_turn` — the pod entrypoint. + +Process contract: + stdin : a single TurnRequestV1 JSON document, EOF-terminated (bounded read) + stdout: versioned JSONL TurnEventV1 records ONLY — nothing else, ever + stderr: diagnostic logs + full tracebacks + exit : 0 = turn completed + 2 = request could not be validated (no turn.started emitted) + 3 = valid request reached execution but failed + +stdout isolation is enforced at the OS file-descriptor level (see +``_isolated_protocol_stdout``): the original FD 1 is duplicated to a private, +non-inheritable descriptor used ONLY for protocol events, and process FD 1 is +redirected to stderr. So ``print()``, ``os.write(1, ...)``, native-library +writes, and child-process stdout can never corrupt the events stream. + +`python -m anton.cloud_turn capabilities` prints a stable machine-readable +manifest of what this milestone actually enables. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import sys + +from anton.cloud_turn import protocol +from anton.cloud_turn.errors import classify_error +from anton.cloud_turn.protocol import ( + SUPPORTED_CONTENT_BLOCK_TYPES, + SUPPORTED_MESSAGE_ROLES, + SUPPORTED_PROTOCOL_VERSIONS, + TurnFailedV1, + event_line, + parse_request, +) +from anton.cloud_turn.runner import run_turn + +logger = logging.getLogger(__name__) + +EXIT_OK = 0 +EXIT_BAD_REQUEST = 2 +EXIT_FAILED = 3 + +#: The cloud-turn implementation version (independent of the wire protocol +#: version). Bump on behavioural changes to the process boundary. +CLOUD_TURN_IMPL_VERSION = "1.0" + + +def _capabilities() -> dict: + """Stable, machine-readable manifest of what THIS milestone enables. + + Reports only behaviour that is actually on — not merely code that exists. + """ + from anton.cloud_turn.session import CLOUD_TOOL_ALLOWLIST, _MODEL_ALLOWLIST_ENV + + try: + import anton + + anton_version = getattr(anton, "__version__", "unknown") + except Exception: + anton_version = "unknown" + + provider_sdks: dict[str, bool] = {} + for name in ("anthropic", "openai"): + try: + __import__(name) + provider_sdks[name] = True + except Exception: + provider_sdks[name] = False + + model_allowlist = [ + m.strip() for m in os.environ.get(_MODEL_ALLOWLIST_ENV, "").split(",") if m.strip() + ] + + return { + "ok": True, + "entrypoint": "anton.cloud_turn", + "cloud_turn_version": CLOUD_TURN_IMPL_VERSION, + "anton_version": anton_version, + "protocol_versions": list(SUPPORTED_PROTOCOL_VERSIONS), + "tools": sorted(CLOUD_TOOL_ALLOWLIST), + "content_block_types": sorted(SUPPORTED_CONTENT_BLOCK_TYPES), + "message_roles": sorted(SUPPORTED_MESSAGE_ROLES), + "model_override": { + # A request may request a model, but only one on the trusted pod-side + # allowlist is honoured; empty allowlist = default-deny. + "supported": True, + "policy": "trusted_allowlist", + "default": "deny", + "allowlist_size": len(model_allowlist), + }, + "memory": {"personal": False, "workspace": False}, + "connectors": False, + "data_vault": False, + "scratchpad_execution": True, + "web_tools": False, + "provider_sdks": provider_sdks, + # The capability FLAG names (all default OFF this milestone). + "capabilities": list(protocol.CapabilitiesV1.model_fields.keys()), + } + + +@contextlib.contextmanager +def _isolated_protocol_stdout(): + """OS-level stdout isolation. Yields an ``emit(event)`` that writes JSONL to + the saved protocol descriptor; everything else (FD 1) is redirected to + stderr. + + Works wherever ``os.dup2`` is available (POSIX + Windows for FDs 0/1/2), + which covers the Linux pod, macOS dev, and CI. + """ + sys.stdout.flush() + sys.stderr.flush() + stderr_fd = sys.stderr.fileno() + + # Private, non-inheritable copy of the real protocol channel (original FD 1). + protocol_fd = os.dup(1) + os.set_inheritable(protocol_fd, False) + # Redirect process FD 1 → stderr: any write to fd 1 (print, os.write(1, …), + # native libs, inherited child stdout) now lands on stderr, never protocol. + os.dup2(stderr_fd, 1) + saved_sys_stdout = sys.stdout + sys.stdout = sys.stderr # Python-level print() → stderr too + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + + def emit(event) -> None: + data = (event_line(event) + "\n").encode("utf-8") + view = memoryview(data) + while view: # os.write may do a partial write; loop until fully flushed + n = os.write(protocol_fd, view) + view = view[n:] + + try: + yield emit + finally: + # Flush diagnostics, then close the protocol descriptor. os.write went + # straight to the kernel, so there is no userspace buffer to lose. + with contextlib.suppress(Exception): + sys.stderr.flush() + sys.stdout = saved_sys_stdout + os.close(protocol_fd) + + +def _safe_ids(raw: str) -> tuple[str | None, str | None]: + """Best-effort identifiers from an UNVALIDATED request body. + + Returns ``None`` for any id that is absent or not a string — we never invent + a valid-looking identifier for a request we could not validate (malformed + JSON, empty stdin, missing/mistyped ids all yield ``None``).""" + try: + data = json.loads(raw) + except Exception: + return None, None + if not isinstance(data, dict): + return None, None + rid = data.get("run_id") + aid = data.get("attempt_id") + return ( + rid if isinstance(rid, str) else None, + aid if isinstance(aid, str) else None, + ) + + +def _run(raw: str, emit, session_builder=None) -> int: + """Parse the request and drive the turn. The testable core — no FD or stdin + handling, so unit tests can inject ``raw`` + a capturing ``emit``.""" + try: + request = parse_request(raw) + except Exception as exc: + # Invalid request: one structured failure event, NO turn.started + # (sequence 1, the only event of the run). Ids are None when the request + # supplied none usable. + run_id, attempt_id = _safe_ids(raw) + logger.exception("invalid cloud-turn request run_id=%s", run_id) + emit( + TurnFailedV1( + run_id=run_id, + attempt_id=attempt_id, + sequence=1, + error=classify_error(exc), + ) + ) + return EXIT_BAD_REQUEST + return asyncio.run(run_turn(request, emit, session_builder=session_builder)) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + + if argv and argv[0] == "capabilities": + print(json.dumps(_capabilities())) + return 0 + + with _isolated_protocol_stdout() as emit: + # Bounded read: never pull an unbounded request into memory. Read at most + # MAX_REQUEST_BYTES+1 (chars ≤ bytes in UTF-8); parse_request enforces + # the precise byte limit. + raw = sys.stdin.read(protocol.MAX_REQUEST_BYTES + 1) + return _run(raw, emit) + + +if __name__ == "__main__": + sys.exit(main()) From 6dc787a2be7845c8e2f7ca027a53082d5e78509b Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:50:59 +0200 Subject: [PATCH 02/11] Turn errors and fixtures --- anton/cloud_turn/errors.py | 107 +++++++++++++++++++++++++++ anton/cloud_turn/fixtures.py | 137 +++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 anton/cloud_turn/errors.py create mode 100644 anton/cloud_turn/fixtures.py diff --git a/anton/cloud_turn/errors.py b/anton/cloud_turn/errors.py new file mode 100644 index 00000000..55b57d30 --- /dev/null +++ b/anton/cloud_turn/errors.py @@ -0,0 +1,107 @@ +"""Typed cloud-turn errors + central exception → :class:`TurnErrorV1` mapping. + +One place decides the wire ``code``/``retryable`` for every failure, so the +mapping can't drift. Wire messages are short and credential-scrubbed; full +tracebacks stay on stderr (the runner logs them via ``logger.exception``). +""" + +from __future__ import annotations + +from anton.cloud_turn.protocol import ErrorCodeV1, TurnErrorV1 +from anton.utils.datasources import scrub_credentials + +#: Wire error messages are truncated to keep them short and log-safe. +MAX_ERROR_MESSAGE_CHARS = 300 + + +class CloudTurnError(Exception): + """Base for failures the runner raises itself. Each subclass pins a stable + wire code + retryable flag.""" + + code: ErrorCodeV1 = ErrorCodeV1.INTERNAL_TURN_FAILURE + retryable: bool = False + + +class InvalidRequestError(CloudTurnError): + code = ErrorCodeV1.INVALID_REQUEST + retryable = False + + +class UnsupportedProtocolVersionError(CloudTurnError): + code = ErrorCodeV1.UNSUPPORTED_PROTOCOL_VERSION + retryable = False + + +class UnsupportedCapabilityError(CloudTurnError): + code = ErrorCodeV1.UNSUPPORTED_CAPABILITY + retryable = False + + +class UnsupportedModelError(CloudTurnError): + code = ErrorCodeV1.UNSUPPORTED_MODEL + retryable = False + + +class DeadlineExceededError(CloudTurnError): + code = ErrorCodeV1.DEADLINE_EXCEEDED + retryable = True + + +def _clean(exc: Exception) -> str: + text = scrub_credentials(f"{type(exc).__name__}: {exc}") + if len(text) > MAX_ERROR_MESSAGE_CHARS: + text = text[: MAX_ERROR_MESSAGE_CHARS - 1] + "…" + return text + + +def _looks_like_auth(message: str) -> bool: + low = message.lower() + return any(s in low for s in ("api key", "unauthorized", "authentication", "401")) + + +def classify_error(exc: Exception) -> TurnErrorV1: + """Map any exception onto a structured :class:`TurnErrorV1`. + + Order matters: several Anton provider errors subclass ``ConnectionError``, + so the specific types are checked before the generic one. + """ + # Errors the runner raises itself already carry code + retryable. + if isinstance(exc, CloudTurnError): + return TurnErrorV1(code=exc.code, message=_clean(exc), retryable=exc.retryable) + + # Anton provider/LLM exceptions (imported lazily so importing this module + # never drags in the provider stack). + from anton.core.llm.provider import ( + ContextOverflowError, + ModelUnavailableError, + ProviderOverloadedError, + TokenLimitExceeded, + TransientProviderError, + ) + + if isinstance(exc, ModelUnavailableError): + # Gateway rejected the model (plan tier / kill switch). + return TurnErrorV1( + code=ErrorCodeV1.UNSUPPORTED_MODEL, message=_clean(exc), retryable=False + ) + if isinstance(exc, (TransientProviderError, ProviderOverloadedError)): + return TurnErrorV1( + code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=True + ) + if isinstance(exc, (TokenLimitExceeded, ContextOverflowError)): + # Quota / context-length — a bare retry of the identical request won't fix it. + return TurnErrorV1( + code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=False + ) + if isinstance(exc, ConnectionError): + if _looks_like_auth(str(exc)): + return TurnErrorV1( + code=ErrorCodeV1.MODEL_AUTH_FAILURE, message=_clean(exc), retryable=False + ) + return TurnErrorV1( + code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=True + ) + + return TurnErrorV1( + code=ErrorCodeV1.INTERNAL_TURN_FAILURE, message=_clean(exc), retryable=False + ) diff --git a/anton/cloud_turn/fixtures.py b/anton/cloud_turn/fixtures.py new file mode 100644 index 00000000..3f12c814 --- /dev/null +++ b/anton/cloud_turn/fixtures.py @@ -0,0 +1,137 @@ +"""Canonical V1 protocol JSON fixtures for downstream consumers. + +Derived from the real Pydantic models so they can't drift from the wire +contract. Committed under ``schemas/fixtures/`` for scratchpad-controller and +cowork-server tests to consume. ``tests/test_cloud_turn_schemas.py`` regenerates +and diffs them, and validates each against the exported JSON Schema. + +Run ``python -m anton.cloud_turn.fixtures`` to rewrite the committed files. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from anton.cloud_turn.protocol import ( + ErrorCodeV1, + MessageV1, + TurnCompletedV1, + TurnFailedV1, + TurnRequestV1, + TurnStartedV1, + TurnErrorV1, +) +from anton.cloud_turn.schema_export import SCHEMA_DIR + +FIXTURE_DIR = SCHEMA_DIR / "fixtures" + + +def _request_text_only() -> TurnRequestV1: + return TurnRequestV1( + run_id="run_1", attempt_id="att_1", conversation_id="conv_1", + organization_id="acme", user_id="u1", workspace_id="proj_1", + input="What is 2 + 2?", + ) + + +def _request_with_history() -> TurnRequestV1: + return TurnRequestV1( + run_id="run_2", attempt_id="att_1", conversation_id="conv_1", + input="And multiply that by 10.", + history=[ + MessageV1(role="user", content="What is 2 + 2?"), + MessageV1(role="assistant", content="2 + 2 = 4."), + ], + ) + + +def _started() -> TurnStartedV1: + return TurnStartedV1(run_id="run_1", attempt_id="att_1", sequence=1) + + +def _completed_text_only() -> TurnCompletedV1: + return TurnCompletedV1( + run_id="run_1", attempt_id="att_1", sequence=2, + final_text="2 + 2 = 4.", + output_messages=[MessageV1(role="assistant", content="2 + 2 = 4.")], + ) + + +def _completed_tool_using() -> TurnCompletedV1: + return TurnCompletedV1( + run_id="run_3", attempt_id="att_1", sequence=2, + final_text="The file has 3 lines.", + output_messages=[ + MessageV1(role="assistant", content=[ + {"type": "text", "text": "Let me count the lines."}, + {"type": "tool_use", "id": "t1", "name": "scratchpad", + "input": {"action": "exec", "name": "main", + "code": "print(sum(1 for _ in open('data.txt')))"}}, + ]), + MessageV1(role="user", content=[ + {"type": "tool_result", "tool_use_id": "t1", "content": "[output]\n3"}, + ]), + MessageV1(role="assistant", content="The file has 3 lines."), + ], + ) + + +def _failed() -> TurnFailedV1: + return TurnFailedV1( + run_id="run_4", attempt_id="att_1", sequence=2, + error=TurnErrorV1( + code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, + message="ConnectionError: the model provider is momentarily overloaded.", + retryable=True, + ), + ) + + +def _failed_invalid_request_null_ids() -> TurnFailedV1: + return TurnFailedV1( + run_id=None, attempt_id=None, sequence=1, + error=TurnErrorV1( + code=ErrorCodeV1.INVALID_REQUEST, + message="InvalidRequestError: not valid JSON", + retryable=False, + ), + ) + + +#: name → model instance. The filename is ``.json``. +FIXTURES = { + "request-text-only": _request_text_only, + "request-with-history": _request_with_history, + "event-turn-started": _started, + "event-turn-completed-text": _completed_text_only, + "event-turn-completed-tool-using": _completed_tool_using, + "event-turn-failed": _failed, + "event-turn-failed-invalid-request-null-ids": _failed_invalid_request_null_ids, +} + + +def expected_files() -> dict[Path, str]: + """Map of committed fixture path → expected serialized JSON.""" + out: dict[Path, str] = {} + for name, factory in FIXTURES.items(): + model = factory() + payload = json.loads(model.model_dump_json()) + out[FIXTURE_DIR / f"{name}.json"] = ( + json.dumps(payload, indent=2, sort_keys=True) + "\n" + ) + return out + + +def write_fixtures() -> list[Path]: + FIXTURE_DIR.mkdir(parents=True, exist_ok=True) + written = [] + for path, content in expected_files().items(): + path.write_text(content, encoding="utf-8") + written.append(path) + return written + + +if __name__ == "__main__": + for p in write_fixtures(): + print(f"wrote {p}") From 6bc58228ae8be46ec1fb8f9b3188bc96ebb13fe7 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:51:10 +0200 Subject: [PATCH 03/11] Turn protocol --- anton/cloud_turn/messages.py | 117 ++++++++++++++ anton/cloud_turn/protocol.py | 292 +++++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 anton/cloud_turn/messages.py create mode 100644 anton/cloud_turn/protocol.py diff --git a/anton/cloud_turn/messages.py b/anton/cloud_turn/messages.py new file mode 100644 index 00000000..fd047e82 --- /dev/null +++ b/anton/cloud_turn/messages.py @@ -0,0 +1,117 @@ +"""Explicit conversion: Anton internal messages → V1 wire messages. + +Anton stores Anthropic-style dicts (``{"role", "content"}`` where content is a +string or a list of ``text`` / ``tool_use`` / ``tool_result`` blocks). We never +put raw internal dicts on the wire — everything goes through :class:`MessageV1` +validation here, so the wire shape is enforced and any unsupported block type +fails loudly rather than being emitted unchecked. + +V1 limitation: only ``text`` / ``tool_use`` / ``tool_result`` blocks are +representable (the cloud tool surface — scratchpad + artifacts — produces only +these). An ``image`` or other block type raises :class:`MessageConversionError` +instead of being silently dropped. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import ValidationError + +from anton.cloud_turn.protocol import ContentBlockV1, MessageV1 + + +class MessageConversionError(Exception): + """An Anton message could not be represented in the V1 wire shape.""" + + +# ── wire → Anton (input direction) ────────────────────────────────────────── +# The request's typed models must become the Anthropic-style dicts Anton's +# ChatSession/turn_stream consume. model_dump() yields exactly that shape +# ({"type": ...} blocks, string content preserved). + + +def to_anton_input(value: "str | list[ContentBlockV1]") -> "str | list[dict]": + """Convert the request's ``input`` to what ``turn_stream`` expects.""" + if isinstance(value, str): + return value + return [block.model_dump() for block in value] + + +def to_anton_history(history: list[MessageV1]) -> list[dict[str, Any]]: + """Convert the request's typed input history to Anton history dicts.""" + return [msg.model_dump() for msg in history] + + +def _to_wire_message(msg: dict) -> MessageV1: + try: + return MessageV1.model_validate(msg) + except ValidationError as exc: + raise MessageConversionError( + f"message not representable in V1: {exc}" + ) from exc + + +def turn_output_messages( + history: list[dict], pre_turn_messages: list[dict] +) -> list[MessageV1]: + """Return only the messages GENERATED during the current turn. + + NOT length-based slicing: ``ChatSession.turn_stream`` can rewrite history + mid-turn — ``_summarize_history`` reassigns the list, collapsing the old + prefix into a *new* summary message (plus an "Understood" separator), and a + tool-result "seal" can ``insert`` a message. A stored index would then point + at the wrong place. + + Instead we anchor on object identity. ``pre_turn_messages`` is the list of + message objects captured BEFORE the turn (``list(session.history)``). The + caller MUST keep it alive across the turn: holding the references guarantees + those objects can't be garbage-collected and have their ``id()`` recycled by + a message created during the turn (compaction frees the collapsed prefix — + without live references its ids get reused, causing false matches). + + The current-turn region is the suffix of the final history after the LAST + message whose identity was present pre-turn (the most recent surviving input + message). Compaction keeps a suffix of the input turns as the anchor and + prepends the summary/separator BEFORE them, so those artifacts fall outside + the region; a seal-inserted tool_result lands after the current assistant + and stays inside it. We then drop the leading user echo (this turn's input, + which cowork-server already owns) and start at the first assistant message. + + Assumption (documented): the input history is well-formed — a compaction + summary only ever precedes surviving input messages, and the current user + input does not merge into a prior history entry (input ends with an + assistant message or is empty). A message compacted away mid-turn is, by + design, absent from the returned record. + """ + pre_turn_ids = {id(m) for m in pre_turn_messages} + # Anchor: index just past the last surviving pre-turn (input) message. + boundary = 0 + for i in range(len(history) - 1, -1, -1): + if id(history[i]) in pre_turn_ids: + boundary = i + 1 + break + generated = history[boundary:] + # Drop the leading input echo: start at the first assistant message. Tool + # results (also user role) are interior — they follow an assistant tool_use. + start = None + for i, m in enumerate(generated): + if m.get("role") == "assistant": + start = i + break + if start is None: + return [] # no assistant output produced this turn + return [_to_wire_message(m) for m in generated[start:]] + + +def final_assistant_text(messages: list[MessageV1]) -> str: + """Concatenated text of the last assistant message (empty if none).""" + for msg in reversed(messages): + if msg.role != "assistant": + continue + if isinstance(msg.content, str): + return msg.content + return "".join( + block.text for block in msg.content if getattr(block, "type", None) == "text" + ) + return "" diff --git a/anton/cloud_turn/protocol.py b/anton/cloud_turn/protocol.py new file mode 100644 index 00000000..963c1f38 --- /dev/null +++ b/anton/cloud_turn/protocol.py @@ -0,0 +1,292 @@ +"""Versioned wire contract between the scratchpad-controller and the pod runner. + +The controller sends a :class:`TurnRequestV1` as a single JSON document on the +pod's stdin (EOF-terminated). The runner replies with :class:`TurnEventV1` +records as JSONL on stdout — one JSON object per line, nothing else. Both sides +pin ``protocol_version``. + +Design rules: +- **Data only.** The request is inert; the runner never interpolates request + fields into code. +- **Typed, not free-form.** Roles and content-block types are enumerated; an + unsupported shape is rejected with a structured error, never coerced. +- **Only implemented fields exist here.** Speculative fields (artifact + manifests, usage, workspace checkpoints, file history) are intentionally + absent until they are implemented and tested. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Any, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +#: Bump when the request/event shape changes incompatibly. +PROTOCOL_VERSION = 1 + +# ── Validation limits (item 3) — documented defaults, not scattered literals ── +#: Max messages in the request's input history. +MAX_HISTORY_MESSAGES = 1000 +#: Max size of the whole request JSON document, in bytes. +MAX_REQUEST_BYTES = 10 * 1024 * 1024 +#: Max characters in a single text (or string) content value. +MAX_TEXT_BLOCK_CHARS = 1_000_000 + + +def _check_text_len(value: str) -> str: + if len(value) > MAX_TEXT_BLOCK_CHARS: + raise ValueError( + f"text block exceeds {MAX_TEXT_BLOCK_CHARS} chars (got {len(value)})" + ) + return value + + +# ── Content blocks ──────────────────────────────────────────────────────────── +# V1 supports exactly these three block types — the surface Anton's cloud tools +# (scratchpad + artifacts) actually produce/consume. Image and any other block +# type are NOT supported in V1 (see module docs / conversion limitation). + + +class TextBlockV1(BaseModel): + type: Literal["text"] = "text" + text: str + + _v = field_validator("text")(_check_text_len) + + +class ToolUseBlockV1(BaseModel): + type: Literal["tool_use"] = "tool_use" + id: str + name: str + input: dict[str, Any] = Field(default_factory=dict) + + +class ToolResultBlockV1(BaseModel): + type: Literal["tool_result"] = "tool_result" + tool_use_id: str + #: A string result, or a list of text blocks. (Cloud tools return strings.) + content: str | list[TextBlockV1] + is_error: bool = False + + @field_validator("content") + @classmethod + def _content_len(cls, v): + if isinstance(v, str): + return _check_text_len(v) + return v + + +ContentBlockV1 = Annotated[ + Union[TextBlockV1, ToolUseBlockV1, ToolResultBlockV1], + Field(discriminator="type"), +] + + +class MessageV1(BaseModel): + """One persistable conversation message. Tool results are carried in + ``user``-role messages (Anthropic convention).""" + + role: Literal["user", "assistant"] + content: str | list[ContentBlockV1] + + @field_validator("content") + @classmethod + def _content_len(cls, v): + if isinstance(v, str): + return _check_text_len(v) + return v + + +#: Supported protocol versions (single-element in V1). Derived here so the +#: capabilities manifest and schema export share one source of truth. +SUPPORTED_PROTOCOL_VERSIONS = (PROTOCOL_VERSION,) +#: The content-block ``type`` discriminators V1 represents, derived from models. +SUPPORTED_CONTENT_BLOCK_TYPES = tuple( + m.model_fields["type"].default + for m in (TextBlockV1, ToolUseBlockV1, ToolResultBlockV1) +) +#: Message roles V1 accepts. +SUPPORTED_MESSAGE_ROLES = ("user", "assistant") + + +# ── Capabilities ──────────────────────────────────────────────────────────── + + +class CapabilitiesV1(BaseModel): + """Feature gates for one cloud turn. Everything unsafe in a shared, + headless pod is OFF by default and enabled only once it has a cloud-safe + implementation.""" + + model_config = ConfigDict(extra="forbid") + + personal_memory: bool = False + connectors: bool = False + local_data_vault: bool = False + interactive_tools: bool = False + local_file_history: bool = False + dotenv_loading: bool = False + + +# ── Request ─────────────────────────────────────────────────────────────────── + + +class TurnRequestV1(BaseModel): + """One turn to run in the pod. Sent as JSON on stdin.""" + + model_config = ConfigDict(extra="forbid") + + protocol_version: Literal[1] = PROTOCOL_VERSION + #: Stable per logical turn — the idempotency key (a redelivery reuses it). + run_id: str + #: Per delivery attempt. + attempt_id: str + conversation_id: str + #: Authoritative identity for keying/attribution; producers may not spoof it. + organization_id: str | None = None + user_id: str | None = None + #: (org, workspace) key input; maps to a cowork project. + workspace_id: str | None = None + # No workspace path on the wire — the mount is trusted pod-side config + # (see ``anton.cloud_turn.session.resolve_trusted_workspace_path``). + #: The user's message for THIS turn — text or typed content blocks. + input: str | list[ContentBlockV1] + #: DB-authoritative ordered history (immutable input). The pod never loads + #: its own history; cowork-server owns persistence. + history: list[MessageV1] = Field(default_factory=list) + #: Optional model override. Honoured only if in the pod's trusted model + #: allowlist (default: none), else rejected — see cloud_turn.session. + model: str | None = None + capabilities: CapabilitiesV1 = Field(default_factory=CapabilitiesV1) + #: Absolute deadline as a Unix timestamp in milliseconds. The runner turns + #: this into a remaining-time soft timeout at startup; the controller's pod + #: timeout is the hard external backstop. None = no inner deadline. + deadline_unix_ms: int | None = None + + @field_validator("history") + @classmethod + def _history_len(cls, v: list[MessageV1]) -> list[MessageV1]: + if len(v) > MAX_HISTORY_MESSAGES: + raise ValueError( + f"history exceeds {MAX_HISTORY_MESSAGES} messages (got {len(v)})" + ) + return v + + +# ── Structured errors (item 4) ────────────────────────────────────────────── + + +class ErrorCodeV1(str, Enum): + """Stable V1 failure codes. Values are the wire strings.""" + + INVALID_REQUEST = "invalid_request" + UNSUPPORTED_PROTOCOL_VERSION = "unsupported_protocol_version" + UNSUPPORTED_CAPABILITY = "unsupported_capability" + UNSUPPORTED_MODEL = "unsupported_model" + DEADLINE_EXCEEDED = "deadline_exceeded" + MODEL_AUTH_FAILURE = "model_auth_failure" + MODEL_PROVIDER_FAILURE = "model_provider_failure" + INTERNAL_TURN_FAILURE = "internal_turn_failure" + + +class TurnErrorV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: ErrorCodeV1 + #: Short, credential-scrubbed. Full tracebacks stay on stderr only. + message: str + retryable: bool = False + + +# ── Events ──────────────────────────────────────────────────────────────────── + + +class _EventBaseV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + protocol_version: Literal[1] = PROTOCOL_VERSION + # Nullable ONLY for the pre-validation failure path: a malformed/empty + # request may carry no valid identifier, and we must not invent a + # valid-looking one. ``None`` means "the request could not be validated and + # supplied no usable identifier". Every event for a VALID request always + # carries real string ids. + run_id: str | None + attempt_id: str | None + #: 1-based monotonic position within the run (started=1, terminal=2). + sequence: int + + +class TurnStartedV1(_EventBaseV1): + """Emitted once, before work begins. Not terminal.""" + + kind: Literal["turn.started"] = "turn.started" + + +class TurnCompletedV1(_EventBaseV1): + """Terminal success.""" + + kind: Literal["turn.completed"] = "turn.completed" + #: The final assistant message's text. + final_text: str + #: Messages GENERATED during this turn (assistant text, tool calls, tool + #: results, final assistant message), in order. Never the input history. + output_messages: list[MessageV1] = Field(default_factory=list) + + +class TurnFailedV1(_EventBaseV1): + """Terminal failure.""" + + kind: Literal["turn.failed"] = "turn.failed" + error: TurnErrorV1 + + +TurnEventV1 = Annotated[ + Union[TurnStartedV1, TurnCompletedV1, TurnFailedV1], + Field(discriminator="kind"), +] + +TERMINAL_KINDS = frozenset({"turn.completed", "turn.failed"}) + + +def event_line(event: TurnStartedV1 | TurnCompletedV1 | TurnFailedV1) -> str: + """Serialize one event to a single JSONL line (no trailing newline).""" + return event.model_dump_json() + + +def parse_request(raw: str) -> TurnRequestV1: + """Parse + validate a TurnRequestV1 from a JSON string. + + Raises typed :mod:`anton.cloud_turn.errors` exceptions (imported lazily to + avoid an import cycle): size / JSON / protocol-version / shape failures each + map to a distinct structured error code. + """ + import json + + from anton.cloud_turn.errors import ( + InvalidRequestError, + UnsupportedProtocolVersionError, + ) + + if len(raw.encode("utf-8")) > MAX_REQUEST_BYTES: + raise InvalidRequestError( + f"request exceeds {MAX_REQUEST_BYTES} bytes" + ) + try: + data = json.loads(raw) + except Exception as exc: + raise InvalidRequestError(f"not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise InvalidRequestError("request must be a JSON object") + + version = data.get("protocol_version", PROTOCOL_VERSION) + if version != PROTOCOL_VERSION: + raise UnsupportedProtocolVersionError( + f"unsupported protocol_version {version!r}; this pod speaks {PROTOCOL_VERSION}" + ) + + from pydantic import ValidationError + + try: + return TurnRequestV1.model_validate(data) + except ValidationError as exc: + raise InvalidRequestError(f"invalid TurnRequestV1: {exc}") from exc From 8c9dd8e073ce5886283feec88bdbc5eea71e9a71 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:51:28 +0200 Subject: [PATCH 04/11] Add turn runner and session builder --- anton/cloud_turn/runner.py | 190 ++++++++++++++++++++++++++++++ anton/cloud_turn/schema_export.py | 62 ++++++++++ anton/cloud_turn/session.py | 179 ++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 anton/cloud_turn/runner.py create mode 100644 anton/cloud_turn/schema_export.py create mode 100644 anton/cloud_turn/session.py diff --git a/anton/cloud_turn/runner.py b/anton/cloud_turn/runner.py new file mode 100644 index 00000000..c3540232 --- /dev/null +++ b/anton/cloud_turn/runner.py @@ -0,0 +1,190 @@ +"""Drive one buffered anton turn to completion and emit terminal events. + +Buffered: consume ``turn_stream()`` (not ``turn()`` — only the streaming path +persists history) to the end, then derive the result from ``session.history``. +No live token streaming yet; that's a later protocol extension. + +Event contract: every VALID request emits ``turn.started`` (sequence 1) then +exactly ONE terminal event (sequence 2). An invalid request is rejected upstream +(``__main__``) with a single ``turn.failed`` and no ``turn.started``. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import time +from typing import Any, Callable + +from anton.cloud_turn.errors import DeadlineExceededError, classify_error +from anton.cloud_turn.messages import final_assistant_text, turn_output_messages +from anton.cloud_turn.protocol import ( + ErrorCodeV1, + MessageV1, + TurnCompletedV1, + TurnErrorV1, + TurnFailedV1, + TurnRequestV1, + TurnStartedV1, +) +from anton.cloud_turn.session import build_cloud_chat_session + +logger = logging.getLogger(__name__) + +EXIT_OK = 0 +EXIT_FAILED = 3 + +Emit = Callable[[object], None] +SessionBuilder = Callable[[TurnRequestV1], Any] + + +class _Sequencer: + """Hands out 1-based monotonic event sequence numbers.""" + + def __init__(self) -> None: + self._n = 0 + + def next(self) -> int: + self._n += 1 + return self._n + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _remaining_seconds(request: TurnRequestV1, now_ms: int) -> float | None: + """Soft timeout from the absolute deadline. None = no inner deadline. + + Raises :class:`DeadlineExceededError` immediately if the deadline has + already passed.""" + if request.deadline_unix_ms is None: + return None + remaining_ms = request.deadline_unix_ms - now_ms + if remaining_ms <= 0: + raise DeadlineExceededError( + f"deadline passed {abs(remaining_ms)}ms before turn start" + ) + return remaining_ms / 1000 + + +def _trace_metadata(request: TurnRequestV1) -> dict[str, str]: + return { + k: v + for k, v in { + "run_id": request.run_id, + "attempt_id": request.attempt_id, + "conversation_id": request.conversation_id, + "organization_id": request.organization_id, + "user_id": request.user_id, + "workspace_id": request.workspace_id, + }.items() + if v is not None + } + + +async def _drive_turn( + request: TurnRequestV1, session: Any +) -> tuple[str, list[MessageV1]]: + """Run the turn to completion; return (final_text, output_messages). + + ``output_messages`` are only the messages generated this turn (assistant + text, tool calls, tool results, final assistant message) — never the input + history. ``final_text`` is the last assistant message's text. + """ + # Identity anchor (NOT a length) — turn_stream may compact/rewrite history + # mid-turn. We hold the pre-turn message objects alive across the whole turn + # so their ids can't be recycled by messages created during compaction. + pre_turn_messages = list(session.history) + async for _event in session.turn_stream( + request.input, trace_metadata=_trace_metadata(request) + ): + # Buffered: the generator exhausting is the turn-ended signal. We derive + # the result from history, so intermediate stream events are just drive. + pass + output_messages = turn_output_messages(session.history, pre_turn_messages) + return final_assistant_text(output_messages), output_messages + + +async def _close(session: Any) -> None: + close = getattr(session, "close", None) + if close is None: + return + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception: + logger.warning("cloud session close failed (non-fatal)", exc_info=True) + + +async def run_turn( + request: TurnRequestV1, + emit: Emit, + session_builder: SessionBuilder | None = None, + now_ms: int | None = None, +) -> int: + """Run one turn, emitting ``turn.started`` then exactly one terminal event. + + Returns an exit code (``EXIT_OK`` / ``EXIT_FAILED``). + """ + builder = session_builder or build_cloud_chat_session + seq = _Sequencer() + emit( + TurnStartedV1( + run_id=request.run_id, attempt_id=request.attempt_id, sequence=seq.next() + ) + ) + session: Any = None + try: + # Deadline first: fail immediately if it already passed (before any work). + remaining_s = _remaining_seconds(request, now_ms if now_ms is not None else _now_ms()) + session = builder(request) + if remaining_s is not None: + final_text, output_messages = await asyncio.wait_for( + _drive_turn(request, session), timeout=remaining_s + ) + else: + final_text, output_messages = await _drive_turn(request, session) + emit( + TurnCompletedV1( + run_id=request.run_id, + attempt_id=request.attempt_id, + sequence=seq.next(), + final_text=final_text, + output_messages=output_messages, + ) + ) + return EXIT_OK + except asyncio.TimeoutError: + logger.warning("cloud turn timed out run_id=%s", request.run_id) + emit( + TurnFailedV1( + run_id=request.run_id, + attempt_id=request.attempt_id, + sequence=seq.next(), + error=TurnErrorV1( + code=ErrorCodeV1.DEADLINE_EXCEEDED, + message="turn exceeded its deadline", + retryable=True, + ), + ) + ) + return EXIT_FAILED + except Exception as exc: + # Full traceback → stderr only (may contain secrets). The wire carries a + # short, credential-scrubbed, structured error. + logger.exception("cloud turn failed run_id=%s", request.run_id) + emit( + TurnFailedV1( + run_id=request.run_id, + attempt_id=request.attempt_id, + sequence=seq.next(), + error=classify_error(exc), + ) + ) + return EXIT_FAILED + finally: + if session is not None: + await _close(session) diff --git a/anton/cloud_turn/schema_export.py b/anton/cloud_turn/schema_export.py new file mode 100644 index 00000000..19f93817 --- /dev/null +++ b/anton/cloud_turn/schema_export.py @@ -0,0 +1,62 @@ +"""Generate committed JSON Schemas for the V1 wire contracts. + +The schemas under ``schemas/`` are GENERATED from the Pydantic models here — do +not hand-edit them. ``tests/test_cloud_turn_schemas.py`` regenerates and diffs +against the committed files so a protocol change can't land without updating the +schema (and whoever reviews it). + +Run ``python -m anton.cloud_turn.schema_export`` to rewrite the committed files. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import TypeAdapter + +from anton.cloud_turn.protocol import TurnRequestV1, TurnEventV1 + +#: Repo-root-relative directory holding the committed schemas + fixtures. +SCHEMA_DIR = Path(__file__).resolve().parents[2] / "schemas" +REQUEST_SCHEMA_PATH = SCHEMA_DIR / "cloud-turn-request-v1.json" +EVENT_SCHEMA_PATH = SCHEMA_DIR / "cloud-turn-event-v1.json" + + +def request_schema() -> dict[str, Any]: + """JSON Schema for :class:`TurnRequestV1`.""" + return TurnRequestV1.model_json_schema() + + +def event_schema() -> dict[str, Any]: + """JSON Schema for the :data:`TurnEventV1` discriminated union.""" + return TypeAdapter(TurnEventV1).json_schema() + + +def _dump(schema: dict[str, Any]) -> str: + # Stable, sorted, newline-terminated so diffs are minimal + deterministic. + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +def expected_files() -> dict[Path, str]: + """Map of committed schema path → its expected serialized content.""" + return { + REQUEST_SCHEMA_PATH: _dump(request_schema()), + EVENT_SCHEMA_PATH: _dump(event_schema()), + } + + +def write_schemas() -> list[Path]: + """(Re)write the committed schema files. Returns the paths written.""" + SCHEMA_DIR.mkdir(parents=True, exist_ok=True) + written = [] + for path, content in expected_files().items(): + path.write_text(content, encoding="utf-8") + written.append(path) + return written + + +if __name__ == "__main__": + for p in write_schemas(): + print(f"wrote {p}") diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py new file mode 100644 index 00000000..d96d8948 --- /dev/null +++ b/anton/cloud_turn/session.py @@ -0,0 +1,179 @@ +"""Cloud-safe ChatSession builder. + +Assembles ``ChatSessionConfig`` directly instead of the desktop +``build_chat_session`` (which loads workspace ``.env``, uses ``~/.anton`` +personal memory, and injects vault creds into ``os.environ`` — all cross-tenant +hazards in a shared pod). Milestone-1 posture: + +* Trusted pod-side workspace path, never a wire field. +* No dotenv loading. +* Scratchpad env from a non-secret allowlist. +* Explicit tool allowlist; new core tools are not auto-enabled. +* Personal memory / connectors / data-vault / local-file history OFF. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from anton.cloud_turn.errors import ( + UnsupportedCapabilityError, + UnsupportedModelError, +) +from anton.cloud_turn.protocol import CapabilitiesV1, TurnRequestV1 + +if TYPE_CHECKING: + from anton.config.settings import AntonSettings + from anton.core.session import ChatSession + +logger = logging.getLogger(__name__) + +#: Trusted mount path — pod-side config, never from the wire request. +DEFAULT_CLOUD_WORKSPACE_PATH = "/workspace" + +#: Operator/CI override for the mount path (pod-side env var, not request data). +_WORKSPACE_PATH_ENV = "ANTON_CLOUD_WORKSPACE_PATH" + +#: Pod-side trusted model allowlist (comma-separated). Default empty = the +#: request may NOT override the model; the trusted settings default is used. +_MODEL_ALLOWLIST_ENV = "ANTON_CLOUD_MODEL_ALLOWLIST" + +#: The only tools exposed in a milestone-1 cloud turn: scratchpad + the +#: workspace-scoped artifact tools. Everything else core registers is dropped. +CLOUD_TOOL_ALLOWLIST = frozenset( + { + "scratchpad", + "create_artifact", + "list_artifacts", + "open_artifact", + "update_artifact", + } +) + + +def resolve_trusted_workspace_path() -> Path: + """Resolve the trusted workspace mount path (never from the wire request). + + Reads :data:`_WORKSPACE_PATH_ENV` or falls back to + :data:`DEFAULT_CLOUD_WORKSPACE_PATH`; rejects relative paths and ``..``, + then canonicalises so downstream containment checks compare a real path. + """ + raw = os.environ.get(_WORKSPACE_PATH_ENV) or DEFAULT_CLOUD_WORKSPACE_PATH + if not os.path.isabs(raw): + raise ValueError( + f"trusted workspace path must be absolute, got {raw!r} " + f"(set {_WORKSPACE_PATH_ENV} to an absolute path)" + ) + if ".." in Path(raw).parts: + raise ValueError(f"trusted workspace path must not contain '..': {raw!r}") + + # Canonicalise (follows symlinks) so later checks compare the real location. + resolved = Path(raw).resolve() + resolved.mkdir(parents=True, exist_ok=True) + if not resolved.is_dir(): + raise ValueError(f"trusted workspace path is not a directory: {resolved}") + return resolved + + +def build_cloud_chat_session(request: TurnRequestV1) -> "ChatSession": + """Assemble a cloud-safe ChatSession for one turn. + + The LLM credential is read by ``LLMClient.from_settings`` from the + ``ANTON_*`` environment (a synthetic-data dev key today; a gateway-issued + run-scoped credential before beta). History comes from the request (the DB + is authoritative) — never from the workspace. + """ + from anton.cloud_turn.messages import to_anton_history + from anton.config.settings import AntonSettings + from anton.core.backends.local import sanitized_scratchpad_runtime_factory + from anton.core.llm.client import LLMClient + from anton.core.session import ChatSession, ChatSessionConfig + from anton.workspace import Workspace + + caps = request.capabilities + + # Milestone 1 is the locked-down posture only; any capability on = fail loud. + _reject_unsupported_capabilities(caps) + + # Trusted pod-side mount path — NOT request-controlled. + base = resolve_trusted_workspace_path() + + # `_env_file=None`: never load the AntonSettings .env chain (~/.anton/.env, + # ~/.cowork/.env, /workspace/.env). Same object is passed to Workspace so it + # doesn't build a second, dotenv-loading one. + settings = AntonSettings(_env_file=None) + settings.resolve_workspace(str(base)) + _apply_model_policy(settings, request.model) + # Skills stay in the workspace, never the pod-shared ~/.anton. + settings.skills_root = base / ".anton" / "skills" + + workspace = Workspace(base, settings=settings) + workspace.initialize() + # No apply_env_to_process(): loading workspace .env into the process env + # would expose tenant secrets to cell code. No capability enables it. + + llm_client = LLMClient.from_settings(settings) + + config = ChatSessionConfig( + llm_client=llm_client, + settings=settings, + workspace=workspace, + session_id=request.conversation_id, + harness="cloud", + # DB-authoritative history; the pod never loads its own. Typed wire + # messages are converted to Anton's internal dict shape. + initial_history=to_anton_history(request.history) or None, + console=None, # headless — no Rich console + cortex=None, # personal memory OFF + episodic=None, + self_awareness=None, + data_vault=None, # connectors OFF + history_store=None, # local-file history OFF (DB authoritative) + tools=[], # no host connector/publish tools + tool_allowlist=CLOUD_TOOL_ALLOWLIST, # only reviewed tools survive the build + runtime_factory=sanitized_scratchpad_runtime_factory, # secret-free scratchpad env + # Milestone 1: text + scratchpad only; web tools land with a later capability. + web_search_enabled=False, + web_fetch_enabled=False, + ) + + session = ChatSession(config) + logger.info( + "cloud session built run_id=%s workspace=%s tool_allowlist=%s", + request.run_id, base, sorted(CLOUD_TOOL_ALLOWLIST), + ) + return session + + +def _reject_unsupported_capabilities(caps: CapabilitiesV1) -> None: + """Fail loud on any capability that has no cloud-safe implementation yet.""" + enabled = [name for name in CapabilitiesV1.model_fields if getattr(caps, name)] + if enabled: + raise UnsupportedCapabilityError( + "cloud session does not implement capabilities: " + + ", ".join(sorted(enabled)) + ) + + +def _trusted_model_allowlist() -> frozenset[str]: + raw = os.environ.get(_MODEL_ALLOWLIST_ENV, "") + return frozenset(m.strip() for m in raw.split(",") if m.strip()) + + +def _apply_model_policy(settings: "AntonSettings", requested: str | None) -> None: + """Model selection is NOT request-controlled by default. + + ``None`` → keep the trusted settings default. A requested model is honoured + only if it is in the pod-side trusted allowlist (default empty), else + rejected with a structured error.""" + if requested is None: + return + allowed = _trusted_model_allowlist() + if requested not in allowed: + raise UnsupportedModelError( + f"model {requested!r} is not permitted for cloud turns" + ) + settings.planning_model = requested From 754cce3876b6e6a63a0e6ac87c03d62466983dbc Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:52:00 +0200 Subject: [PATCH 05/11] Plug the turn into backends and session --- anton/core/artifacts/store.py | 14 ++- anton/core/backends/local.py | 172 ++++++++++++++++++++++++---------- anton/core/session.py | 19 ++++ anton/workspace.py | 7 +- pyproject.toml | 1 + 5 files changed, 161 insertions(+), 52 deletions(-) diff --git a/anton/core/artifacts/store.py b/anton/core/artifacts/store.py index 19cb4672..135d60b1 100644 --- a/anton/core/artifacts/store.py +++ b/anton/core/artifacts/store.py @@ -117,6 +117,13 @@ def ensure_root(self) -> Path: return self._root def folder_for(self, slug: str) -> Path: + # `open`/`update` take the slug straight from tool input. Reject anything + # that resolves outside the root (``..``, absolute, or symlink escape); + # nested-but-contained paths are fine (create never emits them). + candidate = (self._root / slug).resolve() + root = self._root.resolve() + if candidate != root and root not in candidate.parents: + raise ValueError(f"artifact slug escapes the workspace: {slug!r}") return self._root / slug def metadata_path(self, slug: str) -> Path: @@ -385,7 +392,12 @@ def _save(self, artifact: Artifact) -> None: self.readme_path(artifact.slug).write_text(readme, encoding="utf-8") def _load_silent(self, slug: str) -> Artifact | None: - path = self.metadata_path(slug) + try: + path = self.metadata_path(slug) + except ValueError: + # Escaping slug → treat as "no such artifact" so open/update stay graceful. + logger.warning("Rejected out-of-workspace artifact slug %r", slug) + return None if not path.is_file(): return None try: diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index d73ee85c..3bfbffc6 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -77,6 +77,47 @@ def _utf8_env(base: "os._Environ[str] | dict[str, str]") -> dict[str, str]: return env +# Sanitized scratchpad env contract: in sanitize_env mode the child inherits +# ONLY these non-secret names (and only if the parent has them) — never a +# provider key, ANTON_* credential, gateway token, or datasource secret. +# Python runtime vars (PYTHONUTF8, PYTHONPATH) are set explicitly in start(), +# never inherited, so nothing can redirect imports. +_SCRATCHPAD_ENV_ALLOWLIST = frozenset( + { + # process / tooling + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + # temp dirs + "TMPDIR", + "TMP", + "TEMP", + # locale / tty + "LANG", + "LC_ALL", + "LC_CTYPE", + "LANGUAGE", + "TZ", + "TERM", + # TLS trust roots (HTTPS verification from cell code) + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + # Windows: required for subprocess/socket startup + "SYSTEMROOT", + "SYSTEMDRIVE", + } +) + + +def _sanitized_parent_env() -> dict[str, str]: + """Parent env reduced to the non-secret allowlist.""" + return {k: v for k, v in os.environ.items() if k in _SCRATCHPAD_ENV_ALLOWLIST} + + _MAX_OUTPUT = 10_000 @@ -95,6 +136,7 @@ def __init__( coding_base_url: str, cells: list[Cell] | None = None, workspace_path: Path | None = None, + sanitize_env: bool = False, _venvs_base: Path | None = None, ) -> None: super().__init__( @@ -114,6 +156,9 @@ def __init__( # is a "where to put scratchpad venvs" hint; the explicit # arg is "the agent's project, when known". self._explicit_workspace_path: Path | None = workspace_path + # Cloud/headless: build the child env from the non-secret allowlist. + # Default False = desktop behaviour (full inherited env) unchanged. + self._sanitize_env: bool = sanitize_env self._proc: asyncio.subprocess.Process | None = None self._boot_path: str | None = None self._venv_dir: str | None = None @@ -390,59 +435,64 @@ async def start(self) -> None: os.close(fd) self._boot_path = path - # Force UTF-8 mode in the child so its I/O never depends on the host - # code page (ENG-824). - env = _utf8_env(os.environ) + # Force UTF-8 in the child (ENG-824). In sanitized mode the base is the + # non-secret allowlist, not the full parent env. + env = _utf8_env( + _sanitized_parent_env() if self._sanitize_env else os.environ + ) if self._coding_model: env["ANTON_SCRATCHPAD_MODEL"] = self._coding_model if self._coding_provider: env["ANTON_SCRATCHPAD_PROVIDER"] = self._coding_provider - if "ANTHROPIC_API_KEY" not in env and "ANTON_ANTHROPIC_API_KEY" in env: - env["ANTHROPIC_API_KEY"] = env["ANTON_ANTHROPIC_API_KEY"] - if "OPENAI_API_KEY" not in env and "ANTON_OPENAI_API_KEY" in env: - env["OPENAI_API_KEY"] = env["ANTON_OPENAI_API_KEY"] - if "OPENAI_BASE_URL" not in env and "ANTON_OPENAI_BASE_URL" in env: - env["OPENAI_BASE_URL"] = env["ANTON_OPENAI_BASE_URL"] - if ( - "OPENAI_API_KEY" not in env - and "ANTON_MINDS_API_KEY" in env - and self._coding_provider == "openai-compatible" - ): - env["OPENAI_API_KEY"] = env["ANTON_MINDS_API_KEY"] - if ( - "OPENAI_BASE_URL" not in env - and "ANTON_MINDS_URL" in env - and self._coding_provider == "openai-compatible" - ): - # Host-aware (ENG-436): api.mindshub.ai serves /v1, legacy - # mdb.ai serves /api/v1. The previous hardcoded /api/v1 was - # wrong for mindshub. Mirrors config/settings.py + - # cowork-server minds_chat_base_url. - _minds_base = env["ANTON_MINDS_URL"].rstrip("/") - if _minds_base.endswith("/v1"): - env["OPENAI_BASE_URL"] = _minds_base - elif "mdb.ai" in _minds_base: - env["OPENAI_BASE_URL"] = f"{_minds_base}/api/v1" - else: - env["OPENAI_BASE_URL"] = f"{_minds_base}/v1" - if self._coding_api_key: - sdk_key = { - "anthropic": "ANTHROPIC_API_KEY", - "openai": "OPENAI_API_KEY", - "openai-compatible": "OPENAI_API_KEY", - }.get(self._coding_provider, "") - if sdk_key: - env[sdk_key] = self._coding_api_key - if self._coding_provider in ("openai", "openai-compatible"): - base_url = ( - self._coding_base_url - or env.get("ANTON_OPENAI_BASE_URL") - or env.get("OPENAI_BASE_URL") - or "" - ) - if base_url: - env["OPENAI_BASE_URL"] = base_url - env["ANTON_OPENAI_BASE_URL"] = base_url + # Provider-credential propagation is desktop-only; sanitized mode + # withholds every model key (nested get_llm() is off in the cloud pod). + if not self._sanitize_env: + if "ANTHROPIC_API_KEY" not in env and "ANTON_ANTHROPIC_API_KEY" in env: + env["ANTHROPIC_API_KEY"] = env["ANTON_ANTHROPIC_API_KEY"] + if "OPENAI_API_KEY" not in env and "ANTON_OPENAI_API_KEY" in env: + env["OPENAI_API_KEY"] = env["ANTON_OPENAI_API_KEY"] + if "OPENAI_BASE_URL" not in env and "ANTON_OPENAI_BASE_URL" in env: + env["OPENAI_BASE_URL"] = env["ANTON_OPENAI_BASE_URL"] + if ( + "OPENAI_API_KEY" not in env + and "ANTON_MINDS_API_KEY" in env + and self._coding_provider == "openai-compatible" + ): + env["OPENAI_API_KEY"] = env["ANTON_MINDS_API_KEY"] + if ( + "OPENAI_BASE_URL" not in env + and "ANTON_MINDS_URL" in env + and self._coding_provider == "openai-compatible" + ): + # Host-aware (ENG-436): api.mindshub.ai serves /v1, legacy + # mdb.ai serves /api/v1. The previous hardcoded /api/v1 was + # wrong for mindshub. Mirrors config/settings.py + + # cowork-server minds_chat_base_url. + _minds_base = env["ANTON_MINDS_URL"].rstrip("/") + if _minds_base.endswith("/v1"): + env["OPENAI_BASE_URL"] = _minds_base + elif "mdb.ai" in _minds_base: + env["OPENAI_BASE_URL"] = f"{_minds_base}/api/v1" + else: + env["OPENAI_BASE_URL"] = f"{_minds_base}/v1" + if self._coding_api_key: + sdk_key = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "openai-compatible": "OPENAI_API_KEY", + }.get(self._coding_provider, "") + if sdk_key: + env[sdk_key] = self._coding_api_key + if self._coding_provider in ("openai", "openai-compatible"): + base_url = ( + self._coding_base_url + or env.get("ANTON_OPENAI_BASE_URL") + or env.get("OPENAI_BASE_URL") + or "" + ) + if base_url: + env["OPENAI_BASE_URL"] = base_url + env["ANTON_OPENAI_BASE_URL"] = base_url uv = self._find_uv() if uv: env["ANTON_UV_PATH"] = uv @@ -817,3 +867,27 @@ def local_scratchpad_runtime_factory( cells=cells, workspace_path=workspace_path, ) + + +def sanitized_scratchpad_runtime_factory( + *, + name: str, + coding_provider: str, + coding_model: str, + coding_api_key: str, + coding_base_url: str, + cells: list[Cell] | None, + workspace_path: Path | None, +) -> ScratchpadRuntime: + """Cloud/headless factory: like the local factory but with + ``sanitize_env=True`` so the child env carries no secrets.""" + return LocalScratchpadRuntime( + name=name, + coding_provider=coding_provider, + coding_model=coding_model, + coding_api_key=coding_api_key, + coding_base_url=coding_base_url, + cells=cells, + workspace_path=workspace_path, + sanitize_env=True, + ) diff --git a/anton/core/session.py b/anton/core/session.py index 77eece9a..24846acf 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -299,6 +299,10 @@ class ChatSessionConfig: # settings' `router_enabled` (ANTON_ROUTER_ENABLED); hosts pass an # explicit bool to override per session. router_enabled: bool | None = None + # When set, only these tool names survive the build; ``None`` = full desktop + # set. Applied on every ``_build_tools`` call so a lazy rebuild can't leak a + # non-allowlisted tool. + tool_allowlist: frozenset[str] | None = None class ChatSession: @@ -347,6 +351,7 @@ def __init__(self, config: ChatSessionConfig) -> None: self._act_first = config.act_first self._started_at = config.started_at self._extra_tools = config.tools + self._tool_allowlist = config.tool_allowlist self._workspace = config.workspace self._data_vault = config.data_vault self._console = config.console @@ -879,6 +884,20 @@ def _build_tools(self) -> list[dict]: self._build_core_tools() for tool in self._extra_tools: self.tool_registry.register_tool(tool) + # Enforce the allowlist on every build (None = full desktop set). + if self._tool_allowlist is not None: + built = {t.name for t in self.tool_registry.get_tool_defs()} + # Fail loud on a name that matches no built tool (typo / unavailable + # here) rather than silently dropping it. + unknown = set(self._tool_allowlist) - built + if unknown: + raise ValueError( + "tool_allowlist names not registered in this session: " + + ", ".join(sorted(unknown)) + + f" (available: {', '.join(sorted(built))})" + ) + for name in built - set(self._tool_allowlist): + self.tool_registry.unregister_tool(name) return self.tool_registry.dump() def _build_core_tools(self) -> None: diff --git a/anton/workspace.py b/anton/workspace.py index 1626f751..d3258f21 100644 --- a/anton/workspace.py +++ b/anton/workspace.py @@ -27,14 +27,17 @@ class Workspace: """Manages the .anton/ workspace directory and its files.""" - def __init__(self, base: Path) -> None: + def __init__(self, base: Path, settings: AntonSettings | None = None) -> None: self._base = base self._anton_dir = base / ".anton" self._anton_md = self._anton_dir / "anton.md" self._env_file = self._anton_dir / ".env" self._anton_md_last_read: datetime | None = None - settings = AntonSettings() + # Reuse a caller's settings so the cloud pod's dotenv-disabled + # AntonSettings isn't bypassed by a second one built here. Only + # `artifacts_dir` is read; None = desktop behaviour unchanged. + settings = settings or AntonSettings() self._artifacts_dir = self._anton_dir / settings.artifacts_dir @property diff --git a/pyproject.toml b/pyproject.toml index 4901fe87..a394d5b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ asyncio_mode = "auto" pythonpath = ["."] markers = [ "stub_only: test requires stub server (skipped when --live is passed)", + "slow: test spawns a real scratchpad venv/subprocess (seconds)", ] [tool.hatch.build.targets.wheel] From fd151db80bc26a63a16a4045f349908fd96b557f Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:52:15 +0200 Subject: [PATCH 06/11] Cloud turn tests --- tests/test_cloud_turn_capabilities.py | 73 ++++++++ tests/test_cloud_turn_errors.py | 57 +++++++ tests/test_cloud_turn_messages.py | 83 +++++++++ tests/test_cloud_turn_process.py | 237 ++++++++++++++++++++++++++ tests/test_cloud_turn_protocol.py | 222 ++++++++++++++++++++++++ 5 files changed, 672 insertions(+) create mode 100644 tests/test_cloud_turn_capabilities.py create mode 100644 tests/test_cloud_turn_errors.py create mode 100644 tests/test_cloud_turn_messages.py create mode 100644 tests/test_cloud_turn_process.py create mode 100644 tests/test_cloud_turn_protocol.py diff --git a/tests/test_cloud_turn_capabilities.py b/tests/test_cloud_turn_capabilities.py new file mode 100644 index 00000000..f4b42ef6 --- /dev/null +++ b/tests/test_cloud_turn_capabilities.py @@ -0,0 +1,73 @@ +"""`python -m anton.cloud_turn capabilities` — stable machine-readable manifest.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +from anton.cloud_turn import __main__ as entry +from anton.cloud_turn.protocol import PROTOCOL_VERSION +from anton.cloud_turn.session import CLOUD_TOOL_ALLOWLIST + + +def _manifest_via_subprocess() -> tuple[int, dict]: + proc = subprocess.run( + [sys.executable, "-m", "anton.cloud_turn", "capabilities"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60, + ) + return proc.returncode, json.loads(proc.stdout.decode("utf-8")) + + +def test_capabilities_command_is_valid_json_and_exit_zero(): + code, manifest = _manifest_via_subprocess() + assert code == 0 + assert isinstance(manifest, dict) + assert manifest["ok"] is True + + +def test_capabilities_has_stable_required_fields(): + manifest = entry._capabilities() + required = { + "ok", "entrypoint", "cloud_turn_version", "anton_version", + "protocol_versions", "tools", "content_block_types", "message_roles", + "model_override", "memory", "connectors", "data_vault", + "scratchpad_execution", "web_tools", "provider_sdks", "capabilities", + } + assert required <= set(manifest) + assert manifest["entrypoint"] == "anton.cloud_turn" + + +def test_capabilities_reports_exact_cloud_tools(): + manifest = entry._capabilities() + assert manifest["tools"] == sorted(CLOUD_TOOL_ALLOWLIST) + + +def test_capabilities_protocol_versions(): + manifest = entry._capabilities() + assert manifest["protocol_versions"] == [PROTOCOL_VERSION] + + +def test_capabilities_reports_milestone_truthfully(): + m = entry._capabilities() + # Only what THIS milestone actually enables. + assert m["content_block_types"] == ["text", "tool_result", "tool_use"] # sorted + assert m["message_roles"] == ["assistant", "user"] + assert m["scratchpad_execution"] is True + assert m["memory"] == {"personal": False, "workspace": False} + assert m["connectors"] is False + assert m["data_vault"] is False + assert m["web_tools"] is False + assert m["model_override"]["policy"] == "trusted_allowlist" + assert m["model_override"]["default"] == "deny" + + +def test_capabilities_reports_anthropic_sdk_present(): + # anthropic is a hard dependency — always installed. + assert entry._capabilities()["provider_sdks"]["anthropic"] is True + + +def test_capabilities_reports_unavailable_sdk_accurately(monkeypatch): + # Simulate the openai SDK being absent: __import__("openai") then fails. + monkeypatch.setitem(sys.modules, "openai", None) + assert entry._capabilities()["provider_sdks"]["openai"] is False diff --git a/tests/test_cloud_turn_errors.py b/tests/test_cloud_turn_errors.py new file mode 100644 index 00000000..7345be59 --- /dev/null +++ b/tests/test_cloud_turn_errors.py @@ -0,0 +1,57 @@ +"""Central exception → structured TurnErrorV1 mapping (item 4).""" + +from __future__ import annotations + +import pytest + +from anton.cloud_turn.errors import ( + DeadlineExceededError, + InvalidRequestError, + UnsupportedCapabilityError, + UnsupportedModelError, + UnsupportedProtocolVersionError, + classify_error, +) +from anton.core.llm.provider import ( + ContextOverflowError, + ModelUnavailableError, + ProviderOverloadedError, + TokenLimitExceeded, + TransientProviderError, +) + + +@pytest.mark.parametrize( + "exc, code, retryable", + [ + (InvalidRequestError("bad"), "invalid_request", False), + (UnsupportedProtocolVersionError("v2"), "unsupported_protocol_version", False), + (UnsupportedCapabilityError("connectors"), "unsupported_capability", False), + (UnsupportedModelError("nope"), "unsupported_model", False), + (DeadlineExceededError("late"), "deadline_exceeded", True), + (ModelUnavailableError("gate", code="model_disabled", model="m"), "unsupported_model", False), + (TransientProviderError("overloaded"), "model_provider_failure", True), + (ProviderOverloadedError("exhausted"), "model_provider_failure", True), + (TokenLimitExceeded("quota"), "model_provider_failure", False), + (ContextOverflowError("too long"), "model_provider_failure", False), + (ConnectionError("Invalid API key — check your ANTHROPIC_API_KEY"), "model_auth_failure", False), + (ConnectionError("connection reset by peer"), "model_provider_failure", True), + (RuntimeError("unexpected"), "internal_turn_failure", False), + ], +) +def test_each_mapping(exc, code, retryable): + err = classify_error(exc) + assert err.code.value == code + assert err.retryable is retryable + assert err.message # non-empty, short + + +def test_message_is_scrubbed_and_bounded(): + from anton.cloud_turn.errors import MAX_ERROR_MESSAGE_CHARS + + # A key-shaped token is redacted by scrub_credentials; the message is + # truncated so nothing long/verbose reaches the wire. + leaked_key = "sk-ant-api03-" + "A" * 80 + err = classify_error(RuntimeError(f"boom with key {leaked_key} " + "x" * 1000)) + assert leaked_key not in err.message + assert len(err.message) <= MAX_ERROR_MESSAGE_CHARS diff --git a/tests/test_cloud_turn_messages.py b/tests/test_cloud_turn_messages.py new file mode 100644 index 00000000..3329d07f --- /dev/null +++ b/tests/test_cloud_turn_messages.py @@ -0,0 +1,83 @@ +"""Explicit Anton ↔ V1 wire message conversion (item 2).""" + +from __future__ import annotations + +import pytest + +from anton.cloud_turn.messages import ( + MessageConversionError, + final_assistant_text, + to_anton_history, + to_anton_input, + turn_output_messages, +) +from anton.cloud_turn.protocol import MessageV1, TextBlockV1, ToolUseBlockV1 + + +def test_to_anton_input_string_and_blocks(): + assert to_anton_input("hi") == "hi" + blocks = [TextBlockV1(text="a"), ToolUseBlockV1(id="t", name="scratchpad", input={})] + dumped = to_anton_input(blocks) + assert dumped == [ + {"type": "text", "text": "a"}, + {"type": "tool_use", "id": "t", "name": "scratchpad", "input": {}}, + ] + + +def test_to_anton_history_round_trips_to_dicts(): + hist = [MessageV1(role="user", content="q"), MessageV1(role="assistant", content="a")] + assert to_anton_history(hist) == [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "a"}, + ] + + +def test_turn_output_messages_drops_leading_input_echo(): + prior = [ + {"role": "user", "content": "prior"}, # input history + {"role": "assistant", "content": "prior answer"}, + ] + history = prior + [ + {"role": "user", "content": "current input"}, # this turn's echo + {"role": "assistant", "content": "new answer"}, + ] + out = turn_output_messages(history, prior) + assert [m.role for m in out] == ["assistant"] + assert out[0].content == "new answer" + + +def test_turn_output_messages_empty_when_no_assistant(): + history = [{"role": "user", "content": "current input"}] + assert turn_output_messages(history, []) == [] + + +def test_final_assistant_text_from_blocks(): + msgs = [ + MessageV1(role="assistant", content=[TextBlockV1(text="Hello "), TextBlockV1(text="world")]), + ] + assert final_assistant_text(msgs) == "Hello world" + + +def test_unsupported_block_type_raises_not_dropped(): + # V1 supports only text / tool_use / tool_result. An image block (not in the + # cloud tool surface) must fail loudly rather than be silently dropped. + history = [{"role": "assistant", "content": [{"type": "image", "source": {}}]}] + with pytest.raises(MessageConversionError): + turn_output_messages(history, []) + + +def test_compaction_summary_and_separator_are_excluded(): + """Simulates _summarize_history rewriting the prefix: the NEW summary + + 'Understood' separator (new identities) precede the surviving input anchor, + so they must fall outside the current-turn region.""" + surviving_input = {"role": "assistant", "content": "recent input turn"} + history = [ + {"role": "user", "content": "COMPACTED summary of old turns"}, # new artifact + {"role": "assistant", "content": "Understood — using that as reference."}, # new artifact + surviving_input, # anchor (pre-turn) + {"role": "user", "content": "current input"}, # echo + {"role": "assistant", "content": "the answer"}, # generated + ] + out = turn_output_messages(history, [surviving_input]) + assert [m.role for m in out] == ["assistant"] + assert out[0].content == "the answer" diff --git a/tests/test_cloud_turn_process.py b/tests/test_cloud_turn_process.py new file mode 100644 index 00000000..19fc3a6c --- /dev/null +++ b/tests/test_cloud_turn_process.py @@ -0,0 +1,237 @@ +"""Real-subprocess tests for the cloud-turn process boundary. + +These run the actual entrypoint (via ``cloud_turn_fake_entry.py``, which calls +the real ``main()``) as a child process, so FD-level stdout isolation, fresh +process/scratchpad state, exit codes, and the deterministic E2E are proven end +to end — not simulated in-process. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +_HARNESS = str(Path(__file__).parent / "cloud_turn_fake_entry.py") + + +def _run_cli(request, *, workspace, mode="model", script=None, timeout=60, env_extra=None): + """Run the entrypoint as a subprocess; return (exit_code, events, stdout, stderr). + + ``events`` is the parsed JSONL from stdout — the assertion that stdout is a + clean protocol stream is that every non-blank line is valid JSON.""" + import os + + env = os.environ.copy() + env["ANTON_CLOUD_WORKSPACE_PATH"] = str(workspace) + env["CLOUD_TURN_FAKE_MODE"] = mode + if script is not None: + env["CLOUD_TURN_FAKE_SCRIPT"] = json.dumps(script) + if env_extra: + env.update(env_extra) + + stdin = request if isinstance(request, str) else json.dumps(request) + proc = subprocess.run( + [sys.executable, _HARNESS], + input=stdin.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + timeout=timeout, + ) + lines = [ln for ln in proc.stdout.decode("utf-8").splitlines() if ln.strip()] + events = [json.loads(ln) for ln in lines] # raises if stdout isn't clean JSONL + return proc.returncode, events, proc.stdout.decode(), proc.stderr.decode() + + +def _req(**over): + body = {"run_id": "r", "attempt_id": "a", "conversation_id": "c", "input": "hi"} + body.update(over) + return body + + +# ── item 1: FD-level stdout isolation ──────────────────────────────────────── + +def test_stray_stdout_never_corrupts_protocol(tmp_path): + """print(), sys.stdout.write, os.write(1, …) and library logging during the + turn must all land on stderr, leaving stdout a clean protocol stream.""" + code, events, stdout, stderr = _run_cli(_req(), workspace=tmp_path, mode="stray") + assert code == 0 + assert [e["kind"] for e in events] == ["turn.started", "turn.completed"] + assert events[-1]["final_text"] == "clean answer" + assert "STRAY" not in stdout # nothing stray on the protocol channel + assert "STRAY via os.write(1)" in stderr # direct FD-1 write was redirected to stderr + assert "STRAY via print()" in stderr + + +def test_stray_then_failure_stays_clean(tmp_path): + code, events, stdout, stderr = _run_cli(_req(), workspace=tmp_path, mode="stray_fail") + assert code == 3 + assert [e["kind"] for e in events] == ["turn.started", "turn.failed"] + assert "STRAY" not in stdout + # No traceback / internals leak onto the protocol event. + assert "Traceback" not in stdout + assert events[-1]["error"]["code"] == "internal_turn_failure" + + +def test_malformed_input_clean_protocol(tmp_path): + code, events, stdout, stderr = _run_cli("{not valid json", workspace=tmp_path) + assert code == 2 + assert len(events) == 1 and events[0]["kind"] == "turn.failed" + assert events[0]["sequence"] == 1 + assert events[0]["error"]["code"] == "invalid_request" + + +# ── item 7: exit codes + terminal-event guarantees ─────────────────────────── + +def test_exit_zero_on_completion(tmp_path): + code, events, *_ = _run_cli( + _req(), workspace=tmp_path, mode="model", script=[{"text": "done"}] + ) + assert code == 0 + terminals = [e for e in events if e["kind"] in ("turn.completed", "turn.failed")] + assert len(terminals) == 1 and terminals[0]["kind"] == "turn.completed" + + +def test_exit_three_on_execution_failure(tmp_path): + code, events, *_ = _run_cli(_req(), workspace=tmp_path, mode="stray_fail") + assert code == 3 + terminals = [e for e in events if e["kind"] in ("turn.completed", "turn.failed")] + assert len(terminals) == 1 and terminals[0]["kind"] == "turn.failed" + + +def test_oversized_request_rejected_at_process_level(tmp_path): + from anton.cloud_turn.protocol import MAX_REQUEST_BYTES + + oversized = json.dumps(_req(input="x" * (MAX_REQUEST_BYTES + 10))) + code, events, stdout, _ = _run_cli(oversized, workspace=tmp_path, timeout=60) + assert code == 2 + assert len(events) == 1 and events[0]["kind"] == "turn.failed" + assert events[0]["error"]["code"] == "invalid_request" + + +# ── item 3: deterministic local E2E (real CLI, fake model, no network) ──────── + +def test_e2e_text_only_turn(tmp_path): + """Full path: JSON on stdin → parse → cloud-safe session → runner → + turn.started + turn.completed with valid output_messages and final_text.""" + code, events, *_ = _run_cli( + _req(input="What is 2 + 2?"), + workspace=tmp_path, mode="model", script=[{"text": "The answer is 4."}], + ) + assert code == 0 + assert [e["kind"] for e in events] == ["turn.started", "turn.completed"] + completed = events[-1] + assert completed["final_text"] == "The answer is 4." + assert completed["output_messages"] == [ + {"role": "assistant", "content": "The answer is 4."} + ] + + +# ── item 2: fresh process + scratchpad state per invocation ─────────────────── + +_CELL_A = ( + "E2E_SENTINEL = 4242\n" + "import os as _os\n" + "_os.environ['A_MUT'] = 'turnA'\n" + "print('set', E2E_SENTINEL, _os.environ.get('A_MUT'))\n" +) +_CELL_B = ( + "import os as _os\n" + "print('sentinel_present', 'E2E_SENTINEL' in dir())\n" + "print('env_mut', _os.environ.get('A_MUT'))\n" +) + + +def _scratchpad_step(code): + return {"tool": {"name": "scratchpad", "input": { + "action": "exec", "name": "main", "code": code, + "one_line_description": "test cell", + }}} + + +@pytest.mark.slow +def test_fresh_scratchpad_state_across_processes(tmp_path): + """Turn A defines a scratchpad variable + mutates its env; Turn B, a NEW + process against the SAME workspace, must not observe either.""" + # Turn A — sets state, then finishes. + codeA, eventsA, *_ = _run_cli( + _req(run_id="A", input="set state"), + workspace=tmp_path, mode="model", timeout=180, + script=[_scratchpad_step(_CELL_A), {"text": "did A"}], + ) + assert codeA == 0, eventsA + # Sanity: Turn A's scratchpad actually ran and set the sentinel. + a_tool_results = _tool_result_texts(eventsA[-1]) + assert any("set 4242 turnA" in t for t in a_tool_results) + + # Turn B — brand-new process, same workspace, tries to read Turn A's state. + codeB, eventsB, *_ = _run_cli( + _req(run_id="B", input="read state"), + workspace=tmp_path, mode="model", timeout=180, + script=[_scratchpad_step(_CELL_B), {"text": "did B"}], + ) + assert codeB == 0, eventsB + b_tool_results = _tool_result_texts(eventsB[-1]) + joined = "\n".join(b_tool_results) + assert "sentinel_present False" in joined # Python global gone + assert "env_mut None" in joined # env mutation gone + + +def _tool_result_texts(completed_event) -> list[str]: + """Extract tool_result content strings from a turn.completed event.""" + out = [] + for msg in completed_event.get("output_messages", []): + content = msg.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + c = block.get("content") + out.append(c if isinstance(c, str) else json.dumps(c)) + return out + + +# ── item 2: session.close() terminates the inner scratchpad (no orphans) ────── + +async def _real_cloud_session(tmp_path, monkeypatch): + import anton.core.llm.client as llm_client_mod + from unittest.mock import AsyncMock, MagicMock + + from anton.core.llm.provider import ProviderConnectionInfo + from anton.cloud_turn.protocol import TurnRequestV1 + from anton.cloud_turn.session import build_cloud_chat_session + + monkeypatch.setenv("ANTON_CLOUD_WORKSPACE_PATH", str(tmp_path)) + + def _mk(cls, settings): + llm = AsyncMock() + llm.coding_provider = MagicMock() + llm.coding_provider.export_connection_info = MagicMock( + return_value=ProviderConnectionInfo(provider="anthropic", api_key="test") + ) + llm.coding_model = "m" + llm.planning_provider = MagicMock() + llm.planning_provider.native_web_tools = MagicMock(return_value=set()) + return llm + + monkeypatch.setattr(llm_client_mod.LLMClient, "from_settings", classmethod(_mk)) + req = TurnRequestV1(run_id="r", attempt_id="a", conversation_id="c", input="hi") + return build_cloud_chat_session(req) + + +@pytest.mark.slow +async def test_session_close_terminates_scratchpad(tmp_path, monkeypatch): + """A real scratchpad subprocess is started, then session.close() must kill it + — no orphaned child survives the runner.""" + session = await _real_cloud_session(tmp_path, monkeypatch) + pad = await session._scratchpads.get_or_create("main") + await pad.execute("x = 1") + proc = pad._proc + assert proc is not None and proc.returncode is None # alive + + await session.close() + assert pad._proc is None # manager released it + assert proc.returncode is not None # OS process terminated diff --git a/tests/test_cloud_turn_protocol.py b/tests/test_cloud_turn_protocol.py new file mode 100644 index 00000000..ca23b92e --- /dev/null +++ b/tests/test_cloud_turn_protocol.py @@ -0,0 +1,222 @@ +"""Contract tests for the cloud-turn V1 wire protocol.""" + +from __future__ import annotations + +import json + +import pytest + +from anton.cloud_turn import ( + PROTOCOL_VERSION, + CapabilitiesV1, + ErrorCodeV1, + MessageV1, + TurnCompletedV1, + TurnFailedV1, + TurnStartedV1, + event_line, + parse_request, +) +from anton.cloud_turn.errors import ( + InvalidRequestError, + UnsupportedProtocolVersionError, +) +from anton.cloud_turn.protocol import ( + MAX_HISTORY_MESSAGES, + MAX_REQUEST_BYTES, + MAX_TEXT_BLOCK_CHARS, + TERMINAL_KINDS, + TurnErrorV1, + TurnRequestV1, +) + + +def _minimal_request_json(**overrides) -> str: + body = { + "run_id": "run_1", + "attempt_id": "att_1", + "conversation_id": "conv_1", + "input": "hello", + } + body.update(overrides) + return json.dumps(body) + + +# ── request ─────────────────────────────────────────────────────────────── + +def test_capabilities_default_everything_off(): + caps = CapabilitiesV1() + assert not any(getattr(caps, f) for f in CapabilitiesV1.model_fields) + + +def test_request_minimal_parses_with_safe_defaults(): + req = parse_request(_minimal_request_json()) + assert req.protocol_version == PROTOCOL_VERSION + assert "workspace_path" not in TurnRequestV1.model_fields # trusted pod config + assert req.history == [] + assert req.model is None + assert req.deadline_unix_ms is None + assert req.capabilities == CapabilitiesV1() + assert req.input == "hello" + + +def test_request_rejects_unknown_fields(): + with pytest.raises(InvalidRequestError): + parse_request(_minimal_request_json(surprise="nope")) + + +def test_request_rejects_workspace_path_on_the_wire(): + with pytest.raises(InvalidRequestError): + parse_request(_minimal_request_json(workspace_path="/etc")) + + +def test_request_rejects_wrong_protocol_version(): + with pytest.raises(UnsupportedProtocolVersionError): + parse_request(_minimal_request_json(protocol_version=2)) + + +def test_request_rejects_non_json(): + with pytest.raises(InvalidRequestError): + parse_request("this is not json") + + +# ── typed history contract (item 3) ───────────────────────────────────────── + +def test_history_accepts_supported_roles_and_blocks(): + req = parse_request( + _minimal_request_json( + history=[ + {"role": "user", "content": "earlier question"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me check"}, + {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {"a": 1}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, + ], + }, + ] + ) + ) + assert len(req.history) == 3 + assert req.history[1].content[1].name == "scratchpad" + + +def test_history_rejects_unsupported_role(): + with pytest.raises(InvalidRequestError): + parse_request(_minimal_request_json(history=[{"role": "system", "content": "x"}])) + + +def test_history_rejects_unknown_block_type(): + with pytest.raises(InvalidRequestError): + parse_request( + _minimal_request_json( + history=[{"role": "user", "content": [{"type": "image", "source": "..."}]}] + ) + ) + + +# ── validation limits (item 3) ────────────────────────────────────────────── + +def test_history_message_count_limit(): + too_many = [{"role": "user", "content": "x"}] * (MAX_HISTORY_MESSAGES + 1) + with pytest.raises(InvalidRequestError): + parse_request(_minimal_request_json(history=too_many)) + + +def test_text_block_size_limit(): + huge = "x" * (MAX_TEXT_BLOCK_CHARS + 1) + with pytest.raises(InvalidRequestError): + parse_request( + _minimal_request_json( + history=[{"role": "user", "content": [{"type": "text", "text": huge}]}] + ) + ) + + +def test_total_request_size_limit(): + # Build a raw string just over the byte cap without going through the model. + oversized = "x" * (MAX_REQUEST_BYTES + 1) + with pytest.raises(InvalidRequestError): + parse_request(_minimal_request_json(input=oversized)) + + +def test_exact_max_raw_request_size(): + assert MAX_REQUEST_BYTES == 10 * 1024 * 1024 + + +def test_size_enforced_before_json_decoding(monkeypatch): + # Oversized, NOT valid JSON: it must be rejected on size (raw bytes) BEFORE + # any JSON decode is attempted — so the error is about size, not JSON. + from anton.cloud_turn import protocol + + monkeypatch.setattr(protocol, "MAX_REQUEST_BYTES", 50) + with pytest.raises(InvalidRequestError, match="exceeds"): + parse_request("{not valid json " + "x" * 500) + + +def test_total_size_is_the_upper_bound_regardless_of_per_field_limits(): + # Per-field limits (1M-char text block, 1000 messages) are individually far + # larger than the 10 MiB raw cap, so the raw cap is the true upper bound. + assert MAX_TEXT_BLOCK_CHARS < MAX_REQUEST_BYTES + # 1000 messages at even a modest size would exceed the raw cap and be + # rejected on raw size first. + assert MAX_HISTORY_MESSAGES > 0 + + +# ── events (sequence + discriminated union) ───────────────────────────────── + +def test_every_event_has_required_envelope_fields(): + for ev in ( + TurnStartedV1(run_id="r", attempt_id="a", sequence=1), + TurnCompletedV1(run_id="r", attempt_id="a", sequence=2, final_text="done"), + TurnFailedV1( + run_id="r", attempt_id="a", sequence=2, + error=TurnErrorV1(code=ErrorCodeV1.INTERNAL_TURN_FAILURE, message="boom"), + ), + ): + decoded = json.loads(event_line(ev)) + assert decoded["protocol_version"] == PROTOCOL_VERSION + assert decoded["run_id"] == "r" + assert decoded["attempt_id"] == "a" + assert "sequence" in decoded + assert decoded["kind"] == ev.kind + assert "\n" not in event_line(ev) + + +def test_completed_carries_output_messages_and_final_text(): + ev = TurnCompletedV1( + run_id="r", attempt_id="a", sequence=2, final_text="hi", + output_messages=[MessageV1(role="assistant", content="hi")], + ) + decoded = json.loads(event_line(ev)) + assert decoded["kind"] == "turn.completed" + assert decoded["final_text"] == "hi" + assert decoded["output_messages"][0]["role"] == "assistant" + + +def test_failed_carries_structured_error(): + ev = TurnFailedV1( + run_id="r", attempt_id="a", sequence=2, + error=TurnErrorV1(code=ErrorCodeV1.DEADLINE_EXCEEDED, message="late", retryable=True), + ) + decoded = json.loads(event_line(ev)) + assert decoded["error"] == { + "code": "deadline_exceeded", "message": "late", "retryable": True, + } + + +def test_no_speculative_fields_on_completed(): + fields = set(TurnCompletedV1.model_fields) + for gone in ("usage", "history_rows", "artifact_manifest", "workspace_checkpoint"): + assert gone not in fields + + +def test_terminal_kinds(): + assert TERMINAL_KINDS == {"turn.completed", "turn.failed"} + assert TurnStartedV1(run_id="r", attempt_id="a", sequence=1).kind not in TERMINAL_KINDS From edf5001ff0cda820821fc7a2714c8d5d998848ab Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 11:52:28 +0200 Subject: [PATCH 07/11] Cloud turn tests --- tests/cloud_turn_fake_entry.py | 113 +++++++ tests/test_cloud_turn_runner.py | 301 +++++++++++++++++++ tests/test_cloud_turn_schemas.py | 76 +++++ tests/test_cloud_turn_session.py | 489 +++++++++++++++++++++++++++++++ 4 files changed, 979 insertions(+) create mode 100644 tests/cloud_turn_fake_entry.py create mode 100644 tests/test_cloud_turn_runner.py create mode 100644 tests/test_cloud_turn_schemas.py create mode 100644 tests/test_cloud_turn_session.py diff --git a/tests/cloud_turn_fake_entry.py b/tests/cloud_turn_fake_entry.py new file mode 100644 index 00000000..447d17ab --- /dev/null +++ b/tests/cloud_turn_fake_entry.py @@ -0,0 +1,113 @@ +"""Deterministic subprocess entrypoint for cloud-turn E2E / FD tests. + +Run as ``python tests/cloud_turn_fake_entry.py`` — it installs a deterministic +seam, then calls the REAL ``anton.cloud_turn.__main__.main()`` so the full +process boundary (FD isolation, stdin parse, runner lifecycle, scratchpad) runs +for real. NOT collected by pytest (no ``test_`` prefix). + +Modes (env ``CLOUD_TURN_FAKE_MODE``): +* ``model`` — real ChatSession + real scratchpad, but the LLM is a fake provider + scripted by ``CLOUD_TURN_FAKE_SCRIPT`` (JSON list of steps). No network. +* ``stray`` — replace the session builder with one whose turn writes stray + output to stdout/FD 1/logging, then completes (proves FD isolation). +* ``stray_fail`` — like ``stray`` but the turn raises after the stray output. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys + + +def _install_fake_model() -> None: + import anton.core.llm.client as client_mod + from anton.core.llm.client import LLMClient + from anton.core.llm.provider import ( + LLMProvider, + LLMResponse, + ProviderConnectionInfo, + ToolCall, + Usage, + ) + + script = json.loads(os.environ.get("CLOUD_TURN_FAKE_SCRIPT", "[]")) + + class _FakeProvider(LLMProvider): + name = "fake" + + def __init__(self) -> None: + self._i = 0 + + async def complete(self, *, model, system, messages, tools=None, + tool_choice=None, max_tokens=4096, native_web_tools=None): + step = script[min(self._i, len(script) - 1)] if script else {"text": ""} + self._i += 1 + tool_calls = [] + if "tool" in step: + t = step["tool"] + tool_calls = [ToolCall(id=t.get("id", "t1"), name=t["name"], input=t["input"])] + return LLMResponse( + content=step.get("text", ""), + tool_calls=tool_calls, + usage=Usage(context_pressure=0.0), + ) + + def export_connection_info(self): + return ProviderConnectionInfo(provider="fake", api_key="fake") + + def _fake_from_settings(cls, settings): + prov = _FakeProvider() + return LLMClient( + planning_provider=prov, planning_model="fake-model", + coding_provider=prov, coding_model="fake-model", + ) + + client_mod.LLMClient.from_settings = classmethod(_fake_from_settings) + + +def _install_stray_session(fail: bool) -> None: + import anton.cloud_turn.runner as runner_mod + + class _StraySession: + def __init__(self) -> None: + self.history = [] + self.closed = False + + async def turn_stream(self, user_input, **kwargs): + # Every channel that must NOT reach the protocol stream: + print("STRAY via print()") # Python stdout + sys.stdout.write("STRAY via sys.stdout.write\n") # Python stdout + os.write(1, b"STRAY via os.write(1)\n") # direct FD 1 (native-style) + logging.getLogger("some.library").warning("STRAY via logging") + self.history.append({"role": "assistant", "content": "clean answer"}) + if fail: + raise RuntimeError("boom after stray output") + if False: # make this an async generator + yield + + def close(self): + self.closed = True + + runner_mod.build_cloud_chat_session = lambda request: _StraySession() + + +def main() -> int: + mode = os.environ.get("CLOUD_TURN_FAKE_MODE", "model") + if mode == "model": + _install_fake_model() + elif mode == "stray": + _install_stray_session(fail=False) + elif mode == "stray_fail": + _install_stray_session(fail=True) + else: + raise SystemExit(f"unknown CLOUD_TURN_FAKE_MODE={mode!r}") + + from anton.cloud_turn.__main__ import main as real_main + + return real_main([]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_cloud_turn_runner.py b/tests/test_cloud_turn_runner.py new file mode 100644 index 00000000..560439ec --- /dev/null +++ b/tests/test_cloud_turn_runner.py @@ -0,0 +1,301 @@ +"""Tests for the cloud-turn runner + entrypoint. + +Fully offline: a fake session (scripted history growth) replaces the real +ChatSession, so no LLM key or scratchpad is needed. +""" + +from __future__ import annotations + +import asyncio +import json + +from anton.cloud_turn.protocol import TurnRequestV1 +from anton.cloud_turn.runner import EXIT_FAILED, EXIT_OK, run_turn + + +class _FakeSession: + """Simulates Anton's history growth: turn_stream echoes the user input as a + user message (as the real session does), then appends scripted generated + messages, yielding a drive event per append.""" + + def __init__(self, initial_history=None, generated=None, raise_on_stream=None, print_mid=False): + self.history = list(initial_history or []) + self._generated = list(generated or []) + self._raise = raise_on_stream + self._print_mid = print_mid + self.closed = False + + async def turn_stream(self, user_input, **kwargs): + if self._raise: + raise self._raise + self.history.append({"role": "user", "content": user_input}) + yield object() + for msg in self._generated: + if self._print_mid: + print("STRAY STDOUT that must not corrupt the protocol") + self.history.append(msg) + yield object() + + def close(self): + self.closed = True + + +def _req(**over) -> TurnRequestV1: + body = dict(run_id="r", attempt_id="a", conversation_id="c", input="hi") + body.update(over) + return TurnRequestV1(**body) + + +def _collect_emit(): + events = [] + return events, events.append + + +# ── output messages (item 2) ──────────────────────────────────────────────── + +def test_text_only_turn_returns_final_assistant_message(): + events, emit = _collect_emit() + session = _FakeSession(generated=[{"role": "assistant", "content": "Hello world"}]) + code = asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + assert code == EXIT_OK + assert [e.kind for e in events] == ["turn.started", "turn.completed"] + completed = events[1] + assert completed.final_text == "Hello world" + assert len(completed.output_messages) == 1 + assert completed.output_messages[0].role == "assistant" + assert completed.output_messages[0].content == "Hello world" + + +def test_tool_using_turn_returns_all_persistable_messages_in_order(): + events, emit = _collect_emit() + generated = [ + {"role": "assistant", "content": [ + {"type": "text", "text": "let me compute"}, + {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {"code": "1+1"}}, + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "2"}, + ]}, + {"role": "assistant", "content": "The answer is 2"}, + ] + session = _FakeSession(generated=generated) + asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + msgs = events[1].output_messages + # assistant(text+tool_use) → user(tool_result) → final assistant, in order. + assert [m.role for m in msgs] == ["assistant", "user", "assistant"] + assert msgs[0].content[1].type == "tool_use" + assert msgs[1].content[0].type == "tool_result" + assert msgs[1].content[0].tool_use_id == "t1" + assert events[1].final_text == "The answer is 2" + + +def test_input_history_not_duplicated_in_output_messages(): + events, emit = _collect_emit() + prior = [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + ] + session = _FakeSession( + initial_history=prior, + generated=[{"role": "assistant", "content": "new answer"}], + ) + asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + msgs = events[1].output_messages + # Only the newly generated assistant message — not the prior turn, not the + # echoed current user input. + assert len(msgs) == 1 + assert msgs[0].content == "new answer" + + +def test_final_text_matches_final_assistant_text(): + events, emit = _collect_emit() + generated = [ + {"role": "assistant", "content": "intermediate narration"}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "FINAL"}]}, + ] + session = _FakeSession(generated=generated) + asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + assert events[1].final_text == "FINAL" + + +# ── terminal-event contract (item 7) ───────────────────────────────────────── + +def test_sequence_numbers_started_then_terminal(): + events, emit = _collect_emit() + session = _FakeSession(generated=[{"role": "assistant", "content": "ok"}]) + asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + assert [e.sequence for e in events] == [1, 2] + + +def test_builder_error_emits_started_then_failed(): + events, emit = _collect_emit() + + def boom(_req): + raise RuntimeError("no session") + + code = asyncio.run(run_turn(_req(), emit, session_builder=boom)) + assert code == EXIT_FAILED + assert [e.kind for e in events] == ["turn.started", "turn.failed"] + assert events[-1].error.code.value == "internal_turn_failure" + + +def test_stream_error_emits_exactly_one_terminal(): + events, emit = _collect_emit() + session = _FakeSession(raise_on_stream=ValueError("mid-turn boom")) + code = asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) + assert code == EXIT_FAILED + terminals = [e for e in events if e.kind in ("turn.completed", "turn.failed")] + assert len(terminals) == 1 + assert terminals[0].kind == "turn.failed" + assert session.closed is True # closed even on failure + + +# ── deadline semantics (item 5) ────────────────────────────────────────────── + +def test_future_deadline_runs_normally(): + events, emit = _collect_emit() + session = _FakeSession(generated=[{"role": "assistant", "content": "done"}]) + code = asyncio.run( + run_turn(_req(deadline_unix_ms=10_000), emit, + session_builder=lambda r: session, now_ms=0) + ) + assert code == EXIT_OK + assert events[-1].kind == "turn.completed" + + +def test_already_expired_deadline_fails_before_building_session(): + events, emit = _collect_emit() + built = {"n": 0} + + def builder(_r): + built["n"] += 1 + return _FakeSession(generated=[{"role": "assistant", "content": "x"}]) + + code = asyncio.run( + run_turn(_req(deadline_unix_ms=100), emit, session_builder=builder, now_ms=200) + ) + assert code == EXIT_FAILED + assert [e.kind for e in events] == ["turn.started", "turn.failed"] + assert events[-1].error.code.value == "deadline_exceeded" + assert built["n"] == 0 # failed immediately, before building the session + + +def test_timeout_during_execution_fails_with_one_terminal(): + events, emit = _collect_emit() + + class _Slow(_FakeSession): + async def turn_stream(self, user_input, **kwargs): + await asyncio.sleep(0.5) + self.history.append({"role": "assistant", "content": "late"}) + yield object() + + code = asyncio.run( + run_turn(_req(deadline_unix_ms=50), emit, + session_builder=lambda r: _Slow(), now_ms=0) + ) + assert code == EXIT_FAILED + terminals = [e for e in events if e.kind in ("turn.completed", "turn.failed")] + assert len(terminals) == 1 + assert terminals[0].error.code.value == "deadline_exceeded" + + + + +# ── entrypoint core (_run): request parsing → runner, no FD/stdin handling ──── +# FD-level stdout isolation and bounded stdin reads run in a REAL subprocess — +# see tests/test_cloud_turn_process.py. Here we exercise the parse→emit core in +# process with a capturing emit. + +def _run_core(stdin_text, session_builder=None): + from anton.cloud_turn import __main__ as entry + + events = [] + code = entry._run(stdin_text, events.append, session_builder=session_builder) + return code, events + + +def test_run_completes_and_emits_started_then_completed(): + session = _FakeSession(generated=[{"role": "assistant", "content": "done"}]) + req = json.dumps({"run_id": "r", "attempt_id": "a", "conversation_id": "c", "input": "hi"}) + code, events = _run_core(req, session_builder=lambda r: session) + assert code == EXIT_OK + assert [e.kind for e in events] == ["turn.started", "turn.completed"] + assert [e.sequence for e in events] == [1, 2] + assert events[-1].final_text == "done" + + +def test_run_bad_request_one_failed_no_started(): + code, events = _run_core('{"not":"a valid request"}') + assert code == 2 + assert len(events) == 1 + assert events[0].kind == "turn.failed" + assert events[0].sequence == 1 + assert events[0].error.code.value == "invalid_request" + + +# ── item 1: identifiers on pre-validation failures ─────────────────────────── + +def _bad_request_event(stdin_text): + code, events = _run_core(stdin_text) + assert code == 2 + assert len(events) == 1 and events[0].kind == "turn.failed" and events[0].sequence == 1 + return events[0] + + +def test_malformed_json_reports_null_ids(): + ev = _bad_request_event("{not json at all") + assert ev.run_id is None and ev.attempt_id is None + assert ev.error.code.value == "invalid_request" + + +def test_empty_stdin_reports_null_ids(): + ev = _bad_request_event("") + assert ev.run_id is None and ev.attempt_id is None + + +def test_missing_run_id_keeps_attempt_id(): + ev = _bad_request_event( + json.dumps({"attempt_id": "a", "conversation_id": "c", "input": "hi"}) + ) + assert ev.run_id is None + assert ev.attempt_id == "a" # echo the valid one, invent nothing + + +def test_missing_attempt_id_keeps_run_id(): + ev = _bad_request_event( + json.dumps({"run_id": "r", "conversation_id": "c", "input": "hi"}) + ) + assert ev.run_id == "r" + assert ev.attempt_id is None + + +def test_wrong_id_types_report_null(): + ev = _bad_request_event( + json.dumps({"run_id": 123, "attempt_id": ["x"], "conversation_id": "c", "input": "hi"}) + ) + assert ev.run_id is None and ev.attempt_id is None + + +# ── item 2: the runner closes the session on every terminal path ───────────── + +def test_session_closed_on_success(): + session = _FakeSession(generated=[{"role": "assistant", "content": "ok"}]) + asyncio.run(run_turn(_req(), lambda e: None, session_builder=lambda r: session)) + assert session.closed is True + + +def test_session_closed_on_deadline_timeout(): + class _Slow(_FakeSession): + async def turn_stream(self, user_input, **kwargs): + await asyncio.sleep(0.5) + self.history.append({"role": "assistant", "content": "late"}) + yield object() + + slow = _Slow() + code = asyncio.run( + run_turn(_req(deadline_unix_ms=50), lambda e: None, + session_builder=lambda r: slow, now_ms=0) + ) + assert code == EXIT_FAILED + assert slow.closed is True # closed even when the soft deadline fires diff --git a/tests/test_cloud_turn_schemas.py b/tests/test_cloud_turn_schemas.py new file mode 100644 index 00000000..fc8ed1db --- /dev/null +++ b/tests/test_cloud_turn_schemas.py @@ -0,0 +1,76 @@ +"""Exported V1 JSON Schemas + fixtures stay in sync with the models (items 5/6).""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import TypeAdapter + +from anton.cloud_turn import fixtures as fx +from anton.cloud_turn import schema_export as sx +from anton.cloud_turn.protocol import TurnEventV1, TurnRequestV1 + + +# ── item 5: committed schemas match the models ────────────────────────────── + +def test_committed_schemas_match_models(): + """Regenerate from the Pydantic models and diff the committed files, so a + protocol change can't land without updating the checked-in schema.""" + for path, expected in sx.expected_files().items(): + assert path.exists(), f"missing committed schema {path} — run schema_export" + assert path.read_text(encoding="utf-8") == expected, ( + f"{path.name} is stale — run `python -m anton.cloud_turn.schema_export`" + ) + + +def test_schemas_are_valid_json_with_expected_roots(): + req = json.loads(sx.REQUEST_SCHEMA_PATH.read_text()) + ev = json.loads(sx.EVENT_SCHEMA_PATH.read_text()) + assert req["title"] == "TurnRequestV1" + # The event schema is the discriminated union of the three event kinds. + assert "oneOf" in ev or "anyOf" in ev or "$defs" in ev + + +# ── item 6: committed fixtures match the models + validate ─────────────────── + +def test_committed_fixtures_match_models(): + for path, expected in fx.expected_files().items(): + assert path.exists(), f"missing fixture {path} — run fixtures" + assert path.read_text(encoding="utf-8") == expected, ( + f"{path.name} is stale — run `python -m anton.cloud_turn.fixtures`" + ) + + +_event_adapter = TypeAdapter(TurnEventV1) + + +@pytest.mark.parametrize("name", list(fx.FIXTURES)) +def test_each_fixture_validates_against_its_model(name): + """Round-trip each committed fixture through the SAME model the schema is + generated from — proving fixtures conform to the exported contract.""" + data = json.loads((fx.FIXTURE_DIR / f"{name}.json").read_text()) + if name.startswith("request-"): + TurnRequestV1.model_validate(data) + else: + _event_adapter.validate_python(data) + + +def test_representative_request_and_event_fixtures(): + # A representative request and a representative event, explicitly. + req = json.loads((fx.FIXTURE_DIR / "request-with-history.json").read_text()) + parsed = TurnRequestV1.model_validate(req) + assert parsed.history[0].content == "What is 2 + 2?" + + ev = json.loads((fx.FIXTURE_DIR / "event-turn-completed-tool-using.json").read_text()) + completed = _event_adapter.validate_python(ev) + assert completed.kind == "turn.completed" + assert completed.output_messages[1].content[0].type == "tool_result" + + +def test_invalid_request_fixture_has_null_ids(): + ev = json.loads( + (fx.FIXTURE_DIR / "event-turn-failed-invalid-request-null-ids.json").read_text() + ) + assert ev["run_id"] is None and ev["attempt_id"] is None + assert ev["error"]["code"] == "invalid_request" diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py new file mode 100644 index 00000000..dba02eef --- /dev/null +++ b/tests/test_cloud_turn_session.py @@ -0,0 +1,489 @@ +"""Safety tests for the cloud-safe ChatSession builder. + +Two layers: +* Config-level (fast, offline): assert the ChatSessionConfig handed to + ChatSession has every cross-tenant hazard off and the right allowlist/factory. +* Enforcement-level (real code paths): drive the real lazy _build_tools() and a + real sanitized scratchpad subprocess so a claimed property is actually proven, + not just configured. +""" + +from __future__ import annotations + +import pytest + +import anton.core.llm.client as llm_client_mod +import anton.core.session as session_mod +from anton.cloud_turn.protocol import CapabilitiesV1, TurnRequestV1 +from anton.cloud_turn.session import ( + CLOUD_TOOL_ALLOWLIST, + _MODEL_ALLOWLIST_ENV, + _WORKSPACE_PATH_ENV, + build_cloud_chat_session, + resolve_trusted_workspace_path, +) +from anton.core.backends.local import sanitized_scratchpad_runtime_factory + + +class _FakeRegistry: + def __init__(self) -> None: + self.removed: list[str] = [] + + def unregister_tool(self, name: str) -> None: + self.removed.append(name) + + def get_tool_defs(self): + return [] + + +class _FakeSession: + def __init__(self) -> None: + self.tool_registry = _FakeRegistry() + + +def _build(tmp_path, monkeypatch, **req_overrides): + captured: dict = {} + + def fake_chat_session(config): + captured["config"] = config + return _FakeSession() + + monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session) + monkeypatch.setattr( + llm_client_mod.LLMClient, "from_settings", + classmethod(lambda cls, settings: object()), + ) + # The mount path is trusted pod config, supplied via env — never the wire. + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + + body = dict( + run_id="run_1", attempt_id="att_1", conversation_id="conv_1", + input="hello", + ) + body.update(req_overrides) + session = build_cloud_chat_session(TurnRequestV1(**body)) + return session, captured["config"] + + +def test_all_cross_tenant_hazards_off(tmp_path, monkeypatch): + _, cfg = _build(tmp_path, monkeypatch) + assert cfg.cortex is None # personal memory OFF + assert cfg.episodic is None + assert cfg.self_awareness is None + assert cfg.data_vault is None # connectors / vault OFF + assert cfg.history_store is None # local-file history OFF + assert cfg.console is None # headless + assert cfg.tools == [] # no host connector/publish tools + assert cfg.web_search_enabled is False + assert cfg.web_fetch_enabled is False + + +def test_scratchpad_uses_sanitized_factory_and_is_workspace_bound(tmp_path, monkeypatch): + _, cfg = _build(tmp_path, monkeypatch) + # Scratchpad stays ON but through the sanitized factory (no secret env). + assert cfg.runtime_factory is sanitized_scratchpad_runtime_factory + assert cfg.workspace is not None + assert cfg.harness == "cloud" + assert cfg.session_id == "conv_1" + + +def test_db_history_is_seeded_not_loaded(tmp_path, monkeypatch): + _, cfg = _build( + tmp_path, monkeypatch, + history=[{"role": "user", "content": "prior turn"}], + ) + assert cfg.initial_history == [{"role": "user", "content": "prior turn"}] + + +def test_config_uses_explicit_tool_allowlist(tmp_path, monkeypatch): + _, cfg = _build(tmp_path, monkeypatch) + # An explicit allowlist (not a denylist) drives the lazy _build_tools(). + assert cfg.tool_allowlist == CLOUD_TOOL_ALLOWLIST + assert "launch_backend" not in cfg.tool_allowlist + assert "select_path" not in cfg.tool_allowlist + assert "scratchpad" in cfg.tool_allowlist + + +# ── model-selection policy (item 6) ───────────────────────────────────────── + +def test_model_override_rejected_by_default(tmp_path, monkeypatch): + # Default: no trusted allowlist → a request-selected model is rejected. + from anton.cloud_turn.errors import UnsupportedModelError + + monkeypatch.delenv(_MODEL_ALLOWLIST_ENV, raising=False) + with pytest.raises(UnsupportedModelError, match="claude-opus-4-8"): + _build(tmp_path, monkeypatch, model="claude-opus-4-8") + + +def test_model_override_permitted_when_allowlisted(tmp_path, monkeypatch): + monkeypatch.setenv(_MODEL_ALLOWLIST_ENV, "claude-opus-4-8, other-model") + _, cfg = _build(tmp_path, monkeypatch, model="claude-opus-4-8") + assert cfg.settings.planning_model == "claude-opus-4-8" + + +def test_no_model_uses_trusted_settings_default(tmp_path, monkeypatch): + # No override → whatever the trusted settings resolved to (unchanged by us). + monkeypatch.delenv(_MODEL_ALLOWLIST_ENV, raising=False) + _, cfg = _build(tmp_path, monkeypatch) # no model in request + assert cfg.settings.planning_model # a default is present, not forced by wire + + +# ── #1 trusted workspace path ──────────────────────────────────────────────── + +def test_request_cannot_carry_workspace_path(): + # The wire model forbids it (extra="forbid"); a request can't steer the path. + assert "workspace_path" not in TurnRequestV1.model_fields + + +def test_resolver_uses_env_override(tmp_path, monkeypatch): + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + assert resolve_trusted_workspace_path() == tmp_path.resolve() + + +def test_resolver_rejects_relative_path(monkeypatch): + monkeypatch.setenv(_WORKSPACE_PATH_ENV, "not/absolute") + with pytest.raises(ValueError, match="absolute"): + resolve_trusted_workspace_path() + + +def test_resolver_rejects_parent_traversal(monkeypatch): + monkeypatch.setenv(_WORKSPACE_PATH_ENV, "/workspace/../etc") + with pytest.raises(ValueError, match=r"\.\."): + resolve_trusted_workspace_path() + + +def test_artifact_tools_cannot_escape_workspace(tmp_path): + """The only enabled file tools (artifacts) resolve strictly under + /artifacts/. A slug with traversal must be rejected, and a + read-style lookup for an escaping slug must fail closed (no artifact).""" + from anton.core.artifacts.store import ArtifactStore + + store = ArtifactStore(tmp_path / "artifacts") + for bad in ("../../etc", "/etc/passwd", "a/../../b", "foo/../../../bar"): + with pytest.raises(ValueError, match="escapes"): + store.folder_for(bad) + # open() on an escaping slug returns None rather than reading outside. + assert store.open("../../../etc/passwd") is None + # A normal slug still resolves inside the root. + assert store.folder_for("my-report").parent == (tmp_path / "artifacts") + + +def test_artifact_containment_is_canonical_not_lexical(tmp_path): + """Containment is enforced by real-path resolution, so a nested slug that + stays INSIDE the root is allowed (create never emits these — _sanitize_slug + collapses '/'—but open/update must not reject a legitimately-nested path), + while any slug that resolves outside is rejected.""" + from anton.core.artifacts.store import ArtifactStore + + root = tmp_path / "artifacts" + store = ArtifactStore(root) + # Nested-but-contained: allowed, and stays under the root. + nested = store.folder_for("reports/summary") + assert root.resolve() in nested.resolve().parents + # A ".." that stays inside is fine; one that escapes is not. + assert store.folder_for("reports/../summary").resolve() == (root / "summary").resolve() + with pytest.raises(ValueError, match="escapes"): + store.folder_for("reports/../../summary") + + +# ── #2 dotenv is never loaded ──────────────────────────────────────────────── + +def test_apply_env_to_process_never_called(tmp_path, monkeypatch): + import anton.workspace as ws_mod + calls = {"apply_env": 0, "init": 0} + real_init = ws_mod.Workspace.initialize + real_apply = ws_mod.Workspace.apply_env_to_process + + def spy_init(self): + calls["init"] += 1 + return real_init(self) + + def spy_apply(self): + calls["apply_env"] += 1 + return real_apply(self) + + monkeypatch.setattr(ws_mod.Workspace, "initialize", spy_init) + monkeypatch.setattr(ws_mod.Workspace, "apply_env_to_process", spy_apply) + + _build(tmp_path, monkeypatch) + assert calls["init"] == 1 # structure created + assert calls["apply_env"] == 0 # but .env NOT loaded into process env + + +def test_cloud_settings_ignore_dotenv_files(tmp_path, monkeypatch): + """A .env in the AntonSettings chain must NOT seed the cloud session's + settings — dotenv loading is disabled at the source, not merely unused.""" + from anton.config.settings import AntonSettings + + # Neutralise ambient sources for this key so the assertions turn only on + # whether a dotenv file is read (env vars would otherwise win over dotenv). + monkeypatch.delenv("ANTON_ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + # Positive control: an explicit dotenv IS honoured by AntonSettings. + envf = tmp_path / "sentinel.env" + envf.write_text("ANTON_ANTHROPIC_API_KEY=DOTENV_SENTINEL\n") + assert AntonSettings(_env_file=str(envf)).anthropic_api_key == "DOTENV_SENTINEL" + + # A .env sitting in the workspace/cwd must NOT leak into the cloud session, + # which builds AntonSettings with the whole dotenv chain disabled. + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("ANTON_ANTHROPIC_API_KEY=DOTENV_SENTINEL\n") + _, cfg = _build(tmp_path, monkeypatch) + assert cfg.settings.anthropic_api_key != "DOTENV_SENTINEL" + + +# ── #3/#4 real enforcement paths ───────────────────────────────────────────── + +def _mock_llm(): + """Minimal LLM client sufficient for ChatSession.__init__ + _build_tools + (mirrors tests/test_tools.py::_make_session).""" + from unittest.mock import AsyncMock, MagicMock + + from anton.core.llm.provider import ProviderConnectionInfo + + llm = AsyncMock() + llm.coding_provider = MagicMock() + llm.coding_provider.export_connection_info = MagicMock( + return_value=ProviderConnectionInfo(provider="anthropic", api_key="test") + ) + llm.coding_model = "claude-sonnet-4-6" + llm.planning_provider = MagicMock() + llm.planning_provider.native_web_tools = MagicMock(return_value=set()) + return llm + + +def test_final_tool_set_equals_allowlist_after_real_build(tmp_path, monkeypatch): + """#4 regression: the registry is built LAZILY at turn time, so the allowlist + must be enforced by the real _build_tools() — not merely configured. Assert + the EXACT final tool set, so a newly-registered core tool can't slip in.""" + from unittest.mock import MagicMock + + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + monkeypatch.setattr( + llm_client_mod.LLMClient, "from_settings", + classmethod(lambda cls, settings: _mock_llm()), + ) + req = TurnRequestV1(run_id="r", attempt_id="a", conversation_id="c", input="hi") + session = build_cloud_chat_session(req) # REAL ChatSession (not mocked) + # Don't spawn a real scratchpad subprocess when the tools get built. + session._scratchpads = MagicMock(available_packages=[]) + + session._build_tools() + names = {t.name for t in session.tool_registry.get_tool_defs()} + + assert names == set(CLOUD_TOOL_ALLOWLIST), ( + f"cloud tool set drifted from the allowlist: {sorted(names)}" + ) + + +def test_unsupported_capability_fails_loud(tmp_path, monkeypatch): + from anton.cloud_turn.errors import UnsupportedCapabilityError + + for field in CapabilitiesV1.model_fields: + with pytest.raises(UnsupportedCapabilityError, match=field): + _build(tmp_path, monkeypatch, capabilities={field: True}) + + +async def test_sanitized_scratchpad_cannot_read_secret_env(tmp_path, monkeypatch): + """#3 enforcement (real subprocess): with the sanitized factory, secret + sentinels present in the parent env must be UNREADABLE from cell code.""" + # Plant secrets of every category the sanitizer must withhold. + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-SENTINEL") + monkeypatch.setenv("ANTON_ANTHROPIC_API_KEY", "ANTON-KEY-SENTINEL") + monkeypatch.setenv("ANTON_GATEWAY_TOKEN", "GATEWAY-SENTINEL") + monkeypatch.setenv("DS_POSTGRES_MAIN__PASSWORD", "DATASOURCE-SENTINEL") + monkeypatch.setenv("MY_UNRELATED_SECRET", "UNRELATED-SENTINEL") + + pad = sanitized_scratchpad_runtime_factory( + name="cloud-sanitize", + coding_provider="anthropic", + coding_model="", + coding_api_key="", + coding_base_url="", + cells=None, + workspace_path=tmp_path, + ) + await pad.start() + try: + cell = await pad.execute( + "import os, json\n" + "keys = ['ANTHROPIC_API_KEY','ANTON_ANTHROPIC_API_KEY'," + "'ANTON_GATEWAY_TOKEN','DS_POSTGRES_MAIN__PASSWORD','MY_UNRELATED_SECRET']\n" + "print(json.dumps({k: os.environ.get(k) for k in keys}))" + ) + assert cell.error is None, cell.error + import json + + seen = json.loads(cell.stdout.strip()) + assert all(v is None for v in seen.values()), f"secret leaked into cell env: {seen}" + finally: + await pad.close() + + +def _real_session_with_workspace(tmp_path, **cfg_overrides): + """Build a REAL ChatSession bound to a workspace (so artifact + + launch_backend tools register), with the scratchpad stubbed so + _build_tools() doesn't spawn a subprocess.""" + from unittest.mock import MagicMock + + from anton.core.session import ChatSession, ChatSessionConfig + from anton.workspace import Workspace + + ws = Workspace(tmp_path) + ws.initialize() + session = ChatSession( + ChatSessionConfig(llm_client=_mock_llm(), workspace=ws, **cfg_overrides) + ) + session._scratchpads = MagicMock(available_packages=[]) + return session + + +def test_unknown_allowlist_entry_fails_loud(tmp_path): + """#1 (item): an allowlist name that matches no built tool must raise, not be + silently dropped — otherwise the host believes a tool is enabled when it can + never appear.""" + session = _real_session_with_workspace( + tmp_path, tool_allowlist=frozenset({"scratchpad", "totally_made_up_tool"}) + ) + with pytest.raises(ValueError, match="totally_made_up_tool"): + session._build_tools() + + +def test_tool_allowlist_none_preserves_desktop_tools(tmp_path): + """Regression: tool_allowlist=None (the desktop default) applies NO filtering + — the full core set, including launch_backend and select_path, is present.""" + session = _real_session_with_workspace(tmp_path) # tool_allowlist defaults to None + assert session._tool_allowlist is None + session._build_tools() + names = {t.name for t in session.tool_registry.get_tool_defs()} + # Tools the cloud allowlist strips must still be here on desktop. + assert {"launch_backend", "select_path", "scratchpad", "create_artifact"} <= names + + +def test_sanitized_env_contract(monkeypatch): + """#4 (item): the sanitized parent env passes through ONLY the documented + non-secret allowlist (paths / temp dirs / locale / TLS roots), and never a + credential — and only when the parent actually defines the name.""" + from anton.core.backends.local import ( + _SCRATCHPAD_ENV_ALLOWLIST, + _sanitized_parent_env, + ) + + # Documented minimums the contract must permit through when present. + required = { + "PATH": "/usr/bin:/bin", + "HOME": "/home/anton", + "TMPDIR": "/tmp/x", + "LANG": "en_US.UTF-8", + "SSL_CERT_FILE": "/etc/ssl/cert.pem", + } + for k, v in required.items(): + assert k in _SCRATCHPAD_ENV_ALLOWLIST, f"{k} must be in the contract" + monkeypatch.setenv(k, v) + + # Secrets of every category the sanitizer must withhold. + secrets = { + "ANTHROPIC_API_KEY": "sk-ant-x", + "ANTON_MINDS_API_KEY": "anton-x", + "ANTON_GATEWAY_TOKEN": "gw-x", + "DS_POSTGRES_MAIN__PASSWORD": "ds-x", + "OPENAI_API_KEY": "sk-oai-x", + "SOME_RANDOM_SECRET": "nope", + } + for k, v in secrets.items(): + monkeypatch.setenv(k, v) + + env = _sanitized_parent_env() + + for k, v in required.items(): + assert env.get(k) == v, f"required env {k} was dropped" + for k in secrets: + assert k not in env, f"secret {k} leaked through the allowlist" + # Nothing outside the allowlist survives. + assert set(env) <= set(_SCRATCHPAD_ENV_ALLOWLIST) + + +async def test_desktop_scratchpad_env_unchanged(tmp_path, monkeypatch): + """Regression (real subprocess): sanitize_env=False (desktop default via + local_scratchpad_runtime_factory) STILL inherits the parent env — a planted + secret remains readable, i.e. the sanitizer is strictly opt-in.""" + from anton.core.backends.local import local_scratchpad_runtime_factory + + monkeypatch.setenv("MY_DESKTOP_SENTINEL", "DESKTOP-VISIBLE") + pad = local_scratchpad_runtime_factory( + name="desktop-env", + coding_provider="anthropic", + coding_model="", + coding_api_key="", + coding_base_url="", + cells=None, + workspace_path=tmp_path, + ) + await pad.start() + try: + cell = await pad.execute( + "import os; print(os.environ.get('MY_DESKTOP_SENTINEL', 'MISSING'))" + ) + assert cell.error is None, cell.error + assert cell.stdout.strip() == "DESKTOP-VISIBLE" + finally: + await pad.close() + + +# ── item 2: history-delta slicing against the REAL mutation paths ──────────── + +async def test_output_survives_real_compaction_and_preserves_request(tmp_path, monkeypatch): + """Drive the REAL _append_history + _summarize_history (the exact history + mutations turn_stream uses — compaction reassigns the list and collapses the + old prefix) and prove current-turn collection is still correct.""" + from anton.cloud_turn.messages import final_assistant_text, turn_output_messages + + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + monkeypatch.setattr( + llm_client_mod.LLMClient, "from_settings", + classmethod(lambda cls, settings: _mock_llm()), + ) + # Input history long enough to trigger real compaction (>= 6 messages). + req_history = [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} + for i in range(8) + ] + req = TurnRequestV1( + run_id="r", attempt_id="a", conversation_id="c", + input="CURRENT INPUT", history=req_history, + ) + request_history_before = [m.model_copy(deep=True) for m in req.history] + + session = build_cloud_chat_session(req) # REAL ChatSession + pre_turn_messages = list(session.history) # hold refs → stable ids + + # Replay what turn_stream does: echo user input, compact mid-turn, then emit. + session._append_history({"role": "user", "content": "CURRENT INPUT"}) + await session._summarize_history() # REAL compaction (rewrites list) + session._append_history({"role": "assistant", "content": [ + {"type": "text", "text": "working"}, + {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {}}, + ]}) + session._append_history({"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}, + ]}) + session._append_history({"role": "assistant", "content": "FINAL"}) + + out = turn_output_messages(session.history, pre_turn_messages) + + # Only current-turn generated messages, in order; echo + summary excluded. + assert [m.role for m in out] == ["assistant", "user", "assistant"] + assert out[0].content[1].type == "tool_use" + assert out[1].content[0].type == "tool_result" + assert final_assistant_text(out) == "FINAL" + assert all( + not (isinstance(m.content, str) and m.content == "CURRENT INPUT") for m in out + ) + # Generated begin exactly after the boundary → the current-turn tail of the + # final session history, in the same order. + tail = session.history[-3:] + assert [m["role"] for m in tail] == ["assistant", "user", "assistant"] + assert tail[-1]["content"] == "FINAL" + # The request's own history object is untouched by the turn's mutations. + assert req.history == request_history_before From 63ce7f80d00cd1823f7196c5f49edac4b28dbaa7 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 12:05:08 +0200 Subject: [PATCH 08/11] Fix tests --- tests/test_cloud_turn_runner.py | 5 +---- tests/test_cloud_turn_schemas.py | 9 +++++++-- tests/test_cloud_turn_session.py | 15 ++------------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/tests/test_cloud_turn_runner.py b/tests/test_cloud_turn_runner.py index 560439ec..d9e0b2d9 100644 --- a/tests/test_cloud_turn_runner.py +++ b/tests/test_cloud_turn_runner.py @@ -18,11 +18,10 @@ class _FakeSession: user message (as the real session does), then appends scripted generated messages, yielding a drive event per append.""" - def __init__(self, initial_history=None, generated=None, raise_on_stream=None, print_mid=False): + def __init__(self, initial_history=None, generated=None, raise_on_stream=None): self.history = list(initial_history or []) self._generated = list(generated or []) self._raise = raise_on_stream - self._print_mid = print_mid self.closed = False async def turn_stream(self, user_input, **kwargs): @@ -31,8 +30,6 @@ async def turn_stream(self, user_input, **kwargs): self.history.append({"role": "user", "content": user_input}) yield object() for msg in self._generated: - if self._print_mid: - print("STRAY STDOUT that must not corrupt the protocol") self.history.append(msg) yield object() diff --git a/tests/test_cloud_turn_schemas.py b/tests/test_cloud_turn_schemas.py index fc8ed1db..a0a401a4 100644 --- a/tests/test_cloud_turn_schemas.py +++ b/tests/test_cloud_turn_schemas.py @@ -28,8 +28,13 @@ def test_schemas_are_valid_json_with_expected_roots(): req = json.loads(sx.REQUEST_SCHEMA_PATH.read_text()) ev = json.loads(sx.EVENT_SCHEMA_PATH.read_text()) assert req["title"] == "TurnRequestV1" - # The event schema is the discriminated union of the three event kinds. - assert "oneOf" in ev or "anyOf" in ev or "$defs" in ev + assert req["additionalProperties"] is False # extra="forbid" made it onto the wire schema + # The event schema is a union discriminated on `kind`, mapping exactly the + # three event types — not just "some union". + assert ev["discriminator"]["propertyName"] == "kind" + assert set(ev["discriminator"]["mapping"]) == { + "turn.started", "turn.completed", "turn.failed" + } # ── item 6: committed fixtures match the models + validate ─────────────────── diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index dba02eef..269e33e2 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -25,20 +25,9 @@ from anton.core.backends.local import sanitized_scratchpad_runtime_factory -class _FakeRegistry: - def __init__(self) -> None: - self.removed: list[str] = [] - - def unregister_tool(self, name: str) -> None: - self.removed.append(name) - - def get_tool_defs(self): - return [] - - class _FakeSession: - def __init__(self) -> None: - self.tool_registry = _FakeRegistry() + """Placeholder return value for the mocked ChatSession — the config-level + tests only inspect the captured ChatSessionConfig, never the session.""" def _build(tmp_path, monkeypatch, **req_overrides): From 9ec3fbca1d588143a5f016878c493b24ce9d5022 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 12:35:17 +0200 Subject: [PATCH 09/11] Rmv all old contract logic and tests --- anton/cloud_turn/errors.py | 107 --------- anton/cloud_turn/fixtures.py | 137 ------------ anton/cloud_turn/messages.py | 117 ---------- anton/cloud_turn/protocol.py | 292 ------------------------- anton/cloud_turn/runner.py | 190 ---------------- anton/cloud_turn/schema_export.py | 62 ------ tests/test_cloud_turn_capabilities.py | 73 ------- tests/test_cloud_turn_errors.py | 57 ----- tests/test_cloud_turn_messages.py | 83 ------- tests/test_cloud_turn_protocol.py | 222 ------------------- tests/test_cloud_turn_runner.py | 298 -------------------------- tests/test_cloud_turn_schemas.py | 81 ------- 12 files changed, 1719 deletions(-) delete mode 100644 anton/cloud_turn/errors.py delete mode 100644 anton/cloud_turn/fixtures.py delete mode 100644 anton/cloud_turn/messages.py delete mode 100644 anton/cloud_turn/protocol.py delete mode 100644 anton/cloud_turn/runner.py delete mode 100644 anton/cloud_turn/schema_export.py delete mode 100644 tests/test_cloud_turn_capabilities.py delete mode 100644 tests/test_cloud_turn_errors.py delete mode 100644 tests/test_cloud_turn_messages.py delete mode 100644 tests/test_cloud_turn_protocol.py delete mode 100644 tests/test_cloud_turn_runner.py delete mode 100644 tests/test_cloud_turn_schemas.py diff --git a/anton/cloud_turn/errors.py b/anton/cloud_turn/errors.py deleted file mode 100644 index 55b57d30..00000000 --- a/anton/cloud_turn/errors.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Typed cloud-turn errors + central exception → :class:`TurnErrorV1` mapping. - -One place decides the wire ``code``/``retryable`` for every failure, so the -mapping can't drift. Wire messages are short and credential-scrubbed; full -tracebacks stay on stderr (the runner logs them via ``logger.exception``). -""" - -from __future__ import annotations - -from anton.cloud_turn.protocol import ErrorCodeV1, TurnErrorV1 -from anton.utils.datasources import scrub_credentials - -#: Wire error messages are truncated to keep them short and log-safe. -MAX_ERROR_MESSAGE_CHARS = 300 - - -class CloudTurnError(Exception): - """Base for failures the runner raises itself. Each subclass pins a stable - wire code + retryable flag.""" - - code: ErrorCodeV1 = ErrorCodeV1.INTERNAL_TURN_FAILURE - retryable: bool = False - - -class InvalidRequestError(CloudTurnError): - code = ErrorCodeV1.INVALID_REQUEST - retryable = False - - -class UnsupportedProtocolVersionError(CloudTurnError): - code = ErrorCodeV1.UNSUPPORTED_PROTOCOL_VERSION - retryable = False - - -class UnsupportedCapabilityError(CloudTurnError): - code = ErrorCodeV1.UNSUPPORTED_CAPABILITY - retryable = False - - -class UnsupportedModelError(CloudTurnError): - code = ErrorCodeV1.UNSUPPORTED_MODEL - retryable = False - - -class DeadlineExceededError(CloudTurnError): - code = ErrorCodeV1.DEADLINE_EXCEEDED - retryable = True - - -def _clean(exc: Exception) -> str: - text = scrub_credentials(f"{type(exc).__name__}: {exc}") - if len(text) > MAX_ERROR_MESSAGE_CHARS: - text = text[: MAX_ERROR_MESSAGE_CHARS - 1] + "…" - return text - - -def _looks_like_auth(message: str) -> bool: - low = message.lower() - return any(s in low for s in ("api key", "unauthorized", "authentication", "401")) - - -def classify_error(exc: Exception) -> TurnErrorV1: - """Map any exception onto a structured :class:`TurnErrorV1`. - - Order matters: several Anton provider errors subclass ``ConnectionError``, - so the specific types are checked before the generic one. - """ - # Errors the runner raises itself already carry code + retryable. - if isinstance(exc, CloudTurnError): - return TurnErrorV1(code=exc.code, message=_clean(exc), retryable=exc.retryable) - - # Anton provider/LLM exceptions (imported lazily so importing this module - # never drags in the provider stack). - from anton.core.llm.provider import ( - ContextOverflowError, - ModelUnavailableError, - ProviderOverloadedError, - TokenLimitExceeded, - TransientProviderError, - ) - - if isinstance(exc, ModelUnavailableError): - # Gateway rejected the model (plan tier / kill switch). - return TurnErrorV1( - code=ErrorCodeV1.UNSUPPORTED_MODEL, message=_clean(exc), retryable=False - ) - if isinstance(exc, (TransientProviderError, ProviderOverloadedError)): - return TurnErrorV1( - code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=True - ) - if isinstance(exc, (TokenLimitExceeded, ContextOverflowError)): - # Quota / context-length — a bare retry of the identical request won't fix it. - return TurnErrorV1( - code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=False - ) - if isinstance(exc, ConnectionError): - if _looks_like_auth(str(exc)): - return TurnErrorV1( - code=ErrorCodeV1.MODEL_AUTH_FAILURE, message=_clean(exc), retryable=False - ) - return TurnErrorV1( - code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, message=_clean(exc), retryable=True - ) - - return TurnErrorV1( - code=ErrorCodeV1.INTERNAL_TURN_FAILURE, message=_clean(exc), retryable=False - ) diff --git a/anton/cloud_turn/fixtures.py b/anton/cloud_turn/fixtures.py deleted file mode 100644 index 3f12c814..00000000 --- a/anton/cloud_turn/fixtures.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Canonical V1 protocol JSON fixtures for downstream consumers. - -Derived from the real Pydantic models so they can't drift from the wire -contract. Committed under ``schemas/fixtures/`` for scratchpad-controller and -cowork-server tests to consume. ``tests/test_cloud_turn_schemas.py`` regenerates -and diffs them, and validates each against the exported JSON Schema. - -Run ``python -m anton.cloud_turn.fixtures`` to rewrite the committed files. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from anton.cloud_turn.protocol import ( - ErrorCodeV1, - MessageV1, - TurnCompletedV1, - TurnFailedV1, - TurnRequestV1, - TurnStartedV1, - TurnErrorV1, -) -from anton.cloud_turn.schema_export import SCHEMA_DIR - -FIXTURE_DIR = SCHEMA_DIR / "fixtures" - - -def _request_text_only() -> TurnRequestV1: - return TurnRequestV1( - run_id="run_1", attempt_id="att_1", conversation_id="conv_1", - organization_id="acme", user_id="u1", workspace_id="proj_1", - input="What is 2 + 2?", - ) - - -def _request_with_history() -> TurnRequestV1: - return TurnRequestV1( - run_id="run_2", attempt_id="att_1", conversation_id="conv_1", - input="And multiply that by 10.", - history=[ - MessageV1(role="user", content="What is 2 + 2?"), - MessageV1(role="assistant", content="2 + 2 = 4."), - ], - ) - - -def _started() -> TurnStartedV1: - return TurnStartedV1(run_id="run_1", attempt_id="att_1", sequence=1) - - -def _completed_text_only() -> TurnCompletedV1: - return TurnCompletedV1( - run_id="run_1", attempt_id="att_1", sequence=2, - final_text="2 + 2 = 4.", - output_messages=[MessageV1(role="assistant", content="2 + 2 = 4.")], - ) - - -def _completed_tool_using() -> TurnCompletedV1: - return TurnCompletedV1( - run_id="run_3", attempt_id="att_1", sequence=2, - final_text="The file has 3 lines.", - output_messages=[ - MessageV1(role="assistant", content=[ - {"type": "text", "text": "Let me count the lines."}, - {"type": "tool_use", "id": "t1", "name": "scratchpad", - "input": {"action": "exec", "name": "main", - "code": "print(sum(1 for _ in open('data.txt')))"}}, - ]), - MessageV1(role="user", content=[ - {"type": "tool_result", "tool_use_id": "t1", "content": "[output]\n3"}, - ]), - MessageV1(role="assistant", content="The file has 3 lines."), - ], - ) - - -def _failed() -> TurnFailedV1: - return TurnFailedV1( - run_id="run_4", attempt_id="att_1", sequence=2, - error=TurnErrorV1( - code=ErrorCodeV1.MODEL_PROVIDER_FAILURE, - message="ConnectionError: the model provider is momentarily overloaded.", - retryable=True, - ), - ) - - -def _failed_invalid_request_null_ids() -> TurnFailedV1: - return TurnFailedV1( - run_id=None, attempt_id=None, sequence=1, - error=TurnErrorV1( - code=ErrorCodeV1.INVALID_REQUEST, - message="InvalidRequestError: not valid JSON", - retryable=False, - ), - ) - - -#: name → model instance. The filename is ``.json``. -FIXTURES = { - "request-text-only": _request_text_only, - "request-with-history": _request_with_history, - "event-turn-started": _started, - "event-turn-completed-text": _completed_text_only, - "event-turn-completed-tool-using": _completed_tool_using, - "event-turn-failed": _failed, - "event-turn-failed-invalid-request-null-ids": _failed_invalid_request_null_ids, -} - - -def expected_files() -> dict[Path, str]: - """Map of committed fixture path → expected serialized JSON.""" - out: dict[Path, str] = {} - for name, factory in FIXTURES.items(): - model = factory() - payload = json.loads(model.model_dump_json()) - out[FIXTURE_DIR / f"{name}.json"] = ( - json.dumps(payload, indent=2, sort_keys=True) + "\n" - ) - return out - - -def write_fixtures() -> list[Path]: - FIXTURE_DIR.mkdir(parents=True, exist_ok=True) - written = [] - for path, content in expected_files().items(): - path.write_text(content, encoding="utf-8") - written.append(path) - return written - - -if __name__ == "__main__": - for p in write_fixtures(): - print(f"wrote {p}") diff --git a/anton/cloud_turn/messages.py b/anton/cloud_turn/messages.py deleted file mode 100644 index fd047e82..00000000 --- a/anton/cloud_turn/messages.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Explicit conversion: Anton internal messages → V1 wire messages. - -Anton stores Anthropic-style dicts (``{"role", "content"}`` where content is a -string or a list of ``text`` / ``tool_use`` / ``tool_result`` blocks). We never -put raw internal dicts on the wire — everything goes through :class:`MessageV1` -validation here, so the wire shape is enforced and any unsupported block type -fails loudly rather than being emitted unchecked. - -V1 limitation: only ``text`` / ``tool_use`` / ``tool_result`` blocks are -representable (the cloud tool surface — scratchpad + artifacts — produces only -these). An ``image`` or other block type raises :class:`MessageConversionError` -instead of being silently dropped. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import ValidationError - -from anton.cloud_turn.protocol import ContentBlockV1, MessageV1 - - -class MessageConversionError(Exception): - """An Anton message could not be represented in the V1 wire shape.""" - - -# ── wire → Anton (input direction) ────────────────────────────────────────── -# The request's typed models must become the Anthropic-style dicts Anton's -# ChatSession/turn_stream consume. model_dump() yields exactly that shape -# ({"type": ...} blocks, string content preserved). - - -def to_anton_input(value: "str | list[ContentBlockV1]") -> "str | list[dict]": - """Convert the request's ``input`` to what ``turn_stream`` expects.""" - if isinstance(value, str): - return value - return [block.model_dump() for block in value] - - -def to_anton_history(history: list[MessageV1]) -> list[dict[str, Any]]: - """Convert the request's typed input history to Anton history dicts.""" - return [msg.model_dump() for msg in history] - - -def _to_wire_message(msg: dict) -> MessageV1: - try: - return MessageV1.model_validate(msg) - except ValidationError as exc: - raise MessageConversionError( - f"message not representable in V1: {exc}" - ) from exc - - -def turn_output_messages( - history: list[dict], pre_turn_messages: list[dict] -) -> list[MessageV1]: - """Return only the messages GENERATED during the current turn. - - NOT length-based slicing: ``ChatSession.turn_stream`` can rewrite history - mid-turn — ``_summarize_history`` reassigns the list, collapsing the old - prefix into a *new* summary message (plus an "Understood" separator), and a - tool-result "seal" can ``insert`` a message. A stored index would then point - at the wrong place. - - Instead we anchor on object identity. ``pre_turn_messages`` is the list of - message objects captured BEFORE the turn (``list(session.history)``). The - caller MUST keep it alive across the turn: holding the references guarantees - those objects can't be garbage-collected and have their ``id()`` recycled by - a message created during the turn (compaction frees the collapsed prefix — - without live references its ids get reused, causing false matches). - - The current-turn region is the suffix of the final history after the LAST - message whose identity was present pre-turn (the most recent surviving input - message). Compaction keeps a suffix of the input turns as the anchor and - prepends the summary/separator BEFORE them, so those artifacts fall outside - the region; a seal-inserted tool_result lands after the current assistant - and stays inside it. We then drop the leading user echo (this turn's input, - which cowork-server already owns) and start at the first assistant message. - - Assumption (documented): the input history is well-formed — a compaction - summary only ever precedes surviving input messages, and the current user - input does not merge into a prior history entry (input ends with an - assistant message or is empty). A message compacted away mid-turn is, by - design, absent from the returned record. - """ - pre_turn_ids = {id(m) for m in pre_turn_messages} - # Anchor: index just past the last surviving pre-turn (input) message. - boundary = 0 - for i in range(len(history) - 1, -1, -1): - if id(history[i]) in pre_turn_ids: - boundary = i + 1 - break - generated = history[boundary:] - # Drop the leading input echo: start at the first assistant message. Tool - # results (also user role) are interior — they follow an assistant tool_use. - start = None - for i, m in enumerate(generated): - if m.get("role") == "assistant": - start = i - break - if start is None: - return [] # no assistant output produced this turn - return [_to_wire_message(m) for m in generated[start:]] - - -def final_assistant_text(messages: list[MessageV1]) -> str: - """Concatenated text of the last assistant message (empty if none).""" - for msg in reversed(messages): - if msg.role != "assistant": - continue - if isinstance(msg.content, str): - return msg.content - return "".join( - block.text for block in msg.content if getattr(block, "type", None) == "text" - ) - return "" diff --git a/anton/cloud_turn/protocol.py b/anton/cloud_turn/protocol.py deleted file mode 100644 index 963c1f38..00000000 --- a/anton/cloud_turn/protocol.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Versioned wire contract between the scratchpad-controller and the pod runner. - -The controller sends a :class:`TurnRequestV1` as a single JSON document on the -pod's stdin (EOF-terminated). The runner replies with :class:`TurnEventV1` -records as JSONL on stdout — one JSON object per line, nothing else. Both sides -pin ``protocol_version``. - -Design rules: -- **Data only.** The request is inert; the runner never interpolates request - fields into code. -- **Typed, not free-form.** Roles and content-block types are enumerated; an - unsupported shape is rejected with a structured error, never coerced. -- **Only implemented fields exist here.** Speculative fields (artifact - manifests, usage, workspace checkpoints, file history) are intentionally - absent until they are implemented and tested. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Annotated, Any, Literal, Union - -from pydantic import BaseModel, ConfigDict, Field, field_validator - -#: Bump when the request/event shape changes incompatibly. -PROTOCOL_VERSION = 1 - -# ── Validation limits (item 3) — documented defaults, not scattered literals ── -#: Max messages in the request's input history. -MAX_HISTORY_MESSAGES = 1000 -#: Max size of the whole request JSON document, in bytes. -MAX_REQUEST_BYTES = 10 * 1024 * 1024 -#: Max characters in a single text (or string) content value. -MAX_TEXT_BLOCK_CHARS = 1_000_000 - - -def _check_text_len(value: str) -> str: - if len(value) > MAX_TEXT_BLOCK_CHARS: - raise ValueError( - f"text block exceeds {MAX_TEXT_BLOCK_CHARS} chars (got {len(value)})" - ) - return value - - -# ── Content blocks ──────────────────────────────────────────────────────────── -# V1 supports exactly these three block types — the surface Anton's cloud tools -# (scratchpad + artifacts) actually produce/consume. Image and any other block -# type are NOT supported in V1 (see module docs / conversion limitation). - - -class TextBlockV1(BaseModel): - type: Literal["text"] = "text" - text: str - - _v = field_validator("text")(_check_text_len) - - -class ToolUseBlockV1(BaseModel): - type: Literal["tool_use"] = "tool_use" - id: str - name: str - input: dict[str, Any] = Field(default_factory=dict) - - -class ToolResultBlockV1(BaseModel): - type: Literal["tool_result"] = "tool_result" - tool_use_id: str - #: A string result, or a list of text blocks. (Cloud tools return strings.) - content: str | list[TextBlockV1] - is_error: bool = False - - @field_validator("content") - @classmethod - def _content_len(cls, v): - if isinstance(v, str): - return _check_text_len(v) - return v - - -ContentBlockV1 = Annotated[ - Union[TextBlockV1, ToolUseBlockV1, ToolResultBlockV1], - Field(discriminator="type"), -] - - -class MessageV1(BaseModel): - """One persistable conversation message. Tool results are carried in - ``user``-role messages (Anthropic convention).""" - - role: Literal["user", "assistant"] - content: str | list[ContentBlockV1] - - @field_validator("content") - @classmethod - def _content_len(cls, v): - if isinstance(v, str): - return _check_text_len(v) - return v - - -#: Supported protocol versions (single-element in V1). Derived here so the -#: capabilities manifest and schema export share one source of truth. -SUPPORTED_PROTOCOL_VERSIONS = (PROTOCOL_VERSION,) -#: The content-block ``type`` discriminators V1 represents, derived from models. -SUPPORTED_CONTENT_BLOCK_TYPES = tuple( - m.model_fields["type"].default - for m in (TextBlockV1, ToolUseBlockV1, ToolResultBlockV1) -) -#: Message roles V1 accepts. -SUPPORTED_MESSAGE_ROLES = ("user", "assistant") - - -# ── Capabilities ──────────────────────────────────────────────────────────── - - -class CapabilitiesV1(BaseModel): - """Feature gates for one cloud turn. Everything unsafe in a shared, - headless pod is OFF by default and enabled only once it has a cloud-safe - implementation.""" - - model_config = ConfigDict(extra="forbid") - - personal_memory: bool = False - connectors: bool = False - local_data_vault: bool = False - interactive_tools: bool = False - local_file_history: bool = False - dotenv_loading: bool = False - - -# ── Request ─────────────────────────────────────────────────────────────────── - - -class TurnRequestV1(BaseModel): - """One turn to run in the pod. Sent as JSON on stdin.""" - - model_config = ConfigDict(extra="forbid") - - protocol_version: Literal[1] = PROTOCOL_VERSION - #: Stable per logical turn — the idempotency key (a redelivery reuses it). - run_id: str - #: Per delivery attempt. - attempt_id: str - conversation_id: str - #: Authoritative identity for keying/attribution; producers may not spoof it. - organization_id: str | None = None - user_id: str | None = None - #: (org, workspace) key input; maps to a cowork project. - workspace_id: str | None = None - # No workspace path on the wire — the mount is trusted pod-side config - # (see ``anton.cloud_turn.session.resolve_trusted_workspace_path``). - #: The user's message for THIS turn — text or typed content blocks. - input: str | list[ContentBlockV1] - #: DB-authoritative ordered history (immutable input). The pod never loads - #: its own history; cowork-server owns persistence. - history: list[MessageV1] = Field(default_factory=list) - #: Optional model override. Honoured only if in the pod's trusted model - #: allowlist (default: none), else rejected — see cloud_turn.session. - model: str | None = None - capabilities: CapabilitiesV1 = Field(default_factory=CapabilitiesV1) - #: Absolute deadline as a Unix timestamp in milliseconds. The runner turns - #: this into a remaining-time soft timeout at startup; the controller's pod - #: timeout is the hard external backstop. None = no inner deadline. - deadline_unix_ms: int | None = None - - @field_validator("history") - @classmethod - def _history_len(cls, v: list[MessageV1]) -> list[MessageV1]: - if len(v) > MAX_HISTORY_MESSAGES: - raise ValueError( - f"history exceeds {MAX_HISTORY_MESSAGES} messages (got {len(v)})" - ) - return v - - -# ── Structured errors (item 4) ────────────────────────────────────────────── - - -class ErrorCodeV1(str, Enum): - """Stable V1 failure codes. Values are the wire strings.""" - - INVALID_REQUEST = "invalid_request" - UNSUPPORTED_PROTOCOL_VERSION = "unsupported_protocol_version" - UNSUPPORTED_CAPABILITY = "unsupported_capability" - UNSUPPORTED_MODEL = "unsupported_model" - DEADLINE_EXCEEDED = "deadline_exceeded" - MODEL_AUTH_FAILURE = "model_auth_failure" - MODEL_PROVIDER_FAILURE = "model_provider_failure" - INTERNAL_TURN_FAILURE = "internal_turn_failure" - - -class TurnErrorV1(BaseModel): - model_config = ConfigDict(extra="forbid") - - code: ErrorCodeV1 - #: Short, credential-scrubbed. Full tracebacks stay on stderr only. - message: str - retryable: bool = False - - -# ── Events ──────────────────────────────────────────────────────────────────── - - -class _EventBaseV1(BaseModel): - model_config = ConfigDict(extra="forbid") - - protocol_version: Literal[1] = PROTOCOL_VERSION - # Nullable ONLY for the pre-validation failure path: a malformed/empty - # request may carry no valid identifier, and we must not invent a - # valid-looking one. ``None`` means "the request could not be validated and - # supplied no usable identifier". Every event for a VALID request always - # carries real string ids. - run_id: str | None - attempt_id: str | None - #: 1-based monotonic position within the run (started=1, terminal=2). - sequence: int - - -class TurnStartedV1(_EventBaseV1): - """Emitted once, before work begins. Not terminal.""" - - kind: Literal["turn.started"] = "turn.started" - - -class TurnCompletedV1(_EventBaseV1): - """Terminal success.""" - - kind: Literal["turn.completed"] = "turn.completed" - #: The final assistant message's text. - final_text: str - #: Messages GENERATED during this turn (assistant text, tool calls, tool - #: results, final assistant message), in order. Never the input history. - output_messages: list[MessageV1] = Field(default_factory=list) - - -class TurnFailedV1(_EventBaseV1): - """Terminal failure.""" - - kind: Literal["turn.failed"] = "turn.failed" - error: TurnErrorV1 - - -TurnEventV1 = Annotated[ - Union[TurnStartedV1, TurnCompletedV1, TurnFailedV1], - Field(discriminator="kind"), -] - -TERMINAL_KINDS = frozenset({"turn.completed", "turn.failed"}) - - -def event_line(event: TurnStartedV1 | TurnCompletedV1 | TurnFailedV1) -> str: - """Serialize one event to a single JSONL line (no trailing newline).""" - return event.model_dump_json() - - -def parse_request(raw: str) -> TurnRequestV1: - """Parse + validate a TurnRequestV1 from a JSON string. - - Raises typed :mod:`anton.cloud_turn.errors` exceptions (imported lazily to - avoid an import cycle): size / JSON / protocol-version / shape failures each - map to a distinct structured error code. - """ - import json - - from anton.cloud_turn.errors import ( - InvalidRequestError, - UnsupportedProtocolVersionError, - ) - - if len(raw.encode("utf-8")) > MAX_REQUEST_BYTES: - raise InvalidRequestError( - f"request exceeds {MAX_REQUEST_BYTES} bytes" - ) - try: - data = json.loads(raw) - except Exception as exc: - raise InvalidRequestError(f"not valid JSON: {exc}") from exc - if not isinstance(data, dict): - raise InvalidRequestError("request must be a JSON object") - - version = data.get("protocol_version", PROTOCOL_VERSION) - if version != PROTOCOL_VERSION: - raise UnsupportedProtocolVersionError( - f"unsupported protocol_version {version!r}; this pod speaks {PROTOCOL_VERSION}" - ) - - from pydantic import ValidationError - - try: - return TurnRequestV1.model_validate(data) - except ValidationError as exc: - raise InvalidRequestError(f"invalid TurnRequestV1: {exc}") from exc diff --git a/anton/cloud_turn/runner.py b/anton/cloud_turn/runner.py deleted file mode 100644 index c3540232..00000000 --- a/anton/cloud_turn/runner.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Drive one buffered anton turn to completion and emit terminal events. - -Buffered: consume ``turn_stream()`` (not ``turn()`` — only the streaming path -persists history) to the end, then derive the result from ``session.history``. -No live token streaming yet; that's a later protocol extension. - -Event contract: every VALID request emits ``turn.started`` (sequence 1) then -exactly ONE terminal event (sequence 2). An invalid request is rejected upstream -(``__main__``) with a single ``turn.failed`` and no ``turn.started``. -""" - -from __future__ import annotations - -import asyncio -import inspect -import logging -import time -from typing import Any, Callable - -from anton.cloud_turn.errors import DeadlineExceededError, classify_error -from anton.cloud_turn.messages import final_assistant_text, turn_output_messages -from anton.cloud_turn.protocol import ( - ErrorCodeV1, - MessageV1, - TurnCompletedV1, - TurnErrorV1, - TurnFailedV1, - TurnRequestV1, - TurnStartedV1, -) -from anton.cloud_turn.session import build_cloud_chat_session - -logger = logging.getLogger(__name__) - -EXIT_OK = 0 -EXIT_FAILED = 3 - -Emit = Callable[[object], None] -SessionBuilder = Callable[[TurnRequestV1], Any] - - -class _Sequencer: - """Hands out 1-based monotonic event sequence numbers.""" - - def __init__(self) -> None: - self._n = 0 - - def next(self) -> int: - self._n += 1 - return self._n - - -def _now_ms() -> int: - return int(time.time() * 1000) - - -def _remaining_seconds(request: TurnRequestV1, now_ms: int) -> float | None: - """Soft timeout from the absolute deadline. None = no inner deadline. - - Raises :class:`DeadlineExceededError` immediately if the deadline has - already passed.""" - if request.deadline_unix_ms is None: - return None - remaining_ms = request.deadline_unix_ms - now_ms - if remaining_ms <= 0: - raise DeadlineExceededError( - f"deadline passed {abs(remaining_ms)}ms before turn start" - ) - return remaining_ms / 1000 - - -def _trace_metadata(request: TurnRequestV1) -> dict[str, str]: - return { - k: v - for k, v in { - "run_id": request.run_id, - "attempt_id": request.attempt_id, - "conversation_id": request.conversation_id, - "organization_id": request.organization_id, - "user_id": request.user_id, - "workspace_id": request.workspace_id, - }.items() - if v is not None - } - - -async def _drive_turn( - request: TurnRequestV1, session: Any -) -> tuple[str, list[MessageV1]]: - """Run the turn to completion; return (final_text, output_messages). - - ``output_messages`` are only the messages generated this turn (assistant - text, tool calls, tool results, final assistant message) — never the input - history. ``final_text`` is the last assistant message's text. - """ - # Identity anchor (NOT a length) — turn_stream may compact/rewrite history - # mid-turn. We hold the pre-turn message objects alive across the whole turn - # so their ids can't be recycled by messages created during compaction. - pre_turn_messages = list(session.history) - async for _event in session.turn_stream( - request.input, trace_metadata=_trace_metadata(request) - ): - # Buffered: the generator exhausting is the turn-ended signal. We derive - # the result from history, so intermediate stream events are just drive. - pass - output_messages = turn_output_messages(session.history, pre_turn_messages) - return final_assistant_text(output_messages), output_messages - - -async def _close(session: Any) -> None: - close = getattr(session, "close", None) - if close is None: - return - try: - result = close() - if inspect.isawaitable(result): - await result - except Exception: - logger.warning("cloud session close failed (non-fatal)", exc_info=True) - - -async def run_turn( - request: TurnRequestV1, - emit: Emit, - session_builder: SessionBuilder | None = None, - now_ms: int | None = None, -) -> int: - """Run one turn, emitting ``turn.started`` then exactly one terminal event. - - Returns an exit code (``EXIT_OK`` / ``EXIT_FAILED``). - """ - builder = session_builder or build_cloud_chat_session - seq = _Sequencer() - emit( - TurnStartedV1( - run_id=request.run_id, attempt_id=request.attempt_id, sequence=seq.next() - ) - ) - session: Any = None - try: - # Deadline first: fail immediately if it already passed (before any work). - remaining_s = _remaining_seconds(request, now_ms if now_ms is not None else _now_ms()) - session = builder(request) - if remaining_s is not None: - final_text, output_messages = await asyncio.wait_for( - _drive_turn(request, session), timeout=remaining_s - ) - else: - final_text, output_messages = await _drive_turn(request, session) - emit( - TurnCompletedV1( - run_id=request.run_id, - attempt_id=request.attempt_id, - sequence=seq.next(), - final_text=final_text, - output_messages=output_messages, - ) - ) - return EXIT_OK - except asyncio.TimeoutError: - logger.warning("cloud turn timed out run_id=%s", request.run_id) - emit( - TurnFailedV1( - run_id=request.run_id, - attempt_id=request.attempt_id, - sequence=seq.next(), - error=TurnErrorV1( - code=ErrorCodeV1.DEADLINE_EXCEEDED, - message="turn exceeded its deadline", - retryable=True, - ), - ) - ) - return EXIT_FAILED - except Exception as exc: - # Full traceback → stderr only (may contain secrets). The wire carries a - # short, credential-scrubbed, structured error. - logger.exception("cloud turn failed run_id=%s", request.run_id) - emit( - TurnFailedV1( - run_id=request.run_id, - attempt_id=request.attempt_id, - sequence=seq.next(), - error=classify_error(exc), - ) - ) - return EXIT_FAILED - finally: - if session is not None: - await _close(session) diff --git a/anton/cloud_turn/schema_export.py b/anton/cloud_turn/schema_export.py deleted file mode 100644 index 19f93817..00000000 --- a/anton/cloud_turn/schema_export.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Generate committed JSON Schemas for the V1 wire contracts. - -The schemas under ``schemas/`` are GENERATED from the Pydantic models here — do -not hand-edit them. ``tests/test_cloud_turn_schemas.py`` regenerates and diffs -against the committed files so a protocol change can't land without updating the -schema (and whoever reviews it). - -Run ``python -m anton.cloud_turn.schema_export`` to rewrite the committed files. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from pydantic import TypeAdapter - -from anton.cloud_turn.protocol import TurnRequestV1, TurnEventV1 - -#: Repo-root-relative directory holding the committed schemas + fixtures. -SCHEMA_DIR = Path(__file__).resolve().parents[2] / "schemas" -REQUEST_SCHEMA_PATH = SCHEMA_DIR / "cloud-turn-request-v1.json" -EVENT_SCHEMA_PATH = SCHEMA_DIR / "cloud-turn-event-v1.json" - - -def request_schema() -> dict[str, Any]: - """JSON Schema for :class:`TurnRequestV1`.""" - return TurnRequestV1.model_json_schema() - - -def event_schema() -> dict[str, Any]: - """JSON Schema for the :data:`TurnEventV1` discriminated union.""" - return TypeAdapter(TurnEventV1).json_schema() - - -def _dump(schema: dict[str, Any]) -> str: - # Stable, sorted, newline-terminated so diffs are minimal + deterministic. - return json.dumps(schema, indent=2, sort_keys=True) + "\n" - - -def expected_files() -> dict[Path, str]: - """Map of committed schema path → its expected serialized content.""" - return { - REQUEST_SCHEMA_PATH: _dump(request_schema()), - EVENT_SCHEMA_PATH: _dump(event_schema()), - } - - -def write_schemas() -> list[Path]: - """(Re)write the committed schema files. Returns the paths written.""" - SCHEMA_DIR.mkdir(parents=True, exist_ok=True) - written = [] - for path, content in expected_files().items(): - path.write_text(content, encoding="utf-8") - written.append(path) - return written - - -if __name__ == "__main__": - for p in write_schemas(): - print(f"wrote {p}") diff --git a/tests/test_cloud_turn_capabilities.py b/tests/test_cloud_turn_capabilities.py deleted file mode 100644 index f4b42ef6..00000000 --- a/tests/test_cloud_turn_capabilities.py +++ /dev/null @@ -1,73 +0,0 @@ -"""`python -m anton.cloud_turn capabilities` — stable machine-readable manifest.""" - -from __future__ import annotations - -import json -import subprocess -import sys - -from anton.cloud_turn import __main__ as entry -from anton.cloud_turn.protocol import PROTOCOL_VERSION -from anton.cloud_turn.session import CLOUD_TOOL_ALLOWLIST - - -def _manifest_via_subprocess() -> tuple[int, dict]: - proc = subprocess.run( - [sys.executable, "-m", "anton.cloud_turn", "capabilities"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60, - ) - return proc.returncode, json.loads(proc.stdout.decode("utf-8")) - - -def test_capabilities_command_is_valid_json_and_exit_zero(): - code, manifest = _manifest_via_subprocess() - assert code == 0 - assert isinstance(manifest, dict) - assert manifest["ok"] is True - - -def test_capabilities_has_stable_required_fields(): - manifest = entry._capabilities() - required = { - "ok", "entrypoint", "cloud_turn_version", "anton_version", - "protocol_versions", "tools", "content_block_types", "message_roles", - "model_override", "memory", "connectors", "data_vault", - "scratchpad_execution", "web_tools", "provider_sdks", "capabilities", - } - assert required <= set(manifest) - assert manifest["entrypoint"] == "anton.cloud_turn" - - -def test_capabilities_reports_exact_cloud_tools(): - manifest = entry._capabilities() - assert manifest["tools"] == sorted(CLOUD_TOOL_ALLOWLIST) - - -def test_capabilities_protocol_versions(): - manifest = entry._capabilities() - assert manifest["protocol_versions"] == [PROTOCOL_VERSION] - - -def test_capabilities_reports_milestone_truthfully(): - m = entry._capabilities() - # Only what THIS milestone actually enables. - assert m["content_block_types"] == ["text", "tool_result", "tool_use"] # sorted - assert m["message_roles"] == ["assistant", "user"] - assert m["scratchpad_execution"] is True - assert m["memory"] == {"personal": False, "workspace": False} - assert m["connectors"] is False - assert m["data_vault"] is False - assert m["web_tools"] is False - assert m["model_override"]["policy"] == "trusted_allowlist" - assert m["model_override"]["default"] == "deny" - - -def test_capabilities_reports_anthropic_sdk_present(): - # anthropic is a hard dependency — always installed. - assert entry._capabilities()["provider_sdks"]["anthropic"] is True - - -def test_capabilities_reports_unavailable_sdk_accurately(monkeypatch): - # Simulate the openai SDK being absent: __import__("openai") then fails. - monkeypatch.setitem(sys.modules, "openai", None) - assert entry._capabilities()["provider_sdks"]["openai"] is False diff --git a/tests/test_cloud_turn_errors.py b/tests/test_cloud_turn_errors.py deleted file mode 100644 index 7345be59..00000000 --- a/tests/test_cloud_turn_errors.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Central exception → structured TurnErrorV1 mapping (item 4).""" - -from __future__ import annotations - -import pytest - -from anton.cloud_turn.errors import ( - DeadlineExceededError, - InvalidRequestError, - UnsupportedCapabilityError, - UnsupportedModelError, - UnsupportedProtocolVersionError, - classify_error, -) -from anton.core.llm.provider import ( - ContextOverflowError, - ModelUnavailableError, - ProviderOverloadedError, - TokenLimitExceeded, - TransientProviderError, -) - - -@pytest.mark.parametrize( - "exc, code, retryable", - [ - (InvalidRequestError("bad"), "invalid_request", False), - (UnsupportedProtocolVersionError("v2"), "unsupported_protocol_version", False), - (UnsupportedCapabilityError("connectors"), "unsupported_capability", False), - (UnsupportedModelError("nope"), "unsupported_model", False), - (DeadlineExceededError("late"), "deadline_exceeded", True), - (ModelUnavailableError("gate", code="model_disabled", model="m"), "unsupported_model", False), - (TransientProviderError("overloaded"), "model_provider_failure", True), - (ProviderOverloadedError("exhausted"), "model_provider_failure", True), - (TokenLimitExceeded("quota"), "model_provider_failure", False), - (ContextOverflowError("too long"), "model_provider_failure", False), - (ConnectionError("Invalid API key — check your ANTHROPIC_API_KEY"), "model_auth_failure", False), - (ConnectionError("connection reset by peer"), "model_provider_failure", True), - (RuntimeError("unexpected"), "internal_turn_failure", False), - ], -) -def test_each_mapping(exc, code, retryable): - err = classify_error(exc) - assert err.code.value == code - assert err.retryable is retryable - assert err.message # non-empty, short - - -def test_message_is_scrubbed_and_bounded(): - from anton.cloud_turn.errors import MAX_ERROR_MESSAGE_CHARS - - # A key-shaped token is redacted by scrub_credentials; the message is - # truncated so nothing long/verbose reaches the wire. - leaked_key = "sk-ant-api03-" + "A" * 80 - err = classify_error(RuntimeError(f"boom with key {leaked_key} " + "x" * 1000)) - assert leaked_key not in err.message - assert len(err.message) <= MAX_ERROR_MESSAGE_CHARS diff --git a/tests/test_cloud_turn_messages.py b/tests/test_cloud_turn_messages.py deleted file mode 100644 index 3329d07f..00000000 --- a/tests/test_cloud_turn_messages.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Explicit Anton ↔ V1 wire message conversion (item 2).""" - -from __future__ import annotations - -import pytest - -from anton.cloud_turn.messages import ( - MessageConversionError, - final_assistant_text, - to_anton_history, - to_anton_input, - turn_output_messages, -) -from anton.cloud_turn.protocol import MessageV1, TextBlockV1, ToolUseBlockV1 - - -def test_to_anton_input_string_and_blocks(): - assert to_anton_input("hi") == "hi" - blocks = [TextBlockV1(text="a"), ToolUseBlockV1(id="t", name="scratchpad", input={})] - dumped = to_anton_input(blocks) - assert dumped == [ - {"type": "text", "text": "a"}, - {"type": "tool_use", "id": "t", "name": "scratchpad", "input": {}}, - ] - - -def test_to_anton_history_round_trips_to_dicts(): - hist = [MessageV1(role="user", content="q"), MessageV1(role="assistant", content="a")] - assert to_anton_history(hist) == [ - {"role": "user", "content": "q"}, - {"role": "assistant", "content": "a"}, - ] - - -def test_turn_output_messages_drops_leading_input_echo(): - prior = [ - {"role": "user", "content": "prior"}, # input history - {"role": "assistant", "content": "prior answer"}, - ] - history = prior + [ - {"role": "user", "content": "current input"}, # this turn's echo - {"role": "assistant", "content": "new answer"}, - ] - out = turn_output_messages(history, prior) - assert [m.role for m in out] == ["assistant"] - assert out[0].content == "new answer" - - -def test_turn_output_messages_empty_when_no_assistant(): - history = [{"role": "user", "content": "current input"}] - assert turn_output_messages(history, []) == [] - - -def test_final_assistant_text_from_blocks(): - msgs = [ - MessageV1(role="assistant", content=[TextBlockV1(text="Hello "), TextBlockV1(text="world")]), - ] - assert final_assistant_text(msgs) == "Hello world" - - -def test_unsupported_block_type_raises_not_dropped(): - # V1 supports only text / tool_use / tool_result. An image block (not in the - # cloud tool surface) must fail loudly rather than be silently dropped. - history = [{"role": "assistant", "content": [{"type": "image", "source": {}}]}] - with pytest.raises(MessageConversionError): - turn_output_messages(history, []) - - -def test_compaction_summary_and_separator_are_excluded(): - """Simulates _summarize_history rewriting the prefix: the NEW summary + - 'Understood' separator (new identities) precede the surviving input anchor, - so they must fall outside the current-turn region.""" - surviving_input = {"role": "assistant", "content": "recent input turn"} - history = [ - {"role": "user", "content": "COMPACTED summary of old turns"}, # new artifact - {"role": "assistant", "content": "Understood — using that as reference."}, # new artifact - surviving_input, # anchor (pre-turn) - {"role": "user", "content": "current input"}, # echo - {"role": "assistant", "content": "the answer"}, # generated - ] - out = turn_output_messages(history, [surviving_input]) - assert [m.role for m in out] == ["assistant"] - assert out[0].content == "the answer" diff --git a/tests/test_cloud_turn_protocol.py b/tests/test_cloud_turn_protocol.py deleted file mode 100644 index ca23b92e..00000000 --- a/tests/test_cloud_turn_protocol.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Contract tests for the cloud-turn V1 wire protocol.""" - -from __future__ import annotations - -import json - -import pytest - -from anton.cloud_turn import ( - PROTOCOL_VERSION, - CapabilitiesV1, - ErrorCodeV1, - MessageV1, - TurnCompletedV1, - TurnFailedV1, - TurnStartedV1, - event_line, - parse_request, -) -from anton.cloud_turn.errors import ( - InvalidRequestError, - UnsupportedProtocolVersionError, -) -from anton.cloud_turn.protocol import ( - MAX_HISTORY_MESSAGES, - MAX_REQUEST_BYTES, - MAX_TEXT_BLOCK_CHARS, - TERMINAL_KINDS, - TurnErrorV1, - TurnRequestV1, -) - - -def _minimal_request_json(**overrides) -> str: - body = { - "run_id": "run_1", - "attempt_id": "att_1", - "conversation_id": "conv_1", - "input": "hello", - } - body.update(overrides) - return json.dumps(body) - - -# ── request ─────────────────────────────────────────────────────────────── - -def test_capabilities_default_everything_off(): - caps = CapabilitiesV1() - assert not any(getattr(caps, f) for f in CapabilitiesV1.model_fields) - - -def test_request_minimal_parses_with_safe_defaults(): - req = parse_request(_minimal_request_json()) - assert req.protocol_version == PROTOCOL_VERSION - assert "workspace_path" not in TurnRequestV1.model_fields # trusted pod config - assert req.history == [] - assert req.model is None - assert req.deadline_unix_ms is None - assert req.capabilities == CapabilitiesV1() - assert req.input == "hello" - - -def test_request_rejects_unknown_fields(): - with pytest.raises(InvalidRequestError): - parse_request(_minimal_request_json(surprise="nope")) - - -def test_request_rejects_workspace_path_on_the_wire(): - with pytest.raises(InvalidRequestError): - parse_request(_minimal_request_json(workspace_path="/etc")) - - -def test_request_rejects_wrong_protocol_version(): - with pytest.raises(UnsupportedProtocolVersionError): - parse_request(_minimal_request_json(protocol_version=2)) - - -def test_request_rejects_non_json(): - with pytest.raises(InvalidRequestError): - parse_request("this is not json") - - -# ── typed history contract (item 3) ───────────────────────────────────────── - -def test_history_accepts_supported_roles_and_blocks(): - req = parse_request( - _minimal_request_json( - history=[ - {"role": "user", "content": "earlier question"}, - { - "role": "assistant", - "content": [ - {"type": "text", "text": "let me check"}, - {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {"a": 1}}, - ], - }, - { - "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, - ], - }, - ] - ) - ) - assert len(req.history) == 3 - assert req.history[1].content[1].name == "scratchpad" - - -def test_history_rejects_unsupported_role(): - with pytest.raises(InvalidRequestError): - parse_request(_minimal_request_json(history=[{"role": "system", "content": "x"}])) - - -def test_history_rejects_unknown_block_type(): - with pytest.raises(InvalidRequestError): - parse_request( - _minimal_request_json( - history=[{"role": "user", "content": [{"type": "image", "source": "..."}]}] - ) - ) - - -# ── validation limits (item 3) ────────────────────────────────────────────── - -def test_history_message_count_limit(): - too_many = [{"role": "user", "content": "x"}] * (MAX_HISTORY_MESSAGES + 1) - with pytest.raises(InvalidRequestError): - parse_request(_minimal_request_json(history=too_many)) - - -def test_text_block_size_limit(): - huge = "x" * (MAX_TEXT_BLOCK_CHARS + 1) - with pytest.raises(InvalidRequestError): - parse_request( - _minimal_request_json( - history=[{"role": "user", "content": [{"type": "text", "text": huge}]}] - ) - ) - - -def test_total_request_size_limit(): - # Build a raw string just over the byte cap without going through the model. - oversized = "x" * (MAX_REQUEST_BYTES + 1) - with pytest.raises(InvalidRequestError): - parse_request(_minimal_request_json(input=oversized)) - - -def test_exact_max_raw_request_size(): - assert MAX_REQUEST_BYTES == 10 * 1024 * 1024 - - -def test_size_enforced_before_json_decoding(monkeypatch): - # Oversized, NOT valid JSON: it must be rejected on size (raw bytes) BEFORE - # any JSON decode is attempted — so the error is about size, not JSON. - from anton.cloud_turn import protocol - - monkeypatch.setattr(protocol, "MAX_REQUEST_BYTES", 50) - with pytest.raises(InvalidRequestError, match="exceeds"): - parse_request("{not valid json " + "x" * 500) - - -def test_total_size_is_the_upper_bound_regardless_of_per_field_limits(): - # Per-field limits (1M-char text block, 1000 messages) are individually far - # larger than the 10 MiB raw cap, so the raw cap is the true upper bound. - assert MAX_TEXT_BLOCK_CHARS < MAX_REQUEST_BYTES - # 1000 messages at even a modest size would exceed the raw cap and be - # rejected on raw size first. - assert MAX_HISTORY_MESSAGES > 0 - - -# ── events (sequence + discriminated union) ───────────────────────────────── - -def test_every_event_has_required_envelope_fields(): - for ev in ( - TurnStartedV1(run_id="r", attempt_id="a", sequence=1), - TurnCompletedV1(run_id="r", attempt_id="a", sequence=2, final_text="done"), - TurnFailedV1( - run_id="r", attempt_id="a", sequence=2, - error=TurnErrorV1(code=ErrorCodeV1.INTERNAL_TURN_FAILURE, message="boom"), - ), - ): - decoded = json.loads(event_line(ev)) - assert decoded["protocol_version"] == PROTOCOL_VERSION - assert decoded["run_id"] == "r" - assert decoded["attempt_id"] == "a" - assert "sequence" in decoded - assert decoded["kind"] == ev.kind - assert "\n" not in event_line(ev) - - -def test_completed_carries_output_messages_and_final_text(): - ev = TurnCompletedV1( - run_id="r", attempt_id="a", sequence=2, final_text="hi", - output_messages=[MessageV1(role="assistant", content="hi")], - ) - decoded = json.loads(event_line(ev)) - assert decoded["kind"] == "turn.completed" - assert decoded["final_text"] == "hi" - assert decoded["output_messages"][0]["role"] == "assistant" - - -def test_failed_carries_structured_error(): - ev = TurnFailedV1( - run_id="r", attempt_id="a", sequence=2, - error=TurnErrorV1(code=ErrorCodeV1.DEADLINE_EXCEEDED, message="late", retryable=True), - ) - decoded = json.loads(event_line(ev)) - assert decoded["error"] == { - "code": "deadline_exceeded", "message": "late", "retryable": True, - } - - -def test_no_speculative_fields_on_completed(): - fields = set(TurnCompletedV1.model_fields) - for gone in ("usage", "history_rows", "artifact_manifest", "workspace_checkpoint"): - assert gone not in fields - - -def test_terminal_kinds(): - assert TERMINAL_KINDS == {"turn.completed", "turn.failed"} - assert TurnStartedV1(run_id="r", attempt_id="a", sequence=1).kind not in TERMINAL_KINDS diff --git a/tests/test_cloud_turn_runner.py b/tests/test_cloud_turn_runner.py deleted file mode 100644 index d9e0b2d9..00000000 --- a/tests/test_cloud_turn_runner.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Tests for the cloud-turn runner + entrypoint. - -Fully offline: a fake session (scripted history growth) replaces the real -ChatSession, so no LLM key or scratchpad is needed. -""" - -from __future__ import annotations - -import asyncio -import json - -from anton.cloud_turn.protocol import TurnRequestV1 -from anton.cloud_turn.runner import EXIT_FAILED, EXIT_OK, run_turn - - -class _FakeSession: - """Simulates Anton's history growth: turn_stream echoes the user input as a - user message (as the real session does), then appends scripted generated - messages, yielding a drive event per append.""" - - def __init__(self, initial_history=None, generated=None, raise_on_stream=None): - self.history = list(initial_history or []) - self._generated = list(generated or []) - self._raise = raise_on_stream - self.closed = False - - async def turn_stream(self, user_input, **kwargs): - if self._raise: - raise self._raise - self.history.append({"role": "user", "content": user_input}) - yield object() - for msg in self._generated: - self.history.append(msg) - yield object() - - def close(self): - self.closed = True - - -def _req(**over) -> TurnRequestV1: - body = dict(run_id="r", attempt_id="a", conversation_id="c", input="hi") - body.update(over) - return TurnRequestV1(**body) - - -def _collect_emit(): - events = [] - return events, events.append - - -# ── output messages (item 2) ──────────────────────────────────────────────── - -def test_text_only_turn_returns_final_assistant_message(): - events, emit = _collect_emit() - session = _FakeSession(generated=[{"role": "assistant", "content": "Hello world"}]) - code = asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - assert code == EXIT_OK - assert [e.kind for e in events] == ["turn.started", "turn.completed"] - completed = events[1] - assert completed.final_text == "Hello world" - assert len(completed.output_messages) == 1 - assert completed.output_messages[0].role == "assistant" - assert completed.output_messages[0].content == "Hello world" - - -def test_tool_using_turn_returns_all_persistable_messages_in_order(): - events, emit = _collect_emit() - generated = [ - {"role": "assistant", "content": [ - {"type": "text", "text": "let me compute"}, - {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {"code": "1+1"}}, - ]}, - {"role": "user", "content": [ - {"type": "tool_result", "tool_use_id": "t1", "content": "2"}, - ]}, - {"role": "assistant", "content": "The answer is 2"}, - ] - session = _FakeSession(generated=generated) - asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - msgs = events[1].output_messages - # assistant(text+tool_use) → user(tool_result) → final assistant, in order. - assert [m.role for m in msgs] == ["assistant", "user", "assistant"] - assert msgs[0].content[1].type == "tool_use" - assert msgs[1].content[0].type == "tool_result" - assert msgs[1].content[0].tool_use_id == "t1" - assert events[1].final_text == "The answer is 2" - - -def test_input_history_not_duplicated_in_output_messages(): - events, emit = _collect_emit() - prior = [ - {"role": "user", "content": "old question"}, - {"role": "assistant", "content": "old answer"}, - ] - session = _FakeSession( - initial_history=prior, - generated=[{"role": "assistant", "content": "new answer"}], - ) - asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - msgs = events[1].output_messages - # Only the newly generated assistant message — not the prior turn, not the - # echoed current user input. - assert len(msgs) == 1 - assert msgs[0].content == "new answer" - - -def test_final_text_matches_final_assistant_text(): - events, emit = _collect_emit() - generated = [ - {"role": "assistant", "content": "intermediate narration"}, - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]}, - {"role": "assistant", "content": [{"type": "text", "text": "FINAL"}]}, - ] - session = _FakeSession(generated=generated) - asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - assert events[1].final_text == "FINAL" - - -# ── terminal-event contract (item 7) ───────────────────────────────────────── - -def test_sequence_numbers_started_then_terminal(): - events, emit = _collect_emit() - session = _FakeSession(generated=[{"role": "assistant", "content": "ok"}]) - asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - assert [e.sequence for e in events] == [1, 2] - - -def test_builder_error_emits_started_then_failed(): - events, emit = _collect_emit() - - def boom(_req): - raise RuntimeError("no session") - - code = asyncio.run(run_turn(_req(), emit, session_builder=boom)) - assert code == EXIT_FAILED - assert [e.kind for e in events] == ["turn.started", "turn.failed"] - assert events[-1].error.code.value == "internal_turn_failure" - - -def test_stream_error_emits_exactly_one_terminal(): - events, emit = _collect_emit() - session = _FakeSession(raise_on_stream=ValueError("mid-turn boom")) - code = asyncio.run(run_turn(_req(), emit, session_builder=lambda r: session)) - assert code == EXIT_FAILED - terminals = [e for e in events if e.kind in ("turn.completed", "turn.failed")] - assert len(terminals) == 1 - assert terminals[0].kind == "turn.failed" - assert session.closed is True # closed even on failure - - -# ── deadline semantics (item 5) ────────────────────────────────────────────── - -def test_future_deadline_runs_normally(): - events, emit = _collect_emit() - session = _FakeSession(generated=[{"role": "assistant", "content": "done"}]) - code = asyncio.run( - run_turn(_req(deadline_unix_ms=10_000), emit, - session_builder=lambda r: session, now_ms=0) - ) - assert code == EXIT_OK - assert events[-1].kind == "turn.completed" - - -def test_already_expired_deadline_fails_before_building_session(): - events, emit = _collect_emit() - built = {"n": 0} - - def builder(_r): - built["n"] += 1 - return _FakeSession(generated=[{"role": "assistant", "content": "x"}]) - - code = asyncio.run( - run_turn(_req(deadline_unix_ms=100), emit, session_builder=builder, now_ms=200) - ) - assert code == EXIT_FAILED - assert [e.kind for e in events] == ["turn.started", "turn.failed"] - assert events[-1].error.code.value == "deadline_exceeded" - assert built["n"] == 0 # failed immediately, before building the session - - -def test_timeout_during_execution_fails_with_one_terminal(): - events, emit = _collect_emit() - - class _Slow(_FakeSession): - async def turn_stream(self, user_input, **kwargs): - await asyncio.sleep(0.5) - self.history.append({"role": "assistant", "content": "late"}) - yield object() - - code = asyncio.run( - run_turn(_req(deadline_unix_ms=50), emit, - session_builder=lambda r: _Slow(), now_ms=0) - ) - assert code == EXIT_FAILED - terminals = [e for e in events if e.kind in ("turn.completed", "turn.failed")] - assert len(terminals) == 1 - assert terminals[0].error.code.value == "deadline_exceeded" - - - - -# ── entrypoint core (_run): request parsing → runner, no FD/stdin handling ──── -# FD-level stdout isolation and bounded stdin reads run in a REAL subprocess — -# see tests/test_cloud_turn_process.py. Here we exercise the parse→emit core in -# process with a capturing emit. - -def _run_core(stdin_text, session_builder=None): - from anton.cloud_turn import __main__ as entry - - events = [] - code = entry._run(stdin_text, events.append, session_builder=session_builder) - return code, events - - -def test_run_completes_and_emits_started_then_completed(): - session = _FakeSession(generated=[{"role": "assistant", "content": "done"}]) - req = json.dumps({"run_id": "r", "attempt_id": "a", "conversation_id": "c", "input": "hi"}) - code, events = _run_core(req, session_builder=lambda r: session) - assert code == EXIT_OK - assert [e.kind for e in events] == ["turn.started", "turn.completed"] - assert [e.sequence for e in events] == [1, 2] - assert events[-1].final_text == "done" - - -def test_run_bad_request_one_failed_no_started(): - code, events = _run_core('{"not":"a valid request"}') - assert code == 2 - assert len(events) == 1 - assert events[0].kind == "turn.failed" - assert events[0].sequence == 1 - assert events[0].error.code.value == "invalid_request" - - -# ── item 1: identifiers on pre-validation failures ─────────────────────────── - -def _bad_request_event(stdin_text): - code, events = _run_core(stdin_text) - assert code == 2 - assert len(events) == 1 and events[0].kind == "turn.failed" and events[0].sequence == 1 - return events[0] - - -def test_malformed_json_reports_null_ids(): - ev = _bad_request_event("{not json at all") - assert ev.run_id is None and ev.attempt_id is None - assert ev.error.code.value == "invalid_request" - - -def test_empty_stdin_reports_null_ids(): - ev = _bad_request_event("") - assert ev.run_id is None and ev.attempt_id is None - - -def test_missing_run_id_keeps_attempt_id(): - ev = _bad_request_event( - json.dumps({"attempt_id": "a", "conversation_id": "c", "input": "hi"}) - ) - assert ev.run_id is None - assert ev.attempt_id == "a" # echo the valid one, invent nothing - - -def test_missing_attempt_id_keeps_run_id(): - ev = _bad_request_event( - json.dumps({"run_id": "r", "conversation_id": "c", "input": "hi"}) - ) - assert ev.run_id == "r" - assert ev.attempt_id is None - - -def test_wrong_id_types_report_null(): - ev = _bad_request_event( - json.dumps({"run_id": 123, "attempt_id": ["x"], "conversation_id": "c", "input": "hi"}) - ) - assert ev.run_id is None and ev.attempt_id is None - - -# ── item 2: the runner closes the session on every terminal path ───────────── - -def test_session_closed_on_success(): - session = _FakeSession(generated=[{"role": "assistant", "content": "ok"}]) - asyncio.run(run_turn(_req(), lambda e: None, session_builder=lambda r: session)) - assert session.closed is True - - -def test_session_closed_on_deadline_timeout(): - class _Slow(_FakeSession): - async def turn_stream(self, user_input, **kwargs): - await asyncio.sleep(0.5) - self.history.append({"role": "assistant", "content": "late"}) - yield object() - - slow = _Slow() - code = asyncio.run( - run_turn(_req(deadline_unix_ms=50), lambda e: None, - session_builder=lambda r: slow, now_ms=0) - ) - assert code == EXIT_FAILED - assert slow.closed is True # closed even when the soft deadline fires diff --git a/tests/test_cloud_turn_schemas.py b/tests/test_cloud_turn_schemas.py deleted file mode 100644 index a0a401a4..00000000 --- a/tests/test_cloud_turn_schemas.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Exported V1 JSON Schemas + fixtures stay in sync with the models (items 5/6).""" - -from __future__ import annotations - -import json - -import pytest -from pydantic import TypeAdapter - -from anton.cloud_turn import fixtures as fx -from anton.cloud_turn import schema_export as sx -from anton.cloud_turn.protocol import TurnEventV1, TurnRequestV1 - - -# ── item 5: committed schemas match the models ────────────────────────────── - -def test_committed_schemas_match_models(): - """Regenerate from the Pydantic models and diff the committed files, so a - protocol change can't land without updating the checked-in schema.""" - for path, expected in sx.expected_files().items(): - assert path.exists(), f"missing committed schema {path} — run schema_export" - assert path.read_text(encoding="utf-8") == expected, ( - f"{path.name} is stale — run `python -m anton.cloud_turn.schema_export`" - ) - - -def test_schemas_are_valid_json_with_expected_roots(): - req = json.loads(sx.REQUEST_SCHEMA_PATH.read_text()) - ev = json.loads(sx.EVENT_SCHEMA_PATH.read_text()) - assert req["title"] == "TurnRequestV1" - assert req["additionalProperties"] is False # extra="forbid" made it onto the wire schema - # The event schema is a union discriminated on `kind`, mapping exactly the - # three event types — not just "some union". - assert ev["discriminator"]["propertyName"] == "kind" - assert set(ev["discriminator"]["mapping"]) == { - "turn.started", "turn.completed", "turn.failed" - } - - -# ── item 6: committed fixtures match the models + validate ─────────────────── - -def test_committed_fixtures_match_models(): - for path, expected in fx.expected_files().items(): - assert path.exists(), f"missing fixture {path} — run fixtures" - assert path.read_text(encoding="utf-8") == expected, ( - f"{path.name} is stale — run `python -m anton.cloud_turn.fixtures`" - ) - - -_event_adapter = TypeAdapter(TurnEventV1) - - -@pytest.mark.parametrize("name", list(fx.FIXTURES)) -def test_each_fixture_validates_against_its_model(name): - """Round-trip each committed fixture through the SAME model the schema is - generated from — proving fixtures conform to the exported contract.""" - data = json.loads((fx.FIXTURE_DIR / f"{name}.json").read_text()) - if name.startswith("request-"): - TurnRequestV1.model_validate(data) - else: - _event_adapter.validate_python(data) - - -def test_representative_request_and_event_fixtures(): - # A representative request and a representative event, explicitly. - req = json.loads((fx.FIXTURE_DIR / "request-with-history.json").read_text()) - parsed = TurnRequestV1.model_validate(req) - assert parsed.history[0].content == "What is 2 + 2?" - - ev = json.loads((fx.FIXTURE_DIR / "event-turn-completed-tool-using.json").read_text()) - completed = _event_adapter.validate_python(ev) - assert completed.kind == "turn.completed" - assert completed.output_messages[1].content[0].type == "tool_result" - - -def test_invalid_request_fixture_has_null_ids(): - ev = json.loads( - (fx.FIXTURE_DIR / "event-turn-failed-invalid-request-null-ids.json").read_text() - ) - assert ev["run_id"] is None and ev["attempt_id"] is None - assert ev["error"]["code"] == "invalid_request" From a814dcbea76b11c4db36288d1e3e69eb32fe6006 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 12:35:42 +0200 Subject: [PATCH 10/11] NEw contract --- anton/cloud_turn/__init__.py | 43 ++----- anton/cloud_turn/__main__.py | 222 +++++++++++------------------------ anton/cloud_turn/contract.py | 45 +++++++ 3 files changed, 125 insertions(+), 185 deletions(-) create mode 100644 anton/cloud_turn/contract.py diff --git a/anton/cloud_turn/__init__.py b/anton/cloud_turn/__init__.py index bb786fbc..653c54ec 100644 --- a/anton/cloud_turn/__init__.py +++ b/anton/cloud_turn/__init__.py @@ -1,38 +1,13 @@ -"""Cloud turn runner: run one full anton turn inside a sandbox pod. +"""Cloud turn: run one full anton turn inside a sandbox pod. -`python -m anton.cloud_turn` reads a :class:`TurnRequestV1` as JSON on stdin, -runs the turn to completion against the mounted workspace, and emits versioned -:class:`TurnEventV1` records as JSONL on stdout (diagnostics go to stderr). It is -the cloud counterpart of the in-process host harness: the same ChatSession, but -built cloud-safe (see :mod:`anton.cloud_turn.session`) and driven headlessly. +`python -m anton.cloud_turn` reads a :class:`TurnRequestV1` as one JSON line on +stdin, runs the turn to completion against the mounted workspace with a +cloud-safe session, and emits `delta` / `turn_completed` / `turn_failed` JSONL +events on stdout (diagnostics to stderr). It is the headless counterpart of the +desktop CLI host: the same ChatSession, built cloud-safe. """ -from anton.cloud_turn.protocol import ( - PROTOCOL_VERSION, - CapabilitiesV1, - ErrorCodeV1, - MessageV1, - TurnCompletedV1, - TurnErrorV1, - TurnEventV1, - TurnFailedV1, - TurnRequestV1, - TurnStartedV1, - event_line, - parse_request, -) +from anton.cloud_turn.contract import TurnRequestV1 +from anton.cloud_turn.session import build_cloud_chat_session -__all__ = [ - "PROTOCOL_VERSION", - "CapabilitiesV1", - "TurnRequestV1", - "MessageV1", - "TurnEventV1", - "TurnStartedV1", - "TurnCompletedV1", - "TurnFailedV1", - "TurnErrorV1", - "ErrorCodeV1", - "parse_request", - "event_line", -] +__all__ = ["TurnRequestV1", "build_cloud_chat_session"] diff --git a/anton/cloud_turn/__main__.py b/anton/cloud_turn/__main__.py index a625bf8b..fc0e631c 100644 --- a/anton/cloud_turn/__main__.py +++ b/anton/cloud_turn/__main__.py @@ -1,206 +1,126 @@ -"""`python -m anton.cloud_turn` — the pod entrypoint. +"""`python -m anton.cloud_turn` - the sandbox-pod turn entrypoint. -Process contract: - stdin : a single TurnRequestV1 JSON document, EOF-terminated (bounded read) - stdout: versioned JSONL TurnEventV1 records ONLY — nothing else, ever +Contract (matches scratchpad-controller + cowork-server): + stdin : ONE newline-terminated TurnRequestV1 JSON line (controller closes stdin) + stdout: JSONL events - `delta` / `turn_completed` / `turn_failed`, nothing else stderr: diagnostic logs + full tracebacks - exit : 0 = turn completed - 2 = request could not be validated (no turn.started emitted) - 3 = valid request reached execution but failed - -stdout isolation is enforced at the OS file-descriptor level (see -``_isolated_protocol_stdout``): the original FD 1 is duplicated to a private, -non-inheritable descriptor used ONLY for protocol events, and process FD 1 is -redirected to stderr. So ``print()``, ``os.write(1, ...)``, native-library -writes, and child-process stdout can never corrupt the events stream. - -`python -m anton.cloud_turn capabilities` prints a stable machine-readable -manifest of what this milestone actually enables. + exit : 0 (the controller detects the terminal from the event, not the code) + +stdout is isolated at the OS file-descriptor level: the real FD 1 is duplicated +to a private, non-inheritable descriptor used only for protocol events, and +process FD 1 is redirected to stderr. So `print()`, `os.write(1, ...)`, +native-library writes, and child-process stdout can never corrupt the stream. """ from __future__ import annotations import asyncio import contextlib +import inspect import json import logging import os import sys -from anton.cloud_turn import protocol -from anton.cloud_turn.errors import classify_error -from anton.cloud_turn.protocol import ( - SUPPORTED_CONTENT_BLOCK_TYPES, - SUPPORTED_MESSAGE_ROLES, - SUPPORTED_PROTOCOL_VERSIONS, - TurnFailedV1, - event_line, - parse_request, -) -from anton.cloud_turn.runner import run_turn +from anton.cloud_turn.contract import TurnRequestV1 +from anton.cloud_turn.session import build_cloud_chat_session logger = logging.getLogger(__name__) -EXIT_OK = 0 -EXIT_BAD_REQUEST = 2 -EXIT_FAILED = 3 +#: Bound the single request line so a malformed/huge stdin can't exhaust memory. +MAX_REQUEST_BYTES = 10 * 1024 * 1024 +#: Keep wire error strings short and log-safe. +MAX_ERROR_MESSAGE_CHARS = 300 -#: The cloud-turn implementation version (independent of the wire protocol -#: version). Bump on behavioural changes to the process boundary. -CLOUD_TURN_IMPL_VERSION = "1.0" +def _scrub(exc: Exception) -> str: + """Short, credential-scrubbed error string for the wire. Full traceback + stays on stderr (logged by the caller).""" + from anton.utils.datasources import scrub_credentials -def _capabilities() -> dict: - """Stable, machine-readable manifest of what THIS milestone enables. - - Reports only behaviour that is actually on — not merely code that exists. - """ - from anton.cloud_turn.session import CLOUD_TOOL_ALLOWLIST, _MODEL_ALLOWLIST_ENV - - try: - import anton - - anton_version = getattr(anton, "__version__", "unknown") - except Exception: - anton_version = "unknown" - - provider_sdks: dict[str, bool] = {} - for name in ("anthropic", "openai"): - try: - __import__(name) - provider_sdks[name] = True - except Exception: - provider_sdks[name] = False - - model_allowlist = [ - m.strip() for m in os.environ.get(_MODEL_ALLOWLIST_ENV, "").split(",") if m.strip() - ] - - return { - "ok": True, - "entrypoint": "anton.cloud_turn", - "cloud_turn_version": CLOUD_TURN_IMPL_VERSION, - "anton_version": anton_version, - "protocol_versions": list(SUPPORTED_PROTOCOL_VERSIONS), - "tools": sorted(CLOUD_TOOL_ALLOWLIST), - "content_block_types": sorted(SUPPORTED_CONTENT_BLOCK_TYPES), - "message_roles": sorted(SUPPORTED_MESSAGE_ROLES), - "model_override": { - # A request may request a model, but only one on the trusted pod-side - # allowlist is honoured; empty allowlist = default-deny. - "supported": True, - "policy": "trusted_allowlist", - "default": "deny", - "allowlist_size": len(model_allowlist), - }, - "memory": {"personal": False, "workspace": False}, - "connectors": False, - "data_vault": False, - "scratchpad_execution": True, - "web_tools": False, - "provider_sdks": provider_sdks, - # The capability FLAG names (all default OFF this milestone). - "capabilities": list(protocol.CapabilitiesV1.model_fields.keys()), - } + text = scrub_credentials(f"{type(exc).__name__}: {exc}") + if len(text) > MAX_ERROR_MESSAGE_CHARS: + text = text[: MAX_ERROR_MESSAGE_CHARS - 1] + "…" + return text @contextlib.contextmanager def _isolated_protocol_stdout(): - """OS-level stdout isolation. Yields an ``emit(event)`` that writes JSONL to - the saved protocol descriptor; everything else (FD 1) is redirected to - stderr. - - Works wherever ``os.dup2`` is available (POSIX + Windows for FDs 0/1/2), - which covers the Linux pod, macOS dev, and CI. - """ + """OS-level stdout isolation. Yields ``emit(event: dict)`` writing JSONL to + the saved protocol descriptor; everything else (FD 1) goes to stderr.""" sys.stdout.flush() sys.stderr.flush() stderr_fd = sys.stderr.fileno() - # Private, non-inheritable copy of the real protocol channel (original FD 1). protocol_fd = os.dup(1) - os.set_inheritable(protocol_fd, False) - # Redirect process FD 1 → stderr: any write to fd 1 (print, os.write(1, …), - # native libs, inherited child stdout) now lands on stderr, never protocol. - os.dup2(stderr_fd, 1) + os.set_inheritable(protocol_fd, False) # children never inherit the protocol channel + os.dup2(stderr_fd, 1) # any write to fd 1 now lands on stderr saved_sys_stdout = sys.stdout - sys.stdout = sys.stderr # Python-level print() → stderr too + sys.stdout = sys.stderr logging.basicConfig(stream=sys.stderr, level=logging.INFO) - def emit(event) -> None: - data = (event_line(event) + "\n").encode("utf-8") + def emit(event: dict) -> None: + data = (json.dumps(event) + "\n").encode("utf-8") view = memoryview(data) - while view: # os.write may do a partial write; loop until fully flushed + while view: # os.write may partial-write; loop until fully flushed n = os.write(protocol_fd, view) view = view[n:] try: yield emit finally: - # Flush diagnostics, then close the protocol descriptor. os.write went - # straight to the kernel, so there is no userspace buffer to lose. with contextlib.suppress(Exception): sys.stderr.flush() sys.stdout = saved_sys_stdout os.close(protocol_fd) -def _safe_ids(raw: str) -> tuple[str | None, str | None]: - """Best-effort identifiers from an UNVALIDATED request body. - - Returns ``None`` for any id that is absent or not a string — we never invent - a valid-looking identifier for a request we could not validate (malformed - JSON, empty stdin, missing/mistyped ids all yield ``None``).""" +async def _close(session) -> None: + close = getattr(session, "close", None) + if close is None: + return try: - data = json.loads(raw) + result = close() + if inspect.isawaitable(result): + await result except Exception: - return None, None - if not isinstance(data, dict): - return None, None - rid = data.get("run_id") - aid = data.get("attempt_id") - return ( - rid if isinstance(rid, str) else None, - aid if isinstance(aid, str) else None, - ) - - -def _run(raw: str, emit, session_builder=None) -> int: - """Parse the request and drive the turn. The testable core — no FD or stdin - handling, so unit tests can inject ``raw`` + a capturing ``emit``.""" + logger.warning("cloud session close failed (non-fatal)", exc_info=True) + + +async def stream_turn(raw_line: str, emit, session_builder=None) -> None: + """Parse the request, run one turn, and emit exactly one terminal event. + + Streaming: assistant text is emitted as ``delta`` events as it arrives, then + a bare ``turn_completed``. Any failure (parse or turn) -> one ``turn_failed`` + with a scrubbed error string. + """ + from anton.core.llm.provider import StreamTextDelta + + builder = session_builder or build_cloud_chat_session + session = None try: - request = parse_request(raw) + req = TurnRequestV1.from_json(raw_line) + session = builder(req) + async for event in session.turn_stream(req.input): + if isinstance(event, StreamTextDelta): + emit({"kind": "delta", "text": event.text or ""}) + emit({"kind": "turn_completed"}) except Exception as exc: - # Invalid request: one structured failure event, NO turn.started - # (sequence 1, the only event of the run). Ids are None when the request - # supplied none usable. - run_id, attempt_id = _safe_ids(raw) - logger.exception("invalid cloud-turn request run_id=%s", run_id) - emit( - TurnFailedV1( - run_id=run_id, - attempt_id=attempt_id, - sequence=1, - error=classify_error(exc), - ) - ) - return EXIT_BAD_REQUEST - return asyncio.run(run_turn(request, emit, session_builder=session_builder)) + # Full traceback -> stderr only; wire carries a short scrubbed string. + logger.exception("cloud turn failed") + emit({"kind": "turn_failed", "error": _scrub(exc)}) + finally: + if session is not None: + await _close(session) def main(argv: list[str] | None = None) -> int: - argv = sys.argv[1:] if argv is None else argv - - if argv and argv[0] == "capabilities": - print(json.dumps(_capabilities())) - return 0 - with _isolated_protocol_stdout() as emit: - # Bounded read: never pull an unbounded request into memory. Read at most - # MAX_REQUEST_BYTES+1 (chars ≤ bytes in UTF-8); parse_request enforces - # the precise byte limit. - raw = sys.stdin.read(protocol.MAX_REQUEST_BYTES + 1) - return _run(raw, emit) + # One bounded line (the controller writes a single JSON line + \n, then + # closes stdin). ``readline`` returns on the newline without blocking. + raw_line = sys.stdin.readline(MAX_REQUEST_BYTES + 1) + asyncio.run(stream_turn(raw_line, emit)) + return 0 if __name__ == "__main__": diff --git a/anton/cloud_turn/contract.py b/anton/cloud_turn/contract.py new file mode 100644 index 00000000..833d05c6 --- /dev/null +++ b/anton/cloud_turn/contract.py @@ -0,0 +1,45 @@ +"""Wire contract between the scratchpad-controller and the pod entrypoint. + +Matches what the controller sends (`scratchpad_controller.anton_turn.request_line`) +and what cowork-server consumes off the reply stream. Intentionally minimal and +data-only: the entrypoint reads ONE newline-terminated JSON line on stdin. + +Events written back on stdout (JSONL) are the three the controller translates: + {"kind": "delta", "text": "..."} - streamed assistant text, one per chunk + {"kind": "turn_completed"} - terminal success (no payload) + {"kind": "turn_failed", "error": "..."} - terminal failure (scrubbed string) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + + +@dataclass +class TurnRequestV1: + """One turn to run in the pod. Sent as a single JSON line on stdin.""" + + protocol_version: int + conversation_id: str + input: str + #: Mount path the controller passes; the pod uses its own trusted mount and + #: does not act on this value (kept for wire-compatibility). See session.py. + workspace_path: str | None = None + #: Optional model override; None uses the settings default. + model: str | None = None + #: DB-authoritative ordered history ({"role","content"} dicts). The pod never + #: loads its own history; cowork-server owns persistence. + history: list = field(default_factory=list) + + @staticmethod + def from_json(raw: str) -> "TurnRequestV1": + d = json.loads(raw) + return TurnRequestV1( + protocol_version=int(d["protocol_version"]), + conversation_id=str(d["conversation_id"]), + input=str(d["input"]), + workspace_path=d.get("workspace_path"), + model=d.get("model"), + history=d.get("history") or [], + ) From 13e93814207fb4fb5346114e21bf2e0dac768dd1 Mon Sep 17 00:00:00 2001 From: ZoranPandovski Date: Tue, 28 Jul 2026 12:35:55 +0200 Subject: [PATCH 11/11] Update session and new tests --- anton/cloud_turn/session.py | 107 +++------ tests/cloud_turn_fake_entry.py | 5 +- tests/test_cloud_turn_entrypoint.py | 92 ++++++++ tests/test_cloud_turn_process.py | 167 ++++--------- tests/test_cloud_turn_session.py | 354 +++++++--------------------- 5 files changed, 259 insertions(+), 466 deletions(-) create mode 100644 tests/test_cloud_turn_entrypoint.py diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py index d96d8948..ae7b24eb 100644 --- a/anton/cloud_turn/session.py +++ b/anton/cloud_turn/session.py @@ -1,15 +1,19 @@ """Cloud-safe ChatSession builder. -Assembles ``ChatSessionConfig`` directly instead of the desktop -``build_chat_session`` (which loads workspace ``.env``, uses ``~/.anton`` -personal memory, and injects vault creds into ``os.environ`` — all cross-tenant -hazards in a shared pod). Milestone-1 posture: - -* Trusted pod-side workspace path, never a wire field. -* No dotenv loading. -* Scratchpad env from a non-secret allowlist. -* Explicit tool allowlist; new core tools are not auto-enabled. -* Personal memory / connectors / data-vault / local-file history OFF. +Assembles a :class:`~anton.core.session.ChatSession` scoped to the tenant's +mounted workspace with the desktop-only, tenant-leaky behaviours OFF. It does +NOT call the desktop ``build_chat_session`` (which loads workspace ``.env``, +uses ``~/.anton`` personal memory, and injects vault creds into ``os.environ``). + +Safety posture (all internal — nothing here is on the wire): + +* Trusted pod-side workspace mount, never taken from the request. +* No dotenv loading (``AntonSettings(_env_file=None)``, shared into Workspace). +* Personal memory / connectors / data-vault / disk history OFF. +* Scratchpad subprocess env built from a non-secret allowlist, so generated + code can't read the provider key (interim until the Plan-5 gateway removes + the key from the pod entirely). +* Only reviewed, headless-safe tools are exposed (scratchpad + artifacts). """ from __future__ import annotations @@ -19,30 +23,20 @@ from pathlib import Path from typing import TYPE_CHECKING -from anton.cloud_turn.errors import ( - UnsupportedCapabilityError, - UnsupportedModelError, -) -from anton.cloud_turn.protocol import CapabilitiesV1, TurnRequestV1 +from anton.cloud_turn.contract import TurnRequestV1 if TYPE_CHECKING: - from anton.config.settings import AntonSettings from anton.core.session import ChatSession logger = logging.getLogger(__name__) #: Trusted mount path — pod-side config, never from the wire request. DEFAULT_CLOUD_WORKSPACE_PATH = "/workspace" - #: Operator/CI override for the mount path (pod-side env var, not request data). _WORKSPACE_PATH_ENV = "ANTON_CLOUD_WORKSPACE_PATH" -#: Pod-side trusted model allowlist (comma-separated). Default empty = the -#: request may NOT override the model; the trusted settings default is used. -_MODEL_ALLOWLIST_ENV = "ANTON_CLOUD_MODEL_ALLOWLIST" - -#: The only tools exposed in a milestone-1 cloud turn: scratchpad + the -#: workspace-scoped artifact tools. Everything else core registers is dropped. +#: The only tools exposed in a cloud turn: scratchpad + the workspace-scoped +#: artifact tools. Everything else core registers is dropped. CLOUD_TOOL_ALLOWLIST = frozenset( { "scratchpad", @@ -69,8 +63,6 @@ def resolve_trusted_workspace_path() -> Path: ) if ".." in Path(raw).parts: raise ValueError(f"trusted workspace path must not contain '..': {raw!r}") - - # Canonicalise (follows symlinks) so later checks compare the real location. resolved = Path(raw).resolve() resolved.mkdir(parents=True, exist_ok=True) if not resolved.is_dir(): @@ -81,39 +73,31 @@ def resolve_trusted_workspace_path() -> Path: def build_cloud_chat_session(request: TurnRequestV1) -> "ChatSession": """Assemble a cloud-safe ChatSession for one turn. - The LLM credential is read by ``LLMClient.from_settings`` from the - ``ANTON_*`` environment (a synthetic-data dev key today; a gateway-issued - run-scoped credential before beta). History comes from the request (the DB - is authoritative) — never from the workspace. + History is DB-authoritative (from the request). The workspace path is the + trusted pod mount, NOT ``request.workspace_path``. """ - from anton.cloud_turn.messages import to_anton_history from anton.config.settings import AntonSettings from anton.core.backends.local import sanitized_scratchpad_runtime_factory from anton.core.llm.client import LLMClient from anton.core.session import ChatSession, ChatSessionConfig from anton.workspace import Workspace - caps = request.capabilities - - # Milestone 1 is the locked-down posture only; any capability on = fail loud. - _reject_unsupported_capabilities(caps) - - # Trusted pod-side mount path — NOT request-controlled. base = resolve_trusted_workspace_path() # `_env_file=None`: never load the AntonSettings .env chain (~/.anton/.env, - # ~/.cowork/.env, /workspace/.env). Same object is passed to Workspace so it + # ~/.cowork/.env, /workspace/.env). Same object passed to Workspace so it # doesn't build a second, dotenv-loading one. settings = AntonSettings(_env_file=None) settings.resolve_workspace(str(base)) - _apply_model_policy(settings, request.model) + if request.model: + settings.planning_model = request.model # Skills stay in the workspace, never the pod-shared ~/.anton. settings.skills_root = base / ".anton" / "skills" workspace = Workspace(base, settings=settings) workspace.initialize() # No apply_env_to_process(): loading workspace .env into the process env - # would expose tenant secrets to cell code. No capability enables it. + # would expose tenant secrets to cell code. llm_client = LLMClient.from_settings(settings) @@ -123,57 +107,24 @@ def build_cloud_chat_session(request: TurnRequestV1) -> "ChatSession": workspace=workspace, session_id=request.conversation_id, harness="cloud", - # DB-authoritative history; the pod never loads its own. Typed wire - # messages are converted to Anton's internal dict shape. - initial_history=to_anton_history(request.history) or None, - console=None, # headless — no Rich console + # DB-authoritative history; the pod never loads its own. + initial_history=list(request.history) if request.history else None, + console=None, # headless cortex=None, # personal memory OFF episodic=None, self_awareness=None, data_vault=None, # connectors OFF - history_store=None, # local-file history OFF (DB authoritative) + history_store=None, # disk history OFF (DB authoritative) tools=[], # no host connector/publish tools tool_allowlist=CLOUD_TOOL_ALLOWLIST, # only reviewed tools survive the build runtime_factory=sanitized_scratchpad_runtime_factory, # secret-free scratchpad env - # Milestone 1: text + scratchpad only; web tools land with a later capability. web_search_enabled=False, web_fetch_enabled=False, ) session = ChatSession(config) logger.info( - "cloud session built run_id=%s workspace=%s tool_allowlist=%s", - request.run_id, base, sorted(CLOUD_TOOL_ALLOWLIST), + "cloud session built conversation=%s workspace=%s tools=%s", + request.conversation_id, base, sorted(CLOUD_TOOL_ALLOWLIST), ) return session - - -def _reject_unsupported_capabilities(caps: CapabilitiesV1) -> None: - """Fail loud on any capability that has no cloud-safe implementation yet.""" - enabled = [name for name in CapabilitiesV1.model_fields if getattr(caps, name)] - if enabled: - raise UnsupportedCapabilityError( - "cloud session does not implement capabilities: " - + ", ".join(sorted(enabled)) - ) - - -def _trusted_model_allowlist() -> frozenset[str]: - raw = os.environ.get(_MODEL_ALLOWLIST_ENV, "") - return frozenset(m.strip() for m in raw.split(",") if m.strip()) - - -def _apply_model_policy(settings: "AntonSettings", requested: str | None) -> None: - """Model selection is NOT request-controlled by default. - - ``None`` → keep the trusted settings default. A requested model is honoured - only if it is in the pod-side trusted allowlist (default empty), else - rejected with a structured error.""" - if requested is None: - return - allowed = _trusted_model_allowlist() - if requested not in allowed: - raise UnsupportedModelError( - f"model {requested!r} is not permitted for cloud turns" - ) - settings.planning_model = requested diff --git a/tests/cloud_turn_fake_entry.py b/tests/cloud_turn_fake_entry.py index 447d17ab..bf7b6ffa 100644 --- a/tests/cloud_turn_fake_entry.py +++ b/tests/cloud_turn_fake_entry.py @@ -68,7 +68,7 @@ def _fake_from_settings(cls, settings): def _install_stray_session(fail: bool) -> None: - import anton.cloud_turn.runner as runner_mod + import anton.cloud_turn.__main__ as entry_mod class _StraySession: def __init__(self) -> None: @@ -81,7 +81,6 @@ async def turn_stream(self, user_input, **kwargs): sys.stdout.write("STRAY via sys.stdout.write\n") # Python stdout os.write(1, b"STRAY via os.write(1)\n") # direct FD 1 (native-style) logging.getLogger("some.library").warning("STRAY via logging") - self.history.append({"role": "assistant", "content": "clean answer"}) if fail: raise RuntimeError("boom after stray output") if False: # make this an async generator @@ -90,7 +89,7 @@ async def turn_stream(self, user_input, **kwargs): def close(self): self.closed = True - runner_mod.build_cloud_chat_session = lambda request: _StraySession() + entry_mod.build_cloud_chat_session = lambda request: _StraySession() def main() -> int: diff --git a/tests/test_cloud_turn_entrypoint.py b/tests/test_cloud_turn_entrypoint.py new file mode 100644 index 00000000..b3f30170 --- /dev/null +++ b/tests/test_cloud_turn_entrypoint.py @@ -0,0 +1,92 @@ +"""Entrypoint wire contract: request parsing + streaming event emission. + +Offline: a fake session (scripted stream events) replaces ChatSession, so no LLM +key or scratchpad is needed. Mirrors the controller/cowork contract: +`delta` -> ... -> `turn_completed` | `turn_failed`. +""" + +from __future__ import annotations + +import asyncio +import json + +from anton.cloud_turn.contract import TurnRequestV1 +from anton.cloud_turn.__main__ import stream_turn +from anton.core.llm.provider import StreamTextDelta + + +# ── contract parsing ───────────────────────────────────────────────────────── + +def test_from_json_parses_full_request(): + req = TurnRequestV1.from_json(json.dumps({ + "protocol_version": 1, "conversation_id": "c", "input": "hi", + "workspace_path": "/workspace", "model": "m", + "history": [{"role": "user", "content": "prev"}], + })) + assert req.conversation_id == "c" + assert req.input == "hi" + assert req.history == [{"role": "user", "content": "prev"}] + + +def test_from_json_history_defaults_empty(): + req = TurnRequestV1.from_json('{"protocol_version":1,"conversation_id":"c","input":"hi"}') + assert req.history == [] + assert req.workspace_path is None and req.model is None + + +# ── streaming event emission ───────────────────────────────────────────────── + +class _FakeSession: + def __init__(self, deltas=(), raise_on_stream=None): + self._deltas = list(deltas) + self._raise = raise_on_stream + self.closed = False + + async def turn_stream(self, user_input, **kwargs): + if self._raise: + raise self._raise + for d in self._deltas: + yield StreamTextDelta(text=d) + + def close(self): + self.closed = True + + +def _drive(session, req_json='{"protocol_version":1,"conversation_id":"c","input":"hi"}'): + events = [] + asyncio.run(stream_turn(req_json, events.append, session_builder=lambda r: session)) + return events + + +def test_emits_deltas_then_completed(): + session = _FakeSession(deltas=["he", "llo"]) + events = _drive(session) + assert events == [ + {"kind": "delta", "text": "he"}, + {"kind": "delta", "text": "llo"}, + {"kind": "turn_completed"}, + ] + assert session.closed is True # session closed on success + + +def test_text_only_no_deltas_still_completes(): + events = _drive(_FakeSession(deltas=[])) + assert events == [{"kind": "turn_completed"}] + + +def test_turn_failure_is_terminal_and_scrubbed(): + session = _FakeSession(raise_on_stream=RuntimeError("boom sk-ant-" + "A" * 80)) + events = _drive(session) + assert len(events) == 1 + assert events[0]["kind"] == "turn_failed" + assert "boom" in events[0]["error"] + assert "sk-ant-" + "A" * 80 not in events[0]["error"] # credential scrubbed + assert session.closed is True # closed even on failure + + +def test_bad_request_is_a_single_turn_failed(): + events = [] + asyncio.run(stream_turn("{not valid json", events.append)) + assert len(events) == 1 + assert events[0]["kind"] == "turn_failed" + assert events[0]["error"] # non-empty, scrubbed diff --git a/tests/test_cloud_turn_process.py b/tests/test_cloud_turn_process.py index 19fc3a6c..27862ad7 100644 --- a/tests/test_cloud_turn_process.py +++ b/tests/test_cloud_turn_process.py @@ -1,14 +1,15 @@ """Real-subprocess tests for the cloud-turn process boundary. -These run the actual entrypoint (via ``cloud_turn_fake_entry.py``, which calls -the real ``main()``) as a child process, so FD-level stdout isolation, fresh -process/scratchpad state, exit codes, and the deterministic E2E are proven end -to end — not simulated in-process. +Runs the actual entrypoint (via cloud_turn_fake_entry.py, which calls the real +main()) as a child process, so FD-level stdout isolation, fresh +process/scratchpad state, and the deterministic E2E are proven end to end. +Event kinds match the controller contract: delta / turn_completed / turn_failed. """ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -18,29 +19,20 @@ _HARNESS = str(Path(__file__).parent / "cloud_turn_fake_entry.py") -def _run_cli(request, *, workspace, mode="model", script=None, timeout=60, env_extra=None): +def _run_cli(request, *, workspace, mode="model", script=None, timeout=60): """Run the entrypoint as a subprocess; return (exit_code, events, stdout, stderr). - - ``events`` is the parsed JSONL from stdout — the assertion that stdout is a - clean protocol stream is that every non-blank line is valid JSON.""" - import os - + Parsing stdout as JSONL is itself the assertion that stdout is clean.""" env = os.environ.copy() env["ANTON_CLOUD_WORKSPACE_PATH"] = str(workspace) env["CLOUD_TURN_FAKE_MODE"] = mode if script is not None: env["CLOUD_TURN_FAKE_SCRIPT"] = json.dumps(script) - if env_extra: - env.update(env_extra) stdin = request if isinstance(request, str) else json.dumps(request) proc = subprocess.run( [sys.executable, _HARNESS], input=stdin.encode("utf-8"), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - timeout=timeout, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=timeout, ) lines = [ln for ln in proc.stdout.decode("utf-8").splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] # raises if stdout isn't clean JSONL @@ -48,101 +40,65 @@ def _run_cli(request, *, workspace, mode="model", script=None, timeout=60, env_e def _req(**over): - body = {"run_id": "r", "attempt_id": "a", "conversation_id": "c", "input": "hi"} + body = {"protocol_version": 1, "conversation_id": "c", "input": "hi"} body.update(over) return body -# ── item 1: FD-level stdout isolation ──────────────────────────────────────── +# ── FD-level stdout isolation ──────────────────────────────────────────────── def test_stray_stdout_never_corrupts_protocol(tmp_path): """print(), sys.stdout.write, os.write(1, …) and library logging during the turn must all land on stderr, leaving stdout a clean protocol stream.""" - code, events, stdout, stderr = _run_cli(_req(), workspace=tmp_path, mode="stray") - assert code == 0 - assert [e["kind"] for e in events] == ["turn.started", "turn.completed"] - assert events[-1]["final_text"] == "clean answer" - assert "STRAY" not in stdout # nothing stray on the protocol channel - assert "STRAY via os.write(1)" in stderr # direct FD-1 write was redirected to stderr + _, events, stdout, stderr = _run_cli(_req(), workspace=tmp_path, mode="stray") + assert [e["kind"] for e in events] == ["turn_completed"] + assert "STRAY" not in stdout # nothing stray on the protocol channel + assert "STRAY via os.write(1)" in stderr # direct FD-1 write redirected to stderr assert "STRAY via print()" in stderr def test_stray_then_failure_stays_clean(tmp_path): - code, events, stdout, stderr = _run_cli(_req(), workspace=tmp_path, mode="stray_fail") - assert code == 3 - assert [e["kind"] for e in events] == ["turn.started", "turn.failed"] + _, events, stdout, _ = _run_cli(_req(), workspace=tmp_path, mode="stray_fail") + assert [e["kind"] for e in events] == ["turn_failed"] assert "STRAY" not in stdout - # No traceback / internals leak onto the protocol event. - assert "Traceback" not in stdout - assert events[-1]["error"]["code"] == "internal_turn_failure" + assert "Traceback" not in stdout # no traceback leaks onto the wire + assert "boom" in events[-1]["error"] def test_malformed_input_clean_protocol(tmp_path): - code, events, stdout, stderr = _run_cli("{not valid json", workspace=tmp_path) - assert code == 2 - assert len(events) == 1 and events[0]["kind"] == "turn.failed" - assert events[0]["sequence"] == 1 - assert events[0]["error"]["code"] == "invalid_request" - - -# ── item 7: exit codes + terminal-event guarantees ─────────────────────────── - -def test_exit_zero_on_completion(tmp_path): - code, events, *_ = _run_cli( - _req(), workspace=tmp_path, mode="model", script=[{"text": "done"}] - ) - assert code == 0 - terminals = [e for e in events if e["kind"] in ("turn.completed", "turn.failed")] - assert len(terminals) == 1 and terminals[0]["kind"] == "turn.completed" - + _, events, stdout, _ = _run_cli("{not valid json", workspace=tmp_path) + assert len(events) == 1 and events[0]["kind"] == "turn_failed" + assert events[0]["error"] -def test_exit_three_on_execution_failure(tmp_path): - code, events, *_ = _run_cli(_req(), workspace=tmp_path, mode="stray_fail") - assert code == 3 - terminals = [e for e in events if e["kind"] in ("turn.completed", "turn.failed")] - assert len(terminals) == 1 and terminals[0]["kind"] == "turn.failed" - -def test_oversized_request_rejected_at_process_level(tmp_path): - from anton.cloud_turn.protocol import MAX_REQUEST_BYTES - - oversized = json.dumps(_req(input="x" * (MAX_REQUEST_BYTES + 10))) - code, events, stdout, _ = _run_cli(oversized, workspace=tmp_path, timeout=60) - assert code == 2 - assert len(events) == 1 and events[0]["kind"] == "turn.failed" - assert events[0]["error"]["code"] == "invalid_request" - - -# ── item 3: deterministic local E2E (real CLI, fake model, no network) ──────── +# ── deterministic E2E (real CLI, fake model, no network) ───────────────────── def test_e2e_text_only_turn(tmp_path): - """Full path: JSON on stdin → parse → cloud-safe session → runner → - turn.started + turn.completed with valid output_messages and final_text.""" - code, events, *_ = _run_cli( + """Full path: JSON on stdin -> parse -> cloud-safe session -> streaming + delta events -> turn_completed.""" + _, events, *_ = _run_cli( _req(input="What is 2 + 2?"), workspace=tmp_path, mode="model", script=[{"text": "The answer is 4."}], ) - assert code == 0 - assert [e["kind"] for e in events] == ["turn.started", "turn.completed"] - completed = events[-1] - assert completed["final_text"] == "The answer is 4." - assert completed["output_messages"] == [ - {"role": "assistant", "content": "The answer is 4."} - ] + kinds = [e["kind"] for e in events] + assert kinds[-1] == "turn_completed" + text = "".join(e["text"] for e in events if e["kind"] == "delta") + assert text == "The answer is 4." -# ── item 2: fresh process + scratchpad state per invocation ─────────────────── +# ── fresh process + scratchpad state per invocation ────────────────────────── +# Tool output is not on the wire (his contract), so we observe via workspace +# files: Turn A sets a variable + writes a file; Turn B (new process, same +# workspace) reports whether the variable survived. Files persist, runtime does not. _CELL_A = ( - "E2E_SENTINEL = 4242\n" - "import os as _os\n" - "_os.environ['A_MUT'] = 'turnA'\n" - "print('set', E2E_SENTINEL, _os.environ.get('A_MUT'))\n" + "X_SENTINEL = 4242\n" + "open('a_done.txt', 'w').write('a-ok')\n" + "print('set')\n" ) _CELL_B = ( - "import os as _os\n" - "print('sentinel_present', 'E2E_SENTINEL' in dir())\n" - "print('env_mut', _os.environ.get('A_MUT'))\n" + "open('b_result.txt', 'w').write('has_x=' + str('X_SENTINEL' in dir()))\n" + "print('done')\n" ) @@ -155,53 +111,33 @@ def _scratchpad_step(code): @pytest.mark.slow def test_fresh_scratchpad_state_across_processes(tmp_path): - """Turn A defines a scratchpad variable + mutates its env; Turn B, a NEW - process against the SAME workspace, must not observe either.""" - # Turn A — sets state, then finishes. codeA, eventsA, *_ = _run_cli( - _req(run_id="A", input="set state"), + _req(conversation_id="A", input="set state"), workspace=tmp_path, mode="model", timeout=180, script=[_scratchpad_step(_CELL_A), {"text": "did A"}], ) - assert codeA == 0, eventsA - # Sanity: Turn A's scratchpad actually ran and set the sentinel. - a_tool_results = _tool_result_texts(eventsA[-1]) - assert any("set 4242 turnA" in t for t in a_tool_results) + assert eventsA[-1]["kind"] == "turn_completed", eventsA + # Turn A's scratchpad ran and its file persists in the workspace. + assert (tmp_path / "a_done.txt").read_text() == "a-ok" - # Turn B — brand-new process, same workspace, tries to read Turn A's state. codeB, eventsB, *_ = _run_cli( - _req(run_id="B", input="read state"), + _req(conversation_id="B", input="read state"), workspace=tmp_path, mode="model", timeout=180, script=[_scratchpad_step(_CELL_B), {"text": "did B"}], ) - assert codeB == 0, eventsB - b_tool_results = _tool_result_texts(eventsB[-1]) - joined = "\n".join(b_tool_results) - assert "sentinel_present False" in joined # Python global gone - assert "env_mut None" in joined # env mutation gone - - -def _tool_result_texts(completed_event) -> list[str]: - """Extract tool_result content strings from a turn.completed event.""" - out = [] - for msg in completed_event.get("output_messages", []): - content = msg.get("content") - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_result": - c = block.get("content") - out.append(c if isinstance(c, str) else json.dumps(c)) - return out + assert eventsB[-1]["kind"] == "turn_completed", eventsB + # New process => fresh scratchpad namespace: Turn A's variable is gone. + assert (tmp_path / "b_result.txt").read_text() == "has_x=False" -# ── item 2: session.close() terminates the inner scratchpad (no orphans) ────── +# ── session.close() terminates the inner scratchpad (no orphans) ───────────── async def _real_cloud_session(tmp_path, monkeypatch): import anton.core.llm.client as llm_client_mod from unittest.mock import AsyncMock, MagicMock from anton.core.llm.provider import ProviderConnectionInfo - from anton.cloud_turn.protocol import TurnRequestV1 + from anton.cloud_turn.contract import TurnRequestV1 from anton.cloud_turn.session import build_cloud_chat_session monkeypatch.setenv("ANTON_CLOUD_WORKSPACE_PATH", str(tmp_path)) @@ -210,22 +146,19 @@ def _mk(cls, settings): llm = AsyncMock() llm.coding_provider = MagicMock() llm.coding_provider.export_connection_info = MagicMock( - return_value=ProviderConnectionInfo(provider="anthropic", api_key="test") - ) + return_value=ProviderConnectionInfo(provider="anthropic", api_key="test")) llm.coding_model = "m" llm.planning_provider = MagicMock() llm.planning_provider.native_web_tools = MagicMock(return_value=set()) return llm monkeypatch.setattr(llm_client_mod.LLMClient, "from_settings", classmethod(_mk)) - req = TurnRequestV1(run_id="r", attempt_id="a", conversation_id="c", input="hi") + req = TurnRequestV1(protocol_version=1, conversation_id="c", input="hi") return build_cloud_chat_session(req) @pytest.mark.slow async def test_session_close_terminates_scratchpad(tmp_path, monkeypatch): - """A real scratchpad subprocess is started, then session.close() must kill it - — no orphaned child survives the runner.""" session = await _real_cloud_session(tmp_path, monkeypatch) pad = await session._scratchpads.get_or_create("main") await pad.execute("x = 1") diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 269e33e2..1954c96a 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -1,11 +1,11 @@ """Safety tests for the cloud-safe ChatSession builder. Two layers: -* Config-level (fast, offline): assert the ChatSessionConfig handed to - ChatSession has every cross-tenant hazard off and the right allowlist/factory. +* Config-level (fast, offline): the ChatSessionConfig handed to ChatSession has + every cross-tenant hazard off and the right allowlist/factory. * Enforcement-level (real code paths): drive the real lazy _build_tools() and a - real sanitized scratchpad subprocess so a claimed property is actually proven, - not just configured. + real sanitized scratchpad subprocess so a claimed property is proven, not just + configured. """ from __future__ import annotations @@ -14,10 +14,9 @@ import anton.core.llm.client as llm_client_mod import anton.core.session as session_mod -from anton.cloud_turn.protocol import CapabilitiesV1, TurnRequestV1 +from anton.cloud_turn.contract import TurnRequestV1 from anton.cloud_turn.session import ( CLOUD_TOOL_ALLOWLIST, - _MODEL_ALLOWLIST_ENV, _WORKSPACE_PATH_ENV, build_cloud_chat_session, resolve_trusted_workspace_path, @@ -26,8 +25,8 @@ class _FakeSession: - """Placeholder return value for the mocked ChatSession — the config-level - tests only inspect the captured ChatSessionConfig, never the session.""" + """Placeholder return value for the mocked ChatSession - config-level tests + only inspect the captured ChatSessionConfig, never the session.""" def _build(tmp_path, monkeypatch, **req_overrides): @@ -42,25 +41,23 @@ def fake_chat_session(config): llm_client_mod.LLMClient, "from_settings", classmethod(lambda cls, settings: object()), ) - # The mount path is trusted pod config, supplied via env — never the wire. monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) - body = dict( - run_id="run_1", attempt_id="att_1", conversation_id="conv_1", - input="hello", - ) + body = dict(protocol_version=1, conversation_id="conv_1", input="hello") body.update(req_overrides) session = build_cloud_chat_session(TurnRequestV1(**body)) return session, captured["config"] +# ── config-level safety ────────────────────────────────────────────────────── + def test_all_cross_tenant_hazards_off(tmp_path, monkeypatch): _, cfg = _build(tmp_path, monkeypatch) assert cfg.cortex is None # personal memory OFF assert cfg.episodic is None assert cfg.self_awareness is None assert cfg.data_vault is None # connectors / vault OFF - assert cfg.history_store is None # local-file history OFF + assert cfg.history_store is None # disk history OFF assert cfg.console is None # headless assert cfg.tools == [] # no host connector/publish tools assert cfg.web_search_enabled is False @@ -69,7 +66,6 @@ def test_all_cross_tenant_hazards_off(tmp_path, monkeypatch): def test_scratchpad_uses_sanitized_factory_and_is_workspace_bound(tmp_path, monkeypatch): _, cfg = _build(tmp_path, monkeypatch) - # Scratchpad stays ON but through the sanitized factory (no secret env). assert cfg.runtime_factory is sanitized_scratchpad_runtime_factory assert cfg.workspace is not None assert cfg.harness == "cloud" @@ -86,42 +82,24 @@ def test_db_history_is_seeded_not_loaded(tmp_path, monkeypatch): def test_config_uses_explicit_tool_allowlist(tmp_path, monkeypatch): _, cfg = _build(tmp_path, monkeypatch) - # An explicit allowlist (not a denylist) drives the lazy _build_tools(). assert cfg.tool_allowlist == CLOUD_TOOL_ALLOWLIST assert "launch_backend" not in cfg.tool_allowlist - assert "select_path" not in cfg.tool_allowlist assert "scratchpad" in cfg.tool_allowlist -# ── model-selection policy (item 6) ───────────────────────────────────────── - -def test_model_override_rejected_by_default(tmp_path, monkeypatch): - # Default: no trusted allowlist → a request-selected model is rejected. - from anton.cloud_turn.errors import UnsupportedModelError - - monkeypatch.delenv(_MODEL_ALLOWLIST_ENV, raising=False) - with pytest.raises(UnsupportedModelError, match="claude-opus-4-8"): - _build(tmp_path, monkeypatch, model="claude-opus-4-8") - - -def test_model_override_permitted_when_allowlisted(tmp_path, monkeypatch): - monkeypatch.setenv(_MODEL_ALLOWLIST_ENV, "claude-opus-4-8, other-model") +def test_model_override_applied(tmp_path, monkeypatch): _, cfg = _build(tmp_path, monkeypatch, model="claude-opus-4-8") assert cfg.settings.planning_model == "claude-opus-4-8" -def test_no_model_uses_trusted_settings_default(tmp_path, monkeypatch): - # No override → whatever the trusted settings resolved to (unchanged by us). - monkeypatch.delenv(_MODEL_ALLOWLIST_ENV, raising=False) - _, cfg = _build(tmp_path, monkeypatch) # no model in request - assert cfg.settings.planning_model # a default is present, not forced by wire - +# ── trusted workspace path (never from the wire) ───────────────────────────── -# ── #1 trusted workspace path ──────────────────────────────────────────────── - -def test_request_cannot_carry_workspace_path(): - # The wire model forbids it (extra="forbid"); a request can't steer the path. - assert "workspace_path" not in TurnRequestV1.model_fields +def test_request_workspace_path_is_ignored_trusted_mount_used(tmp_path, monkeypatch): + # A request may carry workspace_path (cowork sends "/workspace"), but the pod + # uses its OWN trusted mount, not the wire value. + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + _, cfg = _build(tmp_path, monkeypatch, workspace_path="/etc/evil") + assert cfg.workspace.base == tmp_path.resolve() def test_resolver_uses_env_override(tmp_path, monkeypatch): @@ -142,40 +120,17 @@ def test_resolver_rejects_parent_traversal(monkeypatch): def test_artifact_tools_cannot_escape_workspace(tmp_path): - """The only enabled file tools (artifacts) resolve strictly under - /artifacts/. A slug with traversal must be rejected, and a - read-style lookup for an escaping slug must fail closed (no artifact).""" from anton.core.artifacts.store import ArtifactStore store = ArtifactStore(tmp_path / "artifacts") - for bad in ("../../etc", "/etc/passwd", "a/../../b", "foo/../../../bar"): + for bad in ("../../etc", "/etc/passwd", "a/../../b"): with pytest.raises(ValueError, match="escapes"): store.folder_for(bad) - # open() on an escaping slug returns None rather than reading outside. assert store.open("../../../etc/passwd") is None - # A normal slug still resolves inside the root. assert store.folder_for("my-report").parent == (tmp_path / "artifacts") -def test_artifact_containment_is_canonical_not_lexical(tmp_path): - """Containment is enforced by real-path resolution, so a nested slug that - stays INSIDE the root is allowed (create never emits these — _sanitize_slug - collapses '/'—but open/update must not reject a legitimately-nested path), - while any slug that resolves outside is rejected.""" - from anton.core.artifacts.store import ArtifactStore - - root = tmp_path / "artifacts" - store = ArtifactStore(root) - # Nested-but-contained: allowed, and stays under the root. - nested = store.folder_for("reports/summary") - assert root.resolve() in nested.resolve().parents - # A ".." that stays inside is fine; one that escapes is not. - assert store.folder_for("reports/../summary").resolve() == (root / "summary").resolve() - with pytest.raises(ValueError, match="escapes"): - store.folder_for("reports/../../summary") - - -# ── #2 dotenv is never loaded ──────────────────────────────────────────────── +# ── dotenv never loaded ────────────────────────────────────────────────────── def test_apply_env_to_process_never_called(tmp_path, monkeypatch): import anton.workspace as ws_mod @@ -183,50 +138,35 @@ def test_apply_env_to_process_never_called(tmp_path, monkeypatch): real_init = ws_mod.Workspace.initialize real_apply = ws_mod.Workspace.apply_env_to_process - def spy_init(self): - calls["init"] += 1 - return real_init(self) - - def spy_apply(self): - calls["apply_env"] += 1 - return real_apply(self) - - monkeypatch.setattr(ws_mod.Workspace, "initialize", spy_init) - monkeypatch.setattr(ws_mod.Workspace, "apply_env_to_process", spy_apply) + monkeypatch.setattr(ws_mod.Workspace, "initialize", + lambda self: (calls.__setitem__("init", calls["init"] + 1), real_init(self))[1]) + monkeypatch.setattr(ws_mod.Workspace, "apply_env_to_process", + lambda self: (calls.__setitem__("apply_env", calls["apply_env"] + 1), real_apply(self))[1]) _build(tmp_path, monkeypatch) - assert calls["init"] == 1 # structure created - assert calls["apply_env"] == 0 # but .env NOT loaded into process env + assert calls["init"] == 1 + assert calls["apply_env"] == 0 # .env NOT loaded into process env def test_cloud_settings_ignore_dotenv_files(tmp_path, monkeypatch): - """A .env in the AntonSettings chain must NOT seed the cloud session's - settings — dotenv loading is disabled at the source, not merely unused.""" from anton.config.settings import AntonSettings - # Neutralise ambient sources for this key so the assertions turn only on - # whether a dotenv file is read (env vars would otherwise win over dotenv). monkeypatch.delenv("ANTON_ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - # Positive control: an explicit dotenv IS honoured by AntonSettings. envf = tmp_path / "sentinel.env" envf.write_text("ANTON_ANTHROPIC_API_KEY=DOTENV_SENTINEL\n") assert AntonSettings(_env_file=str(envf)).anthropic_api_key == "DOTENV_SENTINEL" - # A .env sitting in the workspace/cwd must NOT leak into the cloud session, - # which builds AntonSettings with the whole dotenv chain disabled. monkeypatch.chdir(tmp_path) (tmp_path / ".env").write_text("ANTON_ANTHROPIC_API_KEY=DOTENV_SENTINEL\n") _, cfg = _build(tmp_path, monkeypatch) assert cfg.settings.anthropic_api_key != "DOTENV_SENTINEL" -# ── #3/#4 real enforcement paths ───────────────────────────────────────────── +# ── real enforcement paths ─────────────────────────────────────────────────── def _mock_llm(): - """Minimal LLM client sufficient for ChatSession.__init__ + _build_tools - (mirrors tests/test_tools.py::_make_session).""" from unittest.mock import AsyncMock, MagicMock from anton.core.llm.provider import ProviderConnectionInfo @@ -242,78 +182,7 @@ def _mock_llm(): return llm -def test_final_tool_set_equals_allowlist_after_real_build(tmp_path, monkeypatch): - """#4 regression: the registry is built LAZILY at turn time, so the allowlist - must be enforced by the real _build_tools() — not merely configured. Assert - the EXACT final tool set, so a newly-registered core tool can't slip in.""" - from unittest.mock import MagicMock - - monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) - monkeypatch.setattr( - llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: _mock_llm()), - ) - req = TurnRequestV1(run_id="r", attempt_id="a", conversation_id="c", input="hi") - session = build_cloud_chat_session(req) # REAL ChatSession (not mocked) - # Don't spawn a real scratchpad subprocess when the tools get built. - session._scratchpads = MagicMock(available_packages=[]) - - session._build_tools() - names = {t.name for t in session.tool_registry.get_tool_defs()} - - assert names == set(CLOUD_TOOL_ALLOWLIST), ( - f"cloud tool set drifted from the allowlist: {sorted(names)}" - ) - - -def test_unsupported_capability_fails_loud(tmp_path, monkeypatch): - from anton.cloud_turn.errors import UnsupportedCapabilityError - - for field in CapabilitiesV1.model_fields: - with pytest.raises(UnsupportedCapabilityError, match=field): - _build(tmp_path, monkeypatch, capabilities={field: True}) - - -async def test_sanitized_scratchpad_cannot_read_secret_env(tmp_path, monkeypatch): - """#3 enforcement (real subprocess): with the sanitized factory, secret - sentinels present in the parent env must be UNREADABLE from cell code.""" - # Plant secrets of every category the sanitizer must withhold. - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-SENTINEL") - monkeypatch.setenv("ANTON_ANTHROPIC_API_KEY", "ANTON-KEY-SENTINEL") - monkeypatch.setenv("ANTON_GATEWAY_TOKEN", "GATEWAY-SENTINEL") - monkeypatch.setenv("DS_POSTGRES_MAIN__PASSWORD", "DATASOURCE-SENTINEL") - monkeypatch.setenv("MY_UNRELATED_SECRET", "UNRELATED-SENTINEL") - - pad = sanitized_scratchpad_runtime_factory( - name="cloud-sanitize", - coding_provider="anthropic", - coding_model="", - coding_api_key="", - coding_base_url="", - cells=None, - workspace_path=tmp_path, - ) - await pad.start() - try: - cell = await pad.execute( - "import os, json\n" - "keys = ['ANTHROPIC_API_KEY','ANTON_ANTHROPIC_API_KEY'," - "'ANTON_GATEWAY_TOKEN','DS_POSTGRES_MAIN__PASSWORD','MY_UNRELATED_SECRET']\n" - "print(json.dumps({k: os.environ.get(k) for k in keys}))" - ) - assert cell.error is None, cell.error - import json - - seen = json.loads(cell.stdout.strip()) - assert all(v is None for v in seen.values()), f"secret leaked into cell env: {seen}" - finally: - await pad.close() - - def _real_session_with_workspace(tmp_path, **cfg_overrides): - """Build a REAL ChatSession bound to a workspace (so artifact + - launch_backend tools register), with the scratchpad stubbed so - _build_tools() doesn't spawn a subprocess.""" from unittest.mock import MagicMock from anton.core.session import ChatSession, ChatSessionConfig @@ -328,86 +197,93 @@ def _real_session_with_workspace(tmp_path, **cfg_overrides): return session -def test_unknown_allowlist_entry_fails_loud(tmp_path): - """#1 (item): an allowlist name that matches no built tool must raise, not be - silently dropped — otherwise the host believes a tool is enabled when it can - never appear.""" - session = _real_session_with_workspace( - tmp_path, tool_allowlist=frozenset({"scratchpad", "totally_made_up_tool"}) +def test_final_tool_set_equals_allowlist_after_real_build(tmp_path, monkeypatch): + """The registry is built LAZILY at turn time, so the allowlist must be + enforced by the real _build_tools() - assert the EXACT final tool set.""" + from unittest.mock import MagicMock + + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + monkeypatch.setattr( + llm_client_mod.LLMClient, "from_settings", + classmethod(lambda cls, settings: _mock_llm()), + ) + req = TurnRequestV1(protocol_version=1, conversation_id="c", input="hi") + session = build_cloud_chat_session(req) # REAL ChatSession + session._scratchpads = MagicMock(available_packages=[]) + + session._build_tools() + names = {t.name for t in session.tool_registry.get_tool_defs()} + assert names == set(CLOUD_TOOL_ALLOWLIST), ( + f"cloud tool set drifted from the allowlist: {sorted(names)}" ) - with pytest.raises(ValueError, match="totally_made_up_tool"): - session._build_tools() def test_tool_allowlist_none_preserves_desktop_tools(tmp_path): - """Regression: tool_allowlist=None (the desktop default) applies NO filtering - — the full core set, including launch_backend and select_path, is present.""" - session = _real_session_with_workspace(tmp_path) # tool_allowlist defaults to None + session = _real_session_with_workspace(tmp_path) # allowlist defaults to None assert session._tool_allowlist is None session._build_tools() names = {t.name for t in session.tool_registry.get_tool_defs()} - # Tools the cloud allowlist strips must still be here on desktop. assert {"launch_backend", "select_path", "scratchpad", "create_artifact"} <= names def test_sanitized_env_contract(monkeypatch): - """#4 (item): the sanitized parent env passes through ONLY the documented - non-secret allowlist (paths / temp dirs / locale / TLS roots), and never a - credential — and only when the parent actually defines the name.""" - from anton.core.backends.local import ( - _SCRATCHPAD_ENV_ALLOWLIST, - _sanitized_parent_env, - ) + from anton.core.backends.local import _SCRATCHPAD_ENV_ALLOWLIST, _sanitized_parent_env - # Documented minimums the contract must permit through when present. - required = { - "PATH": "/usr/bin:/bin", - "HOME": "/home/anton", - "TMPDIR": "/tmp/x", - "LANG": "en_US.UTF-8", - "SSL_CERT_FILE": "/etc/ssl/cert.pem", - } + required = {"PATH": "/usr/bin:/bin", "HOME": "/home/anton", "TMPDIR": "/tmp/x", + "LANG": "en_US.UTF-8", "SSL_CERT_FILE": "/etc/ssl/cert.pem"} for k, v in required.items(): assert k in _SCRATCHPAD_ENV_ALLOWLIST, f"{k} must be in the contract" monkeypatch.setenv(k, v) - - # Secrets of every category the sanitizer must withhold. - secrets = { - "ANTHROPIC_API_KEY": "sk-ant-x", - "ANTON_MINDS_API_KEY": "anton-x", - "ANTON_GATEWAY_TOKEN": "gw-x", - "DS_POSTGRES_MAIN__PASSWORD": "ds-x", - "OPENAI_API_KEY": "sk-oai-x", - "SOME_RANDOM_SECRET": "nope", - } + secrets = {"ANTHROPIC_API_KEY": "sk-ant-x", "ANTON_MINDS_API_KEY": "anton-x", + "ANTON_GATEWAY_TOKEN": "gw-x", "DS_POSTGRES_MAIN__PASSWORD": "ds-x", + "OPENAI_API_KEY": "sk-oai-x", "SOME_RANDOM_SECRET": "nope"} for k, v in secrets.items(): monkeypatch.setenv(k, v) env = _sanitized_parent_env() - for k, v in required.items(): - assert env.get(k) == v, f"required env {k} was dropped" + assert env.get(k) == v for k in secrets: - assert k not in env, f"secret {k} leaked through the allowlist" - # Nothing outside the allowlist survives. + assert k not in env assert set(env) <= set(_SCRATCHPAD_ENV_ALLOWLIST) +async def test_sanitized_scratchpad_cannot_read_secret_env(tmp_path, monkeypatch): + """Real subprocess: with the sanitized factory, secret sentinels in the + parent env must be UNREADABLE from cell code.""" + import json + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-SENTINEL") + monkeypatch.setenv("ANTON_GATEWAY_TOKEN", "GATEWAY-SENTINEL") + monkeypatch.setenv("DS_POSTGRES_MAIN__PASSWORD", "DATASOURCE-SENTINEL") + + pad = sanitized_scratchpad_runtime_factory( + name="cloud-sanitize", coding_provider="anthropic", coding_model="", + coding_api_key="", coding_base_url="", cells=None, workspace_path=tmp_path, + ) + await pad.start() + try: + cell = await pad.execute( + "import os, json\n" + "keys = ['ANTHROPIC_API_KEY','ANTON_GATEWAY_TOKEN','DS_POSTGRES_MAIN__PASSWORD']\n" + "print(json.dumps({k: os.environ.get(k) for k in keys}))" + ) + assert cell.error is None, cell.error + seen = json.loads(cell.stdout.strip()) + assert all(v is None for v in seen.values()), f"secret leaked: {seen}" + finally: + await pad.close() + + async def test_desktop_scratchpad_env_unchanged(tmp_path, monkeypatch): - """Regression (real subprocess): sanitize_env=False (desktop default via - local_scratchpad_runtime_factory) STILL inherits the parent env — a planted - secret remains readable, i.e. the sanitizer is strictly opt-in.""" + """Regression: sanitize_env=False (desktop default) STILL inherits the + parent env - the sanitizer is strictly opt-in.""" from anton.core.backends.local import local_scratchpad_runtime_factory monkeypatch.setenv("MY_DESKTOP_SENTINEL", "DESKTOP-VISIBLE") pad = local_scratchpad_runtime_factory( - name="desktop-env", - coding_provider="anthropic", - coding_model="", - coding_api_key="", - coding_base_url="", - cells=None, - workspace_path=tmp_path, + name="desktop-env", coding_provider="anthropic", coding_model="", + coding_api_key="", coding_base_url="", cells=None, workspace_path=tmp_path, ) await pad.start() try: @@ -418,61 +294,3 @@ async def test_desktop_scratchpad_env_unchanged(tmp_path, monkeypatch): assert cell.stdout.strip() == "DESKTOP-VISIBLE" finally: await pad.close() - - -# ── item 2: history-delta slicing against the REAL mutation paths ──────────── - -async def test_output_survives_real_compaction_and_preserves_request(tmp_path, monkeypatch): - """Drive the REAL _append_history + _summarize_history (the exact history - mutations turn_stream uses — compaction reassigns the list and collapses the - old prefix) and prove current-turn collection is still correct.""" - from anton.cloud_turn.messages import final_assistant_text, turn_output_messages - - monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) - monkeypatch.setattr( - llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: _mock_llm()), - ) - # Input history long enough to trigger real compaction (>= 6 messages). - req_history = [ - {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} - for i in range(8) - ] - req = TurnRequestV1( - run_id="r", attempt_id="a", conversation_id="c", - input="CURRENT INPUT", history=req_history, - ) - request_history_before = [m.model_copy(deep=True) for m in req.history] - - session = build_cloud_chat_session(req) # REAL ChatSession - pre_turn_messages = list(session.history) # hold refs → stable ids - - # Replay what turn_stream does: echo user input, compact mid-turn, then emit. - session._append_history({"role": "user", "content": "CURRENT INPUT"}) - await session._summarize_history() # REAL compaction (rewrites list) - session._append_history({"role": "assistant", "content": [ - {"type": "text", "text": "working"}, - {"type": "tool_use", "id": "t1", "name": "scratchpad", "input": {}}, - ]}) - session._append_history({"role": "user", "content": [ - {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}, - ]}) - session._append_history({"role": "assistant", "content": "FINAL"}) - - out = turn_output_messages(session.history, pre_turn_messages) - - # Only current-turn generated messages, in order; echo + summary excluded. - assert [m.role for m in out] == ["assistant", "user", "assistant"] - assert out[0].content[1].type == "tool_use" - assert out[1].content[0].type == "tool_result" - assert final_assistant_text(out) == "FINAL" - assert all( - not (isinstance(m.content, str) and m.content == "CURRENT INPUT") for m in out - ) - # Generated begin exactly after the boundary → the current-turn tail of the - # final session history, in the same order. - tail = session.history[-3:] - assert [m["role"] for m in tail] == ["assistant", "user", "assistant"] - assert tail[-1]["content"] == "FINAL" - # The request's own history object is untouched by the turn's mutations. - assert req.history == request_history_before