From 776564ddc75f7ec657eb6219e1044d9cc1806dcd Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 19:42:53 +0900 Subject: [PATCH 01/42] feat: add non-authorizing staged dot read-only rollout observer --- ...dot-autonomous-source-worker-operations.md | 140 ++++++++++ .../dot-rollout-checkpoint.example.json | 27 ++ scripts/management_mcp_rollout_smoke.py | 243 ++++++++++++++++++ tests/test_dot_source_rollout_contract.py | 218 ++++++++++++++++ 4 files changed, 628 insertions(+) create mode 100644 docs/client-features/dot-autonomous-source-worker-operations.md create mode 100644 docs/examples/management-mcp/dot-rollout-checkpoint.example.json create mode 100644 scripts/management_mcp_rollout_smoke.py create mode 100644 tests/test_dot_source_rollout_contract.py diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md new file mode 100644 index 000000000..2d8d3df46 --- /dev/null +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -0,0 +1,140 @@ +# Staged dot development and source-worker rollout (partial #2608) + +This document is the operator runbook for #2608; it is not a new scheduler, +OAuth issuer, manager, Docker controller or publication authority. An operator +owns stage selection and current scopes. Dot never upgrades itself from +`observe` to `beta` or `full`, never grants itself consent, and never +substitutes a workflow run, a publication tag or a running container for an +actual installed source and advancing producer heartbeat. The non-interactive +read-only observer below is **a partial implementation**, not the required +live end-to-end acceptance harness. + +## Bootstrap and authority inventory + +Before starting a stage, independently verify that a durable dot recurring +execution mechanism actually invokes the intended job, that its notifications +and GitHub integration are callable, and that the configured management +`/mcp` transport is reachable with current original OAuth consent and policy. +Record `NOT_RUN` for an unattempted capability and `BLOCKED` for an +unavailable or untrusted one. A successful OAuth refresh neither extends +the original grant's **30-day absolute maximum** nor guarantees future +unattended operation. An absent scheduler, management service, private +worker network, current grant, source credential or exact original identity +is not an implicit approval for another transport or effect. + +Initial provision is an **operator-only** one-time checklist: +set exact Compose channel network, fixed management and per-channel Supervisor +services, pinned Git/uv/Python for runtime staging, each private machine +credential, persistent management/OAuth/journal directories and separate +version roots. The operator (not the MCP child) uses `docker compose` for +initial provisioning; recurring MCP operations must not use Docker CLI, +socket/API, image replacement or pulls. The release-only, approved +one-shot candidate recovery may rely on the existing Docker +`restart: unless-stopped` policy after a correlated Supervisor PID-1 exit +inside that **same** container; management never invokes Docker itself. + +Read the authoritative service contracts: +[management-runtime-mcp](management-runtime-mcp.md), +[management-operation-journal](management-operation-journal.md), +[management-oauth-grants](management-oauth-grants.md), +[source-beta-selection](source-beta-selection.md), +[beta-runtime-smoke-evidence](beta-runtime-smoke-evidence.md), +[management-source-release-dispatch](management-source-release-dispatch.md), +[accepted-source-release-promotion](accepted-source-release-promotion.md) and +[release-worker-source-deployment](release-worker-source-deployment.md). + +## Ordered stages and independent evidence + +| Operator stage | Permitted behavior | Required positive evidence before advancing | +| --- | --- | --- | +| Observe | Authenticated reads only: `auto_coder_whoami`, targets/status, diagnostics and original operation readback | Current configured target and actual signed worker/child lifetime, startup reconciliation, installed source and advancing producer samples; no effects | +| Beta | Observe, then separately authorized configured Issue/PR reevaluation, exact selected tested-source install into the existing beta worker and beta acceptance | Durable reevaluation acceptance (not Issue/PR completion); exact Git SHA and successful original CI run/attempt; selected Git source ref/object; installed package/source/lock/runtime/worker generation; startup and advancing beta heartbeat; original acceptance operation and published immutable ref/OID | +| Full | All beta gates, followed by separately authorized accepted-source promotion and separately authorized release-worker installation | Current exact successful promotion operation, Actions run/attempt, catalog/ref/object and selection, then independently installed release runtime SHA, Supervisor generation, native process and advancing heartbeat; a published source catalog alone never means deployed release | + +Normal development uses authoritative GitHub Issue dependencies, open PRs, +exact-head required CI, actionable review threads and mergeability. Never +allocate implementation against a tracking-only/blocked Issue. The +Issue/PR implementation and merged SHA become a *candidate*, not deployment +evidence. The accepted source and release current refs have independent +immutability/selection requirements; a later promotion of the same SHA can +supersede an earlier selection. + +## Resumable checkpoint and finite recovery + +The [schema-1 example checkpoint](../examples/management-mcp/dot-rollout-checkpoint.example.json) lists every required field. Copy it only into an operator-owned private location, populate original identifiers from verified sources, and never include bearer/refresh credentials.\nThe `schema=1` operator checkpoint contains only nonsecret identifiers: +operator `stage`, exact source SHA and selected CI run/attempt/tested ref and +OID, configured beta/release targets, original management operation IDs, +installed runtime IDs and generations, acceptance ref/OID, promotion +operation/run/catalog ref/OID, stable original idempotency key, and +`pending_effect_state`, immutable failure signature and its recovery count. +This is a **reminder**, never a grant, proof of an effect or a current worker +observation. The worker and original journal MUST be re-queried after +interruption; no current HEAD substitution, changed-intent same-key replay +or new idempotency key after an unknown send. + +`SENT_UNKNOWN` and `NEEDS_ATTENTION` preserve the original operation and +the per-target lane. Reconcile by its original operation ID; never infer +no effect from elapsed time or silence. In any unchanged action, target, source +and failure-signature namespace, use at most one new authorized recovery +operation, then mark `BLOCKED` and notify the operator. Never bypass +the existing slot, retry/EXHAUSTED, merge or authorization gates. + +Stop, preserve the original records, and escalate for: grant revocation or +expiry; policy downgrade; missing source/Git catalog/selected ref or installed +worker provenance; held genuine graceful drain or surviving old OS lifetime; +inaccessible private network; incompatible/failed uv/Python staging; +untrustworthy original receipt, actor or Supervisor; repeated failure; +superseded release selection; unresolved recovered runtime; or migration/schema +incompatibility. One permitted prior **installed runtime** reactivation is +not a rollback of data, databases, migrations, filesystem state, provider tasks, +remote effects or already published GitHub objects. There is no force-restart, +arbitrary command, emergency promotion or Docker control fallback. + +## Bounded observer, safety and exact outputs + +`scripts/management_mcp_rollout_smoke.py` accepts an operator-created +**full schema-1 checkpoint**. Its default is pure local inspection and +`NOT_RUN`; it makes no network calls unless an explicit `--endpoint` is +provided. The optional live client uses the pinned MCP SDK and only an exact +readonly allowlist: `auto_coder_whoami`, `auto_coder_status`, +`auto_coder_deployment_status`, and original `auto_coder_operation_get`. +The only bearer source is `AUTO_CODER_MCP_ACCESS_TOKEN`, never CLI arguments, +checkpoint, stdout, report or logs. The endpoint must be HTTPS ending in +`/mcp` (or HTTP for explicit loopback). Never point it at a deployed +service during disposable tests. + +Example offline invocation on a machine with the project environment: + +```sh +python scripts/management_mcp_rollout_smoke.py +``` + +For **later operator-authorized nonproduction** authenticated read-only checks: + +```sh +AUTO_CODER_MCP_ACCESS_TOKEN=... python scripts/management_mcp_rollout_smoke.py \ + --checkpoint /private/operator-selected-dot-checkpoint.json \ + --endpoint https://disposable-fixture.example/mcp \ + --target-id fixture-beta-worker --operation-id ORIGINAL_OPERATION_ID +``` + +The example domain and IDs are placeholders, not usable configured resources. +The client refuses unknown tool names or mutation requests and never issues +effects. It reports `BLOCKED` for invalid/missing authority or uncertain +original state and `NOT_RUN` for a diagnostic-only observation; digests +of actual structured MCP read responses identify the observed bytes without +leaking raw credentials or implying installed readiness. `positive_deployment_proven` +and `effect_dispatched` remain false. Stdout is newline-delimited JSON (Loguru +console records followed by the final report on the last line); the same +nonsecret diagnostics go to a rotating Loguru state-directory file. Any future `PASS` requires an independent original producer oracle, +not just an MCP JSON response or a checkpoint. + +**Current acceptance gap:** This version has no mutation mode, no +operator-selected disposable Docker/source repo test fixture provider, no real +authenticated beta/release effect flow, no independent Actions/source acceptance +cross-check, and no verified real dot scheduling exercise. Those +#2608/REQ-007/008/011 deliverables are explicitly **NOT_RUN/BLOCKED**, not +simulated `PASS`. Changes to the running beta/release service are prohibited +for this test. The implementation PR stays DRAFT until the genuine disposable +end-to-end staging/unknown/recovery/policy downgrade tests and exact-head CI +succeed and all review is resolved. diff --git a/docs/examples/management-mcp/dot-rollout-checkpoint.example.json b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json new file mode 100644 index 000000000..8e9f6e679 --- /dev/null +++ b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json @@ -0,0 +1,27 @@ +{ + "schema": 1, + "stage": "observe", + "source_sha": "", + "ci_run_id": "", + "ci_run_attempt": "", + "tested_source_ref": "", + "tested_source_oid": "", + "beta_target": "", + "beta_operation_id": "", + "beta_runtime_id": "", + "beta_generation": "", + "beta_acceptance_ref": "", + "beta_acceptance_oid": "", + "promotion_operation_id": "", + "promotion_run_id": "", + "promotion_catalog_ref": "", + "promotion_catalog_oid": "", + "release_target": "", + "release_operation_id": "", + "release_runtime_id": "", + "release_generation": "", + "idempotency_key": "", + "pending_effect_state": "", + "failure_signature": "", + "recovery_attempts": 0 +} diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py new file mode 100644 index 000000000..401684dad --- /dev/null +++ b/scripts/management_mcp_rollout_smoke.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Bounded read-only MCP rollout checkpoint observer; never dispatches an effect. + +Only the authenticated production service supplies live evidence. An offline +checkpoint, a green CI run or a successful diagnostic cannot prove deployment. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from urllib.parse import urlsplit + +from loguru import logger + +READ_TOOLS = frozenset( + { + "auto_coder_whoami", + "auto_coder_targets", + "auto_coder_status", + "auto_coder_deployment_status", + "auto_coder_operation_get", + "auto_coder_beta_source_readback", + "auto_coder_beta_acceptance_get", + } +) +STAGES = frozenset({"observe", "beta", "full"}) +UNKNOWN_EFFECTS = frozenset({"SENT_UNKNOWN", "NEEDS_ATTENTION"}) +SHA = re.compile(r"[0-9a-f]{40}\Z") +SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z") + + +@dataclass(frozen=True) +class DotCheckpoint: + """Operator-owned reminder, never an admission grant or current evidence.""" + + schema: int + stage: str + source_sha: str = "" + ci_run_id: str = "" + ci_run_attempt: str = "" + tested_source_ref: str = "" + tested_source_oid: str = "" + beta_target: str = "" + beta_operation_id: str = "" + beta_runtime_id: str = "" + beta_generation: str = "" + beta_acceptance_ref: str = "" + beta_acceptance_oid: str = "" + promotion_operation_id: str = "" + promotion_run_id: str = "" + promotion_catalog_ref: str = "" + promotion_catalog_oid: str = "" + release_target: str = "" + release_operation_id: str = "" + release_runtime_id: str = "" + release_generation: str = "" + idempotency_key: str = "" + pending_effect_state: str = "" + failure_signature: str = "" + recovery_attempts: int = 0 + + +@dataclass(frozen=True) +class Observation: + tool: str + digest: str + evidence_source: str + + +@dataclass(frozen=True) +class RolloutReport: + verdict: str + stage: str + reason: str + observations: tuple[Observation, ...] = () + positive_deployment_proven: bool = False + effect_dispatched: bool = False + + +def load_checkpoint(path: Path | None) -> DotCheckpoint: + if path is None: + return DotCheckpoint(schema=1, stage="observe") + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or set(raw) != set(DotCheckpoint.__dataclass_fields__): + raise ValueError("checkpoint must contain exactly the documented schema-1 fields") + if type(raw["schema"]) is not int or raw["schema"] != 1: + raise ValueError("unsupported checkpoint schema") + if raw["stage"] not in STAGES: + raise ValueError("unsupported operator-selected stage") + for field in DotCheckpoint.__dataclass_fields__: + if field in {"schema", "recovery_attempts"}: + continue + value = raw[field] + if not isinstance(value, str) or len(value) > 256 or "\x00" in value: + raise ValueError("invalid checkpoint field: " + field) + if raw["source_sha"] and SHA.fullmatch(raw["source_sha"]) is None: + raise ValueError("checkpoint source SHA must be exact full lowercase Git SHA") + if raw["idempotency_key"] and SAFE_ID.fullmatch(raw["idempotency_key"]) is None: + raise ValueError("invalid original idempotency key") + if type(raw["recovery_attempts"]) is not int or not 0 <= raw["recovery_attempts"] <= 1: + raise ValueError("immutable failure-signature recovery budget exceeded") + if raw["recovery_attempts"] and not raw["failure_signature"]: + raise ValueError("recovery attempt requires its immutable failure signature") + return DotCheckpoint(**raw) + + +def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: + """Never promote a checkpoint into live success or release unknown effects.""" + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: + return RolloutReport("BLOCKED", checkpoint.stage, "Reconcile the original unknown effect by original operation ID; never allocate another key") + if checkpoint.recovery_attempts == 1 and checkpoint.failure_signature: + return RolloutReport("BLOCKED", checkpoint.stage, "The unchanged failure signature has exhausted its one permitted recovery attempt") + if checkpoint.stage == "observe": + return RolloutReport("NOT_RUN", checkpoint.stage, "Current authenticated status, original operation and child evidence require an independent live read") + if checkpoint.stage == "beta": + if not (checkpoint.source_sha and checkpoint.ci_run_id and checkpoint.tested_source_ref): + return RolloutReport("BLOCKED", checkpoint.stage, "Exact tested Git source and original CI evidence are missing") + return RolloutReport("NOT_RUN", checkpoint.stage, "Beta mutation and installed worker acceptance require disposable original producers and current operator authority") + if not (checkpoint.beta_acceptance_ref and checkpoint.beta_acceptance_oid and checkpoint.promotion_operation_id and checkpoint.promotion_catalog_oid): + return RolloutReport("BLOCKED", checkpoint.stage, "Current beta acceptance and distinct release promotion evidence are missing") + return RolloutReport("NOT_RUN", checkpoint.stage, "Release installed-worker lifecycle and original receipt cannot be certified by a checkpoint") + + +def checked_endpoint(url: str) -> str: + parsed = urlsplit(url) + if parsed.query or parsed.fragment or parsed.username or parsed.password or parsed.path != "/mcp": + raise ValueError("MCP endpoint must be a fixed resource /mcp URL without credentials or parameters") + if not parsed.hostname or parsed.port == 0: + raise ValueError("MCP endpoint hostname is required") + if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "::1", "localhost"}): + raise ValueError("bearer credentials require HTTPS except for explicit loopback testing") + return url + + +def checked_tool(name: str, arguments: dict[str, object]) -> None: + if name not in READ_TOOLS: + raise ValueError("this observer never dispatches mutating MCP tools") + if name == "auto_coder_whoami" and arguments: + raise ValueError("unexpected identity arguments") + if name == "auto_coder_status" and set(arguments) != {"target_id"}: + raise ValueError("status requires only exact target ID") + if name == "auto_coder_operation_get" and set(arguments) != {"target_id", "operation_id"}: + raise ValueError("operation readback requires original operation and target IDs") + if any(not isinstance(v, str) or SAFE_ID.fullmatch(v) is None for v in arguments.values()): + raise ValueError("read-only observer accepts only exact safe identifiers") + + +def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tuple[tuple[str, dict[str, object]], ...]: + """Produce an explicit allowlist, not a caller-controlled arbitrary MCP tool.""" + if not target or SAFE_ID.fullmatch(target) is None: + raise ValueError("an exact configured target is required") + if operation_id and SAFE_ID.fullmatch(operation_id) is None: + raise ValueError("invalid original operation ID") + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS and not operation_id: + raise ValueError("unknown original effect requires its original operation ID") + plan: tuple[tuple[str, dict[str, object]], ...] = ( + ("auto_coder_whoami", {}), + ("auto_coder_status", {"target_id": target}), + ("auto_coder_deployment_status", {"target_id": target}), + ) + if operation_id: + plan += (("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}),) + for name, arguments in plan: + checked_tool(name, arguments) + return plan + + +async def observe_live(endpoint: str, bearer: str, plan: tuple[tuple[str, dict[str, object]], ...], stage: str) -> RolloutReport: + """Capture hashes of actual MCP read responses, not claimed deployment PASS.""" + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + observations: list[Observation] = [] + async with streamablehttp_client(endpoint, headers={"Authorization": "Bearer " + bearer}, timeout=15, sse_read_timeout=30) as (reader, writer, _): + async with ClientSession(reader, writer) as session: + await session.initialize() + registered = {item.name for item in (await session.list_tools()).tools} + if any(name not in registered for name, _ in plan): + return RolloutReport("BLOCKED", stage, "Required authenticated read-only tool is unavailable") + for name, arguments in plan: + checked_tool(name, arguments) + result = await session.call_tool(name, arguments) + if result.isError or result.structuredContent is None: + return RolloutReport("BLOCKED", stage, "Current authenticated readback was unavailable", tuple(observations)) + encoded = json.dumps(result.structuredContent, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + observations.append(Observation(name, hashlib.sha256(encoded).hexdigest(), "live authenticated MCP structuredContent")) + return RolloutReport( + "NOT_RUN", + stage, + "Read-only diagnostics collected; independent tested Git, accepted release and advancing installed-worker producer evidence remain unverified", + tuple(observations), + ) + + +def _configure_logging() -> None: + location = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "auto-coder" + location.mkdir(parents=True, exist_ok=True, mode=0o700) + logger.remove() + logger.add(sys.stdout, serialize=True) + logger.add(location / "dot-rollout-smoke.log", rotation="2 MB", retention=3, format="{time} | {level} | {name}:{function}:{line} | {message}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Read-only operator checkpoint; no mutation or deployment") + parser.add_argument("--checkpoint", type=Path) + parser.add_argument("--endpoint", help="Explicit operator-selected OAuth MCP resource ending in /mcp") + parser.add_argument("--target-id", help="Exact configured worker alias (required with endpoint)") + parser.add_argument("--operation-id", default="", help="Existing original management operation only") + args = parser.parse_args() + _configure_logging() + try: + checkpoint = load_checkpoint(args.checkpoint) + if args.endpoint: + endpoint = checked_endpoint(args.endpoint) + plan = read_plan(checkpoint, args.target_id or "", args.operation_id) + bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") + if not bearer: + report = RolloutReport("BLOCKED", checkpoint.stage, "No operator-provided current OAuth access token; no MCP request sent") + else: + report = asyncio.run(observe_live(endpoint, bearer, plan, checkpoint.stage)) + else: + report = required_next_observation(checkpoint) + logger.info("Dot rollout observation stage={} verdict={} remote_effect=false", report.stage, report.verdict) + except (OSError, ValueError, TypeError, RuntimeError) as error: + logger.warning("Dot rollout evidence unavailable: {}", type(error).__name__) + report = RolloutReport("BLOCKED", "unknown", "Checkpoint, authority or evidence unavailable; no effect dispatched") + except Exception as error: + logger.warning("Dot rollout transport unavailable: {}", type(error).__name__) + report = RolloutReport("BLOCKED", "unknown", "MCP transport or authenticated producer unavailable; no effect dispatched") + print(json.dumps(asdict(report), sort_keys=True)) + return 0 if report.verdict in {"PASS", "NOT_RUN"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py new file mode 100644 index 000000000..2660eded1 --- /dev/null +++ b/tests/test_dot_source_rollout_contract.py @@ -0,0 +1,218 @@ +"""Offline policy/transport planning only; NOT a fake live MCP acceptance test.""" + +import json +import os +import subprocess +import sys +from dataclasses import asdict, replace +from pathlib import Path + +import pytest +from scripts import management_mcp_rollout_smoke as smoke + + +def checkpoint(**updates): + return replace(smoke.DotCheckpoint(schema=1, stage="observe"), **updates) + + +def test_offline_default_never_sends_and_never_claims_deployment(tmp_path): + script = Path(smoke.__file__) + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path)}, + timeout=15, + check=True, + ) + report = json.loads(result.stdout.splitlines()[-1]) + assert report == { + "verdict": "NOT_RUN", + "stage": "observe", + "reason": "Current authenticated status, original operation and child evidence require an independent live read", + "observations": [], + "positive_deployment_proven": False, + "effect_dispatched": False, + } + assert (tmp_path / "auto-coder" / "dot-rollout-smoke.log").is_file() + + +@pytest.mark.parametrize( + "stage,expected", + [ + ("observe", "NOT_RUN"), + ("beta", "BLOCKED"), + ("full", "BLOCKED"), + ], +) +def test_stage_checkpoint_does_not_supply_positive_evidence(stage, expected): + report = smoke.required_next_observation(checkpoint(stage=stage)) + assert report.verdict == expected + assert report.stage == stage + assert not report.positive_deployment_proven + assert not report.effect_dispatched + + +@pytest.mark.parametrize("stage", ["observe", "beta", "full"]) +@pytest.mark.parametrize("pending", ["SENT_UNKNOWN", "NEEDS_ATTENTION"]) +def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): + original = checkpoint( + stage=stage, + idempotency_key="immutable-original-key", + pending_effect_state=pending, + ) + report = smoke.required_next_observation(original) + assert report.verdict == "BLOCKED" + assert "original operation ID" in report.reason + with pytest.raises(ValueError, match="original operation ID"): + smoke.read_plan(original, "beta-worker", "") + plan = smoke.read_plan(original, "beta-worker", "original-operation") + assert plan[-1] == ( + "auto_coder_operation_get", + {"target_id": "beta-worker", "operation_id": "original-operation"}, + ) + assert all(name in smoke.READ_TOOLS for name, _ in plan) + + +def test_one_recovery_attempt_is_terminal_for_unchanged_failure(): + original = checkpoint(stage="beta", failure_signature="failed-start-sha-v1", recovery_attempts=1) + report = smoke.required_next_observation(original) + assert report.verdict == "BLOCKED" + assert "exhausted" in report.reason + + +@pytest.mark.parametrize( + "updates", + [ + {"stage": "admin"}, + {"schema": True}, + {"recovery_attempts": True}, + {"recovery_attempts": 2}, + {"recovery_attempts": 1}, + {"source_sha": "main"}, + {"source_sha": "A" * 40}, + {"idempotency_key": "invalid key"}, + {"beta_target": 123}, + {"beta_target": "bad\x00target"}, + ], +) +def test_checkpoint_refuses_invalid_authority_and_identity(tmp_path, updates): + raw = asdict(checkpoint()) + raw.update(updates) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ValueError): + smoke.load_checkpoint(path) + + +@pytest.mark.parametrize("field", ["bearer", "refresh_token", "unsafe_mutation_key"]) +def test_checkpoint_refuses_secret_or_unsupported_fields(tmp_path, field): + raw = asdict(checkpoint()) + raw[field] = "unexpected" + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ValueError, match="exactly"): + smoke.load_checkpoint(path) + + +def test_checkpoint_roundtrips_all_immutable_identifiers_without_elevation(tmp_path): + original = checkpoint( + stage="full", + source_sha="a" * 40, + ci_run_id="452", + ci_run_attempt="2", + tested_source_ref="refs/tags/auto-coder-tested-" + "a" * 40, + beta_acceptance_ref="refs/tags/beta-accepted", + beta_acceptance_oid="b" * 40, + promotion_operation_id="promotion-original", + promotion_catalog_oid="c" * 40, + release_operation_id="release-original", + idempotency_key="stable-unchanged-key", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(original))) + loaded = smoke.load_checkpoint(path) + assert loaded == original + report = smoke.required_next_observation(loaded) + assert report.verdict == "NOT_RUN" + assert report.stage == "full" + assert report.positive_deployment_proven is False + + +@pytest.mark.parametrize( + "url", + [ + "http://production.example/mcp", + "https://foo.example/other", + "https://foo.example/mcp?bearer=secret", + "https://user:pass@foo.example/mcp", + "file:///mcp", + "https://foo.example/mcp#fragment", + ], +) +def test_transport_does_not_send_bearer_to_unapproved_origin_profile(url): + with pytest.raises(ValueError): + smoke.checked_endpoint(url) + + +def test_read_only_transport_allows_explicit_https_or_loopback(): + assert smoke.checked_endpoint("https://fixture.example/mcp") == "https://fixture.example/mcp" + assert smoke.checked_endpoint("http://127.0.0.1:8790/mcp") == "http://127.0.0.1:8790/mcp" + + +@pytest.mark.parametrize( + "tool", + [ + "auto_coder_deploy_release_source", + "auto_coder_deploy_beta_source", + "auto_coder_promote_accepted_source_release", + "auto_coder_reevaluate_target", + "auto_coder_update_management_service", + "auto_coder_start_worker", + ], +) +def test_runtime_tool_allowlist_explicitly_refuses_mutation(tool): + with pytest.raises(ValueError, match="never dispatches"): + smoke.checked_tool(tool, {"target_id": "fixture-beta"}) + + +def test_mcp_read_plan_never_contains_a_mutation_or_endpoint_selection(): + plan = smoke.read_plan(checkpoint(stage="full"), "fixture-beta", "operation-123") + assert [name for name, _ in plan] == [ + "auto_coder_whoami", + "auto_coder_status", + "auto_coder_deployment_status", + "auto_coder_operation_get", + ] + assert all("url" not in arguments and "command" not in arguments for _, arguments in plan) + + +def test_missing_token_refuses_before_any_transport(tmp_path): + script = Path(smoke.__file__) + env = {**os.environ, "XDG_STATE_HOME": str(tmp_path)} + env.pop("AUTO_CODER_MCP_ACCESS_TOKEN", None) + result = subprocess.run( + [ + sys.executable, + str(script), + "--endpoint", + "https://never-contacted-fixture.invalid/mcp", + "--target-id", + "fixture-beta", + ], + capture_output=True, + text=True, + env=env, + timeout=15, + ) + assert result.returncode == 2 + report = json.loads(result.stdout.splitlines()[-1]) + assert report["verdict"] == "BLOCKED" + assert report["observations"] == [] + assert report["effect_dispatched"] is False + assert "never-contacted-fixture" not in result.stderr + + +def test_read_only_transport_refuses_missing_original_operation_during_unknown(): + with pytest.raises(ValueError, match="original operation ID"): + smoke.read_plan(checkpoint(pending_effect_state="SENT_UNKNOWN"), "beta-worker", "") From 783c2cbdc35fecd3e8d39ed7b18fd149b6621ecc Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 19:44:35 +0900 Subject: [PATCH 02/42] fix: make unexecuted rollout acceptance exit nonzero --- .../dot-autonomous-source-worker-operations.md | 5 ++++- scripts/management_mcp_rollout_smoke.py | 2 +- tests/test_dot_source_rollout_contract.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 2d8d3df46..e58179f13 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -126,7 +126,10 @@ of actual structured MCP read responses identify the observed bytes without leaking raw credentials or implying installed readiness. `positive_deployment_proven` and `effect_dispatched` remain false. Stdout is newline-delimited JSON (Loguru console records followed by the final report on the last line); the same -nonsecret diagnostics go to a rotating Loguru state-directory file. Any future `PASS` requires an independent original producer oracle, +nonsecret diagnostics go to a rotating Loguru state-directory file. Exit codes +are 0 only for independently established `PASS`, 1 for `FAIL`, 2 for +`BLOCKED` and **3 for `NOT_RUN`**, so a scheduler cannot mistake an +unexecuted acceptance for success. Any future `PASS` requires an independent original producer oracle, not just an MCP JSON response or a checkpoint. **Current acceptance gap:** This version has no mutation mode, no diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index 401684dad..edd984822 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -236,7 +236,7 @@ def main() -> int: logger.warning("Dot rollout transport unavailable: {}", type(error).__name__) report = RolloutReport("BLOCKED", "unknown", "MCP transport or authenticated producer unavailable; no effect dispatched") print(json.dumps(asdict(report), sort_keys=True)) - return 0 if report.verdict in {"PASS", "NOT_RUN"} else 2 + return {"PASS": 0, "FAIL": 1, "BLOCKED": 2, "NOT_RUN": 3}[report.verdict] if __name__ == "__main__": diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 2660eded1..05b6187f0 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -23,8 +23,8 @@ def test_offline_default_never_sends_and_never_claims_deployment(tmp_path): text=True, env={**os.environ, "XDG_STATE_HOME": str(tmp_path)}, timeout=15, - check=True, ) + assert result.returncode == 3 report = json.loads(result.stdout.splitlines()[-1]) assert report == { "verdict": "NOT_RUN", From 55f19a8e0f64479c8a1351cea56e3230f4c332aa Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 19:46:09 +0900 Subject: [PATCH 03/42] fix: bind pending-effect reads to checkpoint operation identity --- .../dot-autonomous-source-worker-operations.md | 6 ++++-- scripts/management_mcp_rollout_smoke.py | 12 ++++++++++-- tests/test_dot_source_rollout_contract.py | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index e58179f13..74a82350a 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -73,8 +73,10 @@ interruption; no current HEAD substitution, changed-intent same-key replay or new idempotency key after an unknown send. `SENT_UNKNOWN` and `NEEDS_ATTENTION` preserve the original operation and -the per-target lane. Reconcile by its original operation ID; never infer -no effect from elapsed time or silence. In any unchanged action, target, source +the per-target lane. Reconcile only by the exact original operation ID +already retained in the checkpoint; an unrelated or newly invented operation +ID refuses even a bounded read plan. Never infer no effect from elapsed time +or silence. In any unchanged action, target, source and failure-signature namespace, use at most one new authorized recovery operation, then mark `BLOCKED` and notify the operator. Never bypass the existing slot, retry/EXHAUSTED, merge or authorization gates. diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index edd984822..fc4f333ce 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -159,8 +159,16 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("an exact configured target is required") if operation_id and SAFE_ID.fullmatch(operation_id) is None: raise ValueError("invalid original operation ID") - if checkpoint.pending_effect_state in UNKNOWN_EFFECTS and not operation_id: - raise ValueError("unknown original effect requires its original operation ID") + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: + if not operation_id: + raise ValueError("unknown original effect requires its original operation ID") + original_operations = { + checkpoint.beta_operation_id, + checkpoint.promotion_operation_id, + checkpoint.release_operation_id, + } - {""} + if operation_id not in original_operations: + raise ValueError("unknown effect must use a checkpoint original operation ID") plan: tuple[tuple[str, dict[str, object]], ...] = ( ("auto_coder_whoami", {}), ("auto_coder_status", {"target_id": target}), diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 05b6187f0..ef6133bd3 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -59,6 +59,7 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): original = checkpoint( stage=stage, idempotency_key="immutable-original-key", + beta_operation_id="original-operation", pending_effect_state=pending, ) report = smoke.required_next_observation(original) @@ -66,6 +67,8 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): assert "original operation ID" in report.reason with pytest.raises(ValueError, match="original operation ID"): smoke.read_plan(original, "beta-worker", "") + with pytest.raises(ValueError, match="checkpoint original operation"): + smoke.read_plan(original, "beta-worker", "unrelated-operation") plan = smoke.read_plan(original, "beta-worker", "original-operation") assert plan[-1] == ( "auto_coder_operation_get", From 8489f63594dbff5cfa419dcc9c8f7ce229518d90 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 20:15:11 +0900 Subject: [PATCH 04/42] fix: bind uncertain rollout operation reads to original target --- ...dot-autonomous-source-worker-operations.md | 11 +++- scripts/management_mcp_rollout_smoke.py | 49 ++++++++++----- tests/test_dot_source_rollout_contract.py | 62 ++++++++++++++++++- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 74a82350a..3d6eb687e 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -74,9 +74,14 @@ or new idempotency key after an unknown send. `SENT_UNKNOWN` and `NEEDS_ATTENTION` preserve the original operation and the per-target lane. Reconcile only by the exact original operation ID -already retained in the checkpoint; an unrelated or newly invented operation -ID refuses even a bounded read plan. Never infer no effect from elapsed time -or silence. In any unchanged action, target, source +already retained in the checkpoint and uniquely bound to its configured +beta/release target (release promotion reads use the release target). A +different target, ambiguous reused operation ID, unbound target or +unrecognized nonempty pending-effect state fails closed before transport. +When reconciliation is pending, the read plan requests original-operation +readback immediately after identity and before deployment diagnostics; an +unrelated or newly invented ID refuses even a bounded read plan. Never infer +no effect from elapsed time or silence. In any unchanged action, target, source and failure-signature namespace, use at most one new authorized recovery operation, then mark `BLOCKED` and notify the operator. Never bypass the existing slot, retry/EXHAUSTED, merge or authorization gates. diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index fc4f333ce..eebd80895 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -109,12 +109,16 @@ def load_checkpoint(path: Path | None) -> DotCheckpoint: raise ValueError("immutable failure-signature recovery budget exceeded") if raw["recovery_attempts"] and not raw["failure_signature"]: raise ValueError("recovery attempt requires its immutable failure signature") + if raw["pending_effect_state"] not in UNKNOWN_EFFECTS | {""}: + raise ValueError("unsupported pending effect state: refuse unknown-operation reconciliation") return DotCheckpoint(**raw) def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: """Never promote a checkpoint into live success or release unknown effects.""" - if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: + if checkpoint.pending_effect_state: + if checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: + return RolloutReport("BLOCKED", checkpoint.stage, "Unsupported pending effect state; never assume no original effect") return RolloutReport("BLOCKED", checkpoint.stage, "Reconcile the original unknown effect by original operation ID; never allocate another key") if checkpoint.recovery_attempts == 1 and checkpoint.failure_signature: return RolloutReport("BLOCKED", checkpoint.stage, "The unchanged failure signature has exhausted its one permitted recovery attempt") @@ -159,23 +163,40 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("an exact configured target is required") if operation_id and SAFE_ID.fullmatch(operation_id) is None: raise ValueError("invalid original operation ID") + if checkpoint.pending_effect_state and checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: + raise ValueError("unsupported pending effect state") + original_read = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: if not operation_id: raise ValueError("unknown original effect requires its original operation ID") - original_operations = { - checkpoint.beta_operation_id, - checkpoint.promotion_operation_id, - checkpoint.release_operation_id, - } - {""} - if operation_id not in original_operations: + bindings = [ + configured_target + for original_id, configured_target in ( + (checkpoint.beta_operation_id, checkpoint.beta_target), + (checkpoint.promotion_operation_id, checkpoint.release_target), + (checkpoint.release_operation_id, checkpoint.release_target), + ) + if original_id == operation_id + ] + if not bindings: raise ValueError("unknown effect must use a checkpoint original operation ID") - plan: tuple[tuple[str, dict[str, object]], ...] = ( - ("auto_coder_whoami", {}), - ("auto_coder_status", {"target_id": target}), - ("auto_coder_deployment_status", {"target_id": target}), - ) - if operation_id: - plan += (("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}),) + if len(bindings) != 1 or not bindings[0] or bindings[0] != target: + raise ValueError("original operation ID must be uniquely bound to its configured target") + # Original-operation reconciliation precedes current deployment diagnostics. + plan: tuple[tuple[str, dict[str, object]], ...] = ( + ("auto_coder_whoami", {}), + original_read, + ("auto_coder_status", {"target_id": target}), + ("auto_coder_deployment_status", {"target_id": target}), + ) + else: + plan = ( + ("auto_coder_whoami", {}), + ("auto_coder_status", {"target_id": target}), + ("auto_coder_deployment_status", {"target_id": target}), + ) + if operation_id: + plan += (original_read,) for name, arguments in plan: checked_tool(name, arguments) return plan diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index ef6133bd3..f1217abfe 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -59,6 +59,7 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): original = checkpoint( stage=stage, idempotency_key="immutable-original-key", + beta_target="beta-worker", beta_operation_id="original-operation", pending_effect_state=pending, ) @@ -70,11 +71,70 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): with pytest.raises(ValueError, match="checkpoint original operation"): smoke.read_plan(original, "beta-worker", "unrelated-operation") plan = smoke.read_plan(original, "beta-worker", "original-operation") - assert plan[-1] == ( + assert plan[1] == ( "auto_coder_operation_get", {"target_id": "beta-worker", "operation_id": "original-operation"}, ) assert all(name in smoke.READ_TOOLS for name, _ in plan) + assert [name for name, _ in plan[:2]] == ["auto_coder_whoami", "auto_coder_operation_get"] + + +@pytest.mark.parametrize("pending", ["SENT_UNKOWN", "READY", "COMPLETE"]) +def test_unknown_pending_state_cannot_skip_original_effect_reconciliation(pending): + original = checkpoint(beta_target="beta-worker", beta_operation_id="original-operation", pending_effect_state=pending) + assert smoke.required_next_observation(original).verdict == "BLOCKED" + with pytest.raises(ValueError, match="pending effect state"): + smoke.read_plan(original, "beta-worker", "original-operation") + + +@pytest.mark.parametrize( + "original,target,operation", + [ + (checkpoint(beta_target="beta-worker", beta_operation_id="beta-op"), "release-worker", "beta-op"), + (checkpoint(release_target="release-worker", release_operation_id="release-op"), "beta-worker", "release-op"), + (checkpoint(release_target="release-worker", promotion_operation_id="promotion-op"), "beta-worker", "promotion-op"), + (checkpoint(beta_operation_id="beta-op"), "beta-worker", "beta-op"), + ], +) +def test_unknown_effect_rejects_cross_target_or_unbound_original_operation(original, target, operation): + original = replace(original, pending_effect_state="SENT_UNKNOWN") + with pytest.raises(ValueError, match="original operation.*target"): + smoke.read_plan(original, target, operation) + + +def test_unknown_effect_requires_unique_original_operation_target_binding(): + original = checkpoint( + beta_target="beta-worker", + beta_operation_id="ambiguous-op", + release_target="release-worker", + release_operation_id="ambiguous-op", + pending_effect_state="NEEDS_ATTENTION", + ) + with pytest.raises(ValueError, match="original operation.*target"): + smoke.read_plan(original, "beta-worker", "ambiguous-op") + + +@pytest.mark.parametrize( + "original,target,operation", + [ + (checkpoint(beta_target="beta-worker", beta_operation_id="beta-op"), "beta-worker", "beta-op"), + (checkpoint(release_target="release-worker", release_operation_id="release-op"), "release-worker", "release-op"), + (checkpoint(release_target="release-worker", promotion_operation_id="promotion-op"), "release-worker", "promotion-op"), + ], +) +def test_pending_original_operation_read_has_one_authorized_target(original, target, operation): + original = replace(original, pending_effect_state="NEEDS_ATTENTION") + plan = smoke.read_plan(original, target, operation) + assert plan[1] == ("auto_coder_operation_get", {"target_id": target, "operation_id": operation}) + assert len(plan) == 4 + + +def test_unrecognized_pending_effect_is_rejected_from_disk(tmp_path): + raw = asdict(checkpoint(beta_target="beta-worker", beta_operation_id="beta-op", pending_effect_state="SENT_UNKOWN")) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ValueError, match="unsupported pending effect state"): + smoke.load_checkpoint(path) def test_one_recovery_attempt_is_terminal_for_unchanged_failure(): From 780b0ede1574c34b7cf653105271bcd19c7d0221 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 22:49:49 +0900 Subject: [PATCH 05/42] fix: retain original dot work and operator stage revision in checkpoints --- ...dot-autonomous-source-worker-operations.md | 11 +++- .../dot-rollout-checkpoint.example.json | 5 ++ scripts/management_mcp_rollout_smoke.py | 32 ++++++++-- tests/test_dot_source_rollout_contract.py | 61 +++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 3d6eb687e..b9d1d0c21 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -62,13 +62,18 @@ supersede an earlier selection. ## Resumable checkpoint and finite recovery The [schema-1 example checkpoint](../examples/management-mcp/dot-rollout-checkpoint.example.json) lists every required field. Copy it only into an operator-owned private location, populate original identifiers from verified sources, and never include bearer/refresh credentials.\nThe `schema=1` operator checkpoint contains only nonsecret identifiers: -operator `stage`, exact source SHA and selected CI run/attempt/tested ref and -OID, configured beta/release targets, original management operation IDs, +operator `stage`, original task ID, repository and Issue/PR numbers, immutable +operator-selected stage revision, exact source SHA and selected CI run/attempt/tested +ref and OID, configured beta/release targets, original management operation IDs, installed runtime IDs and generations, acceptance ref/OID, promotion operation/run/catalog ref/OID, stable original idempotency key, and `pending_effect_state`, immutable failure signature and its recovery count. This is a **reminder**, never a grant, proof of an effect or a current worker -observation. The worker and original journal MUST be re-queried after +observation. Beta/full work without the original task, repository, Issue/PR +identity or stage revision is BLOCKED. These retained values must be compared +with fresh operator authorization before any future mutation; current MCP read +diagnostics cannot attest to a dot stage revision and never elevate it. +The worker and original journal MUST be re-queried after interruption; no current HEAD substitution, changed-intent same-key replay or new idempotency key after an unknown send. diff --git a/docs/examples/management-mcp/dot-rollout-checkpoint.example.json b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json index 8e9f6e679..1658769ad 100644 --- a/docs/examples/management-mcp/dot-rollout-checkpoint.example.json +++ b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json @@ -1,6 +1,11 @@ { "schema": 1, "stage": "observe", + "task_id": "", + "repository": "", + "issue_number": "", + "pr_number": "", + "stage_revision": "", "source_sha": "", "ci_run_id": "", "ci_run_attempt": "", diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index eebd80895..ed5bdb0f6 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -35,6 +35,8 @@ UNKNOWN_EFFECTS = frozenset({"SENT_UNKNOWN", "NEEDS_ATTENTION"}) SHA = re.compile(r"[0-9a-f]{40}\Z") SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z") +REPOSITORY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z") +ISSUE_NUMBER = re.compile(r"[1-9][0-9]{0,8}\Z") @dataclass(frozen=True) @@ -43,6 +45,11 @@ class DotCheckpoint: schema: int stage: str + task_id: str = "" + repository: str = "" + issue_number: str = "" + pr_number: str = "" + stage_revision: str = "" source_sha: str = "" ci_run_id: str = "" ci_run_attempt: str = "" @@ -101,6 +108,17 @@ def load_checkpoint(path: Path | None) -> DotCheckpoint: value = raw[field] if not isinstance(value, str) or len(value) > 256 or "\x00" in value: raise ValueError("invalid checkpoint field: " + field) + if raw["task_id"] and SAFE_ID.fullmatch(raw["task_id"]) is None: + raise ValueError("invalid original task identity") + if raw["repository"] and REPOSITORY.fullmatch(raw["repository"]) is None: + raise ValueError("invalid original repository identity") + for field in ("issue_number", "pr_number"): + if raw[field] and ISSUE_NUMBER.fullmatch(raw[field]) is None: + raise ValueError("invalid original GitHub work identity: " + field) + if raw[field] and not raw["repository"]: + raise ValueError("original Issue/PR identity requires its repository") + if raw["stage_revision"] and SAFE_ID.fullmatch(raw["stage_revision"]) is None: + raise ValueError("invalid original operator stage revision") if raw["source_sha"] and SHA.fullmatch(raw["source_sha"]) is None: raise ValueError("checkpoint source SHA must be exact full lowercase Git SHA") if raw["idempotency_key"] and SAFE_ID.fullmatch(raw["idempotency_key"]) is None: @@ -122,6 +140,8 @@ def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: return RolloutReport("BLOCKED", checkpoint.stage, "Reconcile the original unknown effect by original operation ID; never allocate another key") if checkpoint.recovery_attempts == 1 and checkpoint.failure_signature: return RolloutReport("BLOCKED", checkpoint.stage, "The unchanged failure signature has exhausted its one permitted recovery attempt") + if checkpoint.stage != "observe" and not (checkpoint.task_id and checkpoint.repository and (checkpoint.issue_number or checkpoint.pr_number) and checkpoint.stage_revision): + return RolloutReport("BLOCKED", checkpoint.stage, "Original work identity or operator stage revision is missing; never infer authority from current HEAD or stage name") if checkpoint.stage == "observe": return RolloutReport("NOT_RUN", checkpoint.stage, "Current authenticated status, original operation and child evidence require an independent live read") if checkpoint.stage == "beta": @@ -165,7 +185,9 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("invalid original operation ID") if checkpoint.pending_effect_state and checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: raise ValueError("unsupported pending effect state") - original_read = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) + original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) + status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) + deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: if not operation_id: raise ValueError("unknown original effect requires its original operation ID") @@ -186,14 +208,14 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl plan: tuple[tuple[str, dict[str, object]], ...] = ( ("auto_coder_whoami", {}), original_read, - ("auto_coder_status", {"target_id": target}), - ("auto_coder_deployment_status", {"target_id": target}), + status_read, + deployment_read, ) else: plan = ( ("auto_coder_whoami", {}), - ("auto_coder_status", {"target_id": target}), - ("auto_coder_deployment_status", {"target_id": target}), + status_read, + deployment_read, ) if operation_id: plan += (original_read,) diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index f1217abfe..223063122 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -157,6 +157,12 @@ def test_one_recovery_attempt_is_terminal_for_unchanged_failure(): {"idempotency_key": "invalid key"}, {"beta_target": 123}, {"beta_target": "bad\x00target"}, + {"task_id": "bad task"}, + {"repository": "https://github.com/owner/repo"}, + {"repository": "owner/repo", "issue_number": "00042"}, + {"issue_number": "2608"}, + {"repository": "owner/repo", "pr_number": "-42"}, + {"stage_revision": "invalid revision"}, ], ) def test_checkpoint_refuses_invalid_authority_and_identity(tmp_path, updates): @@ -181,6 +187,11 @@ def test_checkpoint_refuses_secret_or_unsupported_fields(tmp_path, field): def test_checkpoint_roundtrips_all_immutable_identifiers_without_elevation(tmp_path): original = checkpoint( stage="full", + task_id="task-2608-original", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + pr_number="2835", + stage_revision="operator-stage-rev-04", source_sha="a" * 40, ci_run_id="452", ci_run_attempt="2", @@ -279,3 +290,53 @@ def test_missing_token_refuses_before_any_transport(tmp_path): def test_read_only_transport_refuses_missing_original_operation_during_unknown(): with pytest.raises(ValueError, match="original operation ID"): smoke.read_plan(checkpoint(pending_effect_state="SENT_UNKNOWN"), "beta-worker", "") + + +def test_checkpoint_preserves_original_work_and_stage_revision_across_cli_resume(tmp_path): + original = checkpoint( + stage="beta", + task_id="dot-issue-2608", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + pr_number="2835", + stage_revision="operator-decision-20261111-rev2", + source_sha="a" * 40, + tested_source_ref="refs/heads/original-reviewed-feature", + ci_run_id="43010413128", + ci_run_attempt="1", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(original))) + assert smoke.load_checkpoint(path) == original + result = subprocess.run( + [sys.executable, str(Path(smoke.__file__)), "--checkpoint", str(path)], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path)}, + timeout=15, + ) + assert result.returncode == 3 + report = json.loads(result.stdout.splitlines()[-1]) + assert report["verdict"] == "NOT_RUN" and report["effect_dispatched"] is False + assert smoke.load_checkpoint(path).task_id == "dot-issue-2608" + assert smoke.load_checkpoint(path).stage_revision == "operator-decision-20261111-rev2" + assert smoke.load_checkpoint(path).tested_source_ref == "refs/heads/original-reviewed-feature" + + +@pytest.mark.parametrize("missing", ["task_id", "repository", "issue_number", "stage_revision"]) +def test_beta_rollout_cannot_proceed_without_original_work_identity_or_stage_revision(missing): + values = { + "stage": "beta", + "task_id": "original-task", + "repository": "kitamura-tetsuo/auto-coder", + "issue_number": "2608", + "stage_revision": "operator-stage-rev2", + "source_sha": "a" * 40, + "ci_run_id": "43010413128", + "tested_source_ref": "refs/heads/original-reviewed-feature", + } + values[missing] = "" + report = smoke.required_next_observation(checkpoint(**values)) + assert report.verdict == "BLOCKED" + assert "work identity or operator stage revision" in report.reason + assert not report.effect_dispatched From 5a008a7c91f2923d5e500f719844607e6939ab97 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Wed, 11 Nov 2026 23:50:37 +0900 Subject: [PATCH 06/42] fix: bind every dot operation read to original checkpoint identity --- ...dot-autonomous-source-worker-operations.md | 3 ++ scripts/management_mcp_rollout_smoke.py | 15 +++---- tests/test_dot_source_rollout_contract.py | 39 ++++++++++++++++++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index b9d1d0c21..091c74baa 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -83,6 +83,9 @@ already retained in the checkpoint and uniquely bound to its configured beta/release target (release promotion reads use the release target). A different target, ambiguous reused operation ID, unbound target or unrecognized nonempty pending-effect state fails closed before transport. +The same unique original-operation/target binding is required for any optional +operation readback, even without an unresolved send; a diagnostic request +must not select an unrelated operation from the service. When reconciliation is pending, the read plan requests original-operation readback immediately after identity and before deployment diagnostics; an unrelated or newly invented ID refuses even a bounded read plan. Never infer diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index ed5bdb0f6..af8c3c648 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -185,12 +185,9 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("invalid original operation ID") if checkpoint.pending_effect_state and checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: raise ValueError("unsupported pending effect state") - original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) - status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) - deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) - if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: - if not operation_id: - raise ValueError("unknown original effect requires its original operation ID") + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS and not operation_id: + raise ValueError("unknown original effect requires its original operation ID") + if operation_id: bindings = [ configured_target for original_id, configured_target in ( @@ -201,9 +198,13 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl if original_id == operation_id ] if not bindings: - raise ValueError("unknown effect must use a checkpoint original operation ID") + raise ValueError("requested original operation ID must be a checkpoint original operation ID") if len(bindings) != 1 or not bindings[0] or bindings[0] != target: raise ValueError("original operation ID must be uniquely bound to its configured target") + original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) + status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) + deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: # Original-operation reconciliation precedes current deployment diagnostics. plan: tuple[tuple[str, dict[str, object]], ...] = ( ("auto_coder_whoami", {}), diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 223063122..2ba9b3fcc 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -251,7 +251,8 @@ def test_runtime_tool_allowlist_explicitly_refuses_mutation(tool): def test_mcp_read_plan_never_contains_a_mutation_or_endpoint_selection(): - plan = smoke.read_plan(checkpoint(stage="full"), "fixture-beta", "operation-123") + original = checkpoint(stage="full", beta_target="fixture-beta", beta_operation_id="operation-123") + plan = smoke.read_plan(original, "fixture-beta", "operation-123") assert [name for name, _ in plan] == [ "auto_coder_whoami", "auto_coder_status", @@ -340,3 +341,39 @@ def test_beta_rollout_cannot_proceed_without_original_work_identity_or_stage_rev assert report.verdict == "BLOCKED" assert "work identity or operator stage revision" in report.reason assert not report.effect_dispatched + + +@pytest.mark.parametrize( + "original,target,operation", + [ + (checkpoint(), "fixture-beta", "unclaimed-read"), + ( + checkpoint(beta_target="fixture-beta", beta_operation_id="real-original"), + "fixture-beta", + "another-operation", + ), + ( + checkpoint(beta_target="fixture-beta", beta_operation_id="real-original"), + "fixture-release", + "real-original", + ), + ( + checkpoint(beta_target="fixture-beta", beta_operation_id="duplicated", release_target="fixture-release", release_operation_id="duplicated"), + "fixture-beta", + "duplicated", + ), + ], +) +def test_diagnostic_operation_read_must_match_unique_checkpoint_original_binding(original, target, operation): + with pytest.raises(ValueError, match="original operation"): + smoke.read_plan(original, target, operation) + + +def test_diagnostic_operation_read_accepts_exact_original_binding_without_dispatch(): + original = checkpoint(beta_target="fixture-beta", beta_operation_id="real-original") + plan = smoke.read_plan(original, "fixture-beta", "real-original") + assert plan[-1] == ( + "auto_coder_operation_get", + {"target_id": "fixture-beta", "operation_id": "real-original"}, + ) + assert all(name in smoke.READ_TOOLS for name, _ in plan) From 18ed1789a47c20bc6fffbeee242c2a3e2ab34d7a Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 00:45:52 +0900 Subject: [PATCH 07/42] fix: bind unresolved dot readback to exact pending operation --- ...dot-autonomous-source-worker-operations.md | 7 +- .../dot-rollout-checkpoint.example.json | 1 + scripts/management_mcp_rollout_smoke.py | 54 ++++++++++---- tests/test_dot_source_rollout_contract.py | 74 ++++++++++++++++++- 4 files changed, 115 insertions(+), 21 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 091c74baa..a30a1746a 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -67,7 +67,7 @@ operator-selected stage revision, exact source SHA and selected CI run/attempt/t ref and OID, configured beta/release targets, original management operation IDs, installed runtime IDs and generations, acceptance ref/OID, promotion operation/run/catalog ref/OID, stable original idempotency key, and -`pending_effect_state`, immutable failure signature and its recovery count. +`pending_effect_state`, exact `pending_operation_id`, immutable failure signature and its recovery count. This is a **reminder**, never a grant, proof of an effect or a current worker observation. Beta/full work without the original task, repository, Issue/PR identity or stage revision is BLOCKED. These retained values must be compared @@ -80,7 +80,10 @@ or new idempotency key after an unknown send. `SENT_UNKNOWN` and `NEEDS_ATTENTION` preserve the original operation and the per-target lane. Reconcile only by the exact original operation ID already retained in the checkpoint and uniquely bound to its configured -beta/release target (release promotion reads use the release target). A +beta/release target (release promotion reads use the release target). The +pending operation ID is stored separately from all completed operation IDs: +when a pending effect exists, CLI readback MUST select that exact ID, even if +another recorded operation belongs to the same target. A different target, ambiguous reused operation ID, unbound target or unrecognized nonempty pending-effect state fails closed before transport. The same unique original-operation/target binding is required for any optional diff --git a/docs/examples/management-mcp/dot-rollout-checkpoint.example.json b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json index 1658769ad..f45a0065b 100644 --- a/docs/examples/management-mcp/dot-rollout-checkpoint.example.json +++ b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json @@ -27,6 +27,7 @@ "release_generation": "", "idempotency_key": "", "pending_effect_state": "", + "pending_operation_id": "", "failure_signature": "", "recovery_attempts": 0 } diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index af8c3c648..d2e361639 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -71,6 +71,7 @@ class DotCheckpoint: release_generation: str = "" idempotency_key: str = "" pending_effect_state: str = "" + pending_operation_id: str = "" failure_signature: str = "" recovery_attempts: int = 0 @@ -129,14 +130,42 @@ def load_checkpoint(path: Path | None) -> DotCheckpoint: raise ValueError("recovery attempt requires its immutable failure signature") if raw["pending_effect_state"] not in UNKNOWN_EFFECTS | {""}: raise ValueError("unsupported pending effect state: refuse unknown-operation reconciliation") + if raw["pending_effect_state"]: + if not raw["pending_operation_id"]: + raise ValueError("pending effect requires its exact original operation ID") + _original_operation_target(DotCheckpoint(**raw), raw["pending_operation_id"]) + elif raw["pending_operation_id"]: + raise ValueError("pending original operation ID requires a pending effect state") return DotCheckpoint(**raw) +def _original_operation_target(checkpoint: DotCheckpoint, operation_id: str) -> str: + """Bind a saved operation to exactly one original target, never a caller guess.""" + bindings = [ + target + for saved_id, target in ( + (checkpoint.beta_operation_id, checkpoint.beta_target), + (checkpoint.promotion_operation_id, checkpoint.release_target), + (checkpoint.release_operation_id, checkpoint.release_target), + ) + if saved_id == operation_id + ] + if len(bindings) != 1 or not bindings[0]: + raise ValueError("original operation ID must be uniquely bound to its configured target") + return bindings[0] + + def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: """Never promote a checkpoint into live success or release unknown effects.""" if checkpoint.pending_effect_state: if checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: return RolloutReport("BLOCKED", checkpoint.stage, "Unsupported pending effect state; never assume no original effect") + if not checkpoint.pending_operation_id: + return RolloutReport("BLOCKED", checkpoint.stage, "Pending effect lacks its exact original operation ID") + try: + _original_operation_target(checkpoint, checkpoint.pending_operation_id) + except ValueError: + return RolloutReport("BLOCKED", checkpoint.stage, "Pending original operation ID is not uniquely bound to its target") return RolloutReport("BLOCKED", checkpoint.stage, "Reconcile the original unknown effect by original operation ID; never allocate another key") if checkpoint.recovery_attempts == 1 and checkpoint.failure_signature: return RolloutReport("BLOCKED", checkpoint.stage, "The unchanged failure signature has exhausted its one permitted recovery attempt") @@ -185,22 +214,15 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("invalid original operation ID") if checkpoint.pending_effect_state and checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: raise ValueError("unsupported pending effect state") - if checkpoint.pending_effect_state in UNKNOWN_EFFECTS and not operation_id: - raise ValueError("unknown original effect requires its original operation ID") - if operation_id: - bindings = [ - configured_target - for original_id, configured_target in ( - (checkpoint.beta_operation_id, checkpoint.beta_target), - (checkpoint.promotion_operation_id, checkpoint.release_target), - (checkpoint.release_operation_id, checkpoint.release_target), - ) - if original_id == operation_id - ] - if not bindings: - raise ValueError("requested original operation ID must be a checkpoint original operation ID") - if len(bindings) != 1 or not bindings[0] or bindings[0] != target: - raise ValueError("original operation ID must be uniquely bound to its configured target") + if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: + if not checkpoint.pending_operation_id or not operation_id: + raise ValueError("unknown original effect requires its original operation ID") + if operation_id != checkpoint.pending_operation_id: + raise ValueError("pending original operation ID cannot be replaced by another saved operation") + if checkpoint.pending_operation_id and not checkpoint.pending_effect_state: + raise ValueError("pending original operation ID without unresolved effect state") + if operation_id and _original_operation_target(checkpoint, operation_id) != target: + raise ValueError("original operation ID must be uniquely bound to its configured target") original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 2ba9b3fcc..b9464b0df 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -62,13 +62,14 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): beta_target="beta-worker", beta_operation_id="original-operation", pending_effect_state=pending, + pending_operation_id="original-operation", ) report = smoke.required_next_observation(original) assert report.verdict == "BLOCKED" assert "original operation ID" in report.reason with pytest.raises(ValueError, match="original operation ID"): smoke.read_plan(original, "beta-worker", "") - with pytest.raises(ValueError, match="checkpoint original operation"): + with pytest.raises(ValueError, match="pending original operation ID"): smoke.read_plan(original, "beta-worker", "unrelated-operation") plan = smoke.read_plan(original, "beta-worker", "original-operation") assert plan[1] == ( @@ -97,7 +98,7 @@ def test_unknown_pending_state_cannot_skip_original_effect_reconciliation(pendin ], ) def test_unknown_effect_rejects_cross_target_or_unbound_original_operation(original, target, operation): - original = replace(original, pending_effect_state="SENT_UNKNOWN") + original = replace(original, pending_effect_state="SENT_UNKNOWN", pending_operation_id=operation) with pytest.raises(ValueError, match="original operation.*target"): smoke.read_plan(original, target, operation) @@ -109,6 +110,7 @@ def test_unknown_effect_requires_unique_original_operation_target_binding(): release_target="release-worker", release_operation_id="ambiguous-op", pending_effect_state="NEEDS_ATTENTION", + pending_operation_id="ambiguous-op", ) with pytest.raises(ValueError, match="original operation.*target"): smoke.read_plan(original, "beta-worker", "ambiguous-op") @@ -123,7 +125,7 @@ def test_unknown_effect_requires_unique_original_operation_target_binding(): ], ) def test_pending_original_operation_read_has_one_authorized_target(original, target, operation): - original = replace(original, pending_effect_state="NEEDS_ATTENTION") + original = replace(original, pending_effect_state="NEEDS_ATTENTION", pending_operation_id=operation) plan = smoke.read_plan(original, target, operation) assert plan[1] == ("auto_coder_operation_get", {"target_id": target, "operation_id": operation}) assert len(plan) == 4 @@ -137,6 +139,72 @@ def test_unrecognized_pending_effect_is_rejected_from_disk(tmp_path): smoke.load_checkpoint(path) +def test_pending_effect_does_not_read_other_saved_operation_on_same_target(): + original = checkpoint( + release_target="fixture-release", + promotion_operation_id="promotion-unknown", + release_operation_id="release-older", + pending_effect_state="SENT_UNKNOWN", + pending_operation_id="promotion-unknown", + ) + with pytest.raises(ValueError, match="pending original operation ID"): + smoke.read_plan(original, "fixture-release", "release-older") + plan = smoke.read_plan(original, "fixture-release", "promotion-unknown") + assert plan[1] == ( + "auto_coder_operation_get", + {"target_id": "fixture-release", "operation_id": "promotion-unknown"}, + ) + + +@pytest.mark.parametrize( + "updates,reason", + [ + ({"pending_effect_state": "SENT_UNKNOWN", "beta_target": "fixture-beta", "beta_operation_id": "op-1"}, "pending effect requires"), + ({"pending_operation_id": "op-1", "beta_target": "fixture-beta", "beta_operation_id": "op-1"}, "requires a pending effect"), + ( + { + "pending_effect_state": "NEEDS_ATTENTION", + "pending_operation_id": "missing", + "beta_target": "fixture-beta", + "beta_operation_id": "op-1", + }, + "uniquely bound", + ), + ], +) +def test_checkpoint_refuses_unbound_pending_original_operation(tmp_path, updates, reason): + raw = asdict(checkpoint()) + raw.update(updates) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ValueError, match=reason): + smoke.load_checkpoint(path) + + +def test_pending_original_identity_survives_resumption_without_reselecting_other_operation(tmp_path): + original = checkpoint( + stage="beta", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + task_id="dot-original", + stage_revision="operator-original-rev2", + beta_target="fixture-beta", + beta_operation_id="unresolved-beta", + release_target="fixture-release", + release_operation_id="prior-release", + pending_effect_state="NEEDS_ATTENTION", + pending_operation_id="unresolved-beta", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(original))) + loaded = smoke.load_checkpoint(path) + assert loaded.pending_operation_id == "unresolved-beta" + assert smoke.read_plan(loaded, "fixture-beta", "unresolved-beta")[1][0] == "auto_coder_operation_get" + with pytest.raises(ValueError, match="pending original operation ID"): + smoke.read_plan(loaded, "fixture-release", "prior-release") + assert smoke.required_next_observation(loaded).verdict == "BLOCKED" + + def test_one_recovery_attempt_is_terminal_for_unchanged_failure(): original = checkpoint(stage="beta", failure_signature="failed-start-sha-v1", recovery_attempts=1) report = smoke.required_next_observation(original) From 7c15ad1173e76afbb2b5decd02f206167f011de9 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 01:08:28 +0900 Subject: [PATCH 08/42] fix: require complete tested-source lineage before staged rollout observation --- ...dot-autonomous-source-worker-operations.md | 6 ++- scripts/management_mcp_rollout_smoke.py | 4 +- tests/test_dot_source_rollout_contract.py | 42 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index a30a1746a..cd02b58d1 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -70,7 +70,11 @@ operation/run/catalog ref/OID, stable original idempotency key, and `pending_effect_state`, exact `pending_operation_id`, immutable failure signature and its recovery count. This is a **reminder**, never a grant, proof of an effect or a current worker observation. Beta/full work without the original task, repository, Issue/PR -identity or stage revision is BLOCKED. These retained values must be compared +identity or stage revision is BLOCKED. Before a beta or full stage can +report even a diagnostic-only `NOT_RUN`, the checkpoint must also retain the +selected source SHA and ref/OID, original CI run/attempt and configured beta +target; absent upstream lineage is `BLOCKED` rather than silently skipped in +full mode. These retained values must be compared with fresh operator authorization before any future mutation; current MCP read diagnostics cannot attest to a dot stage revision and never elevate it. The worker and original journal MUST be re-queried after diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index d2e361639..6699b5306 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -173,9 +173,9 @@ def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: return RolloutReport("BLOCKED", checkpoint.stage, "Original work identity or operator stage revision is missing; never infer authority from current HEAD or stage name") if checkpoint.stage == "observe": return RolloutReport("NOT_RUN", checkpoint.stage, "Current authenticated status, original operation and child evidence require an independent live read") + if not (checkpoint.source_sha and checkpoint.ci_run_id and checkpoint.ci_run_attempt and checkpoint.tested_source_ref and checkpoint.tested_source_oid and checkpoint.beta_target): + return RolloutReport("BLOCKED", checkpoint.stage, "Exact tested Git source SHA/ref/OID, original CI run/attempt and configured beta target are required") if checkpoint.stage == "beta": - if not (checkpoint.source_sha and checkpoint.ci_run_id and checkpoint.tested_source_ref): - return RolloutReport("BLOCKED", checkpoint.stage, "Exact tested Git source and original CI evidence are missing") return RolloutReport("NOT_RUN", checkpoint.stage, "Beta mutation and installed worker acceptance require disposable original producers and current operator authority") if not (checkpoint.beta_acceptance_ref and checkpoint.beta_acceptance_oid and checkpoint.promotion_operation_id and checkpoint.promotion_catalog_oid): return RolloutReport("BLOCKED", checkpoint.stage, "Current beta acceptance and distinct release promotion evidence are missing") diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index b9464b0df..c78b6f9ac 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -264,6 +264,8 @@ def test_checkpoint_roundtrips_all_immutable_identifiers_without_elevation(tmp_p ci_run_id="452", ci_run_attempt="2", tested_source_ref="refs/tags/auto-coder-tested-" + "a" * 40, + tested_source_oid="a" * 40, + beta_target="fixture-beta", beta_acceptance_ref="refs/tags/beta-accepted", beta_acceptance_oid="b" * 40, promotion_operation_id="promotion-original", @@ -371,6 +373,8 @@ def test_checkpoint_preserves_original_work_and_stage_revision_across_cli_resume stage_revision="operator-decision-20261111-rev2", source_sha="a" * 40, tested_source_ref="refs/heads/original-reviewed-feature", + tested_source_oid="a" * 40, + beta_target="fixture-beta", ci_run_id="43010413128", ci_run_attempt="1", ) @@ -445,3 +449,41 @@ def test_diagnostic_operation_read_accepts_exact_original_binding_without_dispat {"target_id": "fixture-beta", "operation_id": "real-original"}, ) assert all(name in smoke.READ_TOOLS for name, _ in plan) + + +@pytest.mark.parametrize("missing", ["ci_run_attempt", "tested_source_oid", "beta_target"]) +def test_beta_refuses_incomplete_original_ci_or_selected_target(missing): + evidence = { + "stage": "beta", + "task_id": "original-task", + "repository": "kitamura-tetsuo/auto-coder", + "issue_number": "2608", + "stage_revision": "operator-revision", + "source_sha": "a" * 40, + "ci_run_id": "43010413128", + "ci_run_attempt": "1", + "tested_source_ref": "refs/heads/reviewed-source", + "tested_source_oid": "a" * 40, + "beta_target": "fixture-beta", + } + evidence[missing] = "" + report = smoke.required_next_observation(checkpoint(**evidence)) + assert report.verdict == "BLOCKED" + assert not report.effect_dispatched + + +def test_full_cannot_skip_original_beta_and_tested_source_lineage(): + evidence = checkpoint( + stage="full", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-revision", + beta_acceptance_ref="refs/tags/accepted-beta", + beta_acceptance_oid="b" * 40, + promotion_operation_id="promotion-original", + promotion_catalog_oid="c" * 40, + ) + report = smoke.required_next_observation(evidence) + assert report.verdict == "BLOCKED" + assert not report.effect_dispatched From 28da5653fc1a8d6de680bb06c463c5fe59e0031e Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 02:40:51 +0900 Subject: [PATCH 09/42] fix: refuse stale operator stage selections before rollout reads --- ...dot-autonomous-source-worker-operations.md | 10 ++- scripts/management_mcp_rollout_smoke.py | 31 ++++++++ tests/test_dot_source_rollout_contract.py | 77 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index cd02b58d1..5d674ba8a 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -123,7 +123,14 @@ readonly allowlist: `auto_coder_whoami`, `auto_coder_status`, The only bearer source is `AUTO_CODER_MCP_ACCESS_TOKEN`, never CLI arguments, checkpoint, stdout, report or logs. The endpoint must be HTTPS ending in `/mcp` (or HTTP for explicit loopback). Never point it at a deployed -service during disposable tests. +service during disposable tests. Before any live diagnostic using a beta/full +checkpoint, the operator must newly supply `--operator-stage` and +`--operator-stage-revision`. They must exactly match the original saved stage +and revision; missing, changed, or invalid revisions are `BLOCKED` before +network access. The observe-only default needs no fresh revision. This check +prevents a stale checkpoint from silently selecting a different stage; it is +not server attestation or an authorization grant. The current authenticated +MCP policy/grant and live producer evidence remain separately necessary. Example offline invocation on a machine with the project environment: @@ -136,6 +143,7 @@ For **later operator-authorized nonproduction** authenticated read-only checks: ```sh AUTO_CODER_MCP_ACCESS_TOKEN=... python scripts/management_mcp_rollout_smoke.py \ --checkpoint /private/operator-selected-dot-checkpoint.json \ + --operator-stage beta --operator-stage-revision ORIGINAL_OPERATOR_REVISION \ --endpoint https://disposable-fixture.example/mcp \ --target-id fixture-beta-worker --operation-id ORIGINAL_OPERATION_ID ``` diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index 6699b5306..cac34109f 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -39,6 +39,10 @@ ISSUE_NUMBER = re.compile(r"[1-9][0-9]{0,8}\Z") +class OperatorScopeError(ValueError): + """A saved operator selection is not proof of present operator authority.""" + + @dataclass(frozen=True) class DotCheckpoint: """Operator-owned reminder, never an admission grant or current evidence.""" @@ -182,6 +186,26 @@ def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: return RolloutReport("NOT_RUN", checkpoint.stage, "Release installed-worker lifecycle and original receipt cannot be certified by a checkpoint") +def confirm_operator_scope(checkpoint: DotCheckpoint, current_stage: str | None, current_revision: str) -> None: + """Refuse stale stage selection before any network request; never grant effects.""" + if current_stage not in STAGES: + raise OperatorScopeError("current operator stage is required before live diagnostics") + if current_stage != checkpoint.stage: + raise OperatorScopeError("current operator stage differs from the original checkpoint; no MCP request sent") + if current_stage == "observe": + if current_revision and current_revision != checkpoint.stage_revision: + raise OperatorScopeError("current operator stage revision differs from the original checkpoint") + return + if not (checkpoint.task_id and checkpoint.repository and (checkpoint.issue_number or checkpoint.pr_number) and checkpoint.stage_revision): + raise OperatorScopeError("original work identity and operator stage revision are required") + if not current_revision: + raise OperatorScopeError("current operator stage revision is required before live diagnostics") + if SAFE_ID.fullmatch(current_revision) is None: + raise OperatorScopeError("current operator stage revision is invalid") + if current_revision != checkpoint.stage_revision: + raise OperatorScopeError("current operator stage revision differs from the original checkpoint; no MCP request sent") + + def checked_endpoint(url: str) -> str: parsed = urlsplit(url) if parsed.query or parsed.fragment or parsed.username or parsed.password or parsed.path != "/mcp": @@ -288,12 +312,16 @@ def main() -> int: parser.add_argument("--endpoint", help="Explicit operator-selected OAuth MCP resource ending in /mcp") parser.add_argument("--target-id", help="Exact configured worker alias (required with endpoint)") parser.add_argument("--operation-id", default="", help="Existing original management operation only") + parser.add_argument("--operator-stage", help="Fresh operator-selected stage for diagnostics, not a grant") + parser.add_argument("--operator-stage-revision", default="", help="Fresh operator-selected stage revision; must match the saved original") args = parser.parse_args() _configure_logging() try: checkpoint = load_checkpoint(args.checkpoint) if args.endpoint: endpoint = checked_endpoint(args.endpoint) + current_stage = args.operator_stage or ("observe" if checkpoint.stage == "observe" else None) + confirm_operator_scope(checkpoint, current_stage, args.operator_stage_revision) plan = read_plan(checkpoint, args.target_id or "", args.operation_id) bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") if not bearer: @@ -303,6 +331,9 @@ def main() -> int: else: report = required_next_observation(checkpoint) logger.info("Dot rollout observation stage={} verdict={} remote_effect=false", report.stage, report.verdict) + except OperatorScopeError as error: + logger.warning("Original operator scope differs from current selection; no MCP request sent") + report = RolloutReport("BLOCKED", checkpoint.stage, str(error)) except (OSError, ValueError, TypeError, RuntimeError) as error: logger.warning("Dot rollout evidence unavailable: {}", type(error).__name__) report = RolloutReport("BLOCKED", "unknown", "Checkpoint, authority or evidence unavailable; no effect dispatched") diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index c78b6f9ac..d2ad25c0d 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -487,3 +487,80 @@ def test_full_cannot_skip_original_beta_and_tested_source_lineage(): report = smoke.required_next_observation(evidence) assert report.verdict == "BLOCKED" assert not report.effect_dispatched + + +@pytest.mark.parametrize( + "active_stage,active_revision,error", + [ + ("observe", "", "stage differs"), + ("beta", "operator-new-revision", "revision differs"), + ("beta", "", "current operator stage revision"), + ("full", "operator-original-revision", "stage differs"), + ], +) +def test_live_resumption_requires_matching_current_operator_scope(active_stage, active_revision, error): + saved = checkpoint( + stage="beta", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-original-revision", + ) + with pytest.raises(smoke.OperatorScopeError, match=error): + smoke.confirm_operator_scope(saved, active_stage, active_revision) + + +def test_operator_scope_confirmation_does_not_supply_current_grant_or_success(): + saved = checkpoint( + stage="beta", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-original-revision", + ) + smoke.confirm_operator_scope(saved, "beta", "operator-original-revision") + result = smoke.required_next_observation(saved) + assert result.verdict == "BLOCKED" + assert not result.positive_deployment_proven + assert not result.effect_dispatched + + +def test_cli_refuses_stale_operator_revision_before_mcp_transport(tmp_path): + saved = checkpoint( + stage="beta", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-original-revision", + beta_target="fixture-beta", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(saved))) + result = subprocess.run( + [ + sys.executable, + str(Path(smoke.__file__)), + "--checkpoint", + str(path), + "--endpoint", + "https://never-contacted-fixture.invalid/mcp", + "--target-id", + "fixture-beta", + "--operator-stage", + "beta", + "--operator-stage-revision", + "operator-current-revision", + ], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path), "AUTO_CODER_MCP_ACCESS_TOKEN": "disposable-unused-test-token"}, + timeout=15, + ) + assert result.returncode == 2 + report = json.loads(result.stdout.splitlines()[-1]) + assert report["verdict"] == "BLOCKED" + assert "revision differs" in report["reason"] + assert report["observations"] == [] + assert not report["effect_dispatched"] + assert not report["positive_deployment_proven"] + assert smoke.load_checkpoint(path) == saved From c7e054efd98f0b432ebfcc8160471ad4fc35a187 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 03:20:21 +0900 Subject: [PATCH 10/42] fix: bind read-only rollout diagnostics to checkpoint target scope --- ...dot-autonomous-source-worker-operations.md | 10 ++++-- scripts/management_mcp_rollout_smoke.py | 8 +++++ tests/test_dot_source_rollout_contract.py | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 5d674ba8a..594231320 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -129,8 +129,14 @@ checkpoint, the operator must newly supply `--operator-stage` and and revision; missing, changed, or invalid revisions are `BLOCKED` before network access. The observe-only default needs no fresh revision. This check prevents a stale checkpoint from silently selecting a different stage; it is -not server attestation or an authorization grant. The current authenticated -MCP policy/grant and live producer evidence remain separately necessary. +not server attestation or an authorization grant. Read-only target diagnostics +are limited to the checkpoint's selected beta target in beta stage and its +configured beta/release targets in full stage. Observe stage may select a +fresh explicit target only when no targets were saved. A pending unknown effect +is instead read back from its exact original operation-to-target binding, +including when the current saved stage differs; this read never authorizes a +new effect. The current authenticated MCP policy/grant and live producer +evidence remain separately necessary. Example offline invocation on a machine with the project environment: diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index cac34109f..8120aeac7 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -247,6 +247,14 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("pending original operation ID without unresolved effect state") if operation_id and _original_operation_target(checkpoint, operation_id) != target: raise ValueError("original operation ID must be uniquely bound to its configured target") + if checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: + # Diagnostics must not attribute a different worker to the saved stage. + saved_targets = {checkpoint.beta_target, checkpoint.release_target} - {""} + selected_targets = {checkpoint.beta_target} - {""} if checkpoint.stage == "beta" else saved_targets + if checkpoint.stage != "observe" and not selected_targets: + raise ValueError("configured stage target is missing from the original checkpoint") + if selected_targets and target not in selected_targets: + raise ValueError("diagnostic target is outside the configured original stage targets") original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index d2ad25c0d..176ad822d 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -564,3 +564,34 @@ def test_cli_refuses_stale_operator_revision_before_mcp_transport(tmp_path): assert not report["effect_dispatched"] assert not report["positive_deployment_proven"] assert smoke.load_checkpoint(path) == saved + + +@pytest.mark.parametrize( + "stage,target", + [ + ("observe", "unselected-worker"), + ("beta", "fixture-release"), + ("beta", "unselected-worker"), + ("full", "unselected-worker"), + ], +) +def test_read_diagnostics_refuse_unselected_worker_targets(stage, target): + original = checkpoint( + stage=stage, + beta_target="fixture-beta", + release_target="fixture-release", + ) + with pytest.raises(ValueError, match="configured.*target"): + smoke.read_plan(original, target, "") + + +def test_read_diagnostics_respect_stage_but_preserve_original_pending_reconciliation(): + original = checkpoint(stage="beta", beta_target="fixture-beta", release_target="fixture-release") + assert smoke.read_plan(original, "fixture-beta", "")[1][1] == {"target_id": "fixture-beta"} + full = replace(original, stage="full") + assert smoke.read_plan(full, "fixture-release", "")[1][1] == {"target_id": "fixture-release"} + unknown = replace(original, pending_effect_state="SENT_UNKNOWN", pending_operation_id="old-release", release_operation_id="old-release") + assert smoke.read_plan(unknown, "fixture-release", "old-release")[1] == ( + "auto_coder_operation_get", + {"target_id": "fixture-release", "operation_id": "old-release"}, + ) From ba5da575c3cb7f67ee8961f3653c9665f0e4b43a Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 05:47:02 +0900 Subject: [PATCH 11/42] fix(dot): reject malformed original CI identifiers and missing release target --- ...dot-autonomous-source-worker-operations.md | 8 +++- scripts/management_mcp_rollout_smoke.py | 9 ++++ tests/test_dot_source_rollout_contract.py | 47 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 594231320..4ca709d1d 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -69,7 +69,13 @@ installed runtime IDs and generations, acceptance ref/OID, promotion operation/run/catalog ref/OID, stable original idempotency key, and `pending_effect_state`, exact `pending_operation_id`, immutable failure signature and its recovery count. This is a **reminder**, never a grant, proof of an effect or a current worker -observation. Beta/full work without the original task, repository, Issue/PR +observation. The original CI run/attempt are positive canonical decimal identifiers and the tested, +accepted-beta and promotion Git OIDs are exact 40-character lowercase object IDs; +malformed checkpoint identifiers fail closed before any request. +Full-stage diagnostics additionally require the distinct original configured +release worker target: a promotion record without a selected release target +cannot even establish which installed runtime remains to be checked. +Beta/full work without the original task, repository, Issue/PR identity or stage revision is BLOCKED. Before a beta or full stage can report even a diagnostic-only `NOT_RUN`, the checkpoint must also retain the selected source SHA and ref/OID, original CI run/attempt and configured beta diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index 8120aeac7..60d9614a6 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -37,6 +37,7 @@ SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z") REPOSITORY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z") ISSUE_NUMBER = re.compile(r"[1-9][0-9]{0,8}\Z") +POSITIVE_CI_NUMBER = re.compile(r"[1-9][0-9]{0,18}\Z") class OperatorScopeError(ValueError): @@ -126,6 +127,12 @@ def load_checkpoint(path: Path | None) -> DotCheckpoint: raise ValueError("invalid original operator stage revision") if raw["source_sha"] and SHA.fullmatch(raw["source_sha"]) is None: raise ValueError("checkpoint source SHA must be exact full lowercase Git SHA") + for field in ("ci_run_id", "ci_run_attempt"): + if raw[field] and POSITIVE_CI_NUMBER.fullmatch(raw[field]) is None: + raise ValueError("invalid original CI run or attempt: " + field) + for field in ("tested_source_oid", "beta_acceptance_oid", "promotion_catalog_oid"): + if raw[field] and SHA.fullmatch(raw[field]) is None: + raise ValueError("invalid original Git object ID: " + field) if raw["idempotency_key"] and SAFE_ID.fullmatch(raw["idempotency_key"]) is None: raise ValueError("invalid original idempotency key") if type(raw["recovery_attempts"]) is not int or not 0 <= raw["recovery_attempts"] <= 1: @@ -183,6 +190,8 @@ def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: return RolloutReport("NOT_RUN", checkpoint.stage, "Beta mutation and installed worker acceptance require disposable original producers and current operator authority") if not (checkpoint.beta_acceptance_ref and checkpoint.beta_acceptance_oid and checkpoint.promotion_operation_id and checkpoint.promotion_catalog_oid): return RolloutReport("BLOCKED", checkpoint.stage, "Current beta acceptance and distinct release promotion evidence are missing") + if not checkpoint.release_target: + return RolloutReport("BLOCKED", checkpoint.stage, "Distinct configured release target is missing; promotion evidence cannot prove worker deployment") return RolloutReport("NOT_RUN", checkpoint.stage, "Release installed-worker lifecycle and original receipt cannot be certified by a checkpoint") diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 176ad822d..c26322c71 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -270,6 +270,7 @@ def test_checkpoint_roundtrips_all_immutable_identifiers_without_elevation(tmp_p beta_acceptance_oid="b" * 40, promotion_operation_id="promotion-original", promotion_catalog_oid="c" * 40, + release_target="fixture-release", release_operation_id="release-original", idempotency_key="stable-unchanged-key", ) @@ -283,6 +284,52 @@ def test_checkpoint_roundtrips_all_immutable_identifiers_without_elevation(tmp_p assert report.positive_deployment_proven is False +@pytest.mark.parametrize( + "field,value", + [ + ("ci_run_id", "not-a-run"), + ("ci_run_id", "0"), + ("ci_run_attempt", "0"), + ("ci_run_attempt", "2.0"), + ("tested_source_oid", "not-an-object-id"), + ("beta_acceptance_oid", "NOT-A-GIT-OID"), + ("promotion_catalog_oid", "c" * 39), + ], +) +def test_loaded_checkpoint_refuses_unverifiable_original_git_identifiers(tmp_path, field, value): + raw = asdict(checkpoint()) + raw[field] = value + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ValueError, match="original Git|original CI"): + smoke.load_checkpoint(path) + + +def test_full_stage_refuses_missing_distinct_release_target_even_with_prior_promotion(): + original = checkpoint( + stage="full", + task_id="task-2608", + repository="owner/repo", + issue_number="2608", + stage_revision="operator-v1", + source_sha="a" * 40, + ci_run_id="123", + ci_run_attempt="1", + tested_source_ref="refs/heads/tested", + tested_source_oid="a" * 40, + beta_target="fixture-beta", + beta_acceptance_ref="refs/tags/accepted", + beta_acceptance_oid="b" * 40, + promotion_operation_id="promotion-original", + promotion_catalog_oid="c" * 40, + ) + report = smoke.required_next_observation(original) + assert report.verdict == "BLOCKED" + assert "release target" in report.reason + assert report.effect_dispatched is False + assert report.positive_deployment_proven is False + + @pytest.mark.parametrize( "url", [ From 4215e0375b46c1f35eb0ac15e12636f95276905f Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 05:52:24 +0900 Subject: [PATCH 12/42] fix(dot): block effectful operation_get at read-only transport boundary --- ...dot-autonomous-source-worker-operations.md | 12 +++++- scripts/management_mcp_rollout_smoke.py | 16 ++++++++ tests/test_dot_source_rollout_contract.py | 38 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 4ca709d1d..6fdd7a679 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -162,7 +162,17 @@ AUTO_CODER_MCP_ACCESS_TOKEN=... python scripts/management_mcp_rollout_smoke.py \ The example domain and IDs are placeholders, not usable configured resources. The client refuses unknown tool names or mutation requests and never issues -effects. It reports `BLOCKED` for invalid/missing authority or uncertain +effects. The service's existing `auto_coder_operation_get` can advance a +pending worker/source operation via the dispatcher, despite its read-facing +name and scope. Therefore the observer only **plans** original-ID reconciliation: +any live plan containing that tool is refused with `BLOCKED` **before** +opening MCP transport. A verified non-dispatching original-operation reader is +required before automatic read-only reconciliation can be enabled; fetching +status/deployment alone is not substitute evidence that an unknown effect +settled. This is an explicit additional REQ-004/007/008 implementation blocker, +not a completed acceptance scenario. + +The observer reports `BLOCKED` for invalid/missing authority or uncertain original state and `NOT_RUN` for a diagnostic-only observation; digests of actual structured MCP read responses identify the observed bytes without leaking raw credentials or implying installed readiness. `positive_deployment_proven` diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index 60d9614a6..c5c6e2dd4 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -44,6 +44,10 @@ class OperatorScopeError(ValueError): """A saved operator selection is not proof of present operator authority.""" +class EffectfulReadError(ValueError): + """A nominally read-only service tool may advance existing operations.""" + + @dataclass(frozen=True) class DotCheckpoint: """Operator-owned reminder, never an admission grant or current evidence.""" @@ -288,8 +292,17 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl return plan +def checked_live_plan(plan: tuple[tuple[str, dict[str, object]], ...]) -> None: + """Refuse server-side dispatch through an existing operation-get API.""" + for name, arguments in plan: + checked_tool(name, arguments) + if name == "auto_coder_operation_get": + raise EffectfulReadError("Original operation_get may dispatch an existing effect; " "a verified non-dispatching operation reader is required") + + async def observe_live(endpoint: str, bearer: str, plan: tuple[tuple[str, dict[str, object]], ...], stage: str) -> RolloutReport: """Capture hashes of actual MCP read responses, not claimed deployment PASS.""" + checked_live_plan(plan) from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client @@ -351,6 +364,9 @@ def main() -> int: except OperatorScopeError as error: logger.warning("Original operator scope differs from current selection; no MCP request sent") report = RolloutReport("BLOCKED", checkpoint.stage, str(error)) + except EffectfulReadError as error: + logger.warning("Potential server-side operation dispatch refused before transport") + report = RolloutReport("BLOCKED", checkpoint.stage, str(error)) except (OSError, ValueError, TypeError, RuntimeError) as error: logger.warning("Dot rollout evidence unavailable: {}", type(error).__name__) report = RolloutReport("BLOCKED", "unknown", "Checkpoint, authority or evidence unavailable; no effect dispatched") diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index c26322c71..1a9e27788 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -1,5 +1,6 @@ """Offline policy/transport planning only; NOT a fake live MCP acceptance test.""" +import asyncio import json import os import subprocess @@ -351,6 +352,43 @@ def test_read_only_transport_allows_explicit_https_or_loopback(): assert smoke.checked_endpoint("http://127.0.0.1:8790/mcp") == "http://127.0.0.1:8790/mcp" +@pytest.mark.parametrize("pending", ["SENT_UNKNOWN", "NEEDS_ATTENTION", ""]) +def test_pretransport_plan_refuses_server_side_operation_dispatch(pending): + original = checkpoint( + beta_target="fixture-beta", + beta_operation_id="operation-original", + pending_effect_state=pending, + pending_operation_id="operation-original" if pending else "", + ) + plan = smoke.read_plan(original, "fixture-beta", "operation-original") + assert any(name == "auto_coder_operation_get" for name, _ in plan) + with pytest.raises(smoke.EffectfulReadError, match="may dispatch"): + smoke.checked_live_plan(plan) + + +def test_effectful_service_operation_read_is_refused_before_import_or_transport(): + original = checkpoint( + beta_target="fixture-beta", + beta_operation_id="pending-original", + pending_effect_state="SENT_UNKNOWN", + pending_operation_id="pending-original", + ) + plan = smoke.read_plan(original, "fixture-beta", "pending-original") + with pytest.raises(smoke.EffectfulReadError, match="may dispatch"): + asyncio.run(smoke.observe_live("https://must-not-resolve.invalid/mcp", "synthetic-not-a-real-token", plan, "observe")) + + +def test_pretransport_status_reads_are_still_effect_free(): + original = checkpoint(beta_target="fixture-beta") + plan = smoke.read_plan(original, "fixture-beta", "") + assert [name for name, _ in plan] == [ + "auto_coder_whoami", + "auto_coder_status", + "auto_coder_deployment_status", + ] + assert smoke.checked_live_plan(plan) is None + + @pytest.mark.parametrize( "tool", [ From 3da7b7ad410bc17d7b162d8b18f197bd43305d7c Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 07:26:38 +0900 Subject: [PATCH 13/42] fix(dot): refuse conflated release targets and incomplete live stage lineage --- ...dot-autonomous-source-worker-operations.md | 10 ++- scripts/management_mcp_rollout_smoke.py | 10 +++ tests/test_dot_source_rollout_contract.py | 82 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 6fdd7a679..6aad140c0 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -75,6 +75,10 @@ malformed checkpoint identifiers fail closed before any request. Full-stage diagnostics additionally require the distinct original configured release worker target: a promotion record without a selected release target cannot even establish which installed runtime remains to be checked. +The beta and release target identities must be distinct whenever both are +recorded. A shared target cannot be interpreted as separate release-worker +activation, even with valid Git promotion evidence. Saved checkpoints, +offline planning and live read plans refuse this configuration. Beta/full work without the original task, repository, Issue/PR identity or stage revision is BLOCKED. Before a beta or full stage can report even a diagnostic-only `NOT_RUN`, the checkpoint must also retain the @@ -133,7 +137,11 @@ service during disposable tests. Before any live diagnostic using a beta/full checkpoint, the operator must newly supply `--operator-stage` and `--operator-stage-revision`. They must exactly match the original saved stage and revision; missing, changed, or invalid revisions are `BLOCKED` before -network access. The observe-only default needs no fresh revision. This check +network access. The live beta/full client also checks the checkpoint's original work, tested +source SHA/ref/OID, CI run/attempt, configured beta target and remaining +recovery budget **before opening network transport**. Without these original +bindings it returns BLOCKED, not a live read against an incomplete plan. +The observe-only default needs no fresh revision. This check prevents a stale checkpoint from silently selecting a different stage; it is not server attestation or an authorization grant. Read-only target diagnostics are limited to the checkpoint's selected beta target in beta stage and its diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index c5c6e2dd4..74ec83102 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -143,6 +143,8 @@ def load_checkpoint(path: Path | None) -> DotCheckpoint: raise ValueError("immutable failure-signature recovery budget exceeded") if raw["recovery_attempts"] and not raw["failure_signature"]: raise ValueError("recovery attempt requires its immutable failure signature") + if raw["beta_target"] and raw["release_target"] and raw["beta_target"] == raw["release_target"]: + raise ValueError("distinct beta and release targets are required") if raw["pending_effect_state"] not in UNKNOWN_EFFECTS | {""}: raise ValueError("unsupported pending effect state: refuse unknown-operation reconciliation") if raw["pending_effect_state"]: @@ -172,6 +174,8 @@ def _original_operation_target(checkpoint: DotCheckpoint, operation_id: str) -> def required_next_observation(checkpoint: DotCheckpoint) -> RolloutReport: """Never promote a checkpoint into live success or release unknown effects.""" + if checkpoint.beta_target and checkpoint.release_target and checkpoint.beta_target == checkpoint.release_target: + return RolloutReport("BLOCKED", checkpoint.stage, "distinct beta and release targets are required") if checkpoint.pending_effect_state: if checkpoint.pending_effect_state not in UNKNOWN_EFFECTS: return RolloutReport("BLOCKED", checkpoint.stage, "Unsupported pending effect state; never assume no original effect") @@ -245,6 +249,8 @@ def checked_tool(name: str, arguments: dict[str, object]) -> None: def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tuple[tuple[str, dict[str, object]], ...]: """Produce an explicit allowlist, not a caller-controlled arbitrary MCP tool.""" + if checkpoint.beta_target and checkpoint.release_target and checkpoint.beta_target == checkpoint.release_target: + raise ValueError("distinct beta and release targets are required") if not target or SAFE_ID.fullmatch(target) is None: raise ValueError("an exact configured target is required") if operation_id and SAFE_ID.fullmatch(operation_id) is None: @@ -352,6 +358,10 @@ def main() -> int: endpoint = checked_endpoint(args.endpoint) current_stage = args.operator_stage or ("observe" if checkpoint.stage == "observe" else None) confirm_operator_scope(checkpoint, current_stage, args.operator_stage_revision) + if checkpoint.stage != "observe" and not checkpoint.pending_effect_state: + preflight = required_next_observation(checkpoint) + if preflight.verdict == "BLOCKED": + raise OperatorScopeError(preflight.reason) plan = read_plan(checkpoint, args.target_id or "", args.operation_id) bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") if not bearer: diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index 1a9e27788..c54f6d999 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -680,3 +680,85 @@ def test_read_diagnostics_respect_stage_but_preserve_original_pending_reconcilia "auto_coder_operation_get", {"target_id": "fixture-release", "operation_id": "old-release"}, ) + + +def test_checkpoint_cannot_conflate_beta_and_release_targets(tmp_path): + original = checkpoint( + stage="full", + beta_target="same-worker", + release_target="same-worker", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(original))) + with pytest.raises(ValueError, match="distinct beta and release"): + smoke.load_checkpoint(path) + + +def test_full_stage_read_plan_refuses_same_beta_and_release_target(): + original = checkpoint(stage="full", beta_target="same-worker", release_target="same-worker") + with pytest.raises(ValueError, match="distinct beta and release"): + smoke.read_plan(original, "same-worker", "") + + +def test_full_stage_offline_preflight_refuses_same_beta_and_release_target(): + original = checkpoint( + stage="full", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-revision", + source_sha="a" * 40, + ci_run_id="43010413128", + ci_run_attempt="1", + tested_source_ref="refs/heads/reviewed", + tested_source_oid="a" * 40, + beta_target="same-worker", + release_target="same-worker", + beta_acceptance_ref="refs/tags/beta", + beta_acceptance_oid="b" * 40, + promotion_operation_id="original-promotion", + promotion_catalog_oid="c" * 40, + ) + report = smoke.required_next_observation(original) + assert report.verdict == "BLOCKED" + assert "distinct beta and release" in report.reason + assert not report.effect_dispatched + + +def test_live_beta_refuses_incomplete_tested_git_provenance_before_transport(tmp_path): + original = checkpoint( + stage="beta", + task_id="original-task", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-revision", + beta_target="fixture-beta", + ) + path = tmp_path / "checkpoint.json" + path.write_text(json.dumps(asdict(original))) + result = subprocess.run( + [ + sys.executable, + str(Path(smoke.__file__)), + "--checkpoint", + str(path), + "--operator-stage", + "beta", + "--operator-stage-revision", + "operator-revision", + "--endpoint", + "https://never-contacted-fixture.invalid/mcp", + "--target-id", + "fixture-beta", + ], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path), "AUTO_CODER_MCP_ACCESS_TOKEN": "unused-disposable-token"}, + timeout=15, + ) + assert result.returncode == 2 + report = json.loads(result.stdout.splitlines()[-1]) + assert report["verdict"] == "BLOCKED" + assert "Exact tested Git source" in report["reason"] + assert report["observations"] == [] + assert not report["effect_dispatched"] From 7635b03675748e85e41fb921cd2fa7cf789eb31b Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 07:36:20 +0900 Subject: [PATCH 14/42] fix(dot): inspect original operations without dispatching effects --- ...dot-autonomous-source-worker-operations.md | 23 ++++++----- .../client-features/management-runtime-mcp.md | 7 +++- scripts/management_mcp_rollout_smoke.py | 10 ++--- .../management/mcp_runtime_tools.py | 23 ++++++++--- tests/test_dot_source_rollout_contract.py | 41 ++++++++----------- tests/test_management_mcp.py | 17 ++++---- tests/test_management_runtime_mcp.py | 21 +++++++++- ...test_management_source_release_dispatch.py | 11 ++++- 8 files changed, 97 insertions(+), 56 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 6aad140c0..2d416535c 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -129,7 +129,8 @@ arbitrary command, emergency promotion or Docker control fallback. `NOT_RUN`; it makes no network calls unless an explicit `--endpoint` is provided. The optional live client uses the pinned MCP SDK and only an exact readonly allowlist: `auto_coder_whoami`, `auto_coder_status`, -`auto_coder_deployment_status`, and original `auto_coder_operation_get`. +`auto_coder_deployment_status`, and original non-dispatching +`auto_coder_operation_inspect`. The only bearer source is `AUTO_CODER_MCP_ACCESS_TOKEN`, never CLI arguments, checkpoint, stdout, report or logs. The endpoint must be HTTPS ending in `/mcp` (or HTTP for explicit loopback). Never point it at a deployed @@ -170,15 +171,17 @@ AUTO_CODER_MCP_ACCESS_TOKEN=... python scripts/management_mcp_rollout_smoke.py \ The example domain and IDs are placeholders, not usable configured resources. The client refuses unknown tool names or mutation requests and never issues -effects. The service's existing `auto_coder_operation_get` can advance a -pending worker/source operation via the dispatcher, despite its read-facing -name and scope. Therefore the observer only **plans** original-ID reconciliation: -any live plan containing that tool is refused with `BLOCKED` **before** -opening MCP transport. A verified non-dispatching original-operation reader is -required before automatic read-only reconciliation can be enabled; fetching -status/deployment alone is not substitute evidence that an unknown effect -settled. This is an explicit additional REQ-004/007/008 implementation blocker, -not a completed acceptance scenario. +effects. The legacy `auto_coder_operation_get` can advance a pending worker/source +operation via the dispatcher despite its read-facing name and scope. The +separate `auto_coder_operation_inspect` reads only the authenticated owner's +original journal projection, including the immutable operation ID, target, +effect state and already-recorded receipt; it never enqueues, submits or +settles an effect. The observer plans the exact pending ID and original target, +then uses only this non-dispatching reader. A server without that tool reports +`BLOCKED`, not an alternative call to the effectful API. A diagnostic receipt +is not by itself proof of current worker installation, source publication or +successful bounded recovery. The real fixture acceptance flow remains a +separate REQ-007/008 obligation. The observer reports `BLOCKED` for invalid/missing authority or uncertain original state and `NOT_RUN` for a diagnostic-only observation; digests diff --git a/docs/client-features/management-runtime-mcp.md b/docs/client-features/management-runtime-mcp.md index aa6e48c20..e423592f7 100644 --- a/docs/client-features/management-runtime-mcp.md +++ b/docs/client-features/management-runtime-mcp.md @@ -73,7 +73,8 @@ new effect, and it does not erase a previously committed receipt. | `auto_coder_logs` | `auto-coder.read` | `target_id`, optional `limit`, `item_type`, `item_number`, `execution_id`, `process_run_id`, `after_sequence`, `cursor` | Existing source-redacted, bounded log projection and retention-gap outcomes | | `auto_coder_validation_attempt` | `auto-coder.read` | `target_id`, required positive `pr_number`, and exactly one of `attempt_id` or `attempt_sequence` | Existing exact validation-attempt projection and no-match/unavailable outcomes | | `auto_coder_pr_diagnostics` | `auto-coder.read` | `target_id`, positive `pr_number`, optional `limit` (1–100) and `cursor` | Independent retained review-cycle, effective-decision, effect and pending-work evidence; see [`retained-pr-publication-diagnostics.md`](retained-pr-publication-diagnostics.md) | -| `auto_coder_operation_get` | `auto-coder.read` | `operation_id`, plus `target_id` for worker operations | Non-secret original actor/client, immutable intent, effect state and verified receipt; exact read-only reconciliation is allowed | +| `auto_coder_operation_get` | `auto-coder.read` | `operation_id`, plus `target_id` for worker operations | Original operation reconciliation; may submit or enqueue already-admitted unsettled effects, so callers must not use it as a side-effect-free diagnostic | +| `auto_coder_operation_inspect` | `auto-coder.read` | `operation_id`, plus `target_id` for worker operations | Owner-scoped immutable intent, journal state and prior verified receipts without dispatcher submission, enqueueing or worker effects; it never settles an unresolved send | | `auto_coder_reevaluate_target` | `auto-coder.read` and `auto-coder.operate` | `target_id`, `target_type` (`issue`/`pr`), positive `number`, `idempotency_key` (1–128 characters), `expected_process_run_id`, `expected_deployment_generation` | One journaled reevaluation request for the current live worker incarnation | | `auto_coder_deployment_status` | `auto-coder.read` | `target_id` | Signed Supervisor state and actual installed worker observation where available | | `auto_coder_start_worker` | `auto-coder.read` and `auto-coder.deploy` | `target_id`, `idempotency_key`, `expected_supervisor_generation`, optional `expected_runtime_id` | Start the existing selected runtime only after a genuine stop | @@ -92,7 +93,9 @@ or nonpositive item numbers, invalid identity/key characters and out-of-range limits. It accepts no `force`, `retry`, `resume`, `emergency`, `backend`, model, permission, command, arbitrary URL, path, repository, channel or machine key. `auto_coder_reevaluate_target` is an explicitly mutating, idempotent tool; -inspection tools are read-only. Tool annotations do not confer authorization. +`auto_coder_operation_inspect` is the non-dispatching operation reader. The legacy +`auto_coder_operation_get` may advance existing operations and is explicitly +marked non-read-only; tool annotations do not confer an absence of effects. The normal OAuth resource authentication still yields HTTP **401** for invalid credentials, **403** for missing original scope/current permission, and **503** when the grant authority is unavailable *before any MCP tool executes*. diff --git a/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py index 74ec83102..6faa3281b 100644 --- a/scripts/management_mcp_rollout_smoke.py +++ b/scripts/management_mcp_rollout_smoke.py @@ -26,7 +26,7 @@ "auto_coder_targets", "auto_coder_status", "auto_coder_deployment_status", - "auto_coder_operation_get", + "auto_coder_operation_inspect", "auto_coder_beta_source_readback", "auto_coder_beta_acceptance_get", } @@ -241,7 +241,7 @@ def checked_tool(name: str, arguments: dict[str, object]) -> None: raise ValueError("unexpected identity arguments") if name == "auto_coder_status" and set(arguments) != {"target_id"}: raise ValueError("status requires only exact target ID") - if name == "auto_coder_operation_get" and set(arguments) != {"target_id", "operation_id"}: + if name == "auto_coder_operation_inspect" and set(arguments) != {"target_id", "operation_id"}: raise ValueError("operation readback requires original operation and target IDs") if any(not isinstance(v, str) or SAFE_ID.fullmatch(v) is None for v in arguments.values()): raise ValueError("read-only observer accepts only exact safe identifiers") @@ -274,7 +274,7 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl raise ValueError("configured stage target is missing from the original checkpoint") if selected_targets and target not in selected_targets: raise ValueError("diagnostic target is outside the configured original stage targets") - original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_get", {"target_id": target, "operation_id": operation_id}) + original_read: tuple[str, dict[str, object]] = ("auto_coder_operation_inspect", {"target_id": target, "operation_id": operation_id}) status_read: tuple[str, dict[str, object]] = ("auto_coder_status", {"target_id": target}) deployment_read: tuple[str, dict[str, object]] = ("auto_coder_deployment_status", {"target_id": target}) if checkpoint.pending_effect_state in UNKNOWN_EFFECTS: @@ -301,9 +301,9 @@ def read_plan(checkpoint: DotCheckpoint, target: str, operation_id: str) -> tupl def checked_live_plan(plan: tuple[tuple[str, dict[str, object]], ...]) -> None: """Refuse server-side dispatch through an existing operation-get API.""" for name, arguments in plan: - checked_tool(name, arguments) if name == "auto_coder_operation_get": - raise EffectfulReadError("Original operation_get may dispatch an existing effect; " "a verified non-dispatching operation reader is required") + raise EffectfulReadError("Original operation_get may dispatch an existing effect; use operation_inspect instead") + checked_tool(name, arguments) async def observe_live(endpoint: str, bearer: str, plan: tuple[tuple[str, dict[str, object]], ...], stage: str) -> RolloutReport: diff --git a/src/auto_coder/management/mcp_runtime_tools.py b/src/auto_coder/management/mcp_runtime_tools.py index d5f764249..f7eae058d 100644 --- a/src/auto_coder/management/mcp_runtime_tools.py +++ b/src/auto_coder/management/mcp_runtime_tools.py @@ -552,6 +552,14 @@ def _owned_operation(self, target_id: str, operation_id: str, grant: Authenticat return record async def operation_get(self, operation_id: str, target_id: str | None = None) -> dict[str, object]: + """Query an original operation, retaining the existing reconciliation behavior.""" + return await self._operation_query(operation_id, target_id, advance=True) + + async def operation_inspect(self, operation_id: str, target_id: str | None = None) -> dict[str, object]: + """Return the original owned journal projection without dispatching effects.""" + return await self._operation_query(operation_id, target_id, advance=False) + + async def _operation_query(self, operation_id: str, target_id: str | None, *, advance: bool) -> dict[str, object]: grant = self._grant((READ_SCOPE,)) release_lookup = await asyncio.to_thread(self.journal.get, operation_id) if release_lookup.record is not None and release_lookup.record.intent.action == SOURCE_RELEASE_ACTION: @@ -559,7 +567,7 @@ async def operation_get(self, operation_id: str, target_id: str | None = None) - owned_release = self._owned_operation(target_id if target_id is not None else str(recorded_target), operation_id, grant) if not isinstance(owned_release, OperationRecord): return owned_release - if owned_release.effect.state is EffectState.SENT_UNKNOWN: + if advance and owned_release.effect.state is EffectState.SENT_UNKNOWN: updated_release = await self.dispatcher.submit(operation_id) owned_release = updated_release or owned_release return self.source_release.project(owned_release) @@ -600,9 +608,11 @@ async def operation_get(self, operation_id: str, target_id: str | None = None) - return management if target_id is not None and target_id != management.intent.target.instance_id: return self._response("NOT_FOUND", "operation_not_found") - await self.observe_authenticated_identity(grant) - queued = self.dispatcher.enqueue(operation_id) - return {**await asyncio.to_thread(self.self_update.project, management), "dispatch_queued": queued} + if advance: + await self.observe_authenticated_identity(grant) + queued = self.dispatcher.enqueue(operation_id) + return {**await asyncio.to_thread(self.self_update.project, management), "dispatch_queued": queued} + return await asyncio.to_thread(self.self_update.project, management) if target_id is None: return self._response("INVALID_REQUEST", "worker_target_id_required") owned = self._owned_operation(target_id, operation_id, grant) @@ -612,7 +622,7 @@ async def operation_get(self, operation_id: str, target_id: str | None = None) - # An inspection-only MCP call must not become the actuator for # the next worker-changing step. The service dispatcher owns it. return self.lifecycle.project(owned) - if owned.effect.state in {EffectState.SENT_UNKNOWN, EffectState.CONFIRMED} and owned.phase not in {OperationPhase.SUCCEEDED, OperationPhase.FAILED, OperationPhase.ROLLED_BACK}: + if advance and owned.effect.state in {EffectState.SENT_UNKNOWN, EffectState.CONFIRMED} and owned.phase not in {OperationPhase.SUCCEEDED, OperationPhase.FAILED, OperationPhase.ROLLED_BACK}: updated = await self.dispatcher.submit(operation_id) if updated is not None: owned = updated @@ -1051,7 +1061,8 @@ def tool_registrations(self) -> tuple[ManagementTool, ...]: ManagementTool("auto_coder_logs", self.logs, (READ_SCOPE,), read), ManagementTool("auto_coder_validation_attempt", self.validation_attempt, (READ_SCOPE,), read), ManagementTool("auto_coder_pr_diagnostics", self.pr_diagnostics, (READ_SCOPE,), read), - ManagementTool("auto_coder_operation_get", self.operation_get, (READ_SCOPE,), read), + ManagementTool("auto_coder_operation_get", self.operation_get, (READ_SCOPE,), mutation), + ManagementTool("auto_coder_operation_inspect", self.operation_inspect, (READ_SCOPE,), read), ManagementTool("auto_coder_reevaluate_target", self.reevaluate_target, (READ_SCOPE, OPERATE_SCOPE), mutation), ManagementTool("auto_coder_update_management_service", self.update_management_service, (READ_SCOPE, DEPLOY_SCOPE), ToolAnnotations(readOnlyHint=False, idempotentHint=True, destructiveHint=True, openWorldHint=False), ("source_sha", "idempotency_key")), ManagementTool("auto_coder_deployment_status", self.deployment_status, (READ_SCOPE,), read), diff --git a/tests/test_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py index c54f6d999..5d2224fd8 100644 --- a/tests/test_dot_source_rollout_contract.py +++ b/tests/test_dot_source_rollout_contract.py @@ -74,11 +74,11 @@ def test_unknown_effect_precedes_every_stage_and_never_rekeys(stage, pending): smoke.read_plan(original, "beta-worker", "unrelated-operation") plan = smoke.read_plan(original, "beta-worker", "original-operation") assert plan[1] == ( - "auto_coder_operation_get", + "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": "original-operation"}, ) assert all(name in smoke.READ_TOOLS for name, _ in plan) - assert [name for name, _ in plan[:2]] == ["auto_coder_whoami", "auto_coder_operation_get"] + assert [name for name, _ in plan[:2]] == ["auto_coder_whoami", "auto_coder_operation_inspect"] @pytest.mark.parametrize("pending", ["SENT_UNKOWN", "READY", "COMPLETE"]) @@ -128,7 +128,7 @@ def test_unknown_effect_requires_unique_original_operation_target_binding(): def test_pending_original_operation_read_has_one_authorized_target(original, target, operation): original = replace(original, pending_effect_state="NEEDS_ATTENTION", pending_operation_id=operation) plan = smoke.read_plan(original, target, operation) - assert plan[1] == ("auto_coder_operation_get", {"target_id": target, "operation_id": operation}) + assert plan[1] == ("auto_coder_operation_inspect", {"target_id": target, "operation_id": operation}) assert len(plan) == 4 @@ -152,7 +152,7 @@ def test_pending_effect_does_not_read_other_saved_operation_on_same_target(): smoke.read_plan(original, "fixture-release", "release-older") plan = smoke.read_plan(original, "fixture-release", "promotion-unknown") assert plan[1] == ( - "auto_coder_operation_get", + "auto_coder_operation_inspect", {"target_id": "fixture-release", "operation_id": "promotion-unknown"}, ) @@ -200,7 +200,7 @@ def test_pending_original_identity_survives_resumption_without_reselecting_other path.write_text(json.dumps(asdict(original))) loaded = smoke.load_checkpoint(path) assert loaded.pending_operation_id == "unresolved-beta" - assert smoke.read_plan(loaded, "fixture-beta", "unresolved-beta")[1][0] == "auto_coder_operation_get" + assert smoke.read_plan(loaded, "fixture-beta", "unresolved-beta")[1][0] == "auto_coder_operation_inspect" with pytest.raises(ValueError, match="pending original operation ID"): smoke.read_plan(loaded, "fixture-release", "prior-release") assert smoke.required_next_observation(loaded).verdict == "BLOCKED" @@ -352,30 +352,25 @@ def test_read_only_transport_allows_explicit_https_or_loopback(): assert smoke.checked_endpoint("http://127.0.0.1:8790/mcp") == "http://127.0.0.1:8790/mcp" -@pytest.mark.parametrize("pending", ["SENT_UNKNOWN", "NEEDS_ATTENTION", ""]) -def test_pretransport_plan_refuses_server_side_operation_dispatch(pending): +@pytest.mark.parametrize("pending", ["SENT_UNKNOWN", "NEEDS_ATTENTION"]) +def test_pretransport_plan_uses_non_dispatching_original_inspection(pending): original = checkpoint( beta_target="fixture-beta", beta_operation_id="operation-original", pending_effect_state=pending, - pending_operation_id="operation-original" if pending else "", + pending_operation_id="operation-original", ) plan = smoke.read_plan(original, "fixture-beta", "operation-original") - assert any(name == "auto_coder_operation_get" for name, _ in plan) - with pytest.raises(smoke.EffectfulReadError, match="may dispatch"): - smoke.checked_live_plan(plan) + assert plan[1][0] == "auto_coder_operation_inspect" + assert smoke.checked_live_plan(plan) is None -def test_effectful_service_operation_read_is_refused_before_import_or_transport(): - original = checkpoint( - beta_target="fixture-beta", - beta_operation_id="pending-original", - pending_effect_state="SENT_UNKNOWN", - pending_operation_id="pending-original", - ) - plan = smoke.read_plan(original, "fixture-beta", "pending-original") +def test_effectful_legacy_operation_get_is_refused_before_import_or_transport(): + unsafe_plan = (("auto_coder_operation_get", {"target_id": "fixture-beta", "operation_id": "pending-original"}),) with pytest.raises(smoke.EffectfulReadError, match="may dispatch"): - asyncio.run(smoke.observe_live("https://must-not-resolve.invalid/mcp", "synthetic-not-a-real-token", plan, "observe")) + asyncio.run(smoke.observe_live("https://must-not-resolve.invalid/mcp", "synthetic-not-a-real-token", unsafe_plan, "observe")) + with pytest.raises(ValueError, match="never dispatches"): + smoke.checked_tool("auto_coder_operation_get", {"target_id": "fixture-beta", "operation_id": "pending-original"}) def test_pretransport_status_reads_are_still_effect_free(): @@ -412,7 +407,7 @@ def test_mcp_read_plan_never_contains_a_mutation_or_endpoint_selection(): "auto_coder_whoami", "auto_coder_status", "auto_coder_deployment_status", - "auto_coder_operation_get", + "auto_coder_operation_inspect", ] assert all("url" not in arguments and "command" not in arguments for _, arguments in plan) @@ -530,7 +525,7 @@ def test_diagnostic_operation_read_accepts_exact_original_binding_without_dispat original = checkpoint(beta_target="fixture-beta", beta_operation_id="real-original") plan = smoke.read_plan(original, "fixture-beta", "real-original") assert plan[-1] == ( - "auto_coder_operation_get", + "auto_coder_operation_inspect", {"target_id": "fixture-beta", "operation_id": "real-original"}, ) assert all(name in smoke.READ_TOOLS for name, _ in plan) @@ -677,7 +672,7 @@ def test_read_diagnostics_respect_stage_but_preserve_original_pending_reconcilia assert smoke.read_plan(full, "fixture-release", "")[1][1] == {"target_id": "fixture-release"} unknown = replace(original, pending_effect_state="SENT_UNKNOWN", pending_operation_id="old-release", release_operation_id="old-release") assert smoke.read_plan(unknown, "fixture-release", "old-release")[1] == ( - "auto_coder_operation_get", + "auto_coder_operation_inspect", {"target_id": "fixture-release", "operation_id": "old-release"}, ) diff --git a/tests/test_management_mcp.py b/tests/test_management_mcp.py index 30f1b842d..f89454fa3 100644 --- a/tests/test_management_mcp.py +++ b/tests/test_management_mcp.py @@ -426,6 +426,7 @@ def test_real_discovery_firebase_exchange_and_whoami(real_boundary) -> None: "auto_coder_validation_attempt", "auto_coder_pr_diagnostics", "auto_coder_operation_get", + "auto_coder_operation_inspect", "auto_coder_reevaluate_target", "auto_coder_update_management_service", "auto_coder_deployment_status", @@ -440,14 +441,16 @@ def test_real_discovery_firebase_exchange_and_whoami(real_boundary) -> None: "auto_coder_promote_accepted_source_release", ] assert listed[0]["annotations"]["readOnlyHint"] and listed[0]["annotations"]["idempotentHint"] - assert all(tool["annotations"]["readOnlyHint"] for tool in listed[1:8]) - assert listed[8]["annotations"]["readOnlyHint"] is False - assert listed[9]["annotations"]["readOnlyHint"] is False and listed[9]["annotations"]["destructiveHint"] is True - assert listed[10]["annotations"]["readOnlyHint"] is True + assert all(tool["annotations"]["readOnlyHint"] for tool in listed[1:7]) + assert listed[7]["annotations"]["readOnlyHint"] is False + assert listed[8]["annotations"]["readOnlyHint"] is True + assert listed[9]["annotations"]["readOnlyHint"] is False + assert listed[10]["annotations"]["readOnlyHint"] is False and listed[10]["annotations"]["destructiveHint"] is True assert listed[11]["annotations"]["readOnlyHint"] is True - assert all(tool["annotations"]["readOnlyHint"] is False for tool in listed[12:18]) - assert listed[18]["annotations"]["readOnlyHint"] is True - assert listed[19]["annotations"]["readOnlyHint"] is False + assert listed[12]["annotations"]["readOnlyHint"] is True + assert all(tool["annotations"]["readOnlyHint"] is False for tool in listed[13:19]) + assert listed[19]["annotations"]["readOnlyHint"] is True + assert listed[20]["annotations"]["readOnlyHint"] is False identity = _mcp(client, token, "tools/call", 3, {"name": "auto_coder_whoami", "arguments": {}}).json()["result"]["structuredContent"] assert identity == {"project_id": "mcp-test-project", "uid": "alice", "client_id": "alice-client", "scopes": ["auto-coder.read"], "resource": settings.oauth.resource} diff --git a/tests/test_management_runtime_mcp.py b/tests/test_management_runtime_mcp.py index fbe92b331..fefcb27b6 100644 --- a/tests/test_management_runtime_mcp.py +++ b/tests/test_management_runtime_mcp.py @@ -185,7 +185,8 @@ def _request(beta: FixedWorkerBoundary, *, key: str = "key-1", target_id: str = def test_real_oauth_http_tool_scopes_policy_and_idempotent_journal(runtime_boundary) -> None: settings, _grants, signed, policy, beta, _release = runtime_boundary - with TestClient(_application(runtime_boundary), base_url=settings.oauth.canonical_issuer) as client: + app = _application(runtime_boundary) + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: read_token = _issue(client, settings, signed, "alice", "alice-client", READ_SCOPE)["access_token"] operate_token = _issue(client, settings, signed, "bob", "bob-client", READ_SCOPE + " " + OPERATE_SCOPE)["access_token"] status = _call(client, read_token, "auto_coder_status", {"target_id": "beta-worker"}) @@ -209,6 +210,13 @@ def test_real_oauth_http_tool_scopes_policy_and_idempotent_journal(runtime_bound assert beta.send_calls == 1 queried = _call(client, operate_token, "auto_coder_operation_get", {"target_id": "beta-worker", "operation_id": first["operation_id"]}, 5) assert queried == first + original_record = app.state.management_runtime.journal.get(first["operation_id"]).record + read_calls = beta.read_calls + inspected = _call(client, operate_token, "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": first["operation_id"]}) + assert inspected == first + assert app.state.management_runtime.journal.get(first["operation_id"]).record == original_record + assert beta.read_calls == read_calls and beta.send_calls == 1 + assert _call(client, read_token, "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": first["operation_id"]})["status"] == "NOT_FOUND" different_key = _call(client, operate_token, "auto_coder_reevaluate_target", _request(beta, key="key-2"), 6) assert different_key["status"] == "accepted_for_reevaluation" assert beta.send_calls == 2 @@ -710,8 +718,17 @@ def test_lost_response_reconciles_exact_receipt_without_resend_after_restart(run op_id = first["operation_id"] grants.revoke("bob-client", token) # The original worker receipt was committed; only a GET is now allowed. - with TestClient(_application(runtime_boundary), base_url=settings.oauth.canonical_issuer) as client: + next_app = _application(runtime_boundary) + with TestClient(next_app, base_url=settings.oauth.canonical_issuer) as client: fresh_token = _issue(client, settings, signed, "bob", "bob-client", READ_SCOPE)["access_token"] + before = next_app.state.management_runtime.journal.get(op_id).record + reads_before = beta.read_calls + inspected = _call(client, fresh_token, "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": op_id}) + assert before is not None + assert inspected["effect_state"] == before.effect.state.value + assert inspected["phase"] == before.phase.value + assert next_app.state.management_runtime.journal.get(op_id).record == before + assert beta.send_calls == 1 and beta.read_calls == reads_before recovered = _call(client, fresh_token, "auto_coder_operation_get", {"target_id": "beta-worker", "operation_id": op_id}) assert recovered["status"] == "accepted_for_reevaluation" assert beta.send_calls == 1 and beta.read_calls >= 1 diff --git a/tests/test_management_source_release_dispatch.py b/tests/test_management_source_release_dispatch.py index 325ce96ad..f5d7ee699 100644 --- a/tests/test_management_source_release_dispatch.py +++ b/tests/test_management_source_release_dispatch.py @@ -251,8 +251,17 @@ def test_lost_or_denied_dispatch_restart_never_sends_again(dispatch, failure): assert first["effect_state"] == "sent_unknown" operation_id = first["operation_id"] d.remote.failure = "" - with TestClient(d.app(), base_url=d.settings.oauth.canonical_issuer) as client: + app = d.app() + with TestClient(app, base_url=d.settings.oauth.canonical_issuer) as client: token = _token(client, d) + before = app.state.management_runtime.journal.get(operation_id).record + prior_dispatches = list(d.remote.dispatches) + prior_writes = d.remote.release_pointer_writes + inspected = _call(client, token, "auto_coder_operation_inspect", {"operation_id": operation_id}) + assert inspected["operation_id"] == operation_id + assert app.state.management_runtime.journal.get(operation_id).record == before + assert d.remote.dispatches == prior_dispatches + assert d.remote.release_pointer_writes == prior_writes result = _read(client, token, operation_id) assert result["status"] == ("SUCCEEDED" if failure == "after" else "NEEDS_ATTENTION"), result assert len(d.remote.dispatches) == 1 From 669529cef1f64892ed4ffdce61bbe0a3f79f9eb7 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 08:27:03 +0900 Subject: [PATCH 15/42] test: reuse verified identical source fixture locks across Docker cases --- ...test_management_self_update_integration.py | 25 +++++++- ...t_management_source_snapshot_lock_cache.py | 63 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/test_management_source_snapshot_lock_cache.py diff --git a/tests/test_management_self_update_integration.py b/tests/test_management_self_update_integration.py index 0d10b90f4..106e0dee7 100644 --- a/tests/test_management_self_update_integration.py +++ b/tests/test_management_self_update_integration.py @@ -248,6 +248,29 @@ def do_POST(self) -> None: return Handler +# All isolated source fixtures in one process use the same pinned project lock. +# Cache only a successfully resolved lock for identical resolver inputs; preserve +# per-fixture Git history, source manifests and installed --frozen validation. +_SNAPSHOT_LOCK_CACHE: dict[tuple[str, str, tuple[tuple[str, str], ...]], bytes] = {} +_SNAPSHOT_LOCK_MUTEX = threading.Lock() + + +def _ensure_snapshot_lock(root: Path, uv: str, environment: dict[str, str]) -> None: + project_digest = hashlib.sha256((root / "pyproject.toml").read_bytes()).hexdigest() + uv_environment = tuple(sorted((key, value) for key, value in environment.items() if key.startswith("UV_"))) + cache_key = (project_digest, str(Path(sys.executable).resolve()), uv_environment) + with _SNAPSHOT_LOCK_MUTEX: + locked = _SNAPSHOT_LOCK_CACHE.get(cache_key) + if locked is None: + _run([uv, "lock", "--python", sys.executable], root, env=environment) + locked = (root / "uv.lock").read_bytes() + if not locked: + raise AssertionError("disposable source lock resolution produced an empty lockfile") + _SNAPSHOT_LOCK_CACHE[cache_key] = locked + else: + (root / "uv.lock").write_bytes(locked) + + def _snapshot_source(root: Path, revision: str) -> str: """Commit actual working production modules into an isolated tested source.""" source = root / "src" / "auto_coder" @@ -277,7 +300,7 @@ def _snapshot_source(root: Path, revision: str) -> str: # CI locks the checked-out project. This new disposable project first # needs its own lock; installed staging still uses production --frozen. lock_environment = {name: value for name, value in os.environ.items() if name != "UV_LOCKED"} - _run([uv, "lock", "--python", sys.executable], root, env=lock_environment) + _ensure_snapshot_lock(root, uv, lock_environment) (source / "fixture_revision.json").write_text(json.dumps({"revision": revision}), encoding="utf-8") _run(["git", "add", "."], root) _run(["git", "commit", "-qm", revision], root) diff --git a/tests/test_management_source_snapshot_lock_cache.py b/tests/test_management_source_snapshot_lock_cache.py new file mode 100644 index 000000000..7012aac79 --- /dev/null +++ b/tests/test_management_source_snapshot_lock_cache.py @@ -0,0 +1,63 @@ +"""Small unit checks for isolated source fixture lock reuse, not host acceptance.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests import test_management_self_update_integration as helper + + +def _project(root: Path, dependencies: str) -> None: + root.mkdir() + (root / "pyproject.toml").write_text(dependencies, encoding="utf-8") + + +def test_identical_resolver_inputs_reuse_verified_lock_without_new_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(helper, "_SNAPSHOT_LOCK_CACHE", {}) + calls: list[Path] = [] + + def resolve(arguments: list[str], cwd: Path, *, env: dict[str, str]) -> str: + assert arguments == ["uv", "lock", "--python", helper.sys.executable] + assert env == {"UV_INDEX_URL": "https://fixture.invalid/simple"} + calls.append(cwd) + (cwd / "uv.lock").write_text("verified:" + (cwd / "pyproject.toml").read_text(), encoding="utf-8") + return "" + + monkeypatch.setattr(helper, "_run", resolve) + first, second, changed = (tmp_path / name for name in ("first", "second", "changed")) + _project(first, "original-dependencies") + _project(second, "original-dependencies") + _project(changed, "changed-dependencies") + for root in (first, second, changed): + helper._ensure_snapshot_lock(root, "uv", {"UV_INDEX_URL": "https://fixture.invalid/simple"}) + + assert calls == [first, changed] + assert (first / "uv.lock").read_bytes() == (second / "uv.lock").read_bytes() + assert (changed / "uv.lock").read_bytes() != (second / "uv.lock").read_bytes() + + +def test_failed_resolution_never_populates_or_reuses_lock(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(helper, "_SNAPSHOT_LOCK_CACHE", {}) + calls = 0 + + def resolve(arguments: list[str], cwd: Path, *, env: dict[str, str]) -> str: + nonlocal calls + calls += 1 + if calls == 1: + (cwd / "uv.lock").write_text("partial", encoding="utf-8") + raise subprocess.TimeoutExpired(arguments, 90) + (cwd / "uv.lock").write_text("verified", encoding="utf-8") + return "" + + monkeypatch.setattr(helper, "_run", resolve) + first, second = tmp_path / "first", tmp_path / "second" + _project(first, "same-dependencies") + _project(second, "same-dependencies") + with pytest.raises(subprocess.TimeoutExpired): + helper._ensure_snapshot_lock(first, "uv", {}) + helper._ensure_snapshot_lock(second, "uv", {}) + assert calls == 2 + assert (second / "uv.lock").read_text() == "verified" From dbaef3599f1a5789894c866082c4a6d70d57d0d9 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 08:57:31 +0900 Subject: [PATCH 16/42] feat(dot): add opt-in disposable MCP acceptance harness and original-send fencing --- ...dot-autonomous-source-worker-operations.md | 26 + scripts/dot_disposable_fixture.py | 320 +++++++++++ scripts/dot_mcp_acceptance.py | 521 ++++++++++++++++++ tests/test_dot_disposable_fixture.py | 131 +++++ tests/test_dot_mcp_acceptance.py | 216 ++++++++ 5 files changed, 1214 insertions(+) create mode 100644 scripts/dot_disposable_fixture.py create mode 100644 scripts/dot_mcp_acceptance.py create mode 100644 tests/test_dot_disposable_fixture.py create mode 100644 tests/test_dot_mcp_acceptance.py diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 2d416535c..8ed40ab73 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -204,3 +204,29 @@ simulated `PASS`. Changes to the running beta/release service are prohibited for this test. The implementation PR stays DRAFT until the genuine disposable end-to-end staging/unknown/recovery/policy downgrade tests and exact-head CI succeed and all review is resolved. + +### Disposable-only acceptance harness (partial) + +`scripts/dot_disposable_fixture.py` creates an owner-private, per-fixture Docker +Compose configuration using the fixed `kitamura-tetsuo/auto-coder-test` +repository, non-root workers, no host ports or runtime Docker socket, and an +observe-only management policy. It neither executes Compose nor creates grants. +The operator must separately provision and explicitly authorize a disposable +fixture before any live acceptance. The generated image names are pinned fixture +requirements, not automatically verified runtime identities. + +`scripts/dot_mcp_acceptance.py` is opt-in and returns `NOT_RUN` without a fixture. +Its default path only reads current authenticated MCP observations. The explicit +`--enable-fixture-mutation` mode confines available actions to test-only beta +reevaluation or beta-source selection and requires current selected stage, +revision, original Git/Actions proof and an immutable fsynced pre-send ticket. +Unknown, interrupted or conflicting sends remain `BLOCKED`; the original +idempotency key is never reissued. Live producer observations are hashed for +origin tracking. No successful synthetic fixture test certifies live acceptance. + +These scripts provide reusable acceptance components, not a completed #2608 +contract: obtaining real fixture grants, actual controller/worker/source and +GitHub Actions evidence, staged unknown-effect/recovery regressions, and dot +scheduler recurrence still require independent end-to-end verification. Never +use this harness against production or existing beta/release services or treat +its local unit tests as deployment, authorization or merge proof. diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py new file mode 100644 index 000000000..e0d61eaa8 --- /dev/null +++ b/scripts/dot_disposable_fixture.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Provision configuration for a disposable, nonproduction management MCP topology. + +This is an operator-side fixture generator, not a runtime Docker controller. +It never invokes Docker, contacts GitHub, changes a policy grant or starts a worker. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import secrets +import subprocess +import sys +from pathlib import Path + +import yaml +from loguru import logger + +TEST_REPOSITORY = "kitamura-tetsuo/auto-coder-test" +MANAGER_IMAGE = "auto-coder:management-fixture-17a312445be4" +WORKER_IMAGE = "auto-coder:release-acceptance-065555a6" +SAFE_FIXTURE = re.compile(r"pr[0-9]{3,6}-[a-z0-9-]{6,28}\Z") +SCOPES = ["auto-coder.read", "auto-coder.operate", "auto-coder.deploy"] + + +def _private_file(path: Path, value: str | bytes) -> None: + """Create one new fixture-owned file, never overwrite existing state.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(value.encode("utf-8") if isinstance(value, str) else value) + stream.flush() + os.fsync(stream.fileno()) + + +def _yaml(value: object) -> str: + return yaml.safe_dump(value, sort_keys=False) + + +def _source_fixture_revision(source: Path | None) -> str: + """Accept a clean exact main of the named test-only Git remote or no source.""" + if source is None: + return "" + if not (source / "pyproject.toml").is_file() or not (source / "uv.lock").is_file(): + raise ValueError("disposable tested source must contain the real locked Python project") + + def git(*arguments: str) -> str: + try: + completed = subprocess.run( + ["git", "-C", str(source), *arguments], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise ValueError("disposable source Git identity is unavailable") from error + return completed.stdout.strip() + + remote = git("remote", "get-url", "origin") + if remote not in ( + "https://github.com/kitamura-tetsuo/auto-coder-test.git", + "git@github.com:kitamura-tetsuo/auto-coder-test.git", + ): + raise ValueError("disposable tested source must use the exact test repository remote") + revision = git("rev-parse", "HEAD") + if not re.fullmatch(r"[0-9a-f]{40}", revision) or revision != git("rev-parse", "main"): + raise ValueError("disposable tested source must be the pinned local main commit") + if git("status", "--porcelain", "--untracked-files=no"): + raise ValueError("disposable tested source must have a clean index and tracked files") + return revision + + +def provision( + *, + source: Path, + fixture_id: str, + firebase_project: str, + owner_uid: int, + owner_gid: int, + test_source: Path | None = None, +) -> Path: + """Write a fresh topology constrained to the test repository and private volumes.""" + source = source.resolve(strict=True) + if not (source / "pyproject.toml").is_file() or not (source / "tests/fixtures/supervisor_offline_pid1.py").is_file(): + raise ValueError("source root must be the checked-out Auto-Coder test fixture source") + if SAFE_FIXTURE.fullmatch(fixture_id) is None: + raise ValueError("fixture ID must be an exact lowercase disposable identifier") + if not isinstance(firebase_project, str) or not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9-]{3,64}", firebase_project): + raise ValueError("Firebase project ID must be a bounded explicit value") + if owner_uid < 1 or owner_gid < 1: + raise ValueError("fixture worker cannot run as root") + test_source = test_source.resolve(strict=True) if test_source is not None else None + test_sha = _source_fixture_revision(test_source) + scratch = source / ".agent-tmp" + if scratch.exists() and (scratch.is_symlink() or not scratch.is_dir()): + raise ValueError("fixture scratch root must be an ordinary directory") + scratch.mkdir(mode=0o700, exist_ok=True) + root = scratch / fixture_id + root.mkdir(mode=0o700) + for directory in ( + "secrets", + "policy", + "management/oauth", + "management/operations", + "beta/runtime/home", + "beta/runtime/logs", + "beta/workspace", + "release/runtime/home", + "release/runtime/logs", + "release/workspace", + "routing", + ): + (root / directory).mkdir(parents=True, mode=0o700) + nonce = secrets.token_hex(16) + octet = 32 + int(hashlib.sha256(fixture_id.encode()).hexdigest()[:2], 16) % 160 + subnet = f"10.249.{octet}.0/24" + addresses = {"beta": f"10.249.{octet}.11", "release": f"10.249.{octet}.12"} + beta_id, release_id = "dot-" + fixture_id + "-beta", "dot-" + fixture_id + "-release" + release_repo = "fixture/" + fixture_id + "-release" + project = "dot-" + fixture_id + + for role in ("beta", "release"): + _private_file(root / "secrets" / f"{role}-bridge", base64.b64encode(os.urandom(48)) + b"\n") + _private_file(root / "secrets/source-public-read", "") + _private_file(root / "routing/ownership.json", json.dumps({TEST_REPOSITORY: "beta", release_repo: "release"})) + for role in ("beta", "release"): + _private_file(root / role / "runtime/llm_config.toml", '[backend]\norder = ["jules"]\n\n[backends.jules]\nbackend_type = "jules"\nenabled = false\n') + _private_file(root / "management/oauth/allowed_uids.json", json.dumps({"project_id": firebase_project, "allowed_uids": []})) + _private_file( + root / "policy/operating-policy.yaml", + _yaml( + { + "profile": "observe", + "targets": { + beta_id: {"channel": "beta", "repositories": [TEST_REPOSITORY]}, + release_id: {"channel": "release", "repositories": [release_repo]}, + }, + } + ), + ) + _private_file( + root / "management/config.yaml", + _yaml( + { + "state_root": "/fixture/management/oauth", + "operation_state_root": "/fixture/management/operations", + "operating_policy_path": "/fixture/policy/operating-policy.yaml", + "public_origin": "http://127.0.0.1:8081", + "bind_host": "127.0.0.1", + "port": 8081, + "local_development": True, + "firebase_project_id": firebase_project, + "source_repository": TEST_REPOSITORY, + "source_evidence_token_file": "/fixture/secrets/source-public-read", + "enabled_scopes": SCOPES, + "clients": [{"client_id": "dot-disposable-operator", "redirect_uris": ["http://127.0.0.1:49152/callback"], "allowed_scopes": SCOPES}], + } + ), + ) + + registry = { + "workers": [ + { + "target_id": beta_id if role == "beta" else release_id, + "supervisor_instance_id": beta_id if role == "beta" else release_id, + "channel": role, + "repository": TEST_REPOSITORY if role == "beta" else release_repo, + "bridge_address": addresses[role], + "bridge_port": 8843, + "bridge_secret_file": f"/fixture/secrets/{role}-bridge", + } + for role in ("beta", "release") + ] + } + services: dict[str, object] = {} + for role in ("beta", "release"): + role_id = beta_id if role == "beta" else release_id + fixture_mounts = [ + f"{source}:/source:ro", + f"{source}/tests/fixtures:/fixtures:ro", + ] + if test_source is not None: + fixture_mounts = [f"{source}:/code:ro", f"{source}/tests/fixtures:/fixtures:ro", f"{test_source}:/source:ro"] + services[role] = { + "image": WORKER_IMAGE, + "entrypoint": ["python", "/fixtures/supervisor_offline_pid1.py"], + "working_dir": "/source", + "user": f"{owner_uid}:{owner_gid}", + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "networks": {"fixture": {"ipv4_address": addresses[role]}}, + "environment": { + **({} if test_source is not None else {"PYTHONPATH": "/source/src"}), + "HOME": "/runtime/home", + "AUTO_CODER_SUPERVISOR_INSTANCE_ID": role_id, + "AUTO_CODER_CHANNEL": role, + "AUTO_CODER_REPOSITORY": TEST_REPOSITORY if role == "beta" else release_repo, + "AUTO_CODER_RUNTIME_ROOT": "/runtime", + "AUTO_CODER_WORKSPACE_ROOT": "/workspace", + "AUTO_CODER_OWNERSHIP_FILE": "/fixture/routing/ownership.json", + "AUTO_CODER_CONFIG_PATH": "/runtime/llm_config.toml", + "AUTO_CODER_ARTIFACT_DIGEST": "disposable-fixture-only", + "AUTO_CODER_BRIDGE_BIND_ADDRESS": addresses[role], + "AUTO_CODER_BRIDGE_PORT": "8843", + "AUTO_CODER_BRIDGE_SECRET_FILE": f"/fixture/secrets/{role}-bridge", + "AUTO_CODER_SUPERVISOR_AUTO_START": "1", + "AUTO_CODER_FIXTURE_HOLD_BOOTSTRAP": "1", + "AUTO_CODER_TEST_REQUEST_AUDIT": "1", + "AUTO_CODER_DISABLE_AUTO_UPDATE": "1", + "AUTO_CODER_HEALTH_LOG_ENABLED": "0", + "AUTO_CODER_SOURCE_REPOSITORY_ROOT": "/source", + "GITHUB_TOKEN": "nonfunctional-disposable-no-github-write", + }, + "volumes": [ + *fixture_mounts, + f"{root}/secrets:/fixture/secrets:ro", + f"{root}/routing:/fixture/routing:ro", + f"{root}/{role}/runtime:/runtime", + f"{root}/{role}/workspace:/workspace", + ], + "restart": "no", + } + services["management"] = { + "image": MANAGER_IMAGE, + "entrypoint": ["python", "-m", "auto_coder.cli", "management", "serve", "--config", "/fixture/management/config.yaml"], + "working_dir": "/source", + "user": f"{owner_uid}:{owner_gid}", + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "networks": {"fixture": {"ipv4_address": f"10.249.{octet}.10"}}, + "environment": { + "PYTHONPATH": "/source/src", + "HOME": "/fixture/management/oauth", + "DOT_DISPOSABLE_FIXTURE_NONCE": nonce, + "AUTO_CODER_WORKER_REGISTRY": json.dumps(registry, separators=(",", ":")), + }, + "volumes": [ + f"{source}:/source:ro", + f"{root}/management:/fixture/management", + f"{root}/policy:/fixture/policy:ro", + f"{root}/secrets:/fixture/secrets:ro", + f"{root}/fixture.json:/fixture/fixture.json:ro", + ], + "restart": "no", + "depends_on": ["beta", "release"], + } + _private_file( + root / "compose.yaml", + _yaml( + { + "name": project, + "services": services, + "networks": {"fixture": {"driver": "bridge", "ipam": {"config": [{"subnet": subnet}]}}}, + } + ), + ) + _private_file( + root / "fixture.json", + json.dumps( + { + "schema": 1, + "fixture_nonce": nonce, + "repository": TEST_REPOSITORY, + "fixture_id": fixture_id, + "compose_project": project, + "subnet": subnet, + "endpoint": "http://127.0.0.1:8081/mcp", + "endpoint_namespace": "management-container-only", + "target_ids": {"beta": beta_id, "release": release_id}, + "mutation_policy": "observe until separately configured and granted", + "docker_authority_in_worker_or_management": False, + "source_fixture_sha": test_sha, + "source_fixture_available": bool(test_sha), + }, + sort_keys=True, + indent=2, + ) + + "\n", + ) + return root + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path(__file__).parents[1]) + parser.add_argument("--fixture-id", required=True) + parser.add_argument("--firebase-project-id", required=True) + parser.add_argument("--test-source", type=Path, help="Clean main of the explicitly approved test-only Git repository") + parser.add_argument("--uid", type=int, default=os.getuid()) + parser.add_argument("--gid", type=int, default=os.getgid()) + args = parser.parse_args() + logger.remove() + logger.add(sys.stdout) + state = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "auto-coder" + state.mkdir(parents=True, exist_ok=True) + logger.add(state / "dot-disposable-fixture.log", rotation="2 MB", retention=3) + try: + root = provision( + source=args.source, + fixture_id=args.fixture_id, + firebase_project=args.firebase_project_id, + owner_uid=args.uid, + owner_gid=args.gid, + test_source=args.test_source, + ) + except (OSError, ValueError) as error: + logger.error("No fixture created: {}", type(error).__name__) + return 2 + print(json.dumps({"verdict": "NOT_RUN", "compose_path": str(root / "compose.yaml"), "manifest_path": str(root / "fixture.json"), "reason": "Provisioned private fixture configuration; no Docker or authenticated effect executed"})) + return 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py new file mode 100644 index 000000000..e092417e2 --- /dev/null +++ b/scripts/dot_mcp_acceptance.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Opt-in, disposable-only MCP acceptance probe with durable send fencing. + +The fixture's Docker provisioning is an external operator action. This client +never imports or invokes Docker, changes operator policy, or grants OAuth. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import ipaddress +import json +import os +import re +import secrets +import stat +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from loguru import logger + +# Support both direct script execution and package imports. +if __package__: + from . import management_mcp_rollout_smoke as smoke +else: + import management_mcp_rollout_smoke as smoke + +TEST_REPOSITORY = "kitamura-tetsuo/auto-coder-test" +SHA = re.compile(r"[0-9a-f]{40}\Z") +NONCE = re.compile(r"[0-9a-f]{32}\Z") +FIXTURE = re.compile(r"pr[0-9]{3,6}-[a-z0-9-]{6,28}\Z") +SCENARIOS = frozenset({"observe", "reevaluate", "beta-source", "inspect-original"}) +MUTATIONS = { + "reevaluate": "auto_coder_reevaluate_target", + "beta-source": "auto_coder_deploy_beta_source", +} +READS = frozenset(smoke.READ_TOOLS) + + +class Blocked(RuntimeError): + """Missing current authority or trustworthy external evidence.""" + + +class Failed(RuntimeError): + """A real producer contradicted the immutable expected identity.""" + + +@dataclass(frozen=True) +class Fixture: + schema: int + fixture_nonce: str + repository: str + fixture_id: str + compose_project: str + subnet: str + endpoint: str + endpoint_namespace: str + target_ids: dict[str, str] + mutation_policy: str + docker_authority_in_worker_or_management: bool + source_fixture_sha: str + source_fixture_available: bool + + @classmethod + def load(cls, path: Path) -> "Fixture": + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or set(raw) != set(cls.__dataclass_fields__): + raise Blocked("Disposable fixture manifest has missing or extra fields") + try: + fixture = cls(**raw) + except TypeError as error: + raise Blocked("Disposable fixture manifest types are invalid") from error + if type(fixture.schema) is not int or fixture.schema != 1 or not isinstance(fixture.fixture_id, str) or FIXTURE.fullmatch(fixture.fixture_id) is None: + raise Blocked("Disposable fixture identity is invalid") + if not isinstance(fixture.fixture_nonce, str) or NONCE.fullmatch(fixture.fixture_nonce) is None: + raise Blocked("Disposable fixture nonce is invalid") + if fixture.repository != TEST_REPOSITORY or fixture.compose_project != "dot-" + fixture.fixture_id: + raise Blocked("Target source repository or Compose project is not the approved disposable fixture") + if fixture.endpoint != "http://127.0.0.1:8081/mcp" or fixture.endpoint_namespace != "management-container-only": + raise Blocked("The fixture MCP endpoint must be its own container loopback") + if fixture.mutation_policy != "observe until separately configured and granted" or fixture.docker_authority_in_worker_or_management is not False: + raise Blocked("Fixture grants or Docker authority differ from the original fail-closed configuration") + if type(fixture.source_fixture_available) is not bool or not isinstance(fixture.source_fixture_sha, str): + raise Blocked("Fixture source identity is malformed") + if fixture.source_fixture_available != bool(fixture.source_fixture_sha) or (fixture.source_fixture_sha and SHA.fullmatch(fixture.source_fixture_sha) is None): + raise Blocked("Fixture source requires a pinned full Git SHA") + if fixture.target_ids != {"beta": "dot-" + fixture.fixture_id + "-beta", "release": "dot-" + fixture.fixture_id + "-release"}: + raise Blocked("Fixture target IDs are not fixed to the original disposable project") + try: + network = ipaddress.ip_network(fixture.subnet, strict=True) + except ValueError as error: + raise Blocked("Fixture network identity is invalid") from error + if network.version != 4 or network.prefixlen != 24 or not str(network).startswith("10.249."): + raise Blocked("Fixture private network is outside the approved test address pool") + return fixture + + +@dataclass(frozen=True) +class Evidence: + tool: str + sha256: str + origin: str + status: str + + +@dataclass(frozen=True) +class Result: + verdict: str + scenario: str + reason: str + observations: tuple[Evidence, ...] = () + operation_id: str = "" + positive_deployment_proven: bool = False + effect_dispatched: bool | None = False + + +def bound_local_fixture(fixture: Fixture, *, environ: dict[str, str] | None = None, docker_socket: Path = Path("/var/run/docker.sock")) -> None: + """Enforce a trusted, nonproduction container boundary before opening MCP.""" + environment = os.environ if environ is None else environ + if environment.get("DOT_DISPOSABLE_FIXTURE_NONCE") != fixture.fixture_nonce: + raise Blocked("Run only from the exact disposable management container; no transport opened") + if environment.get("DOCKER_HOST") or environment.get("DOCKER_CONTEXT"): + raise Blocked("The MCP client must have no runtime Docker authority") + try: + socket_mode = docker_socket.stat().st_mode + except FileNotFoundError: + socket_mode = 0 + if stat.S_ISSOCK(socket_mode): + raise Blocked("The runtime Docker socket is forbidden") + try: + registry = json.loads(environment["AUTO_CODER_WORKER_REGISTRY"]) + except (KeyError, ValueError, TypeError) as error: + raise Blocked("Fixture worker registry cannot be independently checked") from error + if not isinstance(registry, dict) or set(registry) != {"workers"} or not isinstance(registry["workers"], list) or len(registry["workers"]) != 2: + raise Blocked("Fixture must have exactly two independent worker bindings") + expected = { + fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), + fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), + } + actual = {} + for entry in registry["workers"]: + if not isinstance(entry, dict) or not isinstance(entry.get("target_id"), str): + raise Blocked("Worker registry entry is invalid") + target = entry["target_id"] + if entry.get("supervisor_instance_id") != target or entry.get("bridge_port") != 8843: + raise Blocked("Physical target identity or bridge port changed") + if entry.get("bridge_secret_file") != "/fixture/secrets/" + entry.get("channel", "") + "-bridge": + raise Blocked("Worker secret path is outside the fixture") + try: + address = ipaddress.ip_address(entry["bridge_address"]) + except (KeyError, ValueError) as error: + raise Blocked("Worker bridge address is invalid") from error + if address not in ipaddress.ip_network(fixture.subnet): + raise Blocked("Worker bridge address is outside the fixture network") + actual[target] = (entry.get("channel"), entry.get("repository")) + if actual != expected: + raise Blocked("Worker registry does not match exact disposable beta/release targets") + + +def original_stage(checkpoint: smoke.DotCheckpoint, scenario: str, selected_stage: str, selected_revision: str, *, mutation: bool) -> None: + if scenario not in SCENARIOS: + raise Blocked("Unsupported acceptance scenario") + if not mutation: + if scenario not in {"observe", "inspect-original"}: + raise Blocked("Read-only is the default; mutation requires explicit selection") + return + if scenario not in MUTATIONS: + raise Blocked("No mutating action is available for this scenario") + if checkpoint.stage not in {"beta", "full"}: + raise Blocked("Observe stage can never dispatch an effect") + try: + smoke.confirm_operator_scope(checkpoint, selected_stage, selected_revision) + except smoke.OperatorScopeError as error: + raise Blocked(str(error)) from error + required = smoke.required_next_observation(checkpoint) + if required.verdict == "BLOCKED": + raise Blocked(required.reason) + if checkpoint.pending_effect_state or checkpoint.pending_operation_id or checkpoint.recovery_attempts: + raise Blocked("An original unresolved effect or exhausted recovery budget forbids any new send") + if not checkpoint.idempotency_key or smoke.SAFE_ID.fullmatch(checkpoint.idempotency_key) is None: + raise Blocked("Persist the immutable original idempotency key before admission") + + +def ticket_path(scenario: str) -> Path: + if scenario not in MUTATIONS: + raise Blocked("Only a fixed disposable mutation may reserve a send ticket") + return Path("/fixture/management") / f"dot-fixture-{scenario}-original-send.json" + + +def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotCheckpoint, action: str, args: dict[str, object]) -> None: + """Fsync original nonsecret intent before an effect, refusing all replays.""" + if path.exists() or path.is_symlink(): + raise Blocked("A previous admission may have been sent; reconcile its original ticket before retry") + for existing_path in path.parent.glob("dot-fixture-*-original-send.json"): + try: + previous = json.loads(existing_path.read_text(encoding="utf-8")) + except (ValueError, OSError) as error: + raise Blocked("A previous original send ticket cannot be checked") from error + if not isinstance(previous, dict) or previous.get("fixture_nonce") != fixture.fixture_nonce: + raise Blocked("A previous ticket has an untrusted fixture identity") + if previous.get("target") == fixture.target_ids["beta"] and previous.get("idempotency_key") == checkpoint.idempotency_key: + raise Blocked("Original idempotency key cannot be reused for a different action on one target") + record = { + "schema": 1, + "fixture_nonce": fixture.fixture_nonce, + "work_repository": checkpoint.repository, + "issue_number": checkpoint.issue_number, + "pr_number": checkpoint.pr_number, + "stage_revision": checkpoint.stage_revision, + "action": action, + "target": fixture.target_ids["beta"], + "idempotency_key": checkpoint.idempotency_key, + "args": args, + "effect_state": "ADMISSION_UNKNOWN", + "operation_id": "", + } + encoded = (json.dumps(record, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + if len(encoded) > 8192: + raise Blocked("Original immutable intent is too large to journal safely") + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "wb") as file: + file.write(encoded) + file.flush() + os.fsync(file.fileno()) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + except FileExistsError as error: + raise Blocked("Original admission ticket already exists; never allocate a new key") from error + + +def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, state: str) -> None: + """Persist only the observed original ID/state; never authorize resending.""" + if state not in {"ADMITTED", "SUCCEEDED"} or smoke.SAFE_ID.fullmatch(operation_id) is None: + raise Blocked("Original operation update has an invalid identity") + try: + original = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise Blocked("The durable original send ticket is not readable") from error + if not isinstance(original, dict) or original.get("fixture_nonce") != fixture.fixture_nonce: + raise Blocked("Original send ticket was replaced or corrupted") + if original.get("operation_id") not in {"", operation_id} or original.get("effect_state") not in {"ADMISSION_UNKNOWN", "ADMITTED", "SUCCEEDED"}: + raise Blocked("Original ticket operation ID/state changed unexpectedly") + if original.get("effect_state") == "SUCCEEDED" and state != "SUCCEEDED": + raise Blocked("Terminal original send cannot regress to an admitted state") + updated = {**original, "operation_id": operation_id, "effect_state": state} + temporary = path.parent / (".dot-original-" + secrets.token_hex(8)) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + json.dump(updated, file, sort_keys=True, separators=(",", ":")) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def positive_heartbeat(snapshot: dict[str, Any], *, source_sha: str = "") -> int: + if snapshot.get("status") != "OK" or snapshot.get("alive") is not True or snapshot.get("process_state") != "RUNNING": + raise Blocked("Signed Supervisor cannot establish a live original worker") + if snapshot.get("installed_binding_status") != "VERIFIED": + raise Blocked("An unstaged/bootstrap child is not verified installed source") + binding = snapshot.get("installed_runtime") + if not isinstance(binding, dict) or (source_sha and binding.get("source_sha") != source_sha): + raise Failed("Installed source binding contradicts the exact tested Git commit") + observation = snapshot.get("worker_observation") + if not isinstance(observation, dict): + raise Blocked("Worker-produced observation is unavailable") + lifecycle, heartbeat = observation.get("lifecycle"), observation.get("heartbeat") + if not isinstance(lifecycle, dict) or lifecycle.get("startup_reconciled") is not True or not isinstance(heartbeat, dict): + raise Blocked("Real startup reconciliation or worker heartbeat is unavailable") + sequence = heartbeat.get("sequence") + if heartbeat.get("available") is not True or type(sequence) is not int or sequence < 0: + raise Blocked("A running container is not a positive heartbeat producer") + return sequence + + +def verify_real_source(checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> None: + """Read the original public Git ref, annotated object and source Actions attempt.""" + if not fixture.source_fixture_available or checkpoint.source_sha != fixture.source_fixture_sha: + raise Blocked("Source under test is not the fixture's originally pinned main commit") + from auto_coder.tested_source_refs import TestedSourcePublisher + from auto_coder.util.gh_cache import GitHubGitDataClient + + publisher = TestedSourcePublisher(GitHubGitDataClient("", TEST_REPOSITORY), TEST_REPOSITORY) + proof = publisher.read(checkpoint.source_sha) + if proof is None: + raise Blocked("The exact immutable tested-source Git ref has not been published") + record = proof.record + if (proof.tag_ref, proof.tag_object_oid) != (checkpoint.tested_source_ref, checkpoint.tested_source_oid): + raise Failed("The test repository's annotated proof ref/OID differs from original checkpoint") + if (str(record.run_id), str(record.run_attempt)) != (checkpoint.ci_run_id, checkpoint.ci_run_attempt): + raise Failed("Original source GitHub Actions run/attempt differs from published immutable proof") + publisher.verify_run(record, recorded_attempt=True) + + +def verify_current_operator(identity: dict[str, Any], fixture: Fixture, scenario: str, *, mutation: bool) -> None: + scopes = identity.get("scopes") + expected = {"auto-coder.read"} + if mutation: + expected.add("auto-coder.operate" if scenario == "reevaluate" else "auto-coder.deploy") + if ( + identity.get("resource") != fixture.endpoint + or identity.get("client_id") != "dot-disposable-operator" + or not isinstance(identity.get("uid"), str) + or not identity["uid"] + or not isinstance(identity.get("project_id"), str) + or not isinstance(scopes, (list, tuple)) + or not expected.issubset(set(scopes)) + ): + raise Blocked("The original fixture OAuth client, identity, resource or current scopes are not verified") + + +class Live: + def __init__(self, session: Any, registered: set[str]): + self.session = session + self.registered = registered + self.observations: list[Evidence] = [] + + async def call(self, name: str, arguments: dict[str, object], *, mutation: bool = False) -> dict[str, Any]: + if name not in self.registered: + raise Blocked("Required MCP producer tool is not registered: " + name) + if mutation: + if name not in MUTATIONS.values(): + raise Blocked("Only fixed fixture mutation tools are allowed") + else: + smoke.checked_tool(name, arguments) + result = await self.session.call_tool(name, arguments) + if result.isError or not isinstance(result.structuredContent, dict): + raise Blocked("Current authenticated MCP structured producer evidence is unavailable") + value = result.structuredContent + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + self.observations.append(Evidence(name, hashlib.sha256(encoded).hexdigest(), "authenticated live MCP", str(value.get("status", "")))) + return value + + +async def observed_target(live: Live, target: str, channel: str, repository: str) -> dict[str, Any]: + readback = await live.call("auto_coder_deployment_status", {"target_id": target}) + if readback.get("status") != "OK": + raise Blocked("The configured Supervisor target is not currently observable") + if (readback.get("target_id"), readback.get("instance_id"), readback.get("channel"), readback.get("repository")) != (target, target, channel, repository): + raise Failed("Authenticated server's physical target identity differs from the disposable fixture") + return readback + + +async def inspect_original(live: Live, checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> Result: + original = checkpoint.pending_operation_id + if not original: + raise Blocked("Exact original pending operation ID is required") + try: + target = smoke._original_operation_target(checkpoint, original) + except ValueError as error: + raise Blocked("Pending original operation ID does not have one immutable bound target") from error + if target not in fixture.target_ids.values(): + raise Blocked("Pending original operation targets a nonfixture service") + value = await live.call("auto_coder_operation_inspect", {"target_id": target, "operation_id": original}) + if value.get("operation_id") != original or value.get("status") in {"NOT_FOUND", "UNAVAILABLE"}: + raise Blocked("The original journal operation remains unverified; do not rekey or resubmit") + return Result("BLOCKED", "inspect-original", "Original journal inspected without dispatch; unresolved effects remain held", tuple(live.observations), original) + + +async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, mutate: bool, item_number: int) -> Result: + bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") + if not bearer: + raise Blocked("No current fixture-specific OAuth access bearer; no MCP request sent") + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with streamablehttp_client(fixture.endpoint, headers={"Authorization": "Bearer " + bearer}, timeout=15, sse_read_timeout=30) as (reader, writer, _): + async with ClientSession(reader, writer) as session: + await session.initialize() + live = Live(session, {tool.name for tool in (await session.list_tools()).tools}) + identity = await live.call("auto_coder_whoami", {}) + verify_current_operator(identity, fixture, scenario, mutation=mutate) + targets = await live.call("auto_coder_targets", {}) + expected = { + fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), + fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), + } + actual = {entry["target_id"]: (entry["channel"], entry["repository"]) for entry in targets.get("targets", []) if isinstance(entry, dict) and all(name in entry for name in ("target_id", "channel", "repository"))} + if targets.get("status") != "OK" or actual != expected: + raise Blocked("The current policy target map cannot prove the two disposable workers") + beta = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) + if scenario == "inspect-original": + return await inspect_original(live, checkpoint, fixture) + if not mutate: + release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) + before_beta = positive_heartbeat(beta, source_sha=checkpoint.source_sha) + before_release = positive_heartbeat(release) + await asyncio.sleep(2) + after_beta = positive_heartbeat(await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY), source_sha=checkpoint.source_sha) + after_release = positive_heartbeat(await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1])) + if not after_beta > before_beta or not after_release > before_release: + raise Blocked("Two genuinely advancing worker-produced heartbeats are not yet observable") + return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) + if scenario == "beta-source": + await asyncio.to_thread(verify_real_source, checkpoint, fixture) + selected = await live.call("auto_coder_beta_source_readback", {"target_id": fixture.target_ids["beta"]}) + if selected.get("status") != "OK" or (selected.get("source_sha"), selected.get("tested_source_oid")) != (checkpoint.source_sha, checkpoint.tested_source_oid): + raise Blocked("The selected test-only beta Git source does not match original immutable proof") + else: + if type(item_number) is not int or item_number < 1 or item_number > 999999999: + raise Blocked("A positive test-repository Issue number is required") + worker = beta.get("worker") + if beta.get("alive") is not True or not isinstance(worker, dict): + raise Blocked("No real running beta child to fence a mutation") + generation = beta.get("supervisor_generation") + if not isinstance(generation, str) or not generation: + raise Blocked("Signed Supervisor generation is unavailable") + if scenario == "reevaluate": + arguments: dict[str, object] = { + "target_id": fixture.target_ids["beta"], + "target_type": "issue", + "number": item_number, + "idempotency_key": checkpoint.idempotency_key, + "expected_process_run_id": worker.get("process_run_id"), + "expected_deployment_generation": worker.get("deployment_generation"), + } + else: + arguments = { + "target_id": fixture.target_ids["beta"], + "selected_git_sha": checkpoint.source_sha, + "idempotency_key": checkpoint.idempotency_key, + "expected_supervisor_generation": generation, + "expected_runtime_id": worker.get("runtime_id"), + "expected_process_run_id": worker.get("process_run_id"), + "expected_deployment_generation": worker.get("deployment_generation"), + } + if any(not isinstance(value, (str, int)) or value in {"", None} for value in arguments.values()): + raise Blocked("Original live worker fencing or immutable intent is incomplete") + original_ticket = ticket_path(scenario) + persist_original_ticket(original_ticket, fixture, checkpoint, MUTATIONS[scenario], arguments) + admitted = await live.call(MUTATIONS[scenario], arguments, mutation=True) + original_id = admitted.get("operation_id") + if not isinstance(original_id, str) or smoke.SAFE_ID.fullmatch(original_id) is None: + raise Blocked("Admission response is missing its original operation ID; durable ticket remains held") + update_original_ticket(original_ticket, fixture, original_id, "ADMITTED") + for _ in range(30): + original = await live.call("auto_coder_operation_inspect", {"target_id": fixture.target_ids["beta"], "operation_id": original_id}) + if original.get("operation_id") != original_id: + raise Blocked("Original operation readback is unbound or unavailable") + if original.get("status") in {"NEEDS_ATTENTION", "FAILED", "UNKNOWN", "UNAVAILABLE"}: + raise Blocked("Original worker operation is held or failed; never allocate a new key") + if original.get("status") == "SUCCEEDED": + update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") + if scenario == "reevaluate": + receipt = original.get("receipt") + if not isinstance(receipt, dict) or receipt.get("result") != "accepted_for_reevaluation": + raise Blocked("A completed management operation is not durable worker reevaluation acceptance") + return Result("PASS", scenario, "The genuine worker durably accepted reevaluation; Issue work was not certified complete", tuple(live.observations), original_id, effect_dispatched=True) + installed = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) + first = positive_heartbeat(installed, source_sha=checkpoint.source_sha) + await asyncio.sleep(2) + second = positive_heartbeat(await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY), source_sha=checkpoint.source_sha) + if second <= first: + raise Blocked("Installed source does not have independently advancing worker heartbeats") + return Result("PASS", scenario, "Real worker installed the original tested source and advanced producer heartbeats", tuple(live.observations), original_id, positive_deployment_proven=True, effect_dispatched=True) + await asyncio.sleep(2) + raise Blocked("Original management operation has not reached a verified terminal receipt") + + +def _logging() -> None: + logger.remove() + logger.add(sys.stdout, level="INFO") + state = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "auto-coder" + state.mkdir(parents=True, exist_ok=True, mode=0o700) + logger.add(state / "dot-mcp-acceptance.log", rotation="2 MB", retention=3) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture-manifest", type=Path) + parser.add_argument("--checkpoint", type=Path) + parser.add_argument("--scenario", choices=sorted(SCENARIOS), default="observe") + parser.add_argument("--enable-fixture-mutation", action="store_true") + parser.add_argument("--operator-stage", default="") + parser.add_argument("--operator-stage-revision", default="") + parser.add_argument("--fixture-issue-number", type=int, default=0) + args = parser.parse_args() + _logging() + if args.fixture_manifest is None: + report = Result("NOT_RUN", args.scenario, "No disposable fixture selected; no network or mutation") + else: + try: + fixture = Fixture.load(args.fixture_manifest) + bound_local_fixture(fixture) + checkpoint = smoke.load_checkpoint(args.checkpoint) + original_stage(checkpoint, args.scenario, args.operator_stage, args.operator_stage_revision, mutation=args.enable_fixture_mutation) + if args.enable_fixture_mutation and (not fixture.source_fixture_available or checkpoint.source_sha != fixture.source_fixture_sha): + raise Blocked("Mutation source must be the immutable test-only Git fixture") + report = asyncio.run(run_authenticated(fixture, checkpoint, args.scenario, mutate=args.enable_fixture_mutation, item_number=args.fixture_issue_number)) + except Blocked as error: + ticket_unknown = args.enable_fixture_mutation and args.scenario in MUTATIONS and ticket_path(args.scenario).exists() + report = Result("BLOCKED", args.scenario, str(error), effect_dispatched=None if ticket_unknown else False) + except Failed as error: + report = Result("FAIL", args.scenario, str(error)) + except (OSError, ValueError, TypeError, RuntimeError) as error: + logger.warning("Fixture acceptance unavailable due to {}", type(error).__name__) + report = Result("BLOCKED", args.scenario, "Original fixture, authority or producer evidence unavailable") + except Exception as error: + logger.warning("Untrusted transport failure classified as {}", type(error).__name__) + report = Result("BLOCKED", args.scenario, "Authenticated fixture transport unavailable; retain any original send ticket") + logger.info("Disposable MCP acceptance scenario={} verdict={} effect_dispatched={}", report.scenario, report.verdict, report.effect_dispatched) + print(json.dumps(asdict(report), sort_keys=True)) + return {"PASS": 0, "FAIL": 1, "BLOCKED": 2, "NOT_RUN": 3}[report.verdict] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py new file mode 100644 index 000000000..60a9ba0b7 --- /dev/null +++ b/tests/test_dot_disposable_fixture.py @@ -0,0 +1,131 @@ +"""Fail-closed disposable Compose fixture configuration tests.""" + +import json +import subprocess +from pathlib import Path + +import pytest +import yaml +from scripts import dot_disposable_fixture as fixture + + +def source_tree(tmp_path: Path) -> Path: + root = tmp_path / "source" + (root / "tests" / "fixtures").mkdir(parents=True) + (root / "pyproject.toml").write_text("[project]\nname='fixture'\n", encoding="utf-8") + (root / "tests" / "fixtures" / "supervisor_offline_pid1.py").write_text("# Fixture sentinel\n", encoding="utf-8") + return root + + +def test_provision_is_dockerless_private_and_observe_only(tmp_path): + source = source_tree(tmp_path) + root = fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + marker = json.loads((root / "fixture.json").read_text(encoding="utf-8")) + compose = yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + config = yaml.safe_load((root / "management/config.yaml").read_text(encoding="utf-8")) + policy = yaml.safe_load((root / "policy/operating-policy.yaml").read_text(encoding="utf-8")) + assert marker["repository"] == "kitamura-tetsuo/auto-coder-test" + assert marker["docker_authority_in_worker_or_management"] is False + assert marker["endpoint_namespace"] == "management-container-only" + assert policy["profile"] == "observe" + assert set(policy["targets"]) == set(marker["target_ids"].values()) + assert config["local_development"] is True and config["bind_host"] == "127.0.0.1" + assert compose["services"]["management"]["image"] == fixture.MANAGER_IMAGE + assert all(compose["services"][name]["image"] == fixture.WORKER_IMAGE for name in ("beta", "release")) + assert all(service["cap_drop"] == ["ALL"] for service in compose["services"].values()) + assert all("privileged" not in service and "network_mode" not in service for service in compose["services"].values()) + assert all(not any("docker.sock" in volume or "/var/lib/auto-coder" in volume for volume in service["volumes"]) for service in compose["services"].values()) + assert all(any(volume.endswith(":/fixtures:ro") for volume in compose["services"][name]["volumes"]) for name in ("beta", "release")) + assert all(len(service["networks"]) == 1 and "fixture" in service["networks"] for service in compose["services"].values()) + assert not any("ports" in service for service in compose["services"].values()) + assert any(volume.endswith(":/fixture/fixture.json:ro") for volume in compose["services"]["management"]["volumes"]) + assert all("GITHUB_TOKEN" not in service["environment"] for name, service in compose["services"].items() if name == "management") + assert all(service["environment"]["GITHUB_TOKEN"].startswith("nonfunctional-") for name, service in compose["services"].items() if name in ("beta", "release")) + assert len(marker["fixture_nonce"]) == 32 + assert marker["source_fixture_available"] is False and marker["source_fixture_sha"] == "" + assert (root / "secrets/beta-bridge").stat().st_mode & 0o077 == 0 + + +@pytest.mark.parametrize("fixture_id", ["release-worker", "beta-worker", "../escape", "A-B", "sh", "bad space"]) +def test_nonfixture_identity_is_rejected_before_creating_directory(tmp_path, fixture_id): + source = source_tree(tmp_path) + with pytest.raises(ValueError, match="fixture ID"): + fixture.provision(source=source, fixture_id=fixture_id, firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + assert not (source / ".agent-tmp").exists() + + +def test_cannot_reuse_existing_fixture_or_mutate_saved_policy(tmp_path): + source = source_tree(tmp_path) + root = fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + policy_before = (root / "policy/operating-policy.yaml").read_bytes() + with pytest.raises(FileExistsError): + fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + assert (root / "policy/operating-policy.yaml").read_bytes() == policy_before + + +def test_symlinked_fixture_scratch_directory_is_refused(tmp_path): + source = source_tree(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (source / ".agent-tmp").symlink_to(outside) + with pytest.raises(ValueError, match="ordinary directory"): + fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + assert not list(outside.iterdir()) + + +def test_root_or_untrusted_project_rejected_before_writes(tmp_path): + source = source_tree(tmp_path) + with pytest.raises(ValueError, match="worker cannot run as root"): + fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=0, owner_gid=1000) + with pytest.raises(ValueError, match="Firebase project"): + fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="../../prod", owner_uid=1000, owner_gid=1000) + assert not (source / ".agent-tmp").exists() + + +def test_clean_test_only_git_main_is_required_for_installed_source(tmp_path, _use_real_commands): + source = source_tree(tmp_path) + test_source = source_tree(tmp_path / "candidate") + (test_source / "uv.lock").write_text("version = 1\n", encoding="utf-8") + + def git(*arguments): + return subprocess.run(["git", "-C", str(test_source), *arguments], check=True, capture_output=True, text=True).stdout.strip() + + git("init", "-b", "main") + git("config", "user.name", "Disposable fixture") + git("config", "user.email", "fixture@example.invalid") + git("add", "-A") + git("commit", "-qm", "initial test source") + git("remote", "add", "origin", "https://github.com/kitamura-tetsuo/auto-coder-test.git") + root = fixture.provision( + source=source, + fixture_id="pr2835-test-source", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=test_source, + ) + manifest = json.loads((root / "fixture.json").read_text(encoding="utf-8")) + compose = yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + assert manifest["source_fixture_sha"] == git("rev-parse", "HEAD") + assert manifest["source_fixture_available"] is True + for role in ("beta", "release"): + assert f"{test_source}:/source:ro" in compose["services"][role]["volumes"] + assert "PYTHONPATH" not in compose["services"][role]["environment"] + + +def test_wrong_test_source_remote_refused_before_fixture_creation(tmp_path, _use_real_commands): + source = source_tree(tmp_path) + test_source = source_tree(tmp_path / "candidate") + (test_source / "uv.lock").write_text("version = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(test_source), "init", "-b", "main"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(test_source), "remote", "add", "origin", "https://github.com/kitamura-tetsuo/auto-coder.git"], check=True) + with pytest.raises(ValueError, match="test repository remote"): + fixture.provision( + source=source, + fixture_id="pr2835-test-source", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=test_source, + ) + assert not (source / ".agent-tmp").exists() diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py new file mode 100644 index 000000000..7203227ca --- /dev/null +++ b/tests/test_dot_mcp_acceptance.py @@ -0,0 +1,216 @@ +"""Strict offline safety contracts, not fabricated end-to-end producer PASS.""" + +import json +import os +import subprocess +import sys +from dataclasses import asdict, replace +from pathlib import Path + +import pytest +from scripts import dot_mcp_acceptance as accept +from scripts import management_mcp_rollout_smoke as smoke + + +def fixture(): + return accept.Fixture( + schema=1, + fixture_nonce="a" * 32, + repository="kitamura-tetsuo/auto-coder-test", + fixture_id="pr2835-test-abc", + compose_project="dot-pr2835-test-abc", + subnet="10.249.99.0/24", + endpoint="http://127.0.0.1:8081/mcp", + endpoint_namespace="management-container-only", + target_ids={"beta": "dot-pr2835-test-abc-beta", "release": "dot-pr2835-test-abc-release"}, + mutation_policy="observe until separately configured and granted", + docker_authority_in_worker_or_management=False, + source_fixture_sha="b" * 40, + source_fixture_available=True, + ) + + +def checkpoint(**kwargs): + return replace( + smoke.DotCheckpoint( + schema=1, + stage="beta", + task_id="dot-pr2835", + repository="kitamura-tetsuo/auto-coder", + issue_number="2608", + stage_revision="operator-v1", + source_sha="b" * 40, + ci_run_id="43120903270", + ci_run_attempt="1", + tested_source_ref="refs/tags/auto-coder-tested-" + "b" * 40, + tested_source_oid="c" * 40, + beta_target="dot-pr2835-test-abc-beta", + idempotency_key="stable-original-reevaluation", + ), + **kwargs, + ) + + +def registry(fixture): + return { + "workers": [ + { + "target_id": fixture.target_ids[role], + "supervisor_instance_id": fixture.target_ids[role], + "channel": role, + "repository": fixture.repository if role == "beta" else "fixture/" + fixture.fixture_id + "-release", + "bridge_address": "10.249.99." + ("11" if role == "beta" else "12"), + "bridge_port": 8843, + "bridge_secret_file": f"/fixture/secrets/{role}-bridge", + } + for role in ("beta", "release") + ] + } + + +def test_no_manifest_default_does_no_network_and_returns_not_run(tmp_path): + result = subprocess.run( + [sys.executable, str(Path(accept.__file__))], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path), "AUTO_CODER_MCP_ACCESS_TOKEN": "unusable-token"}, + timeout=20, + ) + assert result.returncode == 3 + output = json.loads(result.stdout.splitlines()[-1]) + assert output["verdict"] == "NOT_RUN" + assert output["effect_dispatched"] is False + assert output["positive_deployment_proven"] is False + assert (tmp_path / "auto-coder" / "dot-mcp-acceptance.log").is_file() + + +def test_fixture_manifest_is_strict_and_refuses_nonfixture_targets(tmp_path): + raw = asdict(fixture()) + path = tmp_path / "fixture.json" + path.write_text(json.dumps(raw), encoding="utf-8") + assert accept.Fixture.load(path) == fixture() + for changed in ( + {**raw, "repository": "kitamura-tetsuo/auto-coder"}, + {**raw, "target_ids": {"beta": "beta-worker", "release": "release-worker"}}, + {**raw, "endpoint": "http://127.0.0.1:8081/mcp?port=9999"}, + {**raw, "docker_authority_in_worker_or_management": True}, + {**raw, "fixture_nonce": "not-a-nonce"}, + {**raw, "source_fixture_sha": "current HEAD"}, + {**raw, "nonsecret_but_unknown": "unexpected"}, + ): + path.write_text(json.dumps(changed), encoding="utf-8") + with pytest.raises(accept.Blocked): + accept.Fixture.load(path) + + +def test_bound_fixture_requires_nonce_registry_and_no_docker_socket(tmp_path): + expected = fixture() + environ = {"DOT_DISPOSABLE_FIXTURE_NONCE": expected.fixture_nonce, "AUTO_CODER_WORKER_REGISTRY": json.dumps(registry(expected))} + absent_socket = tmp_path / "not-a-docker-socket" + accept.bound_local_fixture(expected, environ=environ, docker_socket=absent_socket) + with pytest.raises(accept.Blocked, match="exact disposable"): + accept.bound_local_fixture(expected, environ={**environ, "DOT_DISPOSABLE_FIXTURE_NONCE": "different"}, docker_socket=absent_socket) + with pytest.raises(accept.Blocked, match="Docker"): + accept.bound_local_fixture(expected, environ={**environ, "DOCKER_HOST": "tcp://host:2375"}, docker_socket=absent_socket) + changed = registry(expected) + changed["workers"][0]["repository"] = "kitamura-tetsuo/auto-coder" + with pytest.raises(accept.Blocked, match="registry"): + accept.bound_local_fixture(expected, environ={**environ, "AUTO_CODER_WORKER_REGISTRY": json.dumps(changed)}, docker_socket=absent_socket) + + +@pytest.mark.parametrize("stage", ["observe", "beta", "full"]) +def test_observe_never_confers_mutation_authority(stage): + original = checkpoint(stage=stage) + with pytest.raises(accept.Blocked): + accept.original_stage(original, "reevaluate", stage, "operator-v1", mutation=False) + if stage == "observe": + with pytest.raises(accept.Blocked, match="Observe stage"): + accept.original_stage(original, "reevaluate", stage, "operator-v1", mutation=True) + + +@pytest.mark.parametrize("selected_revision", ["", "another-revision"]) +def test_mutation_requires_current_original_operator_stage_revision(selected_revision): + with pytest.raises(accept.Blocked): + accept.original_stage(checkpoint(), "reevaluate", "beta", selected_revision, mutation=True) + + +@pytest.mark.parametrize("state", ["SENT_UNKNOWN", "NEEDS_ATTENTION"]) +def test_unresolved_original_effect_never_allows_new_mutation(state): + original = checkpoint(beta_operation_id="original-deploy", pending_operation_id="original-deploy", pending_effect_state=state) + with pytest.raises(accept.Blocked, match="original unknown effect"): + accept.original_stage(original, "reevaluate", "beta", "operator-v1", mutation=True) + + +def test_ticket_is_persisted_before_send_and_refuses_replay_or_other_action_same_key(tmp_path): + path = tmp_path / "dot-fixture-reevaluate-original-send.json" + original = checkpoint() + args = {"target_id": fixture().target_ids["beta"], "idempotency_key": original.idempotency_key, "number": 14} + accept.persist_original_ticket(path, fixture(), original, "auto_coder_reevaluate_target", args) + record = json.loads(path.read_text(encoding="utf-8")) + assert record["effect_state"] == "ADMISSION_UNKNOWN" + assert record["operation_id"] == "" + assert record["args"] == args + assert "access_token" not in record + assert path.stat().st_mode & 0o077 == 0 + with pytest.raises(accept.Blocked, match="previous admission"): + accept.persist_original_ticket(path, fixture(), original, "auto_coder_reevaluate_target", args) + with pytest.raises(accept.Blocked, match="cannot be reused"): + accept.persist_original_ticket(tmp_path / "dot-fixture-beta-source-original-send.json", fixture(), original, "auto_coder_deploy_beta_source", args) + accept.update_original_ticket(path, fixture(), "op-original-123", "ADMITTED") + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["operation_id"] == "op-original-123" and saved["effect_state"] == "ADMITTED" + accept.update_original_ticket(path, fixture(), "op-original-123", "SUCCEEDED") + with pytest.raises(accept.Blocked, match="regress"): + accept.update_original_ticket(path, fixture(), "op-original-123", "ADMITTED") + with pytest.raises(accept.Blocked, match="changed"): + accept.update_original_ticket(path, fixture(), "op-other", "SUCCEEDED") + + +def test_unknown_effect_requires_only_saved_original_operation_id(): + original = checkpoint(beta_operation_id="op-original-123", pending_operation_id="op-original-123", pending_effect_state="SENT_UNKNOWN") + assert smoke.read_plan(original, original.beta_target, "op-original-123")[1][0] == "auto_coder_operation_inspect" + assert all(name != "auto_coder_operation_get" for name in accept.READS) + + +def test_container_running_and_heartbeat_not_available_never_pass(): + snapshot = { + "status": "OK", + "alive": True, + "process_state": "RUNNING", + "installed_binding_status": "UNAVAILABLE", + "installed_runtime": None, + "worker_observation": {"lifecycle": {"startup_reconciled": True}, "heartbeat": {"available": False, "sequence": None}}, + } + with pytest.raises(accept.Blocked, match="unstaged"): + accept.positive_heartbeat(snapshot) + snapshot["installed_binding_status"] = "VERIFIED" + snapshot["installed_runtime"] = {"source_sha": "b" * 40} + with pytest.raises(accept.Blocked, match="heartbeat"): + accept.positive_heartbeat(snapshot, source_sha="b" * 40) + with pytest.raises(accept.Failed, match="contradicts"): + accept.positive_heartbeat(snapshot, source_sha="a" * 40) + + +def test_disposable_git_source_not_present_cannot_trigger_proof_network(): + with pytest.raises(accept.Blocked, match="fixture"): + accept.verify_real_source(checkpoint(), replace(fixture(), source_fixture_available=False, source_fixture_sha="")) + + +def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_scopes(): + identity = { + "project_id": "fixture-test", + "uid": "verified-owner", + "client_id": "dot-disposable-operator", + "resource": fixture().endpoint, + "scopes": ["auto-coder.read", "auto-coder.operate", "auto-coder.deploy"], + } + accept.verify_current_operator(identity, fixture(), "reevaluate", mutation=True) + accept.verify_current_operator(identity, fixture(), "beta-source", mutation=True) + for change in ( + {"resource": "https://production.example/mcp"}, + {"client_id": "production-desktop"}, + {"scopes": ["auto-coder.read"]}, + {"uid": ""}, + ): + with pytest.raises(accept.Blocked, match="OAuth"): + accept.verify_current_operator({**identity, **change}, fixture(), "reevaluate", mutation=True) From a4394d496bc293c1618fb2933e8825bfdf683088 Mon Sep 17 00:00:00 2001 From: Auto-Coder Acceptance Fixture Date: Thu, 12 Nov 2026 09:12:58 +0900 Subject: [PATCH 17/42] fix(dot): bind disposable OAuth project and reject untracked source --- ...dot-autonomous-source-worker-operations.md | 41 ++++++++++++++----- scripts/dot_disposable_fixture.py | 9 ++-- scripts/dot_mcp_acceptance.py | 5 ++- tests/test_dot_disposable_fixture.py | 6 +++ tests/test_dot_mcp_acceptance.py | 5 ++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 8ed40ab73..e7f84e90a 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -195,15 +195,14 @@ are 0 only for independently established `PASS`, 1 for `FAIL`, 2 for unexecuted acceptance for success. Any future `PASS` requires an independent original producer oracle, not just an MCP JSON response or a checkpoint. -**Current acceptance gap:** This version has no mutation mode, no -operator-selected disposable Docker/source repo test fixture provider, no real -authenticated beta/release effect flow, no independent Actions/source acceptance -cross-check, and no verified real dot scheduling exercise. Those -#2608/REQ-007/008/011 deliverables are explicitly **NOT_RUN/BLOCKED**, not -simulated `PASS`. Changes to the running beta/release service are prohibited -for this test. The implementation PR stays DRAFT until the genuine disposable -end-to-end staging/unknown/recovery/policy downgrade tests and exact-head CI -succeed and all review is resolved. +**Current acceptance gap:** This read-only observer has no mutation mode; the +separate opt-in disposable harness below implements only limited fixture-only +mutation. The real authenticated beta/release producer flow, original GitHub +Actions publication, staging/unknown/recovery/policy downgrade acceptance and +dot scheduler execution remain **NOT_RUN/BLOCKED**, never simulated `PASS`. +Production beta/release changes are prohibited. Keep this implementation PR +DRAFT until genuine end-to-end verification, exact-head CI and all review +threads have passed. ### Disposable-only acceptance harness (partial) @@ -213,7 +212,29 @@ repository, non-root workers, no host ports or runtime Docker socket, and an observe-only management policy. It neither executes Compose nor creates grants. The operator must separately provision and explicitly authorize a disposable fixture before any live acceptance. The generated image names are pinned fixture -requirements, not automatically verified runtime identities. +requirements, not automatically verified runtime identities. The Firebase ID +must start with `fixture-`, and authenticated `whoami` must return that **exact** +project ID. The test-source checkout must be a fully clean `main` of the fixed +test repository, including no untracked files. The fixture starts with an empty +UID allowlist and intentionally held worker bootstrap; both require separate +operator authorization and real test-only setup to proceed. + +```sh +python scripts/dot_disposable_fixture.py \ + --fixture-id pr2835-sandbox-20261112 \ + --firebase-project-id fixture-actual-sandbox \ + --test-source /path/to/clean/auto-coder-test +# Exit 3/NOT_RUN: this generates configuration only, not Docker or OAuth. +docker compose -f .agent-tmp/pr2835-sandbox-20261112/compose.yaml up -d +# Run only inside the generated management container after inspecting isolation: +docker compose -f .agent-tmp/pr2835-sandbox-20261112/compose.yaml exec -T management \ + python -m scripts.dot_mcp_acceptance --fixture-manifest /fixture/fixture.json +``` + +Without an **exact fixture-specific** `AUTO_CODER_MCP_ACCESS_TOKEN`, the client +returns `BLOCKED` before opening an MCP connection. Even with OAuth, +`observe` needs two independently advancing verified worker heartbeats. Mere +container liveness, accepted MCP receipts or a copied Git SHA are insufficient. `scripts/dot_mcp_acceptance.py` is opt-in and returns `NOT_RUN` without a fixture. Its default path only reads current authenticated MCP observations. The explicit diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index e0d61eaa8..b822cc458 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -71,8 +71,8 @@ def git(*arguments: str) -> str: revision = git("rev-parse", "HEAD") if not re.fullmatch(r"[0-9a-f]{40}", revision) or revision != git("rev-parse", "main"): raise ValueError("disposable tested source must be the pinned local main commit") - if git("status", "--porcelain", "--untracked-files=no"): - raise ValueError("disposable tested source must have a clean index and tracked files") + if git("status", "--porcelain", "--untracked-files=all"): + raise ValueError("disposable tested source must have no tracked or untracked changes") return revision @@ -91,8 +91,8 @@ def provision( raise ValueError("source root must be the checked-out Auto-Coder test fixture source") if SAFE_FIXTURE.fullmatch(fixture_id) is None: raise ValueError("fixture ID must be an exact lowercase disposable identifier") - if not isinstance(firebase_project, str) or not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9-]{3,64}", firebase_project): - raise ValueError("Firebase project ID must be a bounded explicit value") + if not isinstance(firebase_project, str) or not re.fullmatch(r"fixture-[a-z0-9-]{6,50}", firebase_project): + raise ValueError("Firebase project ID must name an explicit fixture-only project") if owner_uid < 1 or owner_gid < 1: raise ValueError("fixture worker cannot run as root") test_source = test_source.resolve(strict=True) if test_source is not None else None @@ -266,6 +266,7 @@ def provision( { "schema": 1, "fixture_nonce": nonce, + "firebase_project_id": firebase_project, "repository": TEST_REPOSITORY, "fixture_id": fixture_id, "compose_project": project, diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index e092417e2..29c0ffbb3 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -54,6 +54,7 @@ class Failed(RuntimeError): class Fixture: schema: int fixture_nonce: str + firebase_project_id: str repository: str fixture_id: str compose_project: str @@ -79,6 +80,8 @@ def load(cls, path: Path) -> "Fixture": raise Blocked("Disposable fixture identity is invalid") if not isinstance(fixture.fixture_nonce, str) or NONCE.fullmatch(fixture.fixture_nonce) is None: raise Blocked("Disposable fixture nonce is invalid") + if not isinstance(fixture.firebase_project_id, str) or re.fullmatch(r"fixture-[a-z0-9-]{6,50}", fixture.firebase_project_id) is None: + raise Blocked("The current Firebase project is not bounded to a disposable fixture") if fixture.repository != TEST_REPOSITORY or fixture.compose_project != "dot-" + fixture.fixture_id: raise Blocked("Target source repository or Compose project is not the approved disposable fixture") if fixture.endpoint != "http://127.0.0.1:8081/mcp" or fixture.endpoint_namespace != "management-container-only": @@ -316,7 +319,7 @@ def verify_current_operator(identity: dict[str, Any], fixture: Fixture, scenario or identity.get("client_id") != "dot-disposable-operator" or not isinstance(identity.get("uid"), str) or not identity["uid"] - or not isinstance(identity.get("project_id"), str) + or identity.get("project_id") != fixture.firebase_project_id or not isinstance(scopes, (list, tuple)) or not expected.issubset(set(scopes)) ): diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index 60a9ba0b7..8c7b36a92 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -25,6 +25,7 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): config = yaml.safe_load((root / "management/config.yaml").read_text(encoding="utf-8")) policy = yaml.safe_load((root / "policy/operating-policy.yaml").read_text(encoding="utf-8")) assert marker["repository"] == "kitamura-tetsuo/auto-coder-test" + assert marker["firebase_project_id"] == "fixture-test-123" assert marker["docker_authority_in_worker_or_management"] is False assert marker["endpoint_namespace"] == "management-container-only" assert policy["profile"] == "observe" @@ -79,6 +80,8 @@ def test_root_or_untrusted_project_rejected_before_writes(tmp_path): fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=0, owner_gid=1000) with pytest.raises(ValueError, match="Firebase project"): fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="../../prod", owner_uid=1000, owner_gid=1000) + with pytest.raises(ValueError, match="Firebase project"): + fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="auto-coder-production", owner_uid=1000, owner_gid=1000) assert not (source / ".agent-tmp").exists() @@ -111,6 +114,9 @@ def git(*arguments): for role in ("beta", "release"): assert f"{test_source}:/source:ro" in compose["services"][role]["volumes"] assert "PYTHONPATH" not in compose["services"][role]["environment"] + (test_source / "untracked-source.py").write_text("# Untrusted source injection\n", encoding="utf-8") + with pytest.raises(ValueError, match="untracked changes"): + fixture._source_fixture_revision(test_source) def test_wrong_test_source_remote_refused_before_fixture_creation(tmp_path, _use_real_commands): diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 7203227ca..7296193bd 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -16,6 +16,7 @@ def fixture(): return accept.Fixture( schema=1, fixture_nonce="a" * 32, + firebase_project_id="fixture-test-123", repository="kitamura-tetsuo/auto-coder-test", fixture_id="pr2835-test-abc", compose_project="dot-pr2835-test-abc", @@ -95,6 +96,7 @@ def test_fixture_manifest_is_strict_and_refuses_nonfixture_targets(tmp_path): {**raw, "endpoint": "http://127.0.0.1:8081/mcp?port=9999"}, {**raw, "docker_authority_in_worker_or_management": True}, {**raw, "fixture_nonce": "not-a-nonce"}, + {**raw, "firebase_project_id": "auto-coder-production"}, {**raw, "source_fixture_sha": "current HEAD"}, {**raw, "nonsecret_but_unknown": "unexpected"}, ): @@ -198,7 +200,7 @@ def test_disposable_git_source_not_present_cannot_trigger_proof_network(): def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_scopes(): identity = { - "project_id": "fixture-test", + "project_id": fixture().firebase_project_id, "uid": "verified-owner", "client_id": "dot-disposable-operator", "resource": fixture().endpoint, @@ -211,6 +213,7 @@ def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_ {"client_id": "production-desktop"}, {"scopes": ["auto-coder.read"]}, {"uid": ""}, + {"project_id": "fixture-production-other"}, ): with pytest.raises(accept.Blocked, match="OAuth"): accept.verify_current_operator({**identity, **change}, fixture(), "reevaluate", mutation=True) From e8af3cef8093733ca4ab83e46f75abb99cd95e66 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 10:12:48 +0900 Subject: [PATCH 18/42] fix(dot): recognize real reevaluation receipts in disposable acceptance --- ...dot-autonomous-source-worker-operations.md | 9 ++- scripts/dot_mcp_acceptance.py | 62 +++++++++++++++++-- tests/test_dot_mcp_acceptance.py | 57 +++++++++++++++++ tests/test_management_runtime_mcp.py | 4 ++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index e7f84e90a..18795e077 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -243,7 +243,14 @@ reevaluation or beta-source selection and requires current selected stage, revision, original Git/Actions proof and an immutable fsynced pre-send ticket. Unknown, interrupted or conflicting sends remain `BLOCKED`; the original idempotency key is never reissued. Live producer observations are hashed for -origin tracking. No successful synthetic fixture test certifies live acceptance. +origin tracking. A real reevaluation journal projects terminal acceptance as +`status=accepted_for_reevaluation`, `phase=succeeded` and +`effect_state=confirmed` with a JSON-encoded +`worker-uds-invalidation-receipt`. The consumer verifies that receipt's +original process-run identity before reporting queue acceptance; this never +asserts Issue/PR implementation completion. A generic `SUCCEEDED` status or +an uncorrelated receipt cannot satisfy this scenario. No successful synthetic +fixture test certifies live acceptance. These scripts provide reusable acceptance components, not a completed #2608 contract: obtaining real fixture grants, actual controller/worker/source and diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 29c0ffbb3..0bd368ea0 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -290,6 +290,50 @@ def positive_heartbeat(snapshot: dict[str, Any], *, source_sha: str = "") -> int return sequence +def verified_reevaluation_receipt(value: dict[str, Any], *, operation_id: str, target_id: str, process_run_id: str) -> bool: + """Recognize the actual journal's accepted-for-reevaluation projection. + + The worker receipt proves queue admission, not Issue work completion. + An unresolved original remains pending; it cannot trigger a new send. + """ + if value.get("operation_id") != operation_id or value.get("target_id") != target_id or value.get("action") != "reevaluate_target": + raise Failed("Original reevaluation operation, action or physical target differs") + if value.get("status") != "accepted_for_reevaluation": + if value.get("status") == "SUCCEEDED": + raise Blocked("A generic completed status is not a worker reevaluation receipt") + return False + if value.get("phase") != "succeeded" or value.get("effect_state") != "confirmed": + raise Blocked("Reevaluation acceptance lacks a confirmed original journal phase") + receipt = value.get("receipt") + if not isinstance(receipt, dict) or receipt.get("source") != "worker-uds-invalidation-receipt" or not isinstance(receipt.get("result"), str): + raise Blocked("The original signed worker invalidation receipt is unavailable") + + def unique_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, item in pairs: + if key in result: + raise ValueError("duplicate original receipt field") + result[key] = item + return result + + try: + detail = json.loads(receipt["result"], object_pairs_hook=unique_pairs) + except ValueError as error: + raise Blocked("Original worker receipt content cannot be verified") from error + if ( + not isinstance(detail, dict) + or set(detail) != {"outcome", "generation", "disposition", "accepting_process_run_id"} + or detail["outcome"] != "accepted_for_reevaluation" + or type(detail["generation"]) is not int + or detail["generation"] <= 0 + or not isinstance(detail["disposition"], str) + or not detail["disposition"] + or detail["accepting_process_run_id"] != process_run_id + ): + raise Failed("Original worker receipt does not bind the accepted reevaluation") + return True + + def verify_real_source(checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> None: """Read the original public Git ref, annotated object and source Actions attempt.""" if not fixture.source_fixture_available or checkpoint.source_sha != fixture.source_fixture_sha: @@ -456,13 +500,19 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s raise Blocked("Original operation readback is unbound or unavailable") if original.get("status") in {"NEEDS_ATTENTION", "FAILED", "UNKNOWN", "UNAVAILABLE"}: raise Blocked("Original worker operation is held or failed; never allocate a new key") - if original.get("status") == "SUCCEEDED": - update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") - if scenario == "reevaluate": - receipt = original.get("receipt") - if not isinstance(receipt, dict) or receipt.get("result") != "accepted_for_reevaluation": - raise Blocked("A completed management operation is not durable worker reevaluation acceptance") + if scenario == "reevaluate": + if verified_reevaluation_receipt( + original, + operation_id=original_id, + target_id=fixture.target_ids["beta"], + process_run_id=str(worker["process_run_id"]), + ): + update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") return Result("PASS", scenario, "The genuine worker durably accepted reevaluation; Issue work was not certified complete", tuple(live.observations), original_id, effect_dispatched=True) + elif original.get("status") == "SUCCEEDED": + if original.get("action") != "deploy_beta_source" or original.get("target_id") != fixture.target_ids["beta"] or original.get("phase") != "succeeded" or original.get("source_sha") != checkpoint.source_sha: + raise Blocked("Terminal beta deployment is not bound to the original tested source and target") + update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") installed = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) first = positive_heartbeat(installed, source_sha=checkpoint.source_sha) await asyncio.sleep(2) diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 7296193bd..6541fe285 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -217,3 +217,60 @@ def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_ ): with pytest.raises(accept.Blocked, match="OAuth"): accept.verify_current_operator({**identity, **change}, fixture(), "reevaluate", mutation=True) + + +def _confirmed_reevaluation_projection(): + """Match the real ManagementRuntime._project_operation response shape.""" + return { + "status": "accepted_for_reevaluation", + "operation_id": "original-req-001", + "target_id": fixture().target_ids["beta"], + "action": "reevaluate_target", + "phase": "succeeded", + "effect_state": "confirmed", + "receipt": { + "source": "worker-uds-invalidation-receipt", + "result": json.dumps( + { + "outcome": "accepted_for_reevaluation", + "generation": 3, + "disposition": "queued", + "accepting_process_run_id": "original-process-123", + }, + sort_keys=True, + ), + "observed_at": 1794445200.0, + }, + } + + +def test_real_reevaluation_journal_projection_is_recognized_without_issue_completion(): + assert accept.verified_reevaluation_receipt( + _confirmed_reevaluation_projection(), + operation_id="original-req-001", + target_id=fixture().target_ids["beta"], + process_run_id="original-process-123", + ) + + +def test_reevaluation_journal_only_accepts_original_confirmed_receipt(): + original = _confirmed_reevaluation_projection() + for change in ( + {"operation_id": "other-operation"}, + {"target_id": fixture().target_ids["release"]}, + {"action": "deploy_beta_source"}, + {"status": "SUCCEEDED"}, + {"phase": "running"}, + {"effect_state": "sent_unknown"}, + {"receipt": None}, + {"receipt": {"source": "operator", "result": original["receipt"]["result"]}}, + {"receipt": {"source": original["receipt"]["source"], "result": "accepted_for_reevaluation"}}, + {"receipt": {"source": original["receipt"]["source"], "result": json.dumps({"outcome": "accepted_for_reevaluation", "generation": 1, "disposition": "queued", "accepting_process_run_id": "other-process"})}}, + ): + with pytest.raises((accept.Blocked, accept.Failed)): + accept.verified_reevaluation_receipt({**original, **change}, operation_id="original-req-001", target_id=fixture().target_ids["beta"], process_run_id="original-process-123") + + +def test_unsettled_reevaluation_does_not_fabricate_acceptance(): + pending = {**_confirmed_reevaluation_projection(), "status": "NEEDS_ATTENTION", "phase": "needs_attention", "effect_state": "sent_unknown", "receipt": None} + assert not accept.verified_reevaluation_receipt(pending, operation_id="original-req-001", target_id=fixture().target_ids["beta"], process_run_id="original-process-123") diff --git a/tests/test_management_runtime_mcp.py b/tests/test_management_runtime_mcp.py index fefcb27b6..47ff23bdb 100644 --- a/tests/test_management_runtime_mcp.py +++ b/tests/test_management_runtime_mcp.py @@ -214,6 +214,10 @@ def test_real_oauth_http_tool_scopes_policy_and_idempotent_journal(runtime_bound read_calls = beta.read_calls inspected = _call(client, operate_token, "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": first["operation_id"]}) assert inspected == first + # Real authenticated MCP journal projection must satisfy the fixture consumer's receipt contract. + from scripts.dot_mcp_acceptance import verified_reevaluation_receipt + + assert verified_reevaluation_receipt(inspected, operation_id=first["operation_id"], target_id="beta-worker", process_run_id=beta.run) assert app.state.management_runtime.journal.get(first["operation_id"]).record == original_record assert beta.read_calls == read_calls and beta.send_calls == 1 assert _call(client, read_token, "auto_coder_operation_inspect", {"target_id": "beta-worker", "operation_id": first["operation_id"]})["status"] == "NOT_FOUND" From c15c7feca320fbf5335bf52f10e30e22435c8bb7 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 10:17:34 +0900 Subject: [PATCH 19/42] fix(dot): retain original send uncertainty across transport errors --- ...dot-autonomous-source-worker-operations.md | 8 +- scripts/dot_mcp_acceptance.py | 56 +++++++++++-- tests/test_dot_mcp_acceptance.py | 82 +++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 18795e077..8482f45ce 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -242,8 +242,12 @@ Its default path only reads current authenticated MCP observations. The explicit reevaluation or beta-source selection and requires current selected stage, revision, original Git/Actions proof and an immutable fsynced pre-send ticket. Unknown, interrupted or conflicting sends remain `BLOCKED`; the original -idempotency key is never reissued. Live producer observations are hashed for -origin tracking. A real reevaluation journal projects terminal acceptance as +idempotency key is never reissued. A transport exception after a durable +original send ticket cannot report `effect_dispatched=false`: the report +carries `effect_dispatched=null`, the original ticket state and verified original +operation ID when available. Pre-send refusal without a ticket reports false; +corrupt, replaced or unreadable tickets report UNKNOWN, not a guessed absence. +Live producer observations are hashed for origin tracking. A real reevaluation journal projects terminal acceptance as `status=accepted_for_reevaluation`, `phase=succeeded` and `effect_state=confirmed` with a JSON-encoded `worker-uds-invalidation-receipt`. The consumer verifies that receipt's diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 0bd368ea0..8813cd0b5 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -120,6 +120,7 @@ class Result: operation_id: str = "" positive_deployment_proven: bool = False effect_dispatched: bool | None = False + original_ticket_state: str = "" def bound_local_fixture(fixture: Fixture, *, environ: dict[str, str] | None = None, docker_socket: Path = Path("/var/run/docker.sock")) -> None: @@ -240,6 +241,51 @@ def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotC raise Blocked("Original admission ticket already exists; never allocate a new key") from error +def exception_result( + scenario: str, + reason: str, + *, + mutation: bool, + verdict: str = "BLOCKED", + fixture: Fixture | None = None, +) -> Result: + """Use the immutable original ticket when an attempted MCP response is lost. + + No exception handler may claim 'not dispatched' once a durable admission + ticket could have been sent. Ticket inspection is local and never retries. + """ + if not mutation or scenario not in MUTATIONS: + return Result(verdict, scenario, reason) + path = ticket_path(scenario) + try: + exists = path.exists() or path.is_symlink() + except OSError: + return Result(verdict, scenario, reason, effect_dispatched=None, original_ticket_state="UNKNOWN") + if not exists: + return Result(verdict, scenario, reason) + # Never follow a replaced original ticket symlink, even in the fixture. + if path.is_symlink(): + return Result(verdict, scenario, reason, effect_dispatched=None, original_ticket_state="UNKNOWN") + try: + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError): + record = None + if ( + not isinstance(record, dict) + or record.get("schema") != 1 + or record.get("action") != MUTATIONS[scenario] + or not isinstance(record.get("fixture_nonce"), str) + or (fixture is not None and record["fixture_nonce"] != fixture.fixture_nonce) + or record.get("effect_state") not in {"ADMISSION_UNKNOWN", "ADMITTED", "SUCCEEDED"} + ): + return Result(verdict, scenario, reason, effect_dispatched=None, original_ticket_state="UNKNOWN") + state = record["effect_state"] + operation_id = record.get("operation_id") + if not isinstance(operation_id, str) or (operation_id and smoke.SAFE_ID.fullmatch(operation_id) is None) or (state != "ADMISSION_UNKNOWN" and not operation_id): + return Result(verdict, scenario, reason, effect_dispatched=None, original_ticket_state="UNKNOWN") + return Result(verdict, scenario, reason, operation_id=operation_id, effect_dispatched=None, original_ticket_state=state) + + def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, state: str) -> None: """Persist only the observed original ID/state; never authorize resending.""" if state not in {"ADMITTED", "SUCCEEDED"} or smoke.SAFE_ID.fullmatch(operation_id) is None: @@ -546,6 +592,7 @@ def main() -> int: if args.fixture_manifest is None: report = Result("NOT_RUN", args.scenario, "No disposable fixture selected; no network or mutation") else: + fixture: Fixture | None = None try: fixture = Fixture.load(args.fixture_manifest) bound_local_fixture(fixture) @@ -555,16 +602,15 @@ def main() -> int: raise Blocked("Mutation source must be the immutable test-only Git fixture") report = asyncio.run(run_authenticated(fixture, checkpoint, args.scenario, mutate=args.enable_fixture_mutation, item_number=args.fixture_issue_number)) except Blocked as error: - ticket_unknown = args.enable_fixture_mutation and args.scenario in MUTATIONS and ticket_path(args.scenario).exists() - report = Result("BLOCKED", args.scenario, str(error), effect_dispatched=None if ticket_unknown else False) + report = exception_result(args.scenario, str(error), mutation=args.enable_fixture_mutation, fixture=fixture) except Failed as error: - report = Result("FAIL", args.scenario, str(error)) + report = exception_result(args.scenario, str(error), mutation=args.enable_fixture_mutation, verdict="FAIL", fixture=fixture) except (OSError, ValueError, TypeError, RuntimeError) as error: logger.warning("Fixture acceptance unavailable due to {}", type(error).__name__) - report = Result("BLOCKED", args.scenario, "Original fixture, authority or producer evidence unavailable") + report = exception_result(args.scenario, "Original fixture, authority or producer evidence unavailable", mutation=args.enable_fixture_mutation, fixture=fixture) except Exception as error: logger.warning("Untrusted transport failure classified as {}", type(error).__name__) - report = Result("BLOCKED", args.scenario, "Authenticated fixture transport unavailable; retain any original send ticket") + report = exception_result(args.scenario, "Authenticated fixture transport unavailable; retain any original send ticket", mutation=args.enable_fixture_mutation, fixture=fixture) logger.info("Disposable MCP acceptance scenario={} verdict={} effect_dispatched={}", report.scenario, report.verdict, report.effect_dispatched) print(json.dumps(asdict(report), sort_keys=True)) return {"PASS": 0, "FAIL": 1, "BLOCKED": 2, "NOT_RUN": 3}[report.verdict] diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 6541fe285..63bcdd9a3 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -274,3 +274,85 @@ def test_reevaluation_journal_only_accepts_original_confirmed_receipt(): def test_unsettled_reevaluation_does_not_fabricate_acceptance(): pending = {**_confirmed_reevaluation_projection(), "status": "NEEDS_ATTENTION", "phase": "needs_attention", "effect_state": "sent_unknown", "receipt": None} assert not accept.verified_reevaluation_receipt(pending, operation_id="original-req-001", target_id=fixture().target_ids["beta"], process_run_id="original-process-123") + + +@pytest.mark.parametrize( + "original_state,original_id,raises", + [ + ("", "", ConnectionError), + ("ADMISSION_UNKNOWN", "", ConnectionError), + ("ADMITTED", "original-op-123", ConnectionError), + ("SUCCEEDED", "original-op-123", RuntimeError), + ("ADMITTED", "original-op-123", Exception), + ("ADMITTED", "original-op-123", accept.Failed), + ], +) +def test_cli_transport_exception_preserves_original_send_uncertainty( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + original_state: str, + original_id: str, + raises: type[Exception], +) -> None: + """Distinguish a pre-send refusal from a lost response after a durable send ticket.""" + test_fixture = fixture() + original = checkpoint() + ticket = tmp_path / "original-send.json" + calls = 0 + + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + monkeypatch.setattr( + sys, + "argv", + [ + "dot_mcp_acceptance.py", + "--fixture-manifest", + str(tmp_path / "fixture.json"), + "--checkpoint", + str(tmp_path / "checkpoint.json"), + "--scenario", + "reevaluate", + "--enable-fixture-mutation", + "--operator-stage", + "beta", + "--operator-stage-revision", + "operator-v1", + ], + ) + monkeypatch.setattr(accept.Fixture, "load", staticmethod(lambda _path: test_fixture)) + monkeypatch.setattr(accept, "bound_local_fixture", lambda *_args: None) + monkeypatch.setattr(accept.smoke, "load_checkpoint", lambda _path: original) + monkeypatch.setattr(accept, "original_stage", lambda *_args, **_kwargs: None) + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + + async def lost_response(*_args: object, **_kwargs: object) -> accept.Result: + nonlocal calls + calls += 1 + if original_state: + arguments = {"target_id": test_fixture.target_ids["beta"], "idempotency_key": original.idempotency_key} + accept.persist_original_ticket(ticket, test_fixture, original, "auto_coder_reevaluate_target", arguments) + if original_id: + accept.update_original_ticket(ticket, test_fixture, original_id, original_state) + raise raises("Original producer response lost") + + monkeypatch.setattr(accept, "run_authenticated", lost_response) + assert accept.main() == (1 if raises is accept.Failed else 2) + result = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert result["verdict"] == ("FAIL" if raises is accept.Failed else "BLOCKED") + assert result["effect_dispatched"] is (None if original_state else False) + assert result["operation_id"] == original_id + assert result["original_ticket_state"] == original_state + assert calls == 1 + if original_state: + assert json.loads(ticket.read_text())["effect_state"] == original_state + + +def test_corrupt_or_replaced_original_send_ticket_never_reports_no_dispatch(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "original-send.json" + path.write_text("{unreadable", encoding="utf-8") + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: path) + outcome = accept.exception_result("reevaluate", "Transport failed", mutation=True) + assert outcome.effect_dispatched is None + assert outcome.operation_id == "" + assert outcome.original_ticket_state == "UNKNOWN" From 3c25e8631b3c7dc7a1d959e13cbded6837fb5a55 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 11:00:51 +0900 Subject: [PATCH 20/42] fix(dot): bind heartbeat progress to stable worker lifetime --- ...dot-autonomous-source-worker-operations.md | 9 +++- scripts/dot_mcp_acceptance.py | 50 +++++++++++++++---- tests/test_dot_mcp_acceptance.py | 49 ++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 8482f45ce..16e344228 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -233,8 +233,13 @@ docker compose -f .agent-tmp/pr2835-sandbox-20261112/compose.yaml exec -T manage Without an **exact fixture-specific** `AUTO_CODER_MCP_ACCESS_TOKEN`, the client returns `BLOCKED` before opening an MCP connection. Even with OAuth, -`observe` needs two independently advancing verified worker heartbeats. Mere -container liveness, accepted MCP receipts or a copied Git SHA are insufficient. +`observe` needs two independently advancing verified worker heartbeats. Both +samples for each worker must retain the exact original Supervisor generation, +process-run ID, runtime ID, deployment generation, and installed source binding. +A worker restart with a larger counter is not continuous heartbeat progress; +the client refuses it rather than claiming installed-source acceptance. The +same two-sample rule gates `beta-source` completion. Mere container liveness, +accepted MCP receipts or a copied Git SHA are insufficient. `scripts/dot_mcp_acceptance.py` is opt-in and returns `NOT_RUN` without a fixture. Its default path only reads current authenticated MCP observations. The explicit diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 8813cd0b5..d7da43b17 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -336,6 +336,40 @@ def positive_heartbeat(snapshot: dict[str, Any], *, source_sha: str = "") -> int return sequence +def require_advancing_worker_heartbeat(before: dict[str, Any], after: dict[str, Any], *, source_sha: str = "") -> None: + """Prove progress within one stable installed worker, not across a restart. + + An increased counter alone cannot distinguish two different process runs. + Both observations must be from the same original Supervisor, runtime, + deployment, process lifetime and installed source binding. + """ + prior_sequence = positive_heartbeat(before, source_sha=source_sha) + current_sequence = positive_heartbeat(after, source_sha=source_sha) + + def identity(snapshot: dict[str, Any]) -> tuple[str, str, str, str, dict[str, Any]]: + worker = snapshot.get("worker") + binding = snapshot.get("installed_runtime") + if not isinstance(worker, dict) or not isinstance(binding, dict): + raise Blocked("Original installed worker identity is unavailable") + fields = ( + snapshot.get("supervisor_generation"), + worker.get("runtime_id"), + worker.get("process_run_id"), + worker.get("deployment_generation"), + ) + if any(not isinstance(value, str) or not value for value in fields): + raise Blocked("Original installed worker identity is incomplete") + selected_runtime = snapshot.get("selected_runtime_id") + if selected_runtime is not None and selected_runtime != worker["runtime_id"]: + raise Blocked("Selected and running worker runtime identities differ") + return fields[0], fields[1], fields[2], fields[3], binding + + if identity(before) != identity(after): + raise Blocked("Worker identity or installed source changed between heartbeat observations") + if current_sequence <= prior_sequence: + raise Blocked("Original worker heartbeat did not advance") + + def verified_reevaluation_receipt(value: dict[str, Any], *, operation_id: str, target_id: str, process_run_id: str) -> bool: """Recognize the actual journal's accepted-for-reevaluation projection. @@ -490,13 +524,11 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s return await inspect_original(live, checkpoint, fixture) if not mutate: release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) - before_beta = positive_heartbeat(beta, source_sha=checkpoint.source_sha) - before_release = positive_heartbeat(release) await asyncio.sleep(2) - after_beta = positive_heartbeat(await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY), source_sha=checkpoint.source_sha) - after_release = positive_heartbeat(await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1])) - if not after_beta > before_beta or not after_release > before_release: - raise Blocked("Two genuinely advancing worker-produced heartbeats are not yet observable") + after_beta = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) + after_release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) + require_advancing_worker_heartbeat(beta, after_beta, source_sha=checkpoint.source_sha) + require_advancing_worker_heartbeat(release, after_release) return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) if scenario == "beta-source": await asyncio.to_thread(verify_real_source, checkpoint, fixture) @@ -560,11 +592,9 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s raise Blocked("Terminal beta deployment is not bound to the original tested source and target") update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") installed = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) - first = positive_heartbeat(installed, source_sha=checkpoint.source_sha) await asyncio.sleep(2) - second = positive_heartbeat(await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY), source_sha=checkpoint.source_sha) - if second <= first: - raise Blocked("Installed source does not have independently advancing worker heartbeats") + after_install = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) + require_advancing_worker_heartbeat(installed, after_install, source_sha=checkpoint.source_sha) return Result("PASS", scenario, "Real worker installed the original tested source and advanced producer heartbeats", tuple(live.observations), original_id, positive_deployment_proven=True, effect_dispatched=True) await asyncio.sleep(2) raise Blocked("Original management operation has not reached a verified terminal receipt") diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 63bcdd9a3..5ae65507d 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -193,6 +193,55 @@ def test_container_running_and_heartbeat_not_available_never_pass(): accept.positive_heartbeat(snapshot, source_sha="a" * 40) +def _real_worker_sample(sequence, *, process_run_id="process-1", generation="deployment-1", supervisor="supervisor-1", source_sha="b" * 40): + return { + "status": "OK", + "alive": True, + "process_state": "RUNNING", + "supervisor_generation": supervisor, + "selected_runtime_id": "runtime-1", + "worker": { + "process_run_id": process_run_id, + "deployment_generation": generation, + "runtime_id": "runtime-1", + }, + "installed_binding_status": "VERIFIED", + "installed_runtime": {"source_sha": source_sha}, + "worker_observation": { + "lifecycle": {"startup_reconciled": True}, + "heartbeat": {"available": True, "sequence": sequence}, + }, + } + + +def test_heartbeats_advance_for_one_unchanged_original_worker(): + accept.require_advancing_worker_heartbeat(_real_worker_sample(12), _real_worker_sample(13), source_sha="b" * 40) + + +@pytest.mark.parametrize( + "changed", + [ + {"process_run_id": "process-2"}, + {"generation": "deployment-2"}, + {"supervisor": "supervisor-2"}, + {"source_sha": "a" * 40}, + ], +) +def test_restart_or_binding_switch_cannot_masquerade_as_worker_heartbeat_progress(changed): + first = _real_worker_sample(12) + second = _real_worker_sample(13, **changed) + with pytest.raises((accept.Blocked, accept.Failed)): + accept.require_advancing_worker_heartbeat(first, second, source_sha="b" * 40) + + +def test_repeated_or_unbound_heartbeat_cannot_prove_progress(): + with pytest.raises(accept.Blocked, match="advance"): + accept.require_advancing_worker_heartbeat(_real_worker_sample(12), _real_worker_sample(12)) + unbound = _real_worker_sample(13, process_run_id="") + with pytest.raises(accept.Blocked, match="identity"): + accept.require_advancing_worker_heartbeat(_real_worker_sample(12), unbound) + + def test_disposable_git_source_not_present_cannot_trigger_proof_network(): with pytest.raises(accept.Blocked, match="fixture"): accept.verify_real_source(checkpoint(), replace(fixture(), source_fixture_available=False, source_fixture_sha="")) From 81d303b6a582c0663a224991aedff2f0402d21dd Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 12:54:23 +0900 Subject: [PATCH 21/42] test(release): wait for authenticated initial stopped bridge state --- ...est_release_worker_acceptance_container.py | 21 ++++++- ...release_worker_bridge_initial_readiness.py | 57 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/test_release_worker_bridge_initial_readiness.py diff --git a/tests/test_release_worker_acceptance_container.py b/tests/test_release_worker_acceptance_container.py index 18719622e..a911b3913 100644 --- a/tests/test_release_worker_acceptance_container.py +++ b/tests/test_release_worker_acceptance_container.py @@ -148,6 +148,25 @@ class _InstalledRelease: previous: dict[str, object] +def _await_initial_stopped_supervisor(client: WorkerNetworkClient) -> WorkerNetworkResult: + """Wait for a signed stopped observation, never accepting UNKNOWN as STOPPED.""" + latest: WorkerNetworkResult | None = None + + def observe() -> WorkerNetworkResult | None: + nonlocal latest + latest = client.inspect_supervisor() + if latest.result_code != "OK" or not isinstance(latest.data, dict): + return None + if latest.data.get("alive") is not False or latest.data.get("worker") is not None: + return None + return latest + + try: + return _wait_for(observe, timeout=45) + except AssertionError as error: + raise AssertionError(f"initial release Supervisor did not prove stopped: {latest}") from error + + def _ready(client: WorkerNetworkClient, source_sha: str) -> dict[str, object] | None: client.inspect_supervisor() result = client.observe_worker() @@ -281,7 +300,7 @@ def _installed_release( facts = _wait_for(lambda: facts if (facts := _container_facts(name))["socket"] else None, timeout=30) assert facts["docker_socket"] is False and facts["docker_binary"] is None client = WorkerNetworkClient(WorkerEndpointConfig("release-worker", "release", "owner/repo", address, 8843, secret)) - stopped = client.inspect_supervisor() + stopped = _await_initial_stopped_supervisor(client) assert stopped.result_code == "OK" and stopped.data["alive"] is False # External bootstrap selects a real prior installed runtime through # the production Supervisor UDS and observes its readiness. diff --git a/tests/test_release_worker_bridge_initial_readiness.py b/tests/test_release_worker_bridge_initial_readiness.py new file mode 100644 index 000000000..8e8dc9f00 --- /dev/null +++ b/tests/test_release_worker_bridge_initial_readiness.py @@ -0,0 +1,57 @@ +"""Unit regressions for the initial signed release bridge observation.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from auto_coder.management.worker_network_client import WorkerEndpointConfig, WorkerNetworkClient, WorkerNetworkResult +from tests import test_release_worker_acceptance_container as acceptance + + +def _response(code: str, *, alive: bool | None = None, worker: object = None) -> WorkerNetworkResult: + return WorkerNetworkResult( + http_status=200 if code == "OK" else 503, + result_code=code, + data={"alive": alive, "worker": worker} if code == "OK" else None, + supervisor_generation="fixed-generation", + request_id="fixed-request", + ) + + +def _poll(predicate: Callable[[], WorkerNetworkResult | None], *, timeout: float) -> WorkerNetworkResult: + assert timeout > 0 + for _ in range(3): + value = predicate() + if value is not None: + return value + raise AssertionError("bounded observation expired") + + +def _client(monkeypatch: pytest.MonkeyPatch, responses: list[WorkerNetworkResult]) -> WorkerNetworkClient: + client = WorkerNetworkClient(WorkerEndpointConfig("release-worker", "release", "owner/repo", "10.1.2.3", 8843, b"x" * 48)) + result = iter(responses) + monkeypatch.setattr(client, "inspect_supervisor", lambda: next(result)) + monkeypatch.setattr(acceptance, "_wait_for", _poll) + return client + + +def test_initial_release_observation_retries_unknown_before_stopped(monkeypatch: pytest.MonkeyPatch) -> None: + accepted = _response("OK", alive=False) + client = _client(monkeypatch, [_response("UNKNOWN"), accepted]) + assert acceptance._await_initial_stopped_supervisor(client) is accepted + + +@pytest.mark.parametrize( + "responses", + [ + [_response("UNKNOWN")] * 3, + [_response("OK", alive=True)] * 3, + [_response("OK", alive=False, worker={"process_run_id": "live"})] * 3, + ], +) +def test_initial_release_observation_never_converts_unproven_state_to_stopped(monkeypatch: pytest.MonkeyPatch, responses: list[WorkerNetworkResult]) -> None: + client = _client(monkeypatch, responses) + with pytest.raises(AssertionError, match="initial release Supervisor did not prove stopped"): + acceptance._await_initial_stopped_supervisor(client) From 6b9cd34b347f6770748595cdd7a689a0484f982b Mon Sep 17 00:00:00 2001 From: tetsuo Date: Thu, 12 Nov 2026 13:28:36 +0900 Subject: [PATCH 22/42] test: measure debounce CPU work independently of runner scheduling --- tests/test_shared_watcher_performance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_shared_watcher_performance.py b/tests/test_shared_watcher_performance.py index 8f0d64e8e..298bc9e69 100644 --- a/tests/test_shared_watcher_performance.py +++ b/tests/test_shared_watcher_performance.py @@ -94,12 +94,13 @@ def test_enhanced_debounce_performance_with_many_files(self): files.append(f"tests/test{i}.py") files.append(f"docs/doc{i}.md") - start_time = time.time() + # CPU time excludes unrelated runner scheduling delays. + start_time = time.process_time() # Apply enhanced debouncing debounced = tool._enhanced_debounce_files(files) - elapsed = time.time() - start_time + elapsed = time.process_time() - start_time # Should complete quickly assert elapsed < 0.1 # Less than 100ms From aabd343df7cf6476d2c880b1d0270aada5eb7f4c Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 17:24:15 +0900 Subject: [PATCH 23/42] test(dot): isolate fixture PAT mounts and keep tokens out of argv --- scripts/dot_disposable_fixture.py | 6 ++- .../supervisor_offline_process_issues.py | 22 +++++++++-- tests/test_dot_disposable_fixture.py | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index b822cc458..eec38f206 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -219,7 +219,7 @@ def provision( }, "volumes": [ *fixture_mounts, - f"{root}/secrets:/fixture/secrets:ro", + f"{root}/secrets/{role}-bridge:/fixture/secrets/{role}-bridge:ro", f"{root}/routing:/fixture/routing:ro", f"{root}/{role}/runtime:/runtime", f"{root}/{role}/workspace:/workspace", @@ -244,7 +244,9 @@ def provision( f"{source}:/source:ro", f"{root}/management:/fixture/management", f"{root}/policy:/fixture/policy:ro", - f"{root}/secrets:/fixture/secrets:ro", + f"{root}/secrets/beta-bridge:/fixture/secrets/beta-bridge:ro", + f"{root}/secrets/release-bridge:/fixture/secrets/release-bridge:ro", + f"{root}/secrets/source-public-read:/fixture/secrets/source-public-read:ro", f"{root}/fixture.json:/fixture/fixture.json:ro", ], "restart": "no", diff --git a/tests/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index f64fd910e..eed7fe22c 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -16,6 +16,22 @@ from pathlib import Path +def activate_scoped_fixture_token(token_path: Path, repository: str) -> bool: + """Load only an explicitly mounted test-repository token into the child environment.""" + if not token_path.exists() and not token_path.is_symlink(): + return False + if repository != "kitamura-tetsuo/auto-coder-test": + raise ValueError("Scoped fixture token is restricted to the test repository") + token_stat = token_path.lstat() + if not token_path.is_file() or token_path.is_symlink() or token_stat.st_mode & 0o077: + raise ValueError("Scoped fixture token file is not a private regular file") + token = token_path.read_text(encoding="utf-8").strip() + if not token.startswith("github_pat_"): + raise ValueError("Scoped fixture token must be a fine-grained PAT") + os.environ["GITHUB_TOKEN"] = token + return True + + def main() -> None: if os.environ.get("AUTO_CODER_TEST_LAUNCH_AUDIT") == "1": # External acceptance counts actual selected-interpreter processes. @@ -41,7 +57,9 @@ def main() -> None: # before the fixture process exits unexpectedly. time.sleep(0.75) raise SystemExit("fixture intentional staged-worker activation failure") - token = os.environ["GITHUB_TOKEN"] + activate_scoped_fixture_token( + Path("/fixture/secrets/github-test-scoped"), os.environ["AUTO_CODER_REPOSITORY"] + ) command = [ sys.executable, "-m", @@ -49,8 +67,6 @@ def main() -> None: "process-issues", "--repo", os.environ["AUTO_CODER_REPOSITORY"], - "--github-token", - token, "--skip-codex-auth-secret-sync", "--disable-webhook", "--log-file", diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index 8c7b36a92..a4eb9b001 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -7,6 +7,7 @@ import pytest import yaml from scripts import dot_disposable_fixture as fixture +from tests.fixtures import supervisor_offline_process_issues as offline_worker def source_tree(tmp_path: Path) -> Path: @@ -42,11 +43,48 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): assert any(volume.endswith(":/fixture/fixture.json:ro") for volume in compose["services"]["management"]["volumes"]) assert all("GITHUB_TOKEN" not in service["environment"] for name, service in compose["services"].items() if name == "management") assert all(service["environment"]["GITHUB_TOKEN"].startswith("nonfunctional-") for name, service in compose["services"].items() if name in ("beta", "release")) + for name, service in compose["services"].items(): + assert f"{root}/secrets:/fixture/secrets:ro" not in service["volumes"] + assert all("github-test-scoped" not in volume for volume in service["volumes"]) + if name in ("beta", "release"): + assert f"{root}/secrets/{name}-bridge:/fixture/secrets/{name}-bridge:ro" in service["volumes"] + management_volumes = compose["services"]["management"]["volumes"] + assert f"{root}/secrets/beta-bridge:/fixture/secrets/beta-bridge:ro" in management_volumes + assert f"{root}/secrets/release-bridge:/fixture/secrets/release-bridge:ro" in management_volumes + assert f"{root}/secrets/source-public-read:/fixture/secrets/source-public-read:ro" in management_volumes assert len(marker["fixture_nonce"]) == 32 assert marker["source_fixture_available"] is False and marker["source_fixture_sha"] == "" assert (root / "secrets/beta-bridge").stat().st_mode & 0o077 == 0 +def test_scoped_fixture_pat_is_private_and_test_repository_only(tmp_path, monkeypatch): + token_path = tmp_path / "scoped-pat" + monkeypatch.setenv("GITHUB_TOKEN", "nonfunctional-placeholder") + assert offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") is False + assert offline_worker.os.environ["GITHUB_TOKEN"] == "nonfunctional-placeholder" + + token_path.write_text("github_pat_synthetic-only", encoding="utf-8") + token_path.chmod(0o600) + with pytest.raises(ValueError, match="test repository"): + offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder") + assert offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") is True + assert offline_worker.os.environ["GITHUB_TOKEN"] == "github_pat_synthetic-only" + + token_path.chmod(0o644) + with pytest.raises(ValueError, match="private regular file"): + offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") + + token_path.unlink() + token_path.symlink_to(tmp_path / "target") + with pytest.raises(ValueError, match="private regular file"): + offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") + + +def test_scoped_fixture_pat_never_enters_child_process_argv(): + script = Path(offline_worker.__file__).read_text(encoding="utf-8") + assert '"--github-token"' not in script + + @pytest.mark.parametrize("fixture_id", ["release-worker", "beta-worker", "../escape", "A-B", "sh", "bad space"]) def test_nonfixture_identity_is_rejected_before_creating_directory(tmp_path, fixture_id): source = source_tree(tmp_path) From 40a3215e645a732a1a8c77a446c71ae84d8d9456 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 17:32:43 +0900 Subject: [PATCH 24/42] style: satisfy Black for scoped fixture token activation --- tests/fixtures/supervisor_offline_process_issues.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index eed7fe22c..e600c4cca 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -57,9 +57,7 @@ def main() -> None: # before the fixture process exits unexpectedly. time.sleep(0.75) raise SystemExit("fixture intentional staged-worker activation failure") - activate_scoped_fixture_token( - Path("/fixture/secrets/github-test-scoped"), os.environ["AUTO_CODER_REPOSITORY"] - ) + activate_scoped_fixture_token(Path("/fixture/secrets/github-test-scoped"), os.environ["AUTO_CODER_REPOSITORY"]) command = [ sys.executable, "-m", From e4a9730586853ca7d37ad14df8212532cd1bbbc3 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 17:44:52 +0900 Subject: [PATCH 25/42] test: keep supervisor and formatting guards aligned with scoped fixture token --- tests/test_dot_disposable_fixture.py | 1 + tests/test_supervisor_integration_workflow.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index a4eb9b001..f55fef875 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -7,6 +7,7 @@ import pytest import yaml from scripts import dot_disposable_fixture as fixture + from tests.fixtures import supervisor_offline_process_issues as offline_worker diff --git a/tests/test_supervisor_integration_workflow.py b/tests/test_supervisor_integration_workflow.py index 594251a6c..a9570e3f1 100644 --- a/tests/test_supervisor_integration_workflow.py +++ b/tests/test_supervisor_integration_workflow.py @@ -429,7 +429,9 @@ def test_production_acceptance_covers_real_ready_and_offline_production_cli() -> assert re.search(r'"--entrypoint",\s*"python"', acceptance) assert "supervisor_offline_pid1.py" in acceptance assert '"process-issues"' in offline - assert '"--github-token"' in offline + assert "activate_scoped_fixture_token(" in offline + assert 'os.environ["GITHUB_TOKEN"] = token' in offline + assert '"--github-token"' not in offline assert '"--skip-codex-auth-secret-sync"' in offline assert "os.execv(sys.executable, command)" in offline assert "WorkerVersionPreparer(" in preparer From f9cb1d83cd00a0dfa91396c39f22af91ffa37a96 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 18:41:45 +0900 Subject: [PATCH 26/42] Reduce safe prompt YAML parse CPU while preserving safe constructors --- src/auto_coder/prompt_loader.py | 5 ++++- tests/test_prompt_loader.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/auto_coder/prompt_loader.py b/src/auto_coder/prompt_loader.py index 3d0199c4d..2a12ee15b 100644 --- a/src/auto_coder/prompt_loader.py +++ b/src/auto_coder/prompt_loader.py @@ -16,6 +16,9 @@ DEFAULT_PROMPTS_PATH = Path(__file__).resolve().parent / "prompts.yaml" _PROMPTS_CACHE: Dict[Path, Dict[str, Any]] = {} +# Prefer LibYAML's safe parser for bounded prompt-load CPU cost. +# Fall back to the same safe constructors when the extension is unavailable. +_SAFE_YAML_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader) _ISSUE_POLICY_PROMPTS = frozenset( { @@ -119,7 +122,7 @@ def load_prompts(path: Optional[str] = None) -> Dict[str, Any]: try: with resolved.open("r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) or {} + data = yaml.load(handle, Loader=_SAFE_YAML_LOADER) or {} except FileNotFoundError as exc: msg = f"Prompt configuration file not found: {resolved}" logger.critical(msg) diff --git a/tests/test_prompt_loader.py b/tests/test_prompt_loader.py index cbc819462..07fafa58c 100644 --- a/tests/test_prompt_loader.py +++ b/tests/test_prompt_loader.py @@ -1157,3 +1157,19 @@ def test_render_prompt_multiple_conditionals(self, tmp_path): ) assert "A" in result assert "B" in result + + +@pytest.mark.parametrize( + "unsafe_payload", + [ + "payload: !!python/name:os.system\n", + 'payload: !!python/object/apply:os.system ["true"]\n', + ], +) +def test_prompt_loader_refuses_python_constructor_tags(tmp_path, unsafe_payload): + """Accelerated YAML parsing must retain SafeLoader's constructor boundary.""" + path = tmp_path / "unsafe-prompts.yaml" + path.write_text(unsafe_payload, encoding="utf-8") + + with pytest.raises(SystemExit, match="Failed to parse prompt configuration"): + prompt_loader.load_prompts(str(path)) From 82a5aec42fd091baedad94d5fc5c3fc8eb22eeb6 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 19:08:44 +0900 Subject: [PATCH 27/42] fix(dot): preserve trusted MCP blocker across task-group wrappers --- scripts/dot_mcp_acceptance.py | 20 ++++++++++++++++++++ tests/test_dot_mcp_acceptance.py | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index d7da43b17..bbbb625cb 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -241,6 +241,17 @@ def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotC raise Blocked("Original admission ticket already exists; never allocate a new key") from error +def _single_nested_acceptance_error(error: BaseException) -> Blocked | Failed | None: + """Preserve one trusted refusal across MCP transport task-group wrappers. + + Multiple failures or unrelated transport exceptions remain unknown effects. + """ + nested = error + while isinstance(nested, ExceptionGroup) and len(nested.exceptions) == 1: + nested = nested.exceptions[0] + return nested if isinstance(nested, (Blocked, Failed)) else None + + def exception_result( scenario: str, reason: str, @@ -635,6 +646,15 @@ def main() -> int: report = exception_result(args.scenario, str(error), mutation=args.enable_fixture_mutation, fixture=fixture) except Failed as error: report = exception_result(args.scenario, str(error), mutation=args.enable_fixture_mutation, verdict="FAIL", fixture=fixture) + except ExceptionGroup as error: + nested = _single_nested_acceptance_error(error) + if isinstance(nested, Blocked): + report = exception_result(args.scenario, str(nested), mutation=args.enable_fixture_mutation, fixture=fixture) + elif isinstance(nested, Failed): + report = exception_result(args.scenario, str(nested), mutation=args.enable_fixture_mutation, verdict="FAIL", fixture=fixture) + else: + logger.warning("Untrusted task-group failure classified as {}", type(error).__name__) + report = exception_result(args.scenario, "Authenticated fixture transport unavailable; retain any original send ticket", mutation=args.enable_fixture_mutation, fixture=fixture) except (OSError, ValueError, TypeError, RuntimeError) as error: logger.warning("Fixture acceptance unavailable due to {}", type(error).__name__) report = exception_result(args.scenario, "Original fixture, authority or producer evidence unavailable", mutation=args.enable_fixture_mutation, fixture=fixture) diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 5ae65507d..78ef45c64 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -405,3 +405,20 @@ def test_corrupt_or_replaced_original_send_ticket_never_reports_no_dispatch(monk assert outcome.effect_dispatched is None assert outcome.operation_id == "" assert outcome.original_ticket_state == "UNKNOWN" + + +def test_mcp_task_group_wrapper_preserves_single_trusted_blocker() -> None: + blocked = accept.Blocked("Signed Supervisor cannot establish a live original worker") + failed = accept.Failed("Installed source binding contradicts the exact tested Git commit") + assert accept._single_nested_acceptance_error( + ExceptionGroup("streamable HTTP", [ExceptionGroup("session", [blocked])]) + ) is blocked + assert accept._single_nested_acceptance_error( + ExceptionGroup("streamable HTTP", [failed]) + ) is failed + assert accept._single_nested_acceptance_error( + ExceptionGroup("streamable HTTP", [ValueError("unexpected transport failure")]) + ) is None + assert accept._single_nested_acceptance_error( + ExceptionGroup("streamable HTTP", [blocked, RuntimeError("other failure")]) + ) is None From d46099e4a78432e68cfaa788eaef57ecc6076aaf Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 19:17:18 +0900 Subject: [PATCH 28/42] test: patch actual safe YAML parser for network error simulation --- tests/test_label_error_handling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_label_error_handling.py b/tests/test_label_error_handling.py index d61fc1e23..c3a9575ac 100644 --- a/tests/test_label_error_handling.py +++ b/tests/test_label_error_handling.py @@ -136,7 +136,7 @@ def test_yaml_with_only_comments(self): class TestNetworkFailures: """Test handling of network failures during configuration loading.""" - @patch("src.auto_coder.prompt_loader.yaml.safe_load") + @patch("src.auto_coder.prompt_loader.yaml.load") def test_network_error_during_yaml_loading(self, mock_yaml_load): """Test handling of network-related errors during YAML loading.""" mock_yaml_load.side_effect = requests.exceptions.ConnectionError("Network error") From 78c1b89ce17f4bb87bf5e526464158d2c80bba70 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 19:34:10 +0900 Subject: [PATCH 29/42] style: format MCP acceptance regression test --- tests/test_dot_mcp_acceptance.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 78ef45c64..e2a2a5938 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -410,15 +410,7 @@ def test_corrupt_or_replaced_original_send_ticket_never_reports_no_dispatch(monk def test_mcp_task_group_wrapper_preserves_single_trusted_blocker() -> None: blocked = accept.Blocked("Signed Supervisor cannot establish a live original worker") failed = accept.Failed("Installed source binding contradicts the exact tested Git commit") - assert accept._single_nested_acceptance_error( - ExceptionGroup("streamable HTTP", [ExceptionGroup("session", [blocked])]) - ) is blocked - assert accept._single_nested_acceptance_error( - ExceptionGroup("streamable HTTP", [failed]) - ) is failed - assert accept._single_nested_acceptance_error( - ExceptionGroup("streamable HTTP", [ValueError("unexpected transport failure")]) - ) is None - assert accept._single_nested_acceptance_error( - ExceptionGroup("streamable HTTP", [blocked, RuntimeError("other failure")]) - ) is None + assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [ExceptionGroup("session", [blocked])])) is blocked + assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [failed])) is failed + assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [ValueError("unexpected transport failure")])) is None + assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [blocked, RuntimeError("other failure")])) is None From 7c8ad5c4aa5f3f8a9447e8c347895d9da86a40d2 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 19:59:10 +0900 Subject: [PATCH 30/42] test: skip live GitHub preflight for inert offline worker fixtures --- tests/fixtures/supervisor_offline_process_issues.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index e600c4cca..fc82e43ba 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -70,6 +70,10 @@ def main() -> None: "--log-file", os.path.join(os.environ["AUTO_CODER_RUNTIME_ROOT"], "logs", "auto-coder.log"), ] + if os.environ.get("GITHUB_TOKEN") in {"nonfunctional-ci-placeholder", "nonfunctional-disposable-no-github-write"}: + # Offline fixtures may bypass the CLI's live GitHub startup probe using + # only a fixed, non-secret placeholder. Never put a real token in argv. + command.extend(["--github-token", "nonfunctional-ci-placeholder"]) run_in_process = False if os.environ.get("AUTO_CODER_FIXTURE_HOLD_CANDIDATE_RUNTIME") == os.environ.get("VIRTUAL_ENV") and os.environ.get("AUTO_CODER_FIXTURE_HOLD_CANDIDATE_RUNTIME"): import asyncio From fc962bf910a9839b3dfb5382f7859def819776eb Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 20:11:33 +0900 Subject: [PATCH 31/42] test: verify fixture PAT argv safety against actual exec --- tests/test_dot_disposable_fixture.py | 40 +++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index f55fef875..3d1fb5eeb 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -81,9 +81,43 @@ def test_scoped_fixture_pat_is_private_and_test_repository_only(tmp_path, monkey offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") -def test_scoped_fixture_pat_never_enters_child_process_argv(): - script = Path(offline_worker.__file__).read_text(encoding="utf-8") - assert '"--github-token"' not in script +@pytest.mark.parametrize( + ("configured_token", "expected_flag"), + [ + ("github_pat_synthetic-secret-never-on-argv", False), + ("nonfunctional-ci-placeholder", True), + ("nonfunctional-disposable-no-github-write", True), + ], +) +def test_scoped_fixture_pat_never_enters_child_process_argv(monkeypatch, configured_token, expected_flag): + # Capture the actual child exec argv, including the optional offline bypass. + monkeypatch.setenv("AUTO_CODER_REPOSITORY", "kitamura-tetsuo/auto-coder-test") + monkeypatch.setenv("AUTO_CODER_RUNTIME_ROOT", "/unused-disposable-test") + monkeypatch.setenv("GITHUB_TOKEN", configured_token) + for name in ( + "AUTO_CODER_TEST_LAUNCH_AUDIT", + "AUTO_CODER_FIXTURE_HOLD_BOOTSTRAP", + "AUTO_CODER_FIXTURE_FAIL_STAGED_ONCE", + "AUTO_CODER_FIXTURE_HOLD_CANDIDATE_RUNTIME", + "AUTO_CODER_TEST_ABANDON_STARTUP_ONCE", + "AUTO_CODER_TEST_HOLD_INSTALLED_TERMINAL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(offline_worker, "activate_scoped_fixture_token", lambda *args: False) + captured = [] + monkeypatch.setattr(offline_worker.os, "execv", lambda executable, argv: captured.append((executable, argv))) + + offline_worker.main() + + assert len(captured) == 1 + executable, argv = captured[0] + assert executable == offline_worker.sys.executable + assert argv[:3] == [executable, "-m", "auto_coder.cli"] + assert not any(value.startswith("github_pat_") for value in argv) + if expected_flag: + assert argv[-2:] == ["--github-token", "nonfunctional-ci-placeholder"] + else: + assert "--github-token" not in argv @pytest.mark.parametrize("fixture_id", ["release-worker", "beta-worker", "../escape", "A-B", "sh", "bad space"]) From b5e17f97c35fdbc59a3fab4d787a3fb50f776fb2 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 20:19:10 +0900 Subject: [PATCH 32/42] fix: make disposable worker ownership routing writable --- scripts/dot_disposable_fixture.py | 4 +++- tests/test_dot_disposable_fixture.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index eec38f206..8126f23bc 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -220,7 +220,9 @@ def provision( "volumes": [ *fixture_mounts, f"{root}/secrets/{role}-bridge:/fixture/secrets/{role}-bridge:ro", - f"{root}/routing:/fixture/routing:ro", + # The real worker acquires an ownership.json.lock in this + # fixture-private directory; keep only this test mount writable. + f"{root}/routing:/fixture/routing:rw", f"{root}/{role}/runtime:/runtime", f"{root}/{role}/workspace:/workspace", ], diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index 3d1fb5eeb..6b71cc950 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -39,6 +39,8 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): assert all("privileged" not in service and "network_mode" not in service for service in compose["services"].values()) assert all(not any("docker.sock" in volume or "/var/lib/auto-coder" in volume for volume in service["volumes"]) for service in compose["services"].values()) assert all(any(volume.endswith(":/fixtures:ro") for volume in compose["services"][name]["volumes"]) for name in ("beta", "release")) + assert all(f"{root}/routing:/fixture/routing:rw" in compose["services"][name]["volumes"] for name in ("beta", "release")) + assert f"{root}/routing:/fixture/routing:rw" not in compose["services"]["management"]["volumes"] assert all(len(service["networks"]) == 1 and "fixture" in service["networks"] for service in compose["services"].values()) assert not any("ports" in service for service in compose["services"].values()) assert any(volume.endswith(":/fixture/fixture.json:ro") for volume in compose["services"]["management"]["volumes"]) From be94ea6a4f86121a0b757d37948e757a8551bfec Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 20:34:21 +0900 Subject: [PATCH 33/42] test: assert scoped PAT safety through actual exec guard --- tests/test_supervisor_integration_workflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_supervisor_integration_workflow.py b/tests/test_supervisor_integration_workflow.py index a9570e3f1..e14f46818 100644 --- a/tests/test_supervisor_integration_workflow.py +++ b/tests/test_supervisor_integration_workflow.py @@ -431,7 +431,11 @@ def test_production_acceptance_covers_real_ready_and_offline_production_cli() -> assert '"process-issues"' in offline assert "activate_scoped_fixture_token(" in offline assert 'os.environ["GITHUB_TOKEN"] = token' in offline - assert '"--github-token"' not in offline + # Fixed inert offline placeholders may bypass a live GitHub startup probe. + # The separate executable-path test verifies that real PATs never enter argv. + argv_guard = (ROOT / "tests/test_dot_disposable_fixture.py").read_text() + assert "test_scoped_fixture_pat_never_enters_child_process_argv" in argv_guard + assert 'monkeypatch.setattr(offline_worker.os, "execv"' in argv_guard assert '"--skip-codex-auth-secret-sync"' in offline assert "os.execv(sys.executable, command)" in offline assert "WorkerVersionPreparer(" in preparer From df59597b9bb8824ff90b8a54a1a744e351eb2d96 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 20:58:22 +0900 Subject: [PATCH 34/42] test: wait for live producer heartbeat cadence in fixture acceptance --- ...dot-autonomous-source-worker-operations.md | 6 +- scripts/dot_mcp_acceptance.py | 58 ++++++++++-- tests/test_dot_mcp_acceptance.py | 89 +++++++++++++++++++ 3 files changed, 143 insertions(+), 10 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 16e344228..a38bf486e 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -238,7 +238,11 @@ samples for each worker must retain the exact original Supervisor generation, process-run ID, runtime ID, deployment generation, and installed source binding. A worker restart with a larger counter is not continuous heartbeat progress; the client refuses it rather than claiming installed-source acceptance. The -same two-sample rule gates `beta-source` completion. Mere container liveness, +read-only heartbeat sampler polls every 5 seconds for up to 150 seconds because +the idle producer's maintenance heartbeat may advance only once per 60 seconds. +Every sampled worker is rechecked until all have advanced, so an earlier worker +cannot disappear during a later worker's wait. The same two-sample rule gates +`beta-source` completion. Mere container liveness, accepted MCP receipts or a copied Git SHA are insufficient. `scripts/dot_mcp_acceptance.py` is opt-in and returns `NOT_RUN` without a fixture. diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index bbbb625cb..48cd250b8 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -46,6 +46,10 @@ class Blocked(RuntimeError): """Missing current authority or trustworthy external evidence.""" +class HeartbeatNotAdvanced(Blocked): + """The same live worker has not emitted a newer heartbeat yet.""" + + class Failed(RuntimeError): """A real producer contradicted the immutable expected identity.""" @@ -378,7 +382,7 @@ def identity(snapshot: dict[str, Any]) -> tuple[str, str, str, str, dict[str, An if identity(before) != identity(after): raise Blocked("Worker identity or installed source changed between heartbeat observations") if current_sequence <= prior_sequence: - raise Blocked("Original worker heartbeat did not advance") + raise HeartbeatNotAdvanced("Original worker heartbeat did not advance") def verified_reevaluation_receipt(value: dict[str, Any], *, operation_id: str, target_id: str, process_run_id: str) -> bool: @@ -493,6 +497,39 @@ async def observed_target(live: Live, target: str, channel: str, repository: str return readback +async def await_advancing_worker_heartbeats( + live: Live, + samples: list[tuple[str, str, str, dict[str, Any], str]], + *, + timeout_seconds: float = 150.0, + poll_seconds: float = 5.0, +) -> None: + """Wait for real producer progress across slow maintenance cycles. + + A missing heartbeat may be retried within the bound, but any change of + original worker identity or installed source immediately fails closed. + """ + if not samples or len({target for target, *_ in samples}) != len(samples): + raise Blocked("Heartbeat targets must be distinct and nonempty") + deadline = time.monotonic() + timeout_seconds + advanced: set[str] = set() + while True: + for target, channel, repository, before, source_sha in samples: + after = await observed_target(live, target, channel, repository) + try: + require_advancing_worker_heartbeat(before, after, source_sha=source_sha) + except HeartbeatNotAdvanced: + advanced.discard(target) + else: + advanced.add(target) + if len(advanced) == len(samples): + return + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Blocked("Original worker heartbeat did not advance within the bounded observation window") + await asyncio.sleep(min(poll_seconds, remaining)) + + async def inspect_original(live: Live, checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> Result: original = checkpoint.pending_operation_id if not original: @@ -535,11 +572,13 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s return await inspect_original(live, checkpoint, fixture) if not mutate: release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) - await asyncio.sleep(2) - after_beta = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) - after_release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) - require_advancing_worker_heartbeat(beta, after_beta, source_sha=checkpoint.source_sha) - require_advancing_worker_heartbeat(release, after_release) + await await_advancing_worker_heartbeats( + live, + [ + (fixture.target_ids["beta"], "beta", TEST_REPOSITORY, beta, checkpoint.source_sha), + (fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1], release, ""), + ], + ) return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) if scenario == "beta-source": await asyncio.to_thread(verify_real_source, checkpoint, fixture) @@ -603,9 +642,10 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s raise Blocked("Terminal beta deployment is not bound to the original tested source and target") update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") installed = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) - await asyncio.sleep(2) - after_install = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) - require_advancing_worker_heartbeat(installed, after_install, source_sha=checkpoint.source_sha) + await await_advancing_worker_heartbeats( + live, + [(fixture.target_ids["beta"], "beta", TEST_REPOSITORY, installed, checkpoint.source_sha)], + ) return Result("PASS", scenario, "Real worker installed the original tested source and advanced producer heartbeats", tuple(live.observations), original_id, positive_deployment_proven=True, effect_dispatched=True) await asyncio.sleep(2) raise Blocked("Original management operation has not reached a verified terminal receipt") diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index e2a2a5938..b3ed33e4c 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -1,5 +1,6 @@ """Strict offline safety contracts, not fabricated end-to-end producer PASS.""" +import asyncio import json import os import subprocess @@ -242,6 +243,94 @@ def test_repeated_or_unbound_heartbeat_cannot_prove_progress(): accept.require_advancing_worker_heartbeat(_real_worker_sample(12), unbound) +def test_heartbeat_waits_for_slow_independent_producer_progress(monkeypatch): + # Real idle workers commonly advance on a 60-second maintenance cycle. + remaining = {"beta": [12, 12, 12, 13], "release": [21, 21, 22]} + calls = [] + sentinel = object() + + async def read_target(live, target, channel, repository): + assert live is sentinel + assert target == fixture().target_ids[channel] + assert repository == (fixture().repository if channel == "beta" else "fixture/" + fixture().fixture_id + "-release") + values = remaining[channel] + sequence = values.pop(0) if len(values) > 1 else values[0] + calls.append((channel, sequence)) + return _real_worker_sample(sequence) + + monkeypatch.setattr(accept, "observed_target", read_target) + asyncio.run( + accept.await_advancing_worker_heartbeats( + sentinel, + [ + (fixture().target_ids["beta"], "beta", fixture().repository, _real_worker_sample(12), "b" * 40), + (fixture().target_ids["release"], "release", "fixture/" + fixture().fixture_id + "-release", _real_worker_sample(21), ""), + ], + timeout_seconds=10, + poll_seconds=0, + ) + ) + assert ("beta", 13) in calls + assert ("release", 22) in calls + + +def test_heartbeat_wait_is_bounded_and_stale_evidence_remains_blocked(monkeypatch): + async def stale_target(live, target, channel, repository): + return _real_worker_sample(12) + + monkeypatch.setattr(accept, "observed_target", stale_target) + with pytest.raises(accept.Blocked, match="bounded observation window"): + asyncio.run( + accept.await_advancing_worker_heartbeats( + object(), + [(fixture().target_ids["beta"], "beta", fixture().repository, _real_worker_sample(12), "b" * 40)], + timeout_seconds=0, + poll_seconds=0, + ) + ) + + +def test_heartbeat_wait_rejects_restart_without_waiting(monkeypatch): + async def restarted_target(live, target, channel, repository): + return _real_worker_sample(13, process_run_id="process-restarted") + + monkeypatch.setattr(accept, "observed_target", restarted_target) + with pytest.raises(accept.Blocked, match="identity"): + asyncio.run( + accept.await_advancing_worker_heartbeats( + object(), + [(fixture().target_ids["beta"], "beta", fixture().repository, _real_worker_sample(12), "b" * 40)], + timeout_seconds=10, + poll_seconds=0, + ) + ) + + +def test_heartbeat_wait_rechecks_earlier_worker_until_both_advance(monkeypatch): + reads = {"beta": 0, "release": 0} + + async def read_target(live, target, channel, repository): + reads[channel] += 1 + if channel == "beta": + return _real_worker_sample(13, process_run_id="process-1" if reads[channel] == 1 else "process-restarted") + return _real_worker_sample(21 if reads[channel] == 1 else 22) + + monkeypatch.setattr(accept, "observed_target", read_target) + with pytest.raises(accept.Blocked, match="identity"): + asyncio.run( + accept.await_advancing_worker_heartbeats( + object(), + [ + (fixture().target_ids["beta"], "beta", fixture().repository, _real_worker_sample(12), "b" * 40), + (fixture().target_ids["release"], "release", "fixture/" + fixture().fixture_id + "-release", _real_worker_sample(21), ""), + ], + timeout_seconds=10, + poll_seconds=0, + ) + ) + assert reads["beta"] == 2 + + def test_disposable_git_source_not_present_cannot_trigger_proof_network(): with pytest.raises(accept.Blocked, match="fixture"): accept.verify_real_source(checkpoint(), replace(fixture(), source_fixture_available=False, source_fixture_sha="")) From 40b431f70872436ac0d0efcc38a1f41c6ba5bb52 Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 21:12:28 +0900 Subject: [PATCH 35/42] fix(dot): reject ambiguous disposable MCP target policy --- ...dot-autonomous-source-worker-operations.md | 5 ++- scripts/dot_mcp_acceptance.py | 33 ++++++++++++++----- tests/test_dot_mcp_acceptance.py | 22 +++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index a38bf486e..df58fddec 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -232,7 +232,10 @@ docker compose -f .agent-tmp/pr2835-sandbox-20261112/compose.yaml exec -T manage ``` Without an **exact fixture-specific** `AUTO_CODER_MCP_ACCESS_TOKEN`, the client -returns `BLOCKED` before opening an MCP connection. Even with OAuth, +returns `BLOCKED` before opening an MCP connection. An authenticated +`auto_coder_targets` result must contain exactly two distinct, complete +beta/release fixture bindings; duplicate IDs, omitted or extra entries, and +malformed targets refuse before any mutating request. Even with OAuth, `observe` needs two independently advancing verified worker heartbeats. Both samples for each worker must retain the exact original Supervisor generation, process-run ID, runtime ID, deployment generation, and installed source binding. diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 48cd250b8..52e69b648 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -465,6 +465,27 @@ def verify_current_operator(identity: dict[str, Any], fixture: Fixture, scenario raise Blocked("The original fixture OAuth client, identity, resource or current scopes are not verified") +def verify_current_targets(value: dict[str, Any], fixture: Fixture) -> None: + """Require two unambiguous current policy targets, without discarding entries.""" + selected = value.get("targets") + expected = { + fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), + fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), + } + if value.get("status") != "OK" or not isinstance(selected, list) or len(selected) != len(expected): + raise Blocked("The current policy must expose exactly two disposable worker targets") + actual: dict[str, tuple[str, str]] = {} + for entry in selected: + if not isinstance(entry, dict) or any(not isinstance(entry.get(key), str) or not entry[key] for key in ("target_id", "channel", "repository")): + raise Blocked("The current policy contains an incomplete physical target binding") + target = entry["target_id"] + if target in actual: + raise Blocked("The current policy contains a duplicate physical target binding") + actual[target] = (entry["channel"], entry["repository"]) + if actual != expected: + raise Blocked("The current policy target map cannot prove the two disposable workers") + + class Live: def __init__(self, session: Any, registered: set[str]): self.session = session @@ -560,23 +581,17 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s identity = await live.call("auto_coder_whoami", {}) verify_current_operator(identity, fixture, scenario, mutation=mutate) targets = await live.call("auto_coder_targets", {}) - expected = { - fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), - fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), - } - actual = {entry["target_id"]: (entry["channel"], entry["repository"]) for entry in targets.get("targets", []) if isinstance(entry, dict) and all(name in entry for name in ("target_id", "channel", "repository"))} - if targets.get("status") != "OK" or actual != expected: - raise Blocked("The current policy target map cannot prove the two disposable workers") + verify_current_targets(targets, fixture) beta = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) if scenario == "inspect-original": return await inspect_original(live, checkpoint, fixture) if not mutate: - release = await observed_target(live, fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1]) + release = await observed_target(live, fixture.target_ids["release"], "release", "fixture/" + fixture.fixture_id + "-release") await await_advancing_worker_heartbeats( live, [ (fixture.target_ids["beta"], "beta", TEST_REPOSITORY, beta, checkpoint.source_sha), - (fixture.target_ids["release"], "release", expected[fixture.target_ids["release"]][1], release, ""), + (fixture.target_ids["release"], "release", "fixture/" + fixture.fixture_id + "-release", release, ""), ], ) return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index b3ed33e4c..985a37069 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -357,6 +357,28 @@ def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_ accept.verify_current_operator({**identity, **change}, fixture(), "reevaluate", mutation=True) +def test_current_fixture_policy_requires_exactly_two_distinct_complete_target_bindings(): + selected = fixture() + beta = {"target_id": selected.target_ids["beta"], "channel": "beta", "repository": selected.repository} + release = {"target_id": selected.target_ids["release"], "channel": "release", "repository": "fixture/" + selected.fixture_id + "-release"} + accept.verify_current_targets({"status": "OK", "targets": [beta, release]}, selected) + accept.verify_current_targets({"status": "OK", "targets": [release, beta]}, selected) + for invalid in ( + {"status": "UNAVAILABLE", "targets": [beta, release]}, + {"status": "OK", "targets": [beta]}, + {"status": "OK", "targets": [beta, release, beta]}, + {"status": "OK", "targets": [beta, release, "invalid"]}, + {"status": "OK", "targets": [beta, beta]}, + {"status": "OK", "targets": [beta, {**release, "repository": "production/worker"}]}, + {"status": "OK", "targets": [beta, {**release, "target_id": 123}]}, + {"status": "OK", "targets": [beta, {"target_id": selected.target_ids["release"], "channel": "release"}]}, + {"status": "OK", "targets": [beta, "invalid"]}, + {"status": "OK", "targets": None}, + ): + with pytest.raises(accept.Blocked, match="current policy"): + accept.verify_current_targets(invalid, selected) + + def _confirmed_reevaluation_projection(): """Match the real ManagementRuntime._project_operation response shape.""" return { From 812ca91bd4d75149103158ab85740f5afea2d67f Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 21:51:40 +0900 Subject: [PATCH 36/42] test(supervisor): bypass live GitHub auth only for disposable worker token --- tests/fixtures/supervisor_offline_process_issues.py | 6 +++++- tests/test_dot_disposable_fixture.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index fc82e43ba..9e4246b9b 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -70,7 +70,11 @@ def main() -> None: "--log-file", os.path.join(os.environ["AUTO_CODER_RUNTIME_ROOT"], "logs", "auto-coder.log"), ] - if os.environ.get("GITHUB_TOKEN") in {"nonfunctional-ci-placeholder", "nonfunctional-disposable-no-github-write"}: + if os.environ.get("GITHUB_TOKEN") in { + "nonfunctional-ci-placeholder", + "nonfunctional-disposable-no-github-write", + "nonfunctional-disposable-worker-token", + }: # Offline fixtures may bypass the CLI's live GitHub startup probe using # only a fixed, non-secret placeholder. Never put a real token in argv. command.extend(["--github-token", "nonfunctional-ci-placeholder"]) diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index 6b71cc950..a2bad71ff 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -89,6 +89,7 @@ def test_scoped_fixture_pat_is_private_and_test_repository_only(tmp_path, monkey ("github_pat_synthetic-secret-never-on-argv", False), ("nonfunctional-ci-placeholder", True), ("nonfunctional-disposable-no-github-write", True), + ("nonfunctional-disposable-worker-token", True), ], ) def test_scoped_fixture_pat_never_enters_child_process_argv(monkeypatch, configured_token, expected_flag): From f288b9c3484bb0f519b45c34a91dcd5e4dd5adfd Mon Sep 17 00:00:00 2001 From: Auto-Coder Automation <12671456+kitamura-tetsuo@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:31:43 +0900 Subject: [PATCH 37/42] test(supervisor): retain signed stopped fixture readiness across main merge --- tests/test_release_worker_acceptance_container.py | 4 ++-- tests/test_release_worker_fixture_readiness.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_release_worker_acceptance_container.py b/tests/test_release_worker_acceptance_container.py index ecf91e0ef..9725876d0 100644 --- a/tests/test_release_worker_acceptance_container.py +++ b/tests/test_release_worker_acceptance_container.py @@ -148,7 +148,7 @@ class _InstalledRelease: previous: dict[str, object] -def _await_initial_stopped_supervisor(client: WorkerNetworkClient) -> WorkerNetworkResult: +def _await_initial_stopped_supervisor(client: WorkerNetworkClient, *, timeout: float = 45) -> WorkerNetworkResult: """Wait for a signed stopped observation, never accepting UNKNOWN as STOPPED.""" latest: WorkerNetworkResult | None = None @@ -162,7 +162,7 @@ def observe() -> WorkerNetworkResult | None: return latest try: - return _wait_for(observe, timeout=45) + return _wait_for(observe, timeout=timeout) except AssertionError as error: raise AssertionError(f"initial release Supervisor did not prove stopped: {latest}") from error diff --git a/tests/test_release_worker_fixture_readiness.py b/tests/test_release_worker_fixture_readiness.py index f2cb60a40..31845f1db 100644 --- a/tests/test_release_worker_fixture_readiness.py +++ b/tests/test_release_worker_fixture_readiness.py @@ -11,7 +11,7 @@ import pytest from auto_coder.management.worker_network_client import WorkerEndpointConfig, WorkerNetworkClient, WorkerNetworkResult -from tests.test_release_worker_acceptance_container import _wait_initial_release_inspection +from tests.test_release_worker_acceptance_container import _await_initial_stopped_supervisor from tests.test_worker_supervisor_bridge import _bridge, _supervisor @@ -42,7 +42,7 @@ def test_release_fixture_waits_for_actual_signed_bridge_start(tmp_path: Path, _u configuration = supervisor.config client = _ObservedClient(WorkerEndpointConfig(configuration.instance_id, configuration.channel, configuration.repository, "127.0.0.1", port, key)) with ThreadPoolExecutor(max_workers=1) as executor: - waiting = executor.submit(_wait_initial_release_inspection, client, timeout=3) + waiting = executor.submit(_await_initial_stopped_supervisor, client, timeout=3) assert client.unavailable.wait(2), "the fixture must observe actual unavailability before bridge startup" assert not waiting.done(), "a transient unavailable inspection must not complete fixture readiness" assert client.responses[0].http_status == 0 and client.responses[0].result_code == "UNKNOWN" @@ -72,8 +72,8 @@ def test_release_fixture_refuses_a_transport_without_authenticated_readiness(tmp configuration = supervisor.config client = _ObservedClient(WorkerEndpointConfig(configuration.instance_id, configuration.channel, configuration.repository, "127.0.0.1", port, key)) try: - with pytest.raises(AssertionError, match="signed inspection did not become ready; last_response=.*UNKNOWN"): - _wait_initial_release_inspection(client, timeout=0.25) + with pytest.raises(AssertionError, match="initial release Supervisor did not prove stopped: .*UNKNOWN"): + _await_initial_stopped_supervisor(client, timeout=0.25) assert client.responses and all(response.result_code == "UNKNOWN" for response in client.responses) assert {response.http_status for response in client.responses} == ({401} if invalid_signature else {0}) assert client.supervisor_generation is None From 1adbe2a84ffe029d8072cc153c2a69ad8c448049 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 12 Nov 2026 23:16:27 +0900 Subject: [PATCH 38/42] fix: complete disposable dot rollout acceptance and original recovery --- ...dot-autonomous-source-worker-operations.md | 127 +++- .../dot-negative-acceptance.md | 117 ++++ .../client-features/management-runtime-mcp.md | 8 + scripts/dot_disposable_fixture.py | 96 ++- scripts/dot_mcp_acceptance.py | 621 +++++++++++++++--- scripts/dot_mcp_negative_acceptance.py | 388 +++++++++++ src/auto_coder/management/worker_lifecycle.py | 1 + .../supervisor_offline_process_issues.py | 11 +- tests/test_dot_disposable_fixture.py | 223 ++++++- tests/test_dot_mcp_acceptance.py | 334 +++++++++- tests/test_dot_mcp_negative_acceptance.py | 303 +++++++++ tests/test_management_worker_lifecycle.py | 9 + 12 files changed, 2083 insertions(+), 155 deletions(-) create mode 100644 docs/client-features/dot-negative-acceptance.md create mode 100644 scripts/dot_mcp_negative_acceptance.py create mode 100644 tests/test_dot_mcp_negative_acceptance.py diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index df58fddec..f09f8430b 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -6,8 +6,9 @@ owns stage selection and current scopes. Dot never upgrades itself from `observe` to `beta` or `full`, never grants itself consent, and never substitutes a workflow run, a publication tag or a running container for an actual installed source and advancing producer heartbeat. The non-interactive -read-only observer below is **a partial implementation**, not the required -live end-to-end acceptance harness. +read-only observer and the separate disposable acceptance client have distinct +roles. The client implements the positive beta-to-release sequence described +below; live producer evidence for the entire #2608 contract is still required. ## Bootstrap and authority inventory @@ -61,7 +62,9 @@ supersede an earlier selection. ## Resumable checkpoint and finite recovery -The [schema-1 example checkpoint](../examples/management-mcp/dot-rollout-checkpoint.example.json) lists every required field. Copy it only into an operator-owned private location, populate original identifiers from verified sources, and never include bearer/refresh credentials.\nThe `schema=1` operator checkpoint contains only nonsecret identifiers: +The [schema-1 example checkpoint](../examples/management-mcp/dot-rollout-checkpoint.example.json) lists every required field. Copy it only into an operator-owned private location, populate original identifiers from verified sources, and never include bearer/refresh credentials. + +The `schema=1` operator checkpoint contains only nonsecret identifiers: operator `stage`, original task ID, repository and Issue/PR numbers, immutable operator-selected stage revision, exact source SHA and selected CI run/attempt/tested ref and OID, configured beta/release targets, original management operation IDs, @@ -195,16 +198,18 @@ are 0 only for independently established `PASS`, 1 for `FAIL`, 2 for unexecuted acceptance for success. Any future `PASS` requires an independent original producer oracle, not just an MCP JSON response or a checkpoint. -**Current acceptance gap:** This read-only observer has no mutation mode; the -separate opt-in disposable harness below implements only limited fixture-only -mutation. The real authenticated beta/release producer flow, original GitHub -Actions publication, staging/unknown/recovery/policy downgrade acceptance and -dot scheduler execution remain **NOT_RUN/BLOCKED**, never simulated `PASS`. +**Current acceptance gap:** This read-only observer has no mutation mode. The +separate opt-in disposable harness implements beta source installation, beta +acceptance, exact Git source promotion and independent release activation. +Implementation and deterministic regression success do not establish live +OAuth, GitHub Actions publication, worker heartbeat, fault/recovery or scheduler +acceptance. Record each missing real scenario as **NOT_RUN/BLOCKED**, never +simulated `PASS`. Production beta/release changes are prohibited. Keep this implementation PR DRAFT until genuine end-to-end verification, exact-head CI and all review threads have passed. -### Disposable-only acceptance harness (partial) +### Disposable-only acceptance harness `scripts/dot_disposable_fixture.py` creates an owner-private, per-fixture Docker Compose configuration using the fixed `kitamura-tetsuo/auto-coder-test` @@ -219,6 +224,35 @@ test repository, including no untracked files. The fixture starts with an empty UID allowlist and intentionally held worker bootstrap; both require separate operator authorization and real test-only setup to proceed. +Both workers install source from `kitamura-tetsuo/auto-coder-test`. Workload +ownership stays distinct: beta processes only the fixed +`kitamura-tetsuo/auto-coder-test-beta` canary repository, while release processes +`kitamura-tetsuo/auto-coder-test`. Their instance IDs, runtime/workspace +directories and ownership records remain independent. Creating the canary +repository and providing its scoped read credential are explicit operator +bootstrap prerequisites. Published acceptance records bind the common source +repository and separately retain the actual beta workload repository. The idle +fixture enables its sole task-only Jules backend so the real CLI can initialize; +empty production Issue/PR author allowlists, disabled label changes and disabled +automatic/dependabot merge keep it from starting repository work. No LLM +credential is mounted. The held bootstrap must still be explicitly released by +the external fixture operator; no application acceptance is inferred from it. + +For source promotion, additionally provide both +`--source-release-workflow-id` and `--accepted-profile-revision` to the generator. +These must identify the actual test repository workflow and acceptance profile. +They generate `/fixture/policy/source-release-policy.json`, bounded to the exact +fixture beta instance, and the corresponding management configuration. The +matching policy used by the real test repository Actions workflow must also be +reviewed. The default operating policy remains observe-only. The generated +credential files are empty and owner-private: `source-public-read` is the +independent Git/Actions reader and the release workload reader; +`beta-work-read` is mounted only in beta and reads only the fixed canary +workload repository. `source-acceptance-publish` authorizes only test +acceptance publication, and `source-release-dispatch` authorizes the test-only +promotion workflow. Current owner authorization and correctly scoped credentials +are required before these operations can run. Never copy production credentials. + ```sh python scripts/dot_disposable_fixture.py \ --fixture-id pr2835-sandbox-20261112 \ @@ -245,14 +279,74 @@ read-only heartbeat sampler polls every 5 seconds for up to 150 seconds because the idle producer's maintenance heartbeat may advance only once per 60 seconds. Every sampled worker is rechecked until all have advanced, so an earlier worker cannot disappear during a later worker's wait. The same two-sample rule gates -`beta-source` completion. Mere container liveness, +`beta-source` and `release-source` completion. Mere container liveness, accepted MCP receipts or a copied Git SHA are insufficient. `scripts/dot_mcp_acceptance.py` is opt-in and returns `NOT_RUN` without a fixture. Its default path only reads current authenticated MCP observations. The explicit -`--enable-fixture-mutation` mode confines available actions to test-only beta -reevaluation or beta-source selection and requires current selected stage, -revision, original Git/Actions proof and an immutable fsynced pre-send ticket. +`--enable-fixture-mutation` mode requires the current selected stage/revision, +original Git/Actions proof and an immutable fsynced pre-send ticket. It exposes +only the following fixed actions against the exact disposable targets: + +| Scenario | Required operator stage | Independent completion evidence | +| --- | --- | --- | +| `reevaluate` | beta or full | Original worker invalidation receipt; never Issue completion | +| `beta-source` | beta or full | Confirmed original deployment, exact installed source and advancing same-child heartbeat | +| `beta-acceptance` | beta or full | Original deployed beta child, real `PASS_PUBLISHED` receipt and independently read immutable acceptance Git ref/OID | +| `promote-source` | full | Original tested-source/acceptance chain, successful exact Actions attempt, immutable release catalog and current source/operation selection; release worker deployment remains separate | +| `release-source` | full | Original promotion/catalog/acceptance linkage, independent release deployment receipt and exact installed release child with advancing heartbeat | + +The first full-stage promotion does not require a fabricated preexisting +promotion catalog. It requires the original confirmed beta acceptance and +current full authority; its successful result supplies the promotion evidence +needed by the separate release activation. A green promotion cannot report +`positive_deployment_proven=true` or `release_worker_deployed=true`. + +Each scenario has one private original-send ticket. The next permitted action +uses its own immutable idempotency key; it cannot reuse a key from another action +on the same target. Any unresolved same-target ticket blocks a new action even +with a different key. A negative probe can release that local fence only after +the actual refusal plus independent real-journal absence has been recorded as +`REFUSED`; a refusal response by itself is insufficient. BUSY/CONFLICT responses +can include an occupying operation ID, which is never adopted as this send's ID. + +The initial checkpoint supplies the original task/repository/Issue/PR, source, +CI and operator selection. Subsequent scenarios can take deployment/acceptance/ +promotion IDs from that checkpoint or the same fixture's completed original +tickets. The client re-queries the actual producer and Git objects before using +them. Successful JSON `details` records the exact derived refs/OIDs and worker +identities; the same evidence is durably appended to the original ticket. + +Use `--resume-original` with the same scenario, checkpoint authority and key to +continue a known original operation after interruption. The client loads its +saved arguments and ID before transport and never repeats admission. An unknown +admission without a saved original ID remains blocked. `--wait-seconds` defaults +to 300 and is limited to 900; a timeout preserves the original instead of +creating another operation. For example, inside the fixture management container: + +```sh +python -m scripts.dot_mcp_acceptance \ + --fixture-manifest /fixture/fixture.json \ + --checkpoint /fixture/management/original-beta-source.json \ + --scenario beta-source --enable-fixture-mutation \ + --operator-stage beta --operator-stage-revision ORIGINAL_OPERATOR_REVISION + +python -m scripts.dot_mcp_acceptance \ + --fixture-manifest /fixture/fixture.json \ + --checkpoint /fixture/management/original-beta-source.json \ + --scenario beta-source --enable-fixture-mutation --resume-original \ + --operator-stage beta --operator-stage-revision ORIGINAL_OPERATOR_REVISION +``` + +The source promotion producer currently needs an explicit original-operation +reconciliation to observe its dispatched Actions run. Only the authorized +`promote-source` mutation scenario can call `auto_coder_operation_get`, bound to +its already-persisted exact target and operation ID, and only after the producer +reports `effect_state=sent_unknown`. It never resubmits the promotion or changes +its key. Default observation and `inspect-original` exclusively use the +non-dispatching reader. Original journal inspection runs before worker +diagnostics, so a failed beta worker cannot hide an original release operation. + Unknown, interrupted or conflicting sends remain `BLOCKED`; the original idempotency key is never reissued. A transport exception after a durable original send ticket cannot report `effect_dispatched=false`: the report @@ -274,3 +368,10 @@ GitHub Actions evidence, staged unknown-effect/recovery regressions, and dot scheduler recurrence still require independent end-to-end verification. Never use this harness against production or existing beta/release services or treat its local unit tests as deployment, authorization or merge proof. + +The separate [negative acceptance procedure](dot-negative-acceptance.md) covers +actual observe/policy refusals and original-ticket/held-drain/recovery evidence. +Its real-journal checks can establish a refused admission without removing an +unresolved effect or inventing an operation ID. Keep these negative results +separate from the positive beta-to-release records and scheduler capability +evidence; none substitutes for another. diff --git a/docs/client-features/dot-negative-acceptance.md b/docs/client-features/dot-negative-acceptance.md new file mode 100644 index 000000000..b68d2bc23 --- /dev/null +++ b/docs/client-features/dot-negative-acceptance.md @@ -0,0 +1,117 @@ +# Disposable dot refusal and bounded recovery acceptance + +The negative scenario companion to +[the staged dot runbook](dot-autonomous-source-worker-operations.md) is +`scripts/dot_mcp_negative_acceptance.py`. It checks the specific refusal and +stop boundaries in #2608/REQ-004/007/008/010/011. It is not a new management +controller, a policy editor, a recovery dispatcher or a data rollback feature. + +Without `--fixture-manifest`, execution returns `NOT_RUN` (exit 3), opens no +network connection and sends no effect. With a manifest, it enforces the same +private container nonce, fixed test repository, physical beta/release targets, +Firebase project, current OAuth identity and scopes as the positive acceptance +client. The checkpoint retains both disposable targets and the exact source. +The current operator stage and revision must match the saved selection. +Bearer values come only from `AUTO_CODER_MCP_ACCESS_TOKEN`, never arguments, +checkpoints, reports or logs. Nonsecret Loguru diagnostics go to stdout and a +rotating file; the last stdout line is the JSON report. + +## Executable scenarios + +| Scenario | Original producer evidence | Result and limit | +| --- | --- | --- | +| `observe-refusal` | The externally selected exact two-target `observe` policy, current real worker fence, authenticated `DENIED/profile_denies_mutation`, and an independent read-only SQLite lookup proving the original request key was never admitted | Negative boundary `PASS`; no deployment claim. The one fixed reevaluation probe requires explicit `--enable-refusal-probe`. | +| `policy-downgrade` | All observe-refusal evidence plus the existing successful reevaluation ticket and its real owned `accepted_for_reevaluation` worker receipt | Negative boundary `PASS`; proves earlier acceptance and current refusal. A saved stage name alone cannot prove a downgrade. | +| `held-drain` | Exact original lifecycle operation remains `DRAINING`, its original drain step exists with no start, and current signed Supervisor projection still identifies the same live incumbent process/runtime | Operational `BLOCKED` with `negative_boundary_verified=true`; no start, retry or recovery. | +| `unknown-effect` | Two non-dispatching reads of the same retained original operation and target continue to show the unresolved producer outcome | Operational `BLOCKED` with the negative boundary verified; an unknown original effect is never reported completed or unsent. | +| `repeated-failure` | The original producer reports one consumed recovery attempt and a held/failed/restored-prior-runtime result; its action, target, exact source and failure category are retained durably | Operational `BLOCKED`. The original failure signature remains exhausted across restarts and cannot be rebound to an invented retry operation. | +| `data-rollback` | Non-dispatching original runtime/lifecycle evidence | `BLOCKED`. No supported producer certifies persistent-data compatibility or restoration. Even a confirmed prior Python runtime restoration cannot establish that databases, files, migrations or external effects were rolled back. Escalate to the operator and keep admission suspended. | + +A `PASS` in this companion means that a specifically named refusal assertion +was demonstrated. `positive_deployment_proven` always remains false. An unknown +operation, exhausted budget or unsupported data restoration retains an +operational `BLOCKED` verdict even when the stop boundary is independently +verified. No result here replaces positive beta acceptance, Git publication, +release activation or scheduler evidence. + +## Refusal probe protocol + +The immutable Python source and release workload use +`kitamura-tetsuo/auto-coder-test`; the beta workload uses the distinct +`kitamura-tetsuo/auto-coder-test-beta`. Both physical targets retain exclusive +repository ownership. The source proof is not a grant to process that source +repository concurrently in both channels. + +An external operator first selects the fixture's `observe` policy. The client +reads that policy but cannot change it. An explicit refusal probe may attempt +only a fixed beta Issue reevaluation on `kitamura-tetsuo/auto-coder-test-beta`; it +cannot request release activation, arbitrary commands or Docker operations. +The request uses current original worker fences and a persisted idempotency +key. A pending original operation or consumed recovery budget prevents even +this negative probe. + +Before sending, the client independently opens the management admission +SQLite database in `mode=ro` and verifies that the original key is absent. +It then fsyncs a normal fixture original-send ticket. Only the exact producer +policy denial, no returned operation ID and a second independent empty-key +lookup permit a terminal `REFUSED` ticket. The report includes a digest of +that nonsecret absence witness. An unavailable database, unexpected response, +lost response or contradictory admission leaves the ticket held; the client +never allocates a replacement key. If the producer unexpectedly admits the +request, the original operation ID is persisted and the scenario fails for +operator inspection. It is never retried to obtain an expected rejection. +An operation ID attached to `BUSY`, `CONFLICT` or an unbound response can name +another intent; it is never adopted as this probe's original admission. + +For example, inside the already authorized disposable management container: + +```sh +python -m scripts.dot_mcp_negative_acceptance \ + --fixture-manifest /fixture/fixture.json \ + --checkpoint /fixture/management/operator-refusal-checkpoint.json \ + --operator-stage observe \ + --operator-stage-revision ORIGINAL_OPERATOR_REVISION \ + --scenario observe-refusal --enable-refusal-probe \ + --fixture-issue-number ACTUAL_TEST_ISSUE_NUMBER +``` + +The placeholders must be replaced with original operator-selected values. +For downgrade testing, retain the positive client's successful +`dot-fixture-reevaluate-original-send.json`; after the operator changes current +policy to `observe`, select `policy-downgrade` with a separate original probe +key. A known completed reevaluation is not an unresolved-operation retry. + +## Original-operation and recovery protocol + +Read-only scenarios require `pending_operation_id` and its original +`SENT_UNKNOWN` or `NEEDS_ATTENTION` checkpoint state, uniquely bound to the +saved beta or release operation field. They request only +`auto_coder_operation_inspect`; they never call the effectful +`auto_coder_operation_get`. Inspecting the original operation precedes any +worker health dependency, so a dead child does not suppress journal evidence. + +The repeated-failure scenario records the actual producer's consumed attempt +in `dot-recovery-.json`, where the signature hashes the +immutable action, target, source SHA and producer failure category. The file +retains the original operation and is created exclusively and fsynced. Repeated +reads preserve its bytes. Another operation cannot replace that failure's +original proof. This companion never consumes a new recovery attempt or +circumvents the producer's normal retry, slot, exhaustion or merge gates. + +For `data-rollback`, the explicit unsupported outcome is the safety contract. +There is no instruction to implement persistent-data rollback, manufacture an +incompatibility oracle or turn an operator assertion into producer evidence. +An operator must assess restoration and compatibility outside the runtime +rollout loop before resuming admission. + +## Verification provenance + +`tests/test_dot_mcp_negative_acceptance.py` is an **offline** regression suite. +Its refusal/downgrade cases use actual HTTP policy, grant and SQLite code with +the existing controlled Firebase network-edge fixture. Lifecycle-consumer +cases use the actual lifecycle projection over deterministic typed inputs. +These tests prove consumer behavior and regression boundaries, not a live +OAuth session, a real installed worker, a physical drain or a GitHub action. +The externally provisioned disposable scenarios above must be run separately +with their original evidence; absent credentials or physical fixtures remain +`BLOCKED`/`NOT_RUN`. diff --git a/docs/client-features/management-runtime-mcp.md b/docs/client-features/management-runtime-mcp.md index e423592f7..bac86f70e 100644 --- a/docs/client-features/management-runtime-mcp.md +++ b/docs/client-features/management-runtime-mcp.md @@ -115,6 +115,14 @@ dead child is never a healthy worker observation. Diagnostics always come from the worker's existing source-projected, redacted and bounded UDS routes; the manager does not read worker files or synthesize missing history. +Source-worker lifecycle operation readback includes the durable journal's +`effect_state` independently of the lifecycle `status` and operation `phase`. +An advanced snapshot does not change `sent_unknown` into `confirmed`. +Consumers require a succeeded phase, confirmed effect, and independent current +installed-source and advancing-heartbeat evidence before accepting deployment. +This adds no dispatch, execution trace, or dashboard event; it exposes an +existing journal fact through the authenticated MCP projection only. + Log pages have one complete private-response budget. The Supervisor reserves the compact authenticated-envelope bytes from the existing 256 KiB limit and asks the UDS producer to fit its source page into the remainder. Recent-tail diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index 8126f23bc..4f787de06 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -22,6 +22,7 @@ from loguru import logger TEST_REPOSITORY = "kitamura-tetsuo/auto-coder-test" +BETA_WORK_REPOSITORY = "kitamura-tetsuo/auto-coder-test-beta" MANAGER_IMAGE = "auto-coder:management-fixture-17a312445be4" WORKER_IMAGE = "auto-coder:release-acceptance-065555a6" SAFE_FIXTURE = re.compile(r"pr[0-9]{3,6}-[a-z0-9-]{6,28}\Z") @@ -84,8 +85,10 @@ def provision( owner_uid: int, owner_gid: int, test_source: Path | None = None, + source_release_workflow_id: int | None = None, + accepted_profile_revision: str | None = None, ) -> Path: - """Write a fresh topology constrained to the test repository and private volumes.""" + """Write a fresh topology constrained to the fixed test repositories and private volumes.""" source = source.resolve(strict=True) if not (source / "pyproject.toml").is_file() or not (source / "tests/fixtures/supervisor_offline_pid1.py").is_file(): raise ValueError("source root must be the checked-out Auto-Coder test fixture source") @@ -97,6 +100,14 @@ def provision( raise ValueError("fixture worker cannot run as root") test_source = test_source.resolve(strict=True) if test_source is not None else None test_sha = _source_fixture_revision(test_source) + release_configuration = source_release_workflow_id is not None or accepted_profile_revision is not None + if release_configuration: + if type(source_release_workflow_id) is not int or source_release_workflow_id <= 0: + raise ValueError("source release requires the exact positive test-repository workflow ID") + if not isinstance(accepted_profile_revision, str) or re.fullmatch(r"[0-9a-f]{64}", accepted_profile_revision) is None: + raise ValueError("source release requires the exact accepted runtime profile revision") + if not test_sha: + raise ValueError("source release requires a clean test-repository source checkout") scratch = source / ".agent-tmp" if scratch.exists() and (scratch.is_symlink() or not scratch.is_dir()): raise ValueError("fixture scratch root must be an ordinary directory") @@ -111,10 +122,11 @@ def provision( "beta/runtime/home", "beta/runtime/logs", "beta/workspace", + "beta/routing", "release/runtime/home", "release/runtime/logs", "release/workspace", - "routing", + "release/routing", ): (root / directory).mkdir(parents=True, mode=0o700) nonce = secrets.token_hex(16) @@ -122,15 +134,23 @@ def provision( subnet = f"10.249.{octet}.0/24" addresses = {"beta": f"10.249.{octet}.11", "release": f"10.249.{octet}.12"} beta_id, release_id = "dot-" + fixture_id + "-beta", "dot-" + fixture_id + "-release" - release_repo = "fixture/" + fixture_id + "-release" project = "dot-" + fixture_id for role in ("beta", "release"): _private_file(root / "secrets" / f"{role}-bridge", base64.b64encode(os.urandom(48)) + b"\n") _private_file(root / "secrets/source-public-read", "") - _private_file(root / "routing/ownership.json", json.dumps({TEST_REPOSITORY: "beta", release_repo: "release"})) + _private_file(root / "secrets/beta-work-read", "") for role in ("beta", "release"): - _private_file(root / role / "runtime/llm_config.toml", '[backend]\norder = ["jules"]\n\n[backends.jules]\nbackend_type = "jules"\nenabled = false\n') + # Both disposable roles install the same test source, but neither can + # dispatch Issue/PR work: the real author allowlists below are empty. + # Workload ownership remains globally unique and private to each role; + # source provenance is independent of that monitored repository. + repository = BETA_WORK_REPOSITORY if role == "beta" else TEST_REPOSITORY + _private_file(root / role / "routing/ownership.json", json.dumps({repository: role})) + # The production CLI rejects an empty ordinary candidate pool before + # starting its heartbeat. A task-only candidate avoids eager model + # clients; no provider key or synchronous fallback is configured. + _private_file(root / role / "runtime/llm_config.toml", '[backend]\norder = ["jules"]\n\n[backends.jules]\nbackend_type = "jules"\nenabled = true\n') _private_file(root / "management/oauth/allowed_uids.json", json.dumps({"project_id": firebase_project, "allowed_uids": []})) _private_file( root / "policy/operating-policy.yaml", @@ -138,12 +158,45 @@ def provision( { "profile": "observe", "targets": { - beta_id: {"channel": "beta", "repositories": [TEST_REPOSITORY]}, - release_id: {"channel": "release", "repositories": [release_repo]}, + beta_id: {"channel": "beta", "repositories": [BETA_WORK_REPOSITORY]}, + release_id: {"channel": "release", "repositories": [TEST_REPOSITORY]}, }, } ), ) + release_settings: dict[str, object] = {} + if release_configuration: + # These empty, separately mounted files are populated only by the + # operator with test-repository-scoped credentials. Generation grants + # no authority and never contacts GitHub or starts a container. + _private_file(root / "secrets/source-acceptance-publish", "") + _private_file(root / "secrets/source-release-dispatch", "") + _private_file( + root / "policy/source-release-policy.json", + json.dumps( + { + "schema_version": 1, + "source_repository": TEST_REPOSITORY, + "release_target": "release", + "release_ref": "refs/tags/auto-coder-release-source-current", + "accepted_beta_instance_ids": [beta_id], + "accepted_profiles": [{"profile_id": "beta-runtime-smoke-v1", "content_revision": accepted_profile_revision}], + "maximum_evidence_age_seconds": 86400, + "source_test_workflow_path": ".github/workflows/test-source-for-runtime.yml", + "promotion_workflow_path": ".github/workflows/promote-accepted-source-release.yml", + "release_branch": "main", + }, + sort_keys=True, + indent=2, + ) + + "\n", + ) + release_settings = { + "source_release_policy_path": "/fixture/policy/source-release-policy.json", + "source_release_token_file": "/fixture/secrets/source-release-dispatch", + "source_release_target_id": release_id, + "source_release_workflow_id": source_release_workflow_id, + } _private_file( root / "management/config.yaml", _yaml( @@ -157,7 +210,8 @@ def provision( "local_development": True, "firebase_project_id": firebase_project, "source_repository": TEST_REPOSITORY, - "source_evidence_token_file": "/fixture/secrets/source-public-read", + "source_evidence_token_file": "/fixture/secrets/source-acceptance-publish" if release_configuration else "/fixture/secrets/source-public-read", + **release_settings, "enabled_scopes": SCOPES, "clients": [{"client_id": "dot-disposable-operator", "redirect_uris": ["http://127.0.0.1:49152/callback"], "allowed_scopes": SCOPES}], } @@ -170,7 +224,7 @@ def provision( "target_id": beta_id if role == "beta" else release_id, "supervisor_instance_id": beta_id if role == "beta" else release_id, "channel": role, - "repository": TEST_REPOSITORY if role == "beta" else release_repo, + "repository": BETA_WORK_REPOSITORY if role == "beta" else TEST_REPOSITORY, "bridge_address": addresses[role], "bridge_port": 8843, "bridge_secret_file": f"/fixture/secrets/{role}-bridge", @@ -181,6 +235,7 @@ def provision( services: dict[str, object] = {} for role in ("beta", "release"): role_id = beta_id if role == "beta" else release_id + read_token = "beta-work-read" if role == "beta" else "source-public-read" fixture_mounts = [ f"{source}:/source:ro", f"{source}/tests/fixtures:/fixtures:ro", @@ -200,7 +255,9 @@ def provision( "HOME": "/runtime/home", "AUTO_CODER_SUPERVISOR_INSTANCE_ID": role_id, "AUTO_CODER_CHANNEL": role, - "AUTO_CODER_REPOSITORY": TEST_REPOSITORY if role == "beta" else release_repo, + "AUTO_CODER_REPOSITORY": BETA_WORK_REPOSITORY if role == "beta" else TEST_REPOSITORY, + "AUTO_CODER_ISSUE_ALLOWLIST": "[]", + "AUTO_CODER_PR_ALLOWLIST": "[]", "AUTO_CODER_RUNTIME_ROOT": "/runtime", "AUTO_CODER_WORKSPACE_ROOT": "/workspace", "AUTO_CODER_OWNERSHIP_FILE": "/fixture/routing/ownership.json", @@ -220,11 +277,12 @@ def provision( "volumes": [ *fixture_mounts, f"{root}/secrets/{role}-bridge:/fixture/secrets/{role}-bridge:ro", - # The real worker acquires an ownership.json.lock in this - # fixture-private directory; keep only this test mount writable. - f"{root}/routing:/fixture/routing:rw", + # The real worker acquires an ownership.json.lock in its + # fixture-private directory; no peer ownership is writable. + f"{root}/{role}/routing:/fixture/routing:rw", f"{root}/{role}/runtime:/runtime", f"{root}/{role}/workspace:/workspace", + *([f"{root}/secrets/{read_token}:/fixture/secrets/github-test-scoped:ro"] if test_source is not None else []), ], "restart": "no", } @@ -249,6 +307,14 @@ def provision( f"{root}/secrets/beta-bridge:/fixture/secrets/beta-bridge:ro", f"{root}/secrets/release-bridge:/fixture/secrets/release-bridge:ro", f"{root}/secrets/source-public-read:/fixture/secrets/source-public-read:ro", + *( + [ + f"{root}/secrets/source-acceptance-publish:/fixture/secrets/source-acceptance-publish:ro", + f"{root}/secrets/source-release-dispatch:/fixture/secrets/source-release-dispatch:ro", + ] + if release_configuration + else [] + ), f"{root}/fixture.json:/fixture/fixture.json:ro", ], "restart": "no", @@ -297,6 +363,8 @@ def main() -> int: parser.add_argument("--fixture-id", required=True) parser.add_argument("--firebase-project-id", required=True) parser.add_argument("--test-source", type=Path, help="Clean main of the explicitly approved test-only Git repository") + parser.add_argument("--source-release-workflow-id", type=int, help="Exact test-repository promotion workflow ID; requires --accepted-profile-revision and --test-source") + parser.add_argument("--accepted-profile-revision", help="Exact beta-runtime-smoke-v1 revision allowed by the test repository's main promotion policy") parser.add_argument("--uid", type=int, default=os.getuid()) parser.add_argument("--gid", type=int, default=os.getgid()) args = parser.parse_args() @@ -313,6 +381,8 @@ def main() -> int: owner_uid=args.uid, owner_gid=args.gid, test_source=args.test_source, + source_release_workflow_id=args.source_release_workflow_id, + accepted_profile_revision=args.accepted_profile_revision, ) except (OSError, ValueError) as error: logger.error("No fixture created: {}", type(error).__name__) diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 52e69b648..2f285cd55 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -18,7 +18,7 @@ import stat import sys import time -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any @@ -31,14 +31,19 @@ import management_mcp_rollout_smoke as smoke TEST_REPOSITORY = "kitamura-tetsuo/auto-coder-test" +BETA_WORK_REPOSITORY = "kitamura-tetsuo/auto-coder-test-beta" SHA = re.compile(r"[0-9a-f]{40}\Z") NONCE = re.compile(r"[0-9a-f]{32}\Z") FIXTURE = re.compile(r"pr[0-9]{3,6}-[a-z0-9-]{6,28}\Z") -SCENARIOS = frozenset({"observe", "reevaluate", "beta-source", "inspect-original"}) MUTATIONS = { "reevaluate": "auto_coder_reevaluate_target", "beta-source": "auto_coder_deploy_beta_source", + "beta-acceptance": "auto_coder_run_beta_acceptance", + "promote-source": "auto_coder_promote_accepted_source_release", + "release-source": "auto_coder_deploy_release_source", } +SCENARIOS = frozenset({"observe", "inspect-original", *MUTATIONS}) +FULL_SCENARIOS = frozenset({"promote-source", "release-source"}) READS = frozenset(smoke.READ_TOOLS) @@ -125,6 +130,7 @@ class Result: positive_deployment_proven: bool = False effect_dispatched: bool | None = False original_ticket_state: str = "" + details: dict[str, object] = field(default_factory=dict) def bound_local_fixture(fixture: Fixture, *, environ: dict[str, str] | None = None, docker_socket: Path = Path("/var/run/docker.sock")) -> None: @@ -147,8 +153,8 @@ def bound_local_fixture(fixture: Fixture, *, environ: dict[str, str] | None = No if not isinstance(registry, dict) or set(registry) != {"workers"} or not isinstance(registry["workers"], list) or len(registry["workers"]) != 2: raise Blocked("Fixture must have exactly two independent worker bindings") expected = { - fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), - fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), + fixture.target_ids["beta"]: ("beta", BETA_WORK_REPOSITORY), + fixture.target_ids["release"]: ("release", TEST_REPOSITORY), } actual = {} for entry in registry["workers"]: @@ -170,25 +176,36 @@ def bound_local_fixture(fixture: Fixture, *, environ: dict[str, str] | None = No raise Blocked("Worker registry does not match exact disposable beta/release targets") -def original_stage(checkpoint: smoke.DotCheckpoint, scenario: str, selected_stage: str, selected_revision: str, *, mutation: bool) -> None: +def original_stage(checkpoint: smoke.DotCheckpoint, scenario: str, selected_stage: str, selected_revision: str, *, mutation: bool, resume_original: bool = False) -> None: if scenario not in SCENARIOS: raise Blocked("Unsupported acceptance scenario") if not mutation: + if resume_original: + raise Blocked("Original mutation reconciliation requires explicit mutation selection") if scenario not in {"observe", "inspect-original"}: raise Blocked("Read-only is the default; mutation requires explicit selection") + if scenario == "observe" and (checkpoint.pending_effect_state or checkpoint.pending_operation_id): + raise Blocked("Inspect the original unresolved operation before worker-only observation") return if scenario not in MUTATIONS: raise Blocked("No mutating action is available for this scenario") if checkpoint.stage not in {"beta", "full"}: raise Blocked("Observe stage can never dispatch an effect") + if scenario in FULL_SCENARIOS and checkpoint.stage != "full": + raise Blocked("Source promotion and release activation require the current full operator stage") try: smoke.confirm_operator_scope(checkpoint, selected_stage, selected_revision) except smoke.OperatorScopeError as error: raise Blocked(str(error)) from error - required = smoke.required_next_observation(checkpoint) - if required.verdict == "BLOCKED": - raise Blocked(required.reason) - if checkpoint.pending_effect_state or checkpoint.pending_operation_id or checkpoint.recovery_attempts: + if not (checkpoint.source_sha and checkpoint.ci_run_id and checkpoint.ci_run_attempt and checkpoint.tested_source_ref and checkpoint.tested_source_oid and checkpoint.beta_target): + raise Blocked("Exact original tested source, CI and beta target lineage are required") + if scenario in FULL_SCENARIOS and (not checkpoint.release_target or checkpoint.release_target == checkpoint.beta_target): + raise Blocked("Full stage requires a distinct original configured release target") + # A full-stage promotion creates its own catalog. Requiring that completed + # catalog here would make the first legitimate promotion unreachable. + if not resume_original and (checkpoint.pending_effect_state or checkpoint.pending_operation_id): + raise Blocked("Reconcile the original unknown effect before any new send") + if checkpoint.recovery_attempts and not resume_original: raise Blocked("An original unresolved effect or exhausted recovery budget forbids any new send") if not checkpoint.idempotency_key or smoke.SAFE_ID.fullmatch(checkpoint.idempotency_key) is None: raise Blocked("Persist the immutable original idempotency key before admission") @@ -204,6 +221,9 @@ def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotC """Fsync original nonsecret intent before an effect, refusing all replays.""" if path.exists() or path.is_symlink(): raise Blocked("A previous admission may have been sent; reconcile its original ticket before retry") + target = args.get("target_id") + if target not in fixture.target_ids.values(): + raise Blocked("Original intent must target one exact disposable worker") for existing_path in path.parent.glob("dot-fixture-*-original-send.json"): try: previous = json.loads(existing_path.read_text(encoding="utf-8")) @@ -211,17 +231,24 @@ def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotC raise Blocked("A previous original send ticket cannot be checked") from error if not isinstance(previous, dict) or previous.get("fixture_nonce") != fixture.fixture_nonce: raise Blocked("A previous ticket has an untrusted fixture identity") - if previous.get("target") == fixture.target_ids["beta"] and previous.get("idempotency_key") == checkpoint.idempotency_key: + if previous.get("target") == target and previous.get("idempotency_key") == checkpoint.idempotency_key: raise Blocked("Original idempotency key cannot be reused for a different action on one target") + if previous.get("target") == target: + refusal = previous.get("refusal_evidence") + refused = previous.get("effect_state") == "REFUSED" and not previous.get("operation_id") and isinstance(refusal, dict) and refusal.get("operation_absent") is True and isinstance(refusal.get("journal_sha256"), str) and re.fullmatch(r"[0-9a-f]{64}", refusal["journal_sha256"]) is not None + if previous.get("effect_state") != "SUCCEEDED" and not refused: + raise Blocked("Another original effect on this target is unresolved; changing the idempotency key cannot bypass it") record = { "schema": 1, "fixture_nonce": fixture.fixture_nonce, + "task_id": checkpoint.task_id, "work_repository": checkpoint.repository, "issue_number": checkpoint.issue_number, "pr_number": checkpoint.pr_number, "stage_revision": checkpoint.stage_revision, + "source_sha": checkpoint.source_sha, "action": action, - "target": fixture.target_ids["beta"], + "target": target, "idempotency_key": checkpoint.idempotency_key, "args": args, "effect_state": "ADMISSION_UNKNOWN", @@ -301,7 +328,7 @@ def exception_result( return Result(verdict, scenario, reason, operation_id=operation_id, effect_dispatched=None, original_ticket_state=state) -def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, state: str) -> None: +def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, state: str, *, evidence: dict[str, object] | None = None) -> None: """Persist only the observed original ID/state; never authorize resending.""" if state not in {"ADMITTED", "SUCCEEDED"} or smoke.SAFE_ID.fullmatch(operation_id) is None: raise Blocked("Original operation update has an invalid identity") @@ -316,10 +343,17 @@ def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, stat if original.get("effect_state") == "SUCCEEDED" and state != "SUCCEEDED": raise Blocked("Terminal original send cannot regress to an admitted state") updated = {**original, "operation_id": operation_id, "effect_state": state} + if evidence is not None: + updated["terminal_evidence"] = evidence + _replace_original_ticket(path, updated) + + +def _replace_original_ticket(path: Path, record: dict[str, object]) -> None: + """Atomically replace only this fixture-owned original evidence record.""" temporary = path.parent / (".dot-original-" + secrets.token_hex(8)) descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as file: - json.dump(updated, file, sort_keys=True, separators=(",", ":")) + json.dump(record, file, sort_keys=True, separators=(",", ":")) file.write("\n") file.flush() os.fsync(file.fileno()) @@ -331,6 +365,23 @@ def update_original_ticket(path: Path, fixture: Fixture, operation_id: str, stat os.close(directory) +def record_refused_ticket(path: Path, fixture: Fixture, *, reason: str, journal_sha256: str) -> None: + """Record a live refusal only after independent absence in the real journal. + + This is used by the negative acceptance orchestrator after checking the + exact original key. A refusal response alone cannot establish no admission. + """ + if not reason or len(reason) > 256 or re.fullmatch(r"[0-9a-f]{64}", journal_sha256) is None or path.is_symlink(): + raise Blocked("Independent original refusal evidence is invalid") + try: + original = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise Blocked("Original refused admission ticket is unavailable") from error + if not isinstance(original, dict) or original.get("fixture_nonce") != fixture.fixture_nonce or original.get("effect_state") != "ADMISSION_UNKNOWN" or original.get("operation_id") != "": + raise Blocked("An admitted or replaced original ticket cannot be reported as refused") + _replace_original_ticket(path, {**original, "effect_state": "REFUSED", "refusal_evidence": {"reason": reason, "journal_sha256": journal_sha256, "operation_absent": True}}) + + def positive_heartbeat(snapshot: dict[str, Any], *, source_sha: str = "") -> int: if snapshot.get("status") != "OK" or snapshot.get("alive") is not True or snapshot.get("process_state") != "RUNNING": raise Blocked("Signed Supervisor cannot establish a live original worker") @@ -345,8 +396,21 @@ def positive_heartbeat(snapshot: dict[str, Any], *, source_sha: str = "") -> int lifecycle, heartbeat = observation.get("lifecycle"), observation.get("heartbeat") if not isinstance(lifecycle, dict) or lifecycle.get("startup_reconciled") is not True or not isinstance(heartbeat, dict): raise Blocked("Real startup reconciliation or worker heartbeat is unavailable") + invocations = observation.get("invocations") + worker = snapshot.get("worker") + if ( + lifecycle.get("state") != "running" + or lifecycle.get("daemon_serving") is not True + or "startup_reconciliation_error" not in lifecycle + or lifecycle["startup_reconciliation_error"] is not None + or lifecycle.get("startup_failure") is not None + or not isinstance(invocations, dict) + or invocations.get("state") != "running" + or (isinstance(worker, dict) and worker.get("stop_requested") is True) + ): + raise Blocked("A draining, failed or nonserving child cannot establish worker readiness") sequence = heartbeat.get("sequence") - if heartbeat.get("available") is not True or type(sequence) is not int or sequence < 0: + if heartbeat.get("available") is not True or type(sequence) is not int or sequence <= 0 or not str(heartbeat.get("stage", "")).startswith("producer:"): raise Blocked("A running container is not a positive heartbeat producer") return sequence @@ -385,6 +449,15 @@ def identity(snapshot: dict[str, Any]) -> tuple[str, str, str, str, dict[str, An raise HeartbeatNotAdvanced("Original worker heartbeat did not advance") +def _unique_json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, item in pairs: + if key in result: + raise ValueError("duplicate original receipt field") + result[key] = item + return result + + def verified_reevaluation_receipt(value: dict[str, Any], *, operation_id: str, target_id: str, process_run_id: str) -> bool: """Recognize the actual journal's accepted-for-reevaluation projection. @@ -403,16 +476,8 @@ def verified_reevaluation_receipt(value: dict[str, Any], *, operation_id: str, t if not isinstance(receipt, dict) or receipt.get("source") != "worker-uds-invalidation-receipt" or not isinstance(receipt.get("result"), str): raise Blocked("The original signed worker invalidation receipt is unavailable") - def unique_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: - result: dict[str, object] = {} - for key, item in pairs: - if key in result: - raise ValueError("duplicate original receipt field") - result[key] = item - return result - try: - detail = json.loads(receipt["result"], object_pairs_hook=unique_pairs) + detail = json.loads(receipt["result"], object_pairs_hook=_unique_json_pairs) except ValueError as error: raise Blocked("Original worker receipt content cannot be verified") from error if ( @@ -429,14 +494,25 @@ def unique_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: return True -def verify_real_source(checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> None: +def _source_client(): + """Use only the fixture's read credential and fixed disposable repository.""" + from auto_coder.util.gh_cache import GitHubGitDataClient + + token = os.environ.get("AUTO_CODER_SOURCE_READ_TOKEN", "") + path = Path("/fixture/secrets/source-public-read") + if not token and path.is_file(): + token = path.read_text(encoding="utf-8").strip() + return GitHubGitDataClient(token, TEST_REPOSITORY) + + +def verify_real_source(checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> dict[str, object]: """Read the original public Git ref, annotated object and source Actions attempt.""" if not fixture.source_fixture_available or checkpoint.source_sha != fixture.source_fixture_sha: raise Blocked("Source under test is not the fixture's originally pinned main commit") + from auto_coder.source_release_catalog import digest from auto_coder.tested_source_refs import TestedSourcePublisher - from auto_coder.util.gh_cache import GitHubGitDataClient - publisher = TestedSourcePublisher(GitHubGitDataClient("", TEST_REPOSITORY), TEST_REPOSITORY) + publisher = TestedSourcePublisher(_source_client(), TEST_REPOSITORY) proof = publisher.read(checkpoint.source_sha) if proof is None: raise Blocked("The exact immutable tested-source Git ref has not been published") @@ -446,6 +522,147 @@ def verify_real_source(checkpoint: smoke.DotCheckpoint, fixture: Fixture) -> Non if (str(record.run_id), str(record.run_attempt)) != (checkpoint.ci_run_id, checkpoint.ci_run_attempt): raise Failed("Original source GitHub Actions run/attempt differs from published immutable proof") publisher.verify_run(record, recorded_attempt=True) + return {"tested_source_record_sha256": digest(asdict(record))} + + +def _receipt(value: dict[str, object], source: str) -> dict[str, object]: + receipt = value.get("receipt") + if not isinstance(receipt, dict) or receipt.get("source") != source or not isinstance(receipt.get("result"), str): + raise Blocked("Original confirmed producer receipt is unavailable") + + try: + detail = json.loads(receipt["result"], object_pairs_hook=_unique_json_pairs) + except (ValueError, TypeError) as error: + raise Blocked("Original producer receipt cannot be parsed exactly") from error + if not isinstance(detail, dict): + raise Blocked("Original producer receipt is not a structured record") + return detail + + +def verified_acceptance_receipt(value: dict[str, object], *, operation_id: str, target_id: str, source_sha: str) -> dict[str, object]: + if (value.get("operation_id"), value.get("target_id"), value.get("action")) != (operation_id, target_id, "run_beta_acceptance"): + raise Failed("Original beta acceptance operation or physical target differs") + if value.get("status") != "PASS_PUBLISHED" or value.get("phase") != "succeeded" or value.get("effect_state") != "confirmed": + raise Blocked("Local beta smoke is not independently published acceptance") + receipt = _receipt(value, "github-source-acceptance-readback") + if receipt.get("status") != "PASS_PUBLISHED" or receipt.get("tag_ref") != "refs/tags/auto-coder-beta-acceptance-" + operation_id or receipt.get("direct_commit_sha") != source_sha or not isinstance(receipt.get("tag_object_oid"), str) or SHA.fullmatch(receipt["tag_object_oid"]) is None: + raise Failed("Published beta acceptance receipt differs from the original source") + return {"beta_acceptance_operation_id": operation_id, "acceptance_ref": receipt["tag_ref"], "acceptance_oid": receipt["tag_object_oid"]} + + +def verify_published_acceptance(checkpoint: smoke.DotCheckpoint, fixture: Fixture, evidence: dict[str, object]) -> dict[str, object]: + """Independently read the actual immutable Git acceptance object.""" + from auto_coder.management.source_acceptance import SourceAcceptancePublisher + + operation_id = str(evidence["beta_acceptance_operation_id"]) + observed = SourceAcceptancePublisher(_source_client(), TEST_REPOSITORY).read(operation_id) + if observed is None: + raise Blocked("Original beta acceptance Git object is not available") + if (observed.tag_ref, observed.tag_object_oid, observed.direct_commit_sha) != (evidence["acceptance_ref"], evidence["acceptance_oid"], checkpoint.source_sha): + raise Failed("Independent beta acceptance Git readback differs from the original receipt") + document = json.loads(observed.annotation) + local = document.get("local_evidence") + if not isinstance(local, dict) or (local.get("operation_id"), local.get("instance_id"), local.get("channel"), local.get("repository"), local.get("worker_repository"), local.get("source_sha")) != ( + operation_id, + fixture.target_ids["beta"], + "beta", + TEST_REPOSITORY, + BETA_WORK_REPOSITORY, + checkpoint.source_sha, + ): + raise Failed("Published beta acceptance is not from the original disposable beta worker") + return {**evidence, "acceptance_annotation_sha256": hashlib.sha256(observed.annotation.encode("utf-8")).hexdigest()} + + +def verified_promotion_receipt(value: dict[str, object], *, operation_id: str, target_id: str, source_sha: str) -> dict[str, object]: + if (value.get("operation_id"), value.get("target_id"), value.get("action")) != (operation_id, target_id, "promote_accepted_source_release"): + raise Failed("Original source promotion operation or target differs") + if value.get("status") != "SUCCEEDED" or value.get("phase") != "succeeded" or value.get("effect_state") != "confirmed" or value.get("source_promotion") != "SOURCE_PROMOTION_CONFIRMED" or value.get("worker_deployed") is not False: + raise Blocked("Original source promotion is unresolved, superseded, or not independently confirmed") + receipt = _receipt(value, "github-source-release-readback") + run = value.get("workflow_run") + if ( + receipt.get("outcome") != "SOURCE_PROMOTION_CONFIRMED" + or receipt.get("source_sha") != source_sha + or receipt.get("worker_deployed") is not False + or not isinstance(receipt.get("catalog_oid"), str) + or SHA.fullmatch(receipt["catalog_oid"]) is None + or not isinstance(run, dict) + or type(receipt.get("run_id")) is not int + or receipt["run_id"] <= 0 + or type(receipt.get("run_attempt")) is not int + or receipt["run_attempt"] <= 0 + or (run.get("id"), run.get("run_attempt")) != (receipt["run_id"], receipt["run_attempt"]) + ): + raise Failed("Source promotion receipt does not bind its original catalog and Actions attempt") + return { + "promotion_operation_id": operation_id, + "catalog_ref": "refs/tags/auto-coder-source-release-" + operation_id, + "catalog_oid": receipt["catalog_oid"], + "promotion_run_id": receipt["run_id"], + "promotion_run_attempt": receipt["run_attempt"], + "workflow_run": run, + "release_worker_deployed": False, + } + + +def confirm_saved_promotion(checkpoint: smoke.DotCheckpoint, evidence: dict[str, object]) -> None: + """A server observation cannot silently replace a saved Actions identity.""" + if checkpoint.promotion_operation_id and checkpoint.promotion_operation_id != evidence["promotion_operation_id"]: + raise Failed("Current promotion differs from the original selected operation") + if checkpoint.promotion_run_id and checkpoint.promotion_run_id != str(evidence["promotion_run_id"]): + raise Failed("Current promotion differs from the original selected Actions run") + if (checkpoint.promotion_catalog_ref or checkpoint.promotion_catalog_oid) and (checkpoint.promotion_catalog_ref, checkpoint.promotion_catalog_oid) != (evidence["catalog_ref"], evidence["catalog_oid"]): + raise Failed("Current promotion catalog differs from the original selected checkpoint ref/OID") + + +def verify_published_promotion(checkpoint: smoke.DotCheckpoint, fixture: Fixture, evidence: dict[str, object], acceptance: dict[str, object]) -> dict[str, object]: + """Read the actual Actions attempt, catalog and both current Git selectors.""" + from auto_coder.management.source_release_dispatch import SourceReleaseDispatch + from auto_coder.source_release_catalog import SourceReleaseCatalog, SourceReleasePolicy, SourceReleaseRequest + from auto_coder.source_release_promotion import AcceptedSourceValidator, SourceReleasePromotion + + policy = SourceReleasePolicy.load(Path("/fixture/policy/source-release-policy.json")) + if policy.source_repository != TEST_REPOSITORY or fixture.target_ids["beta"] not in policy.accepted_beta_instance_ids: + raise Blocked("Original source promotion policy is not bound to this disposable fixture") + client = _source_client() + original = str(evidence["promotion_operation_id"]) + catalog = SourceReleaseCatalog(client, TEST_REPOSITORY).read(original) + if catalog is None: + raise Blocked("Original source promotion catalog is unavailable") + if catalog.tag_object_oid != evidence["catalog_oid"] or "refs/tags/" + catalog.record.tag != evidence["catalog_ref"]: + raise Failed("Independent source catalog ref/OID differs from the original operation") + source = verify_real_source(checkpoint, fixture) + request = SourceReleaseRequest( + operation_id=original, + source_repository=TEST_REPOSITORY, + source_sha=checkpoint.source_sha, + ci_run_id=int(checkpoint.ci_run_id), + ci_run_attempt=int(checkpoint.ci_run_attempt), + tested_source_ref=checkpoint.tested_source_ref, + tested_source_oid=checkpoint.tested_source_oid, + tested_source_record_sha256=str(source["tested_source_record_sha256"]), + acceptance_ref=str(acceptance["acceptance_ref"]), + acceptance_oid=str(acceptance["acceptance_oid"]), + release_target=policy.release_target, + promotion_run_id=int(evidence["promotion_run_id"]), + promotion_run_attempt=int(evidence["promotion_run_attempt"]), + ) + validated = AcceptedSourceValidator(client, policy).validate(request) + promoter = SourceReleasePromotion(client, policy) + promoter._verify_original(catalog.record, request, validated) + # This existing producer method performs repeated readback only. Never + # call execute(), which can publish Git objects or move a release ref. + promoter._confirmed(catalog.record, catalog.tag_object_oid, "CONFIRMED") + run = evidence["workflow_run"] + if not isinstance(run, dict) or type(run.get("workflow_id")) is not int: + raise Blocked("Original promotion workflow identity is unavailable") + response = client.request("GET", f"actions/runs/{request.promotion_run_id}/attempts/{request.promotion_run_attempt}") + if response.status != 200 or not isinstance(response.data, dict): + raise Blocked("Original promotion Actions attempt is not available") + if SourceReleaseDispatch._run(response.data, policy, run["workflow_id"], original) != run or response.data.get("status") != "completed" or response.data.get("conclusion") != "success": + raise Failed("Original promotion Actions attempt is not exactly successful") + return {**evidence, **acceptance, "source_sha": checkpoint.source_sha, "source_repository": TEST_REPOSITORY} def verify_current_operator(identity: dict[str, Any], fixture: Fixture, scenario: str, *, mutation: bool) -> None: @@ -469,8 +686,8 @@ def verify_current_targets(value: dict[str, Any], fixture: Fixture) -> None: """Require two unambiguous current policy targets, without discarding entries.""" selected = value.get("targets") expected = { - fixture.target_ids["beta"]: ("beta", TEST_REPOSITORY), - fixture.target_ids["release"]: ("release", "fixture/" + fixture.fixture_id + "-release"), + fixture.target_ids["beta"]: ("beta", BETA_WORK_REPOSITORY), + fixture.target_ids["release"]: ("release", TEST_REPOSITORY), } if value.get("status") != "OK" or not isinstance(selected, list) or len(selected) != len(expected): raise Blocked("The current policy must expose exactly two disposable worker targets") @@ -491,12 +708,23 @@ def __init__(self, session: Any, registered: set[str]): self.session = session self.registered = registered self.observations: list[Evidence] = [] + self.original_reconciliation: tuple[str, str] | None = None + + def authorize_original_reconciliation(self, target_id: str, operation_id: str) -> None: + """Bind one explicitly authorized promotion readback, never a new key.""" + if any(smoke.SAFE_ID.fullmatch(value) is None for value in (target_id, operation_id)): + raise Blocked("Original reconciliation identity is invalid") + selected = (target_id, operation_id) + if self.original_reconciliation not in {None, selected}: + raise Blocked("One acceptance invocation cannot switch its original operation") + self.original_reconciliation = selected async def call(self, name: str, arguments: dict[str, object], *, mutation: bool = False) -> dict[str, Any]: if name not in self.registered: raise Blocked("Required MCP producer tool is not registered: " + name) if mutation: - if name not in MUTATIONS.values(): + original_read = name == "auto_coder_operation_get" and set(arguments) == {"target_id", "operation_id"} and self.original_reconciliation == (arguments.get("target_id"), arguments.get("operation_id")) + if name not in MUTATIONS.values() and not original_read: raise Blocked("Only fixed fixture mutation tools are allowed") else: smoke.checked_tool(name, arguments) @@ -567,10 +795,245 @@ async def inspect_original(live: Live, checkpoint: smoke.DotCheckpoint, fixture: return Result("BLOCKED", "inspect-original", "Original journal inspected without dispatch; unresolved effects remain held", tuple(live.observations), original) -async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, mutate: bool, item_number: int) -> Result: +def scenario_target(fixture: Fixture, scenario: str) -> str: + return fixture.target_ids["release" if scenario in FULL_SCENARIOS else "beta"] + + +def load_original_ticket(fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, resume: bool) -> dict[str, object]: + """Read one original local send; a missing admission ID never authorizes replay.""" + path = ticket_path(scenario) + if path.is_symlink(): + raise Blocked("Original admission ticket was replaced") + try: + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError) as error: + raise Blocked("Original admission ticket is unavailable") from error + if not isinstance(record, dict): + raise Blocked("Original admission ticket is malformed") + arguments = record.get("args") + expected = { + "schema": 1, + "fixture_nonce": fixture.fixture_nonce, + "task_id": checkpoint.task_id, + "source_sha": checkpoint.source_sha, + "work_repository": checkpoint.repository, + "issue_number": checkpoint.issue_number, + "pr_number": checkpoint.pr_number, + "action": MUTATIONS[scenario], + "target": scenario_target(fixture, scenario), + } + if any(record.get(key) != value for key, value in expected.items()) or not isinstance(arguments, dict) or arguments.get("target_id") != expected["target"]: + raise Blocked("Original admission ticket differs from its fixture, work or target") + if scenario != "reevaluate" and arguments.get("selected_git_sha", arguments.get("source_sha")) != checkpoint.source_sha: + raise Blocked("Original admission ticket selected a different immutable source") + operation_id = record.get("operation_id") + if record.get("effect_state") not in {"ADMITTED", "SUCCEEDED"} or not isinstance(operation_id, str) or smoke.SAFE_ID.fullmatch(operation_id) is None: + raise Blocked("Original admission outcome lacks its operation ID; never resend or allocate a new key") + if resume: + if record.get("stage_revision") != checkpoint.stage_revision or record.get("idempotency_key") != checkpoint.idempotency_key or arguments.get("idempotency_key") != checkpoint.idempotency_key: + raise Blocked("Original operation cannot be resumed with changed authority or idempotency intent") + if checkpoint.pending_operation_id and checkpoint.pending_operation_id != operation_id: + raise Blocked("Original operation cannot be replaced by another pending operation") + selected_operation = {"beta-source": checkpoint.beta_operation_id, "promote-source": checkpoint.promotion_operation_id, "release-source": checkpoint.release_operation_id}.get(scenario) + if selected_operation and selected_operation != operation_id: + raise Blocked("Original operation differs from its saved checkpoint action") + if scenario in FULL_SCENARIOS and checkpoint.beta_acceptance_ref and (arguments.get("acceptance_ref"), arguments.get("acceptance_oid")) != (checkpoint.beta_acceptance_ref, checkpoint.beta_acceptance_oid): + raise Blocked("Original selected beta acceptance cannot be changed during resumption") + if scenario == "beta-acceptance" and checkpoint.beta_operation_id and arguments.get("deployment_operation_id") != checkpoint.beta_operation_id: + raise Blocked("Original beta acceptance deployment cannot be changed during resumption") + elif record.get("effect_state") != "SUCCEEDED": + raise Blocked("A prerequisite original operation has not reached a verified terminal result") + return record + + +def original_prerequisite(fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, selected_id: str = "") -> str: + if selected_id: + if smoke.SAFE_ID.fullmatch(selected_id) is None: + raise Blocked("Original prerequisite operation ID is invalid") + return selected_id + return str(load_original_ticket(fixture, checkpoint, scenario, resume=False)["operation_id"]) + + +def verified_deployment(value: dict[str, object], fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, operation_id: str) -> None: + expected = (operation_id, scenario_target(fixture, scenario), MUTATIONS[scenario].removeprefix("auto_coder_"), checkpoint.source_sha) + if (value.get("operation_id"), value.get("target_id"), value.get("action"), value.get("source_sha")) != expected: + raise Failed("Original deployment receipt differs from its exact source or target") + if value.get("status") != "SUCCEEDED" or value.get("phase") != "succeeded" or value.get("effect_state") != "confirmed": + raise Blocked("Original deployment has no confirmed terminal lifecycle outcome") + + +async def acceptance_evidence(live: Live, fixture: Fixture, checkpoint: smoke.DotCheckpoint, *, operation_id: str = "") -> dict[str, object]: + prefix = "refs/tags/auto-coder-beta-acceptance-" + if not operation_id and checkpoint.beta_acceptance_ref: + if not checkpoint.beta_acceptance_ref.startswith(prefix): + raise Blocked("Original acceptance ref is outside the immutable beta namespace") + operation_id = checkpoint.beta_acceptance_ref.removeprefix(prefix) + operation_id = original_prerequisite(fixture, checkpoint, "beta-acceptance", operation_id) + value = await live.call("auto_coder_beta_acceptance_get", {"target_id": fixture.target_ids["beta"], "operation_id": operation_id}) + evidence = verified_acceptance_receipt(value, operation_id=operation_id, target_id=fixture.target_ids["beta"], source_sha=checkpoint.source_sha) + if checkpoint.beta_acceptance_ref and (checkpoint.beta_acceptance_ref, checkpoint.beta_acceptance_oid) != (evidence["acceptance_ref"], evidence["acceptance_oid"]): + raise Failed("Current acceptance differs from the original selected checkpoint ref/OID") + return await asyncio.to_thread(verify_published_acceptance, checkpoint, fixture, evidence) + + +async def promotion_evidence(live: Live, fixture: Fixture, checkpoint: smoke.DotCheckpoint, *, operation_id: str = "") -> dict[str, object]: + operation_id = original_prerequisite(fixture, checkpoint, "promote-source", operation_id or checkpoint.promotion_operation_id) + value = await live.call("auto_coder_operation_inspect", {"target_id": fixture.target_ids["release"], "operation_id": operation_id}) + evidence = verified_promotion_receipt(value, operation_id=operation_id, target_id=fixture.target_ids["release"], source_sha=checkpoint.source_sha) + confirm_saved_promotion(checkpoint, evidence) + accepted = await acceptance_evidence(live, fixture, checkpoint) + return await asyncio.to_thread(verify_published_promotion, checkpoint, fixture, evidence, accepted) + + +def worker_arguments(snapshot: dict[str, object]) -> dict[str, object]: + worker = snapshot.get("worker") + if snapshot.get("alive") is not True or not isinstance(worker, dict): + raise Blocked("No real running child to fence an original source operation") + arguments = { + "expected_supervisor_generation": snapshot.get("supervisor_generation"), + "expected_runtime_id": worker.get("runtime_id"), + "expected_process_run_id": worker.get("process_run_id"), + "expected_deployment_generation": worker.get("deployment_generation"), + } + if any(not isinstance(value, str) or smoke.SAFE_ID.fullmatch(value) is None for value in arguments.values()): + raise Blocked("Original live worker fencing is incomplete") + return arguments + + +async def prepare_arguments(live: Live, fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, item_number: int) -> dict[str, object]: + target = scenario_target(fixture, scenario) + arguments: dict[str, object] = {"target_id": target, "idempotency_key": checkpoint.idempotency_key} + if scenario == "promote-source": + source = await asyncio.to_thread(verify_real_source, checkpoint, fixture) + accepted = await acceptance_evidence(live, fixture, checkpoint) + return { + **arguments, + "source_sha": checkpoint.source_sha, + "ci_run_id": int(checkpoint.ci_run_id), + "ci_run_attempt": int(checkpoint.ci_run_attempt), + "tested_source_ref": checkpoint.tested_source_ref, + "tested_source_oid": checkpoint.tested_source_oid, + "tested_source_record_sha256": source["tested_source_record_sha256"], + "acceptance_ref": accepted["acceptance_ref"], + "acceptance_oid": accepted["acceptance_oid"], + } + if scenario == "release-source": + promoted = await promotion_evidence(live, fixture, checkpoint) + release = await observed_target(live, target, "release", TEST_REPOSITORY) + return {**arguments, **worker_arguments(release), "selected_git_sha": checkpoint.source_sha, **{key: promoted[key] for key in ("promotion_operation_id", "catalog_ref", "catalog_oid", "acceptance_ref", "acceptance_oid")}} + beta = await observed_target(live, target, "beta", BETA_WORK_REPOSITORY) + fence = worker_arguments(beta) + if scenario == "reevaluate": + if type(item_number) is not int or not 1 <= item_number <= 999999999: + raise Blocked("A positive test-repository Issue number is required") + return {**arguments, "target_type": "issue", "number": item_number, **{key: fence[key] for key in ("expected_process_run_id", "expected_deployment_generation")}} + await asyncio.to_thread(verify_real_source, checkpoint, fixture) + if scenario == "beta-source": + selected = await live.call("auto_coder_beta_source_readback", {"target_id": target}) + if selected.get("status") != "OK" or (selected.get("source_sha"), selected.get("tested_source_oid")) != (checkpoint.source_sha, checkpoint.tested_source_oid): + raise Blocked("The selected test-only beta Git source does not match original immutable proof") + return {**arguments, **fence, "selected_git_sha": checkpoint.source_sha} + positive_heartbeat(beta, source_sha=checkpoint.source_sha) + original = original_prerequisite(fixture, checkpoint, "beta-source", checkpoint.beta_operation_id) + deployed = await live.call("auto_coder_operation_inspect", {"target_id": target, "operation_id": original}) + verified_deployment(deployed, fixture, checkpoint, "beta-source", original) + worker = deployed.get("new_worker") + if not isinstance(worker, dict) or any(worker.get(key) != fence["expected_" + key] for key in ("runtime_id", "process_run_id", "deployment_generation")): + raise Blocked("Current beta worker differs from the original source deployment") + return { + **arguments, + "selected_git_sha": checkpoint.source_sha, + "installed_runtime_id": fence["expected_runtime_id"], + "process_run_id": fence["expected_process_run_id"], + "deployment_generation": fence["expected_deployment_generation"], + "supervisor_generation": fence["expected_supervisor_generation"], + "deployment_operation_id": original, + } + + +async def complete_original(live: Live, fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, original_id: str, arguments: dict[str, object], *, wait_seconds: float) -> Result: + target = scenario_target(fixture, scenario) + deadline = time.monotonic() + wait_seconds + while True: + name = "auto_coder_beta_acceptance_get" if scenario == "beta-acceptance" else "auto_coder_operation_inspect" + original = await live.call(name, {"target_id": target, "operation_id": original_id}) + if original.get("operation_id") != original_id: + raise Blocked("Original operation readback is unbound or unavailable") + if (original.get("action"), original.get("target_id")) != (MUTATIONS[scenario].removeprefix("auto_coder_"), target): + raise Blocked("Original operation readback does not bind its saved action and target") + details: dict[str, object] | None = None + positive = False + if scenario == "reevaluate": + if verified_reevaluation_receipt(original, operation_id=original_id, target_id=target, process_run_id=str(arguments["expected_process_run_id"])): + details = {"reevaluation_accepted": True, "issue_completion_proven": False} + elif scenario == "beta-acceptance" and original.get("status") == "PASS_PUBLISHED": + details = verified_acceptance_receipt(original, operation_id=original_id, target_id=target, source_sha=checkpoint.source_sha) + details = await asyncio.to_thread(verify_published_acceptance, checkpoint, fixture, details) + elif scenario == "promote-source" and original.get("status") == "SUCCEEDED": + promoted = verified_promotion_receipt(original, operation_id=original_id, target_id=target, source_sha=checkpoint.source_sha) + confirm_saved_promotion(checkpoint, promoted) + accepted = {"beta_acceptance_operation_id": str(arguments["acceptance_ref"]).removeprefix("refs/tags/auto-coder-beta-acceptance-"), "acceptance_ref": arguments["acceptance_ref"], "acceptance_oid": arguments["acceptance_oid"]} + await asyncio.to_thread(verify_published_acceptance, checkpoint, fixture, accepted) + details = await asyncio.to_thread(verify_published_promotion, checkpoint, fixture, promoted, accepted) + elif scenario in {"beta-source", "release-source"} and original.get("status") == "SUCCEEDED": + verified_deployment(original, fixture, checkpoint, scenario, original_id) + if scenario == "release-source": + expected = {key: arguments[key] for key in ("promotion_operation_id", "catalog_ref", "catalog_oid", "acceptance_ref", "acceptance_oid")} + if original.get("release_identity") != {**expected, "source_sha": checkpoint.source_sha}: + raise Failed("Release worker operation does not bind the original promotion and acceptance") + # Recheck Git selection after activation; a superseded promotion + # cannot become a successful current full release. + await promotion_evidence(live, fixture, checkpoint, operation_id=str(arguments["promotion_operation_id"])) + role = "release" if scenario == "release-source" else "beta" + workload_repository = TEST_REPOSITORY if role == "release" else BETA_WORK_REPOSITORY + installed = await observed_target(live, target, role, workload_repository) + worker = installed.get("worker") + recorded_worker = original.get("new_worker") + if not isinstance(worker, dict) or not isinstance(recorded_worker, dict) or any(worker.get(key) != recorded_worker.get(key) for key in ("runtime_id", "process_run_id", "deployment_generation")): + raise Blocked("Current installed worker is not the original operation's confirmed child") + await await_advancing_worker_heartbeats(live, [(target, role, workload_repository, installed, checkpoint.source_sha)]) + details = {"source_sha": checkpoint.source_sha, "target_id": target, "worker": worker, "supervisor_generation": installed.get("supervisor_generation"), "release_worker_deployed": role == "release"} + positive = True + if details is not None: + update_original_ticket(ticket_path(scenario), fixture, original_id, "SUCCEEDED", evidence=details) + reasons = { + "reevaluate": "The genuine worker durably accepted reevaluation; Issue work was not certified complete", + "beta-source": "Real beta worker installed the original tested source and advanced producer heartbeats", + "beta-acceptance": "Original beta runtime acceptance was independently published and read back from Git", + "promote-source": "Exact source promotion and Actions/catalog readback are confirmed; release worker activation remains separate", + "release-source": "Independent release worker activation matches the original promotion and advances real producer heartbeats", + } + return Result("PASS", scenario, reasons[scenario], tuple(live.observations), original_id, positive_deployment_proven=positive, effect_dispatched=True, original_ticket_state="SUCCEEDED", details=details) + status = original.get("status") + if status in {"FAILED", "UNKNOWN", "UNAVAILABLE", "NOT_FOUND", "ROLLED_BACK"}: + raise Blocked("Original operation failed or is unavailable; never replay or allocate a new key") + if scenario == "promote-source" and original.get("effect_state") == "sent_unknown": + # Source promotion has no recurring service reconciler. Its explicit + # original query only observes the already-dispatched Actions run; + # the producer's durable SENT_UNKNOWN fence prevents dispatch again. + live.authorize_original_reconciliation(target, original_id) + await live.call("auto_coder_operation_get", {"target_id": target, "operation_id": original_id}, mutation=True) + elif status == "NEEDS_ATTENTION": + raise Blocked("Original worker operation needs attention; preserve its original ID and held lane") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Blocked("Original operation has not completed within the bounded wait; use explicit original resumption") + await asyncio.sleep(min(2.0, remaining)) + + +async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, mutate: bool, item_number: int, resume_original: bool = False, wait_seconds: float = 300.0) -> Result: bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") if not bearer: raise Blocked("No current fixture-specific OAuth access bearer; no MCP request sent") + if type(wait_seconds) not in {int, float} or not 0 < wait_seconds <= 900: + raise Blocked("Original operation wait must be finite and at most 900 seconds") + if mutate and (checkpoint.beta_target != fixture.target_ids["beta"] or (scenario in FULL_SCENARIOS and checkpoint.release_target != fixture.target_ids["release"])): + raise Blocked("Original checkpoint target differs from its exact disposable fixture") + # Resolve local originals before opening transport. An unknown admission + # without its original operation ID cannot be recovered by a second send. + retained = load_original_ticket(fixture, checkpoint, scenario, resume=True) if resume_original else None + if mutate and not resume_original and (ticket_path(scenario).exists() or ticket_path(scenario).is_symlink()): + raise Blocked("A previous admission exists; select explicit original resumption, never replay") from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client @@ -580,90 +1043,36 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s live = Live(session, {tool.name for tool in (await session.list_tools()).tools}) identity = await live.call("auto_coder_whoami", {}) verify_current_operator(identity, fixture, scenario, mutation=mutate) - targets = await live.call("auto_coder_targets", {}) - verify_current_targets(targets, fixture) - beta = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) if scenario == "inspect-original": + # Journal recovery must not depend on the failed worker or an + # unrelated beta Supervisor still being reachable. return await inspect_original(live, checkpoint, fixture) + targets = await live.call("auto_coder_targets", {}) + verify_current_targets(targets, fixture) if not mutate: - release = await observed_target(live, fixture.target_ids["release"], "release", "fixture/" + fixture.fixture_id + "-release") - await await_advancing_worker_heartbeats( - live, - [ - (fixture.target_ids["beta"], "beta", TEST_REPOSITORY, beta, checkpoint.source_sha), - (fixture.target_ids["release"], "release", "fixture/" + fixture.fixture_id + "-release", release, ""), - ], - ) + beta = await observed_target(live, fixture.target_ids["beta"], "beta", BETA_WORK_REPOSITORY) + release = await observed_target(live, fixture.target_ids["release"], "release", TEST_REPOSITORY) + await await_advancing_worker_heartbeats(live, [(fixture.target_ids["beta"], "beta", BETA_WORK_REPOSITORY, beta, checkpoint.source_sha), (fixture.target_ids["release"], "release", TEST_REPOSITORY, release, "")]) return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) - if scenario == "beta-source": - await asyncio.to_thread(verify_real_source, checkpoint, fixture) - selected = await live.call("auto_coder_beta_source_readback", {"target_id": fixture.target_ids["beta"]}) - if selected.get("status") != "OK" or (selected.get("source_sha"), selected.get("tested_source_oid")) != (checkpoint.source_sha, checkpoint.tested_source_oid): - raise Blocked("The selected test-only beta Git source does not match original immutable proof") - else: - if type(item_number) is not int or item_number < 1 or item_number > 999999999: - raise Blocked("A positive test-repository Issue number is required") - worker = beta.get("worker") - if beta.get("alive") is not True or not isinstance(worker, dict): - raise Blocked("No real running beta child to fence a mutation") - generation = beta.get("supervisor_generation") - if not isinstance(generation, str) or not generation: - raise Blocked("Signed Supervisor generation is unavailable") - if scenario == "reevaluate": - arguments: dict[str, object] = { - "target_id": fixture.target_ids["beta"], - "target_type": "issue", - "number": item_number, - "idempotency_key": checkpoint.idempotency_key, - "expected_process_run_id": worker.get("process_run_id"), - "expected_deployment_generation": worker.get("deployment_generation"), - } - else: - arguments = { - "target_id": fixture.target_ids["beta"], - "selected_git_sha": checkpoint.source_sha, - "idempotency_key": checkpoint.idempotency_key, - "expected_supervisor_generation": generation, - "expected_runtime_id": worker.get("runtime_id"), - "expected_process_run_id": worker.get("process_run_id"), - "expected_deployment_generation": worker.get("deployment_generation"), - } - if any(not isinstance(value, (str, int)) or value in {"", None} for value in arguments.values()): - raise Blocked("Original live worker fencing or immutable intent is incomplete") + if retained is not None: + return await complete_original(live, fixture, checkpoint, scenario, str(retained["operation_id"]), retained["args"], wait_seconds=wait_seconds) + arguments = await prepare_arguments(live, fixture, checkpoint, scenario, item_number) original_ticket = ticket_path(scenario) persist_original_ticket(original_ticket, fixture, checkpoint, MUTATIONS[scenario], arguments) admitted = await live.call(MUTATIONS[scenario], arguments, mutation=True) + if admitted.get("status") in {"CONFLICT", "BUSY", "DENIED", "INVALID_REQUEST", "UNAVAILABLE", "NOT_FOUND"}: + # BUSY/CONFLICT can carry a different original operation ID. + # Never bind that occupying operation to this durable send. + raise Blocked("Admission was refused or unavailable; no returned occupying operation may replace the original send") + if (admitted.get("action"), admitted.get("target_id")) != (MUTATIONS[scenario].removeprefix("auto_coder_"), arguments["target_id"]): + raise Blocked("Admission response does not bind this exact original action and target") + if scenario in {"beta-source", "release-source"} and admitted.get("source_sha") != checkpoint.source_sha: + raise Blocked("Admission response does not bind this exact original selected source") original_id = admitted.get("operation_id") if not isinstance(original_id, str) or smoke.SAFE_ID.fullmatch(original_id) is None: raise Blocked("Admission response is missing its original operation ID; durable ticket remains held") update_original_ticket(original_ticket, fixture, original_id, "ADMITTED") - for _ in range(30): - original = await live.call("auto_coder_operation_inspect", {"target_id": fixture.target_ids["beta"], "operation_id": original_id}) - if original.get("operation_id") != original_id: - raise Blocked("Original operation readback is unbound or unavailable") - if original.get("status") in {"NEEDS_ATTENTION", "FAILED", "UNKNOWN", "UNAVAILABLE"}: - raise Blocked("Original worker operation is held or failed; never allocate a new key") - if scenario == "reevaluate": - if verified_reevaluation_receipt( - original, - operation_id=original_id, - target_id=fixture.target_ids["beta"], - process_run_id=str(worker["process_run_id"]), - ): - update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") - return Result("PASS", scenario, "The genuine worker durably accepted reevaluation; Issue work was not certified complete", tuple(live.observations), original_id, effect_dispatched=True) - elif original.get("status") == "SUCCEEDED": - if original.get("action") != "deploy_beta_source" or original.get("target_id") != fixture.target_ids["beta"] or original.get("phase") != "succeeded" or original.get("source_sha") != checkpoint.source_sha: - raise Blocked("Terminal beta deployment is not bound to the original tested source and target") - update_original_ticket(original_ticket, fixture, original_id, "SUCCEEDED") - installed = await observed_target(live, fixture.target_ids["beta"], "beta", TEST_REPOSITORY) - await await_advancing_worker_heartbeats( - live, - [(fixture.target_ids["beta"], "beta", TEST_REPOSITORY, installed, checkpoint.source_sha)], - ) - return Result("PASS", scenario, "Real worker installed the original tested source and advanced producer heartbeats", tuple(live.observations), original_id, positive_deployment_proven=True, effect_dispatched=True) - await asyncio.sleep(2) - raise Blocked("Original management operation has not reached a verified terminal receipt") + return await complete_original(live, fixture, checkpoint, scenario, original_id, arguments, wait_seconds=wait_seconds) def _logging() -> None: @@ -680,6 +1089,8 @@ def main() -> int: parser.add_argument("--checkpoint", type=Path) parser.add_argument("--scenario", choices=sorted(SCENARIOS), default="observe") parser.add_argument("--enable-fixture-mutation", action="store_true") + parser.add_argument("--resume-original", action="store_true", help="Reconcile the exact durable original operation without repeating its admission") + parser.add_argument("--wait-seconds", type=int, default=300, help="Bounded original operation observation, maximum 900 seconds") parser.add_argument("--operator-stage", default="") parser.add_argument("--operator-stage-revision", default="") parser.add_argument("--fixture-issue-number", type=int, default=0) @@ -693,10 +1104,10 @@ def main() -> int: fixture = Fixture.load(args.fixture_manifest) bound_local_fixture(fixture) checkpoint = smoke.load_checkpoint(args.checkpoint) - original_stage(checkpoint, args.scenario, args.operator_stage, args.operator_stage_revision, mutation=args.enable_fixture_mutation) + original_stage(checkpoint, args.scenario, args.operator_stage, args.operator_stage_revision, mutation=args.enable_fixture_mutation, resume_original=args.resume_original) if args.enable_fixture_mutation and (not fixture.source_fixture_available or checkpoint.source_sha != fixture.source_fixture_sha): raise Blocked("Mutation source must be the immutable test-only Git fixture") - report = asyncio.run(run_authenticated(fixture, checkpoint, args.scenario, mutate=args.enable_fixture_mutation, item_number=args.fixture_issue_number)) + report = asyncio.run(run_authenticated(fixture, checkpoint, args.scenario, mutate=args.enable_fixture_mutation, item_number=args.fixture_issue_number, resume_original=args.resume_original, wait_seconds=args.wait_seconds)) except Blocked as error: report = exception_result(args.scenario, str(error), mutation=args.enable_fixture_mutation, fixture=fixture) except Failed as error: diff --git a/scripts/dot_mcp_negative_acceptance.py b/scripts/dot_mcp_negative_acceptance.py new file mode 100644 index 000000000..f7c22563c --- /dev/null +++ b/scripts/dot_mcp_negative_acceptance.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Observe real disposable refusal/recovery boundaries without granting authority. + +Only the two explicit refusal probes send a fixed reevaluation request. They +require an operator-selected observe policy and a durable pre-send ticket. +All other scenarios inspect the original operation and never retry or recover it. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import sqlite3 +import sys +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +from loguru import logger + +if __package__: + from . import dot_mcp_acceptance as accept +else: + import dot_mcp_acceptance as accept + +smoke = accept.smoke +SCENARIOS = frozenset({"observe-refusal", "policy-downgrade", "held-drain", "unknown-effect", "repeated-failure", "data-rollback"}) +REFUSAL_PROBES = frozenset({"observe-refusal", "policy-downgrade"}) +ROOT = Path("/fixture/management") +POLICY = Path("/fixture/policy/operating-policy.yaml") +JOURNAL = ROOT / "operations/management-operations.sqlite3" + + +@dataclass(frozen=True) +class NegativeReport: + verdict: str + scenario: str + reason: str + observations: tuple[accept.Evidence, ...] = () + operation_id: str = "" + negative_boundary_verified: bool = False + positive_deployment_proven: bool = False + effect_dispatched: bool | None = False + failure_signature: str = "" + + +@dataclass(frozen=True) +class FailureIdentity: + action: str + target: str + source_sha: str + reason: str + + @property + def signature(self) -> str: + encoded = json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def refusal_ticket(scenario: str, root: Path = ROOT) -> Path: + if scenario not in REFUSAL_PROBES: + raise accept.Blocked("Only an explicitly selected refusal probe has a send ticket") + return root / f"dot-fixture-{scenario}-original-send.json" + + +def selected_checkpoint(fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, stage: str, revision: str, *, probe: bool) -> None: + """Keep test intent, current operator selection and old unknowns separate.""" + if scenario not in SCENARIOS: + raise accept.Blocked("Unsupported negative acceptance scenario") + try: + smoke.confirm_operator_scope(checkpoint, stage, revision) + except smoke.OperatorScopeError as error: + raise accept.Blocked(str(error)) from error + if (checkpoint.beta_target, checkpoint.release_target) != (fixture.target_ids["beta"], fixture.target_ids["release"]): + raise accept.Blocked("The checkpoint must retain both original disposable targets") + if checkpoint.source_sha != fixture.source_fixture_sha or not fixture.source_fixture_available: + raise accept.Blocked("The negative scenario must retain the exact disposable source") + if scenario in REFUSAL_PROBES: + if not probe: + raise accept.Blocked("An actual refusal probe requires explicit operator selection") + if checkpoint.pending_effect_state or checkpoint.pending_operation_id or checkpoint.recovery_attempts: + raise accept.Blocked("A refusal probe cannot bypass an original unresolved effect or exhausted recovery") + if not checkpoint.task_id or not checkpoint.repository or not (checkpoint.issue_number or checkpoint.pr_number) or not checkpoint.stage_revision: + raise accept.Blocked("Persist the original work and operator revision before a refusal probe") + if not checkpoint.idempotency_key or smoke.SAFE_ID.fullmatch(checkpoint.idempotency_key) is None: + raise accept.Blocked("Persist a fixed original refusal-probe idempotency key") + elif probe: + raise accept.Blocked("This original-operation scenario is strictly read-only") + + +def observe_policy_revision(fixture: accept.Fixture, path: Path = POLICY) -> str: + """Read the mounted operator policy without modifying or granting anything.""" + from auto_coder.operating_policy import PolicyProfile, load_operating_policy + + loaded = load_operating_policy(path) + policy = loaded.policy + if policy is None or policy.profile is not PolicyProfile.OBSERVE: + raise accept.Blocked("The external operator has not selected the required observe policy") + expected = {(fixture.target_ids["beta"], "beta", (accept.BETA_WORK_REPOSITORY,)), (fixture.target_ids["release"], "release", (accept.TEST_REPOSITORY,))} + actual = {(target.target_id, target.channel.value, target.repositories) for target in policy.targets} + if actual != expected or policy.management_self_update is not None: + raise accept.Blocked("The mounted operator policy is not the exact two-target disposable profile") + return policy.revision + + +def journal_original_key(path: Path, target: str, key: str) -> tuple[str, ...]: + """Read actual admission rows independently, with SQLite in read-only mode.""" + try: + with sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=5) as connection: + connection.execute("PRAGMA query_only=ON") + rows = connection.execute("SELECT operation_id,intent_json FROM operations").fetchall() + found = [] + for operation_id, encoded in rows: + intent = json.loads(encoded) + if not isinstance(intent, dict) or not isinstance(intent.get("target"), list) or len(intent["target"]) != 3: + raise ValueError("invalid original journal intent") + if intent["target"][0] == target and intent.get("idempotency_key") == key: + found.append(str(operation_id)) + return tuple(sorted(found)) + except (OSError, sqlite3.Error, ValueError, TypeError) as error: + raise accept.Blocked("The original admission journal cannot independently prove absence") from error + + +def _journal_absence_digest(target: str, key: str, found: tuple[str, ...]) -> str: + payload = {"target": target, "idempotency_key": key, "operation_ids": found} + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +async def prior_reevaluation(live: accept.Live, fixture: accept.Fixture, root: Path) -> None: + """A downgrade requires a real previous acceptance, not a saved profile name.""" + try: + record = json.loads((root / "dot-fixture-reevaluate-original-send.json").read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise accept.Blocked("No original successful reevaluation ticket proves the earlier authority") from error + arguments = record.get("args") if isinstance(record, dict) else None + if ( + not isinstance(record, dict) + or record.get("fixture_nonce") != fixture.fixture_nonce + or record.get("target") != fixture.target_ids["beta"] + or record.get("action") != "auto_coder_reevaluate_target" + or record.get("effect_state") != "SUCCEEDED" + or not isinstance(record.get("operation_id"), str) + or not record["operation_id"] + or not isinstance(arguments, dict) + or not isinstance(arguments.get("expected_process_run_id"), str) + ): + raise accept.Blocked("The original successful reevaluation is not bound to this fixture") + original = await live.call("auto_coder_operation_inspect", {"target_id": fixture.target_ids["beta"], "operation_id": record["operation_id"]}) + if not accept.verified_reevaluation_receipt(original, operation_id=record["operation_id"], target_id=fixture.target_ids["beta"], process_run_id=arguments["expected_process_run_id"]): + raise accept.Blocked("The current producer did not confirm the original earlier reevaluation") + + +async def refusal_probe(live: accept.Live, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, issue_number: int, *, root: Path = ROOT, policy_path: Path = POLICY, journal_path: Path = JOURNAL) -> NegativeReport: + """Prove current policy refusal at the producer and independent admission DB.""" + if scenario not in REFUSAL_PROBES or type(issue_number) is not int or not 1 <= issue_number <= 999999999: + raise accept.Blocked("Select one fixed refusal scenario and a test-repository Issue number") + policy_revision = observe_policy_revision(fixture, policy_path) + if scenario == "policy-downgrade": + await prior_reevaluation(live, fixture, root) + target = fixture.target_ids["beta"] + before = journal_original_key(journal_path, target, checkpoint.idempotency_key) + if before: + raise accept.Blocked("A refusal probe cannot replay an existing original journal intent") + beta = await accept.observed_target(live, target, "beta", accept.BETA_WORK_REPOSITORY) + worker = beta.get("worker") + if beta.get("alive") is not True or not isinstance(worker, dict): + raise accept.Blocked("No genuine current worker fence is available for the refusal probe") + arguments = { + "target_id": target, + "target_type": "issue", + "number": issue_number, + "idempotency_key": checkpoint.idempotency_key, + "expected_process_run_id": worker.get("process_run_id"), + "expected_deployment_generation": worker.get("deployment_generation"), + } + if any(not isinstance(value, (str, int)) or not value for value in arguments.values()): + raise accept.Blocked("The current refusal-probe process fence is unavailable") + if observe_policy_revision(fixture, policy_path) != policy_revision: + raise accept.Blocked("The operator policy changed before the refusal probe") + ticket = refusal_ticket(scenario, root) + accept.persist_original_ticket(ticket, fixture, checkpoint, "auto_coder_reevaluate_target", arguments) + response = await live.call("auto_coder_reevaluate_target", arguments, mutation=True) + operation_id = response.get("operation_id") + if isinstance(operation_id, str) and operation_id and response.get("action") == "reevaluate_target" and response.get("target_id") == target and response.get("status") not in {"BUSY", "CONFLICT", "NOT_FOUND", "DENIED", "UNAVAILABLE", "INVALID_REQUEST"}: + accept.update_original_ticket(ticket, fixture, operation_id, "ADMITTED") + raise accept.Failed("The expected refusal admitted an operation; preserve its original ticket and stop") + if operation_id is not None: + raise accept.Blocked("A busy, conflicting or unbound operation is not this probe's original admission; preserve uncertainty") + if response.get("status") != "DENIED" or response.get("reason") != "profile_denies_mutation": + raise accept.Blocked("The producer did not prove the expected policy refusal; the send remains held") + after = journal_original_key(journal_path, target, checkpoint.idempotency_key) + if after: + raise accept.Failed("The producer refusal conflicts with an admitted original journal intent") + digest = _journal_absence_digest(target, checkpoint.idempotency_key, after) + accept.record_refused_ticket(ticket, fixture, reason="profile_denies_mutation", journal_sha256=digest) + if observe_policy_revision(fixture, policy_path) != policy_revision: + raise accept.Blocked("The refusal is retained, but the operator policy changed during the scenario") + live.observations.append(accept.Evidence("management-operation-journal", digest, "fixture SQLite mode=ro original-key read", "ABSENT")) + return NegativeReport("PASS", scenario, "The current producer denied the fixed request and its original admission key is absent; no worker effect", tuple(live.observations), negative_boundary_verified=True) + + +def original_binding(checkpoint: smoke.DotCheckpoint, fixture: accept.Fixture) -> tuple[str, str]: + operation_id = checkpoint.pending_operation_id + if not operation_id or checkpoint.pending_effect_state not in smoke.UNKNOWN_EFFECTS: + raise accept.Blocked("Retain the exact original unresolved operation before a recovery scenario") + try: + target = smoke._original_operation_target(checkpoint, operation_id) + except ValueError as error: + raise accept.Blocked("Original operation must have one retained target binding") from error + if target not in fixture.target_ids.values(): + raise accept.Blocked("Original operation is outside the disposable fixture") + return target, operation_id + + +def check_original(value: dict[str, object], target: str, operation_id: str, source_sha: str) -> None: + if value.get("operation_id") != operation_id or value.get("target_id") != target: + raise accept.Blocked("The original journal operation is unavailable or differently bound") + if value.get("action") not in {"reevaluate_target", "deploy_beta_source", "deploy_release_source", "start_worker", "stop_worker", "restart_worker"}: + raise accept.Blocked("The original action has no supported recovery acceptance scenario") + if str(value.get("action")).startswith("deploy_") and value.get("source_sha") != source_sha: + raise accept.Failed("Original source operation differs from the immutable selected source") + + +def record_exhausted_failure(root: Path, fixture: accept.Fixture, identity: FailureIdentity, operation_id: str) -> str: + """Retain the producer's consumed budget; never reserve or dispatch a recovery.""" + signature = identity.signature + record = {"schema": 1, "fixture_nonce": fixture.fixture_nonce, "failure": asdict(identity), "signature": signature, "original_operation_id": operation_id, "recovery_attempts": 1, "outcome": "BLOCKED"} + path = root / f"dot-recovery-{signature}.json" + encoded = (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode() + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + except FileExistsError: + try: + prior = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise accept.Blocked("The retained recovery budget is unavailable") from error + if prior != record: + raise accept.Blocked("A different operation cannot replace the original exhausted failure signature") + return signature + with os.fdopen(descriptor, "wb") as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + return signature + + +async def original_scenario(live: accept.Live, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, root: Path = ROOT) -> NegativeReport: + target, operation_id = original_binding(checkpoint, fixture) + arguments = {"target_id": target, "operation_id": operation_id} + original = await live.call("auto_coder_operation_inspect", arguments) + check_original(original, target, operation_id, checkpoint.source_sha) + signature = "" + if scenario == "held-drain": + steps = original.get("steps") + drain = steps.get("drain") if isinstance(steps, dict) else None + incumbent = original.get("incumbent") + if original.get("status") != "DRAINING" or not isinstance(drain, dict) or drain.get("operation_id") != "d-" + operation_id or drain.get("state") not in {"SENT_UNKNOWN", "CONFIRMED"} or not isinstance(incumbent, dict) or "start" in steps: + raise accept.Blocked("No original held graceful drain is currently proven") + channel = "beta" if target == fixture.target_ids["beta"] else "release" + repository = accept.BETA_WORK_REPOSITORY if channel == "beta" else accept.TEST_REPOSITORY + current = await accept.observed_target(live, target, channel, repository) + worker = current.get("worker") + if ( + current.get("status") != "OK" + or current.get("target_id") != target + or current.get("alive") is not True + or current.get("process_state") != "DRAINING" + or not incumbent.get("supervisor_generation") + or current.get("supervisor_generation") != incumbent["supervisor_generation"] + or not isinstance(worker, dict) + or any(not incumbent.get(key) or worker.get(key) != incumbent[key] for key in ("process_run_id", "deployment_generation", "runtime_id")) + ): + raise accept.Blocked("The original draining worker lifetime cannot be independently verified") + reason = "The genuine original drain remains held; no start, retry or recovery was dispatched" + elif scenario == "unknown-effect": + if original.get("status") not in {"NEEDS_ATTENTION", "RECONCILING", "RUNNING", "DRAINING", "STARTING", "ACCEPTED"} and original.get("effect_state") != "sent_unknown": + raise accept.Blocked("The original operation is not currently an unresolved-effect case") + reason = "The original effect remains unresolved; only its non-dispatching journal was inspected" + elif scenario == "repeated-failure": + if type(original.get("recovery_attempts")) is not int or original["recovery_attempts"] != 1 or original.get("status") not in {"NEEDS_ATTENTION", "FAILED", "RECOVERED_PREVIOUS"}: + raise accept.Blocked("The producer has not proven one consumed recovery attempt for this failure") + failure_reason = original.get("reason") + if not isinstance(failure_reason, str) or not failure_reason or smoke.SAFE_ID.fullmatch(failure_reason) is None: + raise accept.Blocked("The original producer failure category is unavailable") + identity = FailureIdentity(str(original["action"]), target, checkpoint.source_sha, failure_reason) + signature = record_exhausted_failure(root, fixture, identity, operation_id) + exhausted = replace(checkpoint, pending_effect_state="", pending_operation_id="", failure_signature=signature, recovery_attempts=1) + if smoke.required_next_observation(exhausted).verdict != "BLOCKED": + raise accept.Failed("The unchanged failure signature did not trip the recovery circuit breaker") + reason = "The producer consumed its one recovery attempt; the durable unchanged failure signature blocks another attempt" + elif scenario == "data-rollback": + # No currently supported producer attests to persistent-data downgrade + # compatibility. A Python runtime receipt must never be promoted into it. + return NegativeReport("BLOCKED", scenario, "Persistent data rollback and compatibility are unsupported; original runtime evidence cannot authorize another recovery or data restoration", tuple(live.observations), operation_id) + else: + raise accept.Blocked("Select a supported read-only original-operation scenario") + after = await live.call("auto_coder_operation_inspect", arguments) + check_original(after, target, operation_id, checkpoint.source_sha) + if any(after.get(key) != original.get(key) for key in ("action", "status", "phase", "source_sha", "recovery_attempts", "recovery_state")): + raise accept.Blocked("The original producer state changed during bounded negative observation") + return NegativeReport("BLOCKED", scenario, reason, tuple(live.observations), operation_id, negative_boundary_verified=True, failure_signature=signature) + + +async def run_authenticated(fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, issue_number: int) -> NegativeReport: + bearer = os.environ.get("AUTO_CODER_MCP_ACCESS_TOKEN", "") + if not bearer: + raise accept.Blocked("No current fixture-specific OAuth bearer; no MCP request sent") + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with streamablehttp_client(fixture.endpoint, headers={"Authorization": "Bearer " + bearer}, timeout=15, sse_read_timeout=30) as (reader, writer, _): + async with ClientSession(reader, writer) as session: + await session.initialize() + live = accept.Live(session, {tool.name for tool in (await session.list_tools()).tools}) + identity = await live.call("auto_coder_whoami", {}) + accept.verify_current_operator(identity, fixture, "reevaluate", mutation=scenario in REFUSAL_PROBES) + accept.verify_current_targets(await live.call("auto_coder_targets", {}), fixture) + if scenario in REFUSAL_PROBES: + return await refusal_probe(live, fixture, checkpoint, scenario, issue_number) + return await original_scenario(live, fixture, checkpoint, scenario) + + +def exception_report(scenario: str, reason: str, *, verdict: str = "BLOCKED", root: Path = ROOT) -> NegativeReport: + if scenario not in REFUSAL_PROBES: + return NegativeReport(verdict, scenario, reason) + path = refusal_ticket(scenario, root) + if not path.exists() and not path.is_symlink(): + return NegativeReport(verdict, scenario, reason) + operation_id = "" + try: + value = json.loads(path.read_text(encoding="utf-8")) + if isinstance(value, dict) and isinstance(value.get("operation_id"), str) and smoke.SAFE_ID.fullmatch(value["operation_id"]): + operation_id = value["operation_id"] + except (OSError, ValueError): + pass + # Conservatively retain uncertainty on every post-ticket exceptional path, + # even if the response was a refusal but its independent readback failed. + return NegativeReport(verdict, scenario, reason, operation_id=operation_id, effect_dispatched=None) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture-manifest", type=Path) + parser.add_argument("--checkpoint", type=Path) + parser.add_argument("--scenario", choices=sorted(SCENARIOS), default="unknown-effect") + parser.add_argument("--enable-refusal-probe", action="store_true") + parser.add_argument("--operator-stage", default="") + parser.add_argument("--operator-stage-revision", default="") + parser.add_argument("--fixture-issue-number", type=int, default=0) + args = parser.parse_args() + logger.remove() + layout = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {file}:{function}:{line} | {message}" + logger.add(sys.stdout, colorize=True, format=layout) + state = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "auto-coder" + state.mkdir(parents=True, exist_ok=True, mode=0o700) + logger.add(state / "dot-mcp-negative-acceptance.log", rotation="2 MB", retention=3, format=layout) + if args.fixture_manifest is None: + report = NegativeReport("NOT_RUN", args.scenario, "No disposable fixture selected; no network or effect") + else: + try: + fixture = accept.Fixture.load(args.fixture_manifest) + accept.bound_local_fixture(fixture) + checkpoint = smoke.load_checkpoint(args.checkpoint) + selected_checkpoint(fixture, checkpoint, args.scenario, args.operator_stage, args.operator_stage_revision, probe=args.enable_refusal_probe) + report = asyncio.run(run_authenticated(fixture, checkpoint, args.scenario, issue_number=args.fixture_issue_number)) + except accept.Failed as error: + report = exception_report(args.scenario, str(error), verdict="FAIL") + except accept.Blocked as error: + report = exception_report(args.scenario, str(error)) + except ExceptionGroup as error: + nested = accept._single_nested_acceptance_error(error) + report = exception_report(args.scenario, str(nested) if nested else "Fixture transport is unavailable; original sends remain held", verdict="FAIL" if isinstance(nested, accept.Failed) else "BLOCKED") + except Exception: + report = exception_report(args.scenario, "Fixture evidence or transport is unavailable; original sends remain held") + logger.info("Negative acceptance verdict={} scenario={}", report.verdict, report.scenario) + print(json.dumps(asdict(report), sort_keys=True, separators=(",", ":"), allow_nan=False)) + return {"PASS": 0, "FAIL": 1, "BLOCKED": 2, "NOT_RUN": 3}[report.verdict] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/auto_coder/management/worker_lifecycle.py b/src/auto_coder/management/worker_lifecycle.py index e15f3be1a..d329aed83 100644 --- a/src/auto_coder/management/worker_lifecycle.py +++ b/src/auto_coder/management/worker_lifecycle.py @@ -319,6 +319,7 @@ def project_snapshot(self, record: OperationRecord, snapshot: dict[str, object]) reason, operation_id=record.operation_id, phase=record.phase.value, + effect_state=record.effect.state.value, action=record.intent.action, target_id=self.runtime._params(record).get("target_id"), channel=record.intent.target.channel, diff --git a/tests/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index 9e4246b9b..cf52ba17b 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -15,13 +15,15 @@ import time from pathlib import Path +FIXTURE_WORK_REPOSITORIES = frozenset({"kitamura-tetsuo/auto-coder-test", "kitamura-tetsuo/auto-coder-test-beta"}) + def activate_scoped_fixture_token(token_path: Path, repository: str) -> bool: """Load only an explicitly mounted test-repository token into the child environment.""" if not token_path.exists() and not token_path.is_symlink(): return False - if repository != "kitamura-tetsuo/auto-coder-test": - raise ValueError("Scoped fixture token is restricted to the test repository") + if repository not in FIXTURE_WORK_REPOSITORIES: + raise ValueError("Scoped fixture token is restricted to the fixed test repositories") token_stat = token_path.lstat() if not token_path.is_file() or token_path.is_symlink() or token_stat.st_mode & 0o077: raise ValueError("Scoped fixture token file is not a private regular file") @@ -70,6 +72,11 @@ def main() -> None: "--log-file", os.path.join(os.environ["AUTO_CODER_RUNTIME_ROOT"], "logs", "auto-coder.log"), ] + if os.environ["AUTO_CODER_REPOSITORY"] in FIXTURE_WORK_REPOSITORIES and os.environ.get("AUTO_CODER_ISSUE_ALLOWLIST") == os.environ.get("AUTO_CODER_PR_ALLOWLIST") == "[]": + # The disposable rollout fixture exercises the real idle daemon and + # receipt/heartbeat producers without admitting repository tasks. + # Use production CLI switches as an additional publication guard. + command.extend(["--no-auto-merge", "--no-auto-merge-dependabot-prs", "--disable-labels"]) if os.environ.get("GITHUB_TOKEN") in { "nonfunctional-ci-placeholder", "nonfunctional-disposable-no-github-write", diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index a2bad71ff..ed5a83702 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -2,12 +2,21 @@ import json import subprocess +from dataclasses import replace from pathlib import Path import pytest import yaml from scripts import dot_disposable_fixture as fixture +from auto_coder.automation_config import AutomationConfig, is_author_allowlisted +from auto_coder.deployment_channel import validate_repository_ownership +from auto_coder.llm_backend_config import LLMBackendConfiguration +from auto_coder.management.app import create_management_app +from auto_coder.management.beta_runtime_smoke import SmokeProfile +from auto_coder.management.oauth_config import load_management_settings +from auto_coder.management.worker_network_client import configured_worker_registry +from auto_coder.source_release_catalog import SourceReleasePolicy from tests.fixtures import supervisor_offline_process_issues as offline_worker @@ -19,6 +28,21 @@ def source_tree(tmp_path: Path) -> Path: return root +def _git_source_tree(tmp_path: Path) -> Path: + root = source_tree(tmp_path) + (root / "uv.lock").write_text("version = 1\n", encoding="utf-8") + for arguments in ( + ("init", "-b", "main"), + ("config", "user.name", "Disposable fixture"), + ("config", "user.email", "fixture@example.invalid"), + ("add", "-A"), + ("commit", "-qm", "initial test source"), + ("remote", "add", "origin", "https://github.com/kitamura-tetsuo/auto-coder-test.git"), + ): + subprocess.run(["git", "-C", str(root), *arguments], check=True, capture_output=True, text=True) + return root + + def test_provision_is_dockerless_private_and_observe_only(tmp_path): source = source_tree(tmp_path) root = fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) @@ -32,6 +56,8 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): assert marker["endpoint_namespace"] == "management-container-only" assert policy["profile"] == "observe" assert set(policy["targets"]) == set(marker["target_ids"].values()) + assert policy["targets"][marker["target_ids"]["beta"]]["repositories"] == [fixture.BETA_WORK_REPOSITORY] + assert policy["targets"][marker["target_ids"]["release"]]["repositories"] == [fixture.TEST_REPOSITORY] assert config["local_development"] is True and config["bind_host"] == "127.0.0.1" assert compose["services"]["management"]["image"] == fixture.MANAGER_IMAGE assert all(compose["services"][name]["image"] == fixture.WORKER_IMAGE for name in ("beta", "release")) @@ -39,8 +65,8 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): assert all("privileged" not in service and "network_mode" not in service for service in compose["services"].values()) assert all(not any("docker.sock" in volume or "/var/lib/auto-coder" in volume for volume in service["volumes"]) for service in compose["services"].values()) assert all(any(volume.endswith(":/fixtures:ro") for volume in compose["services"][name]["volumes"]) for name in ("beta", "release")) - assert all(f"{root}/routing:/fixture/routing:rw" in compose["services"][name]["volumes"] for name in ("beta", "release")) - assert f"{root}/routing:/fixture/routing:rw" not in compose["services"]["management"]["volumes"] + assert all(f"{root}/{name}/routing:/fixture/routing:rw" in compose["services"][name]["volumes"] for name in ("beta", "release")) + assert all("/routing:" not in volume for volume in compose["services"]["management"]["volumes"]) assert all(len(service["networks"]) == 1 and "fixture" in service["networks"] for service in compose["services"].values()) assert not any("ports" in service for service in compose["services"].values()) assert any(volume.endswith(":/fixture/fixture.json:ro") for volume in compose["services"]["management"]["volumes"]) @@ -55,32 +81,178 @@ def test_provision_is_dockerless_private_and_observe_only(tmp_path): assert f"{root}/secrets/beta-bridge:/fixture/secrets/beta-bridge:ro" in management_volumes assert f"{root}/secrets/release-bridge:/fixture/secrets/release-bridge:ro" in management_volumes assert f"{root}/secrets/source-public-read:/fixture/secrets/source-public-read:ro" in management_volumes + assert not any("beta-work-read" in volume for volume in management_volumes) + assert (root / "secrets/beta-work-read").read_bytes() == b"" + assert (root / "secrets/beta-work-read").stat().st_mode & 0o777 == 0o600 assert len(marker["fixture_nonce"]) == 32 assert marker["source_fixture_available"] is False and marker["source_fixture_sha"] == "" assert (root / "secrets/beta-bridge").stat().st_mode & 0o077 == 0 + assert "source_release_policy_path" not in config + + +def test_both_workers_have_viable_config_but_no_issue_or_pr_dispatch(tmp_path, monkeypatch): + source = source_tree(tmp_path) + root = fixture.provision(source=source, fixture_id="pr2835-test-idle", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + compose = yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + registry = json.loads(compose["services"]["management"]["environment"]["AUTO_CODER_WORKER_REGISTRY"]) + assert [target["repository"] for target in registry["workers"]] == [fixture.BETA_WORK_REPOSITORY, fixture.TEST_REPOSITORY] + for role in ("beta", "release"): + repository = fixture.BETA_WORK_REPOSITORY if role == "beta" else fixture.TEST_REPOSITORY + worker = compose["services"][role] + environment = worker["environment"] + configuration = LLMBackendConfiguration.load_from_file(str(root / role / "runtime/llm_config.toml")) + assert configuration.get_ordinary_priority_groups() == [["jules"]] + assert configuration.get_active_noedit_backends() == [] + assert configuration.backends["jules"].api_key is None + assert environment["AUTO_CODER_REPOSITORY"] == repository + assert environment["AUTO_CODER_ISSUE_ALLOWLIST"] == environment["AUTO_CODER_PR_ALLOWLIST"] == "[]" + assert all("api_key" not in key.lower() for key in environment) + assert not any(f"{root}/{'release' if role == 'beta' else 'beta'}/" in volume for volume in worker["volumes"]) + for name in ("AUTO_CODER_CHANNEL", "AUTO_CODER_ARTIFACT_DIGEST", "AUTO_CODER_ISSUE_ALLOWLIST", "AUTO_CODER_PR_ALLOWLIST"): + monkeypatch.setenv(name, environment[name]) + monkeypatch.setenv("AUTO_CODER_RUNTIME_ROOT", str(root / role / "runtime")) + monkeypatch.setenv("AUTO_CODER_OWNERSHIP_FILE", str(root / role / "routing/ownership.json")) + identity = validate_repository_ownership(repository) + assert identity is not None and identity.channel == role + automation = AutomationConfig(repo_name=repository) + assert automation.ISSUE_ALLOWLIST == automation.PR_ALLOWLIST == [] + for author_id in (None, 1, 12671456, 49699333): + assert not is_author_allowlisted(author_id, automation.ISSUE_ALLOWLIST) + assert not is_author_allowlisted(author_id, automation.PR_ALLOWLIST) + + +def test_full_source_release_configuration_has_fixed_policy_and_private_credentials(tmp_path, _use_real_commands): + source = source_tree(tmp_path) + test_source = _git_source_tree(tmp_path / "candidate") + profile = SmokeProfile() + root = fixture.provision( + source=source, + fixture_id="pr2835-test-release", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=test_source, + source_release_workflow_id=12345, + accepted_profile_revision=profile.content_revision, + ) + manifest = json.loads((root / "fixture.json").read_text(encoding="utf-8")) + compose = yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + settings = load_management_settings(root / "management/config.yaml") + release_policy = SourceReleasePolicy.load(root / "policy/source-release-policy.json") + assert settings.source_repository == release_policy.source_repository == fixture.TEST_REPOSITORY + assert settings.source_release_target_id == manifest["target_ids"]["release"] + assert settings.source_release_workflow_id == 12345 + assert settings.source_release_policy_path == Path("/fixture/policy/source-release-policy.json") + assert settings.source_release_token_file == Path("/fixture/secrets/source-release-dispatch") + assert settings.source_evidence_token_file == Path("/fixture/secrets/source-acceptance-publish") + assert release_policy.accepted_beta_instance_ids == (manifest["target_ids"]["beta"],) + assert release_policy.accepted_profiles[0].content_revision == profile.content_revision + assert release_policy.release_ref == "refs/tags/auto-coder-release-source-current" + assert release_policy.promotion_workflow_path == ".github/workflows/promote-accepted-source-release.yml" + assert yaml.safe_load((root / "policy/operating-policy.yaml").read_text(encoding="utf-8"))["profile"] == "observe" + for filename in ("source-public-read", "source-acceptance-publish", "source-release-dispatch"): + secret = root / "secrets" / filename + assert secret.read_bytes() == b"" + assert secret.stat().st_mode & 0o777 == 0o600 + assert f"{secret}:/fixture/secrets/{filename}:ro" in compose["services"]["management"]["volumes"] + for role in ("beta", "release"): + mounts = compose["services"][role]["volumes"] + read_token = "beta-work-read" if role == "beta" else "source-public-read" + other_read_token = "source-public-read" if role == "beta" else "beta-work-read" + assert f"{root}/secrets/{read_token}:/fixture/secrets/github-test-scoped:ro" in mounts + assert not any(f"/secrets/{other_read_token}:" in mount for mount in mounts) + assert not any("source-acceptance-publish" in mount or "source-release-dispatch" in mount for mount in mounts) + + +@pytest.mark.parametrize("release_configuration", [False, True]) +def test_generated_distinct_workloads_load_actual_registry_and_management_app(tmp_path, monkeypatch, _use_real_commands, release_configuration): + source = source_tree(tmp_path) + root = fixture.provision( + source=source, + fixture_id="pr2835-test-registry", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=_git_source_tree(tmp_path / "candidate") if release_configuration else None, + source_release_workflow_id=12345 if release_configuration else None, + accepted_profile_revision=SmokeProfile().content_revision if release_configuration else None, + ) + compose = yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + registry = json.loads(compose["services"]["management"]["environment"]["AUTO_CODER_WORKER_REGISTRY"]) + # Translate only container mount paths; the actual production validators + # consume all generated target, repository and machine credential values. + for worker in registry["workers"]: + worker["bridge_secret_file"] = str(root / "secrets" / f"{worker['channel']}-bridge") + monkeypatch.setenv("AUTO_CODER_WORKER_REGISTRY", json.dumps(registry)) + configured = configured_worker_registry() + assert {(target.channel, target.repository) for target in configured.values()} == {("beta", fixture.BETA_WORK_REPOSITORY), ("release", fixture.TEST_REPOSITORY)} + settings = load_management_settings(root / "management/config.yaml") + state_root = root / "management/oauth" + settings = replace( + settings, + state_root=state_root, + oauth=replace(settings.oauth, database_path=state_root / settings.oauth.database_path.name), + operation_state_root=root / "management/operations", + operating_policy_path=root / "policy/operating-policy.yaml", + source_evidence_token_file=root / "secrets" / settings.source_evidence_token_file.name, + source_release_policy_path=root / "policy/source-release-policy.json" if release_configuration else None, + source_release_token_file=root / "secrets/source-release-dispatch" if release_configuration else None, + ) + app = create_management_app(settings) + runtime = app.state.management_runtime + assert {target: worker.config for target, worker in runtime.clients.items()} == configured + assert runtime.beta_acceptance is not None + assert runtime.settings.source_repository == fixture.TEST_REPOSITORY + assert runtime.clients[next(target for target, config in configured.items() if config.channel == "release")].config.repository == runtime.settings.source_repository + # Sharing source provenance must never weaken unique workload ownership. + registry["workers"][0]["repository"] = fixture.TEST_REPOSITORY + monkeypatch.setenv("AUTO_CODER_WORKER_REGISTRY", json.dumps(registry)) + with pytest.raises(ValueError, match="distinct targets, instances, endpoints, repositories"): + configured_worker_registry() -def test_scoped_fixture_pat_is_private_and_test_repository_only(tmp_path, monkeypatch): + +@pytest.mark.parametrize( + ("workflow", "revision", "message"), + [(None, "a" * 64, "workflow ID"), (0, "a" * 64, "workflow ID"), (True, "a" * 64, "workflow ID"), (1, None, "profile revision"), (1, "latest", "profile revision"), (1, "a" * 64, "source checkout")], +) +def test_incomplete_source_release_configuration_refuses_before_fixture_writes(tmp_path, workflow, revision, message): + source = source_tree(tmp_path) + with pytest.raises(ValueError, match=message): + fixture.provision( + source=source, + fixture_id="pr2835-test-invalid", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + source_release_workflow_id=workflow, + accepted_profile_revision=revision, + ) + assert not (source / ".agent-tmp").exists() + + +@pytest.mark.parametrize("repository", [fixture.TEST_REPOSITORY, fixture.BETA_WORK_REPOSITORY]) +def test_scoped_fixture_pat_is_private_and_test_repository_only(tmp_path, monkeypatch, repository): token_path = tmp_path / "scoped-pat" monkeypatch.setenv("GITHUB_TOKEN", "nonfunctional-placeholder") - assert offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") is False + assert offline_worker.activate_scoped_fixture_token(token_path, repository) is False assert offline_worker.os.environ["GITHUB_TOKEN"] == "nonfunctional-placeholder" token_path.write_text("github_pat_synthetic-only", encoding="utf-8") token_path.chmod(0o600) - with pytest.raises(ValueError, match="test repository"): + with pytest.raises(ValueError, match="fixed test repositories"): offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder") - assert offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") is True + assert offline_worker.activate_scoped_fixture_token(token_path, repository) is True assert offline_worker.os.environ["GITHUB_TOKEN"] == "github_pat_synthetic-only" token_path.chmod(0o644) with pytest.raises(ValueError, match="private regular file"): - offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") + offline_worker.activate_scoped_fixture_token(token_path, repository) token_path.unlink() token_path.symlink_to(tmp_path / "target") with pytest.raises(ValueError, match="private regular file"): - offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder-test") + offline_worker.activate_scoped_fixture_token(token_path, repository) @pytest.mark.parametrize( @@ -123,6 +295,32 @@ def test_scoped_fixture_pat_never_enters_child_process_argv(monkeypatch, configu assert "--github-token" not in argv +@pytest.mark.parametrize("repository", [fixture.TEST_REPOSITORY, fixture.BETA_WORK_REPOSITORY]) +def test_idle_fixture_uses_production_no_publication_cli_switches(monkeypatch, repository): + monkeypatch.setenv("AUTO_CODER_REPOSITORY", repository) + monkeypatch.setenv("AUTO_CODER_RUNTIME_ROOT", "/unused-disposable-test") + monkeypatch.setenv("GITHUB_TOKEN", "nonfunctional-disposable-no-github-write") + monkeypatch.setenv("AUTO_CODER_ISSUE_ALLOWLIST", "[]") + monkeypatch.setenv("AUTO_CODER_PR_ALLOWLIST", "[]") + for name in ( + "AUTO_CODER_TEST_LAUNCH_AUDIT", + "AUTO_CODER_FIXTURE_HOLD_BOOTSTRAP", + "AUTO_CODER_FIXTURE_FAIL_STAGED_ONCE", + "AUTO_CODER_FIXTURE_HOLD_CANDIDATE_RUNTIME", + "AUTO_CODER_TEST_ABANDON_STARTUP_ONCE", + "AUTO_CODER_TEST_HOLD_INSTALLED_TERMINAL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(offline_worker, "activate_scoped_fixture_token", lambda *args: False) + captured = [] + monkeypatch.setattr(offline_worker.os, "execv", lambda executable, argv: captured.append(argv)) + + offline_worker.main() + + assert len(captured) == 1 + assert all(flag in captured[0] for flag in ("--no-auto-merge", "--no-auto-merge-dependabot-prs", "--disable-labels")) + + @pytest.mark.parametrize("fixture_id", ["release-worker", "beta-worker", "../escape", "A-B", "sh", "bad space"]) def test_nonfixture_identity_is_rejected_before_creating_directory(tmp_path, fixture_id): source = source_tree(tmp_path) @@ -163,18 +361,11 @@ def test_root_or_untrusted_project_rejected_before_writes(tmp_path): def test_clean_test_only_git_main_is_required_for_installed_source(tmp_path, _use_real_commands): source = source_tree(tmp_path) - test_source = source_tree(tmp_path / "candidate") - (test_source / "uv.lock").write_text("version = 1\n", encoding="utf-8") + test_source = _git_source_tree(tmp_path / "candidate") def git(*arguments): return subprocess.run(["git", "-C", str(test_source), *arguments], check=True, capture_output=True, text=True).stdout.strip() - git("init", "-b", "main") - git("config", "user.name", "Disposable fixture") - git("config", "user.email", "fixture@example.invalid") - git("add", "-A") - git("commit", "-qm", "initial test source") - git("remote", "add", "origin", "https://github.com/kitamura-tetsuo/auto-coder-test.git") root = fixture.provision( source=source, fixture_id="pr2835-test-source", diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 985a37069..279cd7ec2 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -5,13 +5,19 @@ import os import subprocess import sys +from contextlib import asynccontextmanager from dataclasses import asdict, replace from pathlib import Path +from types import SimpleNamespace +import mcp +import mcp.client.streamable_http import pytest from scripts import dot_mcp_acceptance as accept from scripts import management_mcp_rollout_smoke as smoke +from auto_coder.management.mcp_runtime_tools import ManagementRuntime + def fixture(): return accept.Fixture( @@ -60,7 +66,7 @@ def registry(fixture): "target_id": fixture.target_ids[role], "supervisor_instance_id": fixture.target_ids[role], "channel": role, - "repository": fixture.repository if role == "beta" else "fixture/" + fixture.fixture_id + "-release", + "repository": accept.BETA_WORK_REPOSITORY if role == "beta" else fixture.repository, "bridge_address": "10.249.99." + ("11" if role == "beta" else "12"), "bridge_port": 8843, "bridge_secret_file": f"/fixture/secrets/{role}-bridge", @@ -182,7 +188,7 @@ def test_container_running_and_heartbeat_not_available_never_pass(): "process_state": "RUNNING", "installed_binding_status": "UNAVAILABLE", "installed_runtime": None, - "worker_observation": {"lifecycle": {"startup_reconciled": True}, "heartbeat": {"available": False, "sequence": None}}, + "worker_observation": {"lifecycle": {"state": "running", "startup_reconciled": True, "startup_reconciliation_error": None, "daemon_serving": True}, "invocations": {"state": "running"}, "heartbeat": {"available": False, "sequence": None}}, } with pytest.raises(accept.Blocked, match="unstaged"): accept.positive_heartbeat(snapshot) @@ -209,8 +215,9 @@ def _real_worker_sample(sequence, *, process_run_id="process-1", generation="dep "installed_binding_status": "VERIFIED", "installed_runtime": {"source_sha": source_sha}, "worker_observation": { - "lifecycle": {"startup_reconciled": True}, - "heartbeat": {"available": True, "sequence": sequence}, + "lifecycle": {"state": "running", "startup_reconciled": True, "startup_reconciliation_error": None, "daemon_serving": True}, + "invocations": {"state": "running"}, + "heartbeat": {"available": True, "sequence": sequence, "stage": "producer:maintenance-sleep"}, }, } @@ -359,8 +366,8 @@ def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_ def test_current_fixture_policy_requires_exactly_two_distinct_complete_target_bindings(): selected = fixture() - beta = {"target_id": selected.target_ids["beta"], "channel": "beta", "repository": selected.repository} - release = {"target_id": selected.target_ids["release"], "channel": "release", "repository": "fixture/" + selected.fixture_id + "-release"} + beta = {"target_id": selected.target_ids["beta"], "channel": "beta", "repository": accept.BETA_WORK_REPOSITORY} + release = {"target_id": selected.target_ids["release"], "channel": "release", "repository": selected.repository} accept.verify_current_targets({"status": "OK", "targets": [beta, release]}, selected) accept.verify_current_targets({"status": "OK", "targets": [release, beta]}, selected) for invalid in ( @@ -525,3 +532,318 @@ def test_mcp_task_group_wrapper_preserves_single_trusted_blocker() -> None: assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [failed])) is failed assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [ValueError("unexpected transport failure")])) is None assert accept._single_nested_acceptance_error(ExceptionGroup("streamable HTTP", [blocked, RuntimeError("other failure")])) is None + + +@pytest.mark.parametrize("scenario", ["promote-source", "release-source"]) +def test_full_actions_require_current_full_stage_without_fabricated_prior_promotion(scenario): + original = checkpoint(release_target=fixture().target_ids["release"]) + with pytest.raises(accept.Blocked, match="full operator stage"): + accept.original_stage(original, scenario, "beta", "operator-v1", mutation=True) + full = replace(original, stage="full") + # The first promotion has no promotion operation/catalog yet. Its real + # prerequisite is beta acceptance, checked at the producer boundary. + accept.original_stage(full, scenario, "full", "operator-v1", mutation=True) + with pytest.raises(accept.Blocked, match="revision"): + accept.original_stage(full, scenario, "full", "other-revision", mutation=True) + + +def test_unknown_ticket_blocks_a_new_key_on_the_same_target(tmp_path): + first = tmp_path / "dot-fixture-reevaluate-original-send.json" + original = checkpoint() + args = {"target_id": fixture().target_ids["beta"], "idempotency_key": original.idempotency_key} + accept.persist_original_ticket(first, fixture(), original, accept.MUTATIONS["reevaluate"], args) + next_checkpoint = replace(original, idempotency_key="different-key") + later = tmp_path / "dot-fixture-beta-source-original-send.json" + with pytest.raises(accept.Blocked, match="changing the idempotency key"): + accept.persist_original_ticket(later, fixture(), next_checkpoint, accept.MUTATIONS["beta-source"], {**args, "idempotency_key": "different-key"}) + assert not later.exists() + accept.record_refused_ticket(first, fixture(), reason="profile_denies_mutation", journal_sha256="d" * 64) + accept.persist_original_ticket(later, fixture(), next_checkpoint, accept.MUTATIONS["beta-source"], {**args, "idempotency_key": "different-key"}) + accept.update_original_ticket(later, fixture(), "admitted-original", "ADMITTED") + with pytest.raises(accept.Blocked, match="admitted"): + accept.record_refused_ticket(later, fixture(), reason="profile_denies_mutation", journal_sha256="d" * 64) + + +def _install_offline_mcp_session(monkeypatch, handler): + """Controlled SDK boundary for unit regressions, never live E2E evidence.""" + calls = [] + tools = {"auto_coder_whoami", "auto_coder_targets", "auto_coder_deployment_status", "auto_coder_operation_inspect", "auto_coder_operation_get", "auto_coder_beta_acceptance_get", *accept.MUTATIONS.values()} + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def initialize(self): + return None + + async def list_tools(self): + return SimpleNamespace(tools=[SimpleNamespace(name=name) for name in tools]) + + async def call_tool(self, name, arguments): + calls.append((name, arguments)) + if name == "auto_coder_whoami": + value = {"project_id": fixture().firebase_project_id, "uid": "fixture-owner", "client_id": "dot-disposable-operator", "resource": fixture().endpoint, "scopes": ["auto-coder.read", "auto-coder.operate", "auto-coder.deploy"]} + elif name == "auto_coder_targets": + value = {"status": "OK", "targets": [{"target_id": target, "channel": role, "repository": accept.BETA_WORK_REPOSITORY if role == "beta" else accept.TEST_REPOSITORY} for role, target in fixture().target_ids.items()]} + else: + value = handler(name, arguments) + return SimpleNamespace(isError=False, structuredContent=value) + + @asynccontextmanager + async def transport(*_args, **_kwargs): + yield None, None, None + + monkeypatch.setenv("AUTO_CODER_MCP_ACCESS_TOKEN", "offline-regression-placeholder") + monkeypatch.setattr(mcp, "ClientSession", lambda *_args: Session()) + monkeypatch.setattr(mcp.client.streamable_http, "streamablehttp_client", transport) + return calls + + +def test_original_release_journal_is_inspected_before_unavailable_beta(monkeypatch): + original = checkpoint(stage="observe", release_target=fixture().target_ids["release"], release_operation_id="release-original", pending_operation_id="release-original", pending_effect_state="SENT_UNKNOWN") + + def handler(name, arguments): + assert name == "auto_coder_operation_inspect" + assert arguments == {"target_id": fixture().target_ids["release"], "operation_id": "release-original"} + return {"operation_id": "release-original", "status": "NEEDS_ATTENTION"} + + calls = _install_offline_mcp_session(monkeypatch, handler) + report = asyncio.run(accept.run_authenticated(fixture(), original, "inspect-original", mutate=False, item_number=0)) + assert report.verdict == "BLOCKED" + assert report.operation_id == "release-original" + assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_operation_inspect"] + assert report.effect_dispatched is False + + +def test_explicit_original_resumption_never_repeats_admission(monkeypatch, tmp_path): + original = checkpoint(idempotency_key="original-deploy-key") + ticket = tmp_path / "dot-fixture-beta-source-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + arguments = {"target_id": fixture().target_ids["beta"], "idempotency_key": original.idempotency_key, "selected_git_sha": original.source_sha} + accept.persist_original_ticket(ticket, fixture(), original, accept.MUTATIONS["beta-source"], arguments) + accept.update_original_ticket(ticket, fixture(), "deployment-original", "ADMITTED") + sequence = 10 + + def handler(name, args): + nonlocal sequence + assert name in {"auto_coder_operation_inspect", "auto_coder_deployment_status"} + if name == "auto_coder_operation_inspect": + return {"operation_id": "deployment-original", "target_id": fixture().target_ids["beta"], "action": "deploy_beta_source", "source_sha": original.source_sha, "status": "SUCCEEDED", "phase": "succeeded", "effect_state": "confirmed", "new_worker": _real_worker_sample(10)["worker"]} + sequence += 1 + return {**_real_worker_sample(sequence), "target_id": fixture().target_ids["beta"], "instance_id": fixture().target_ids["beta"], "channel": "beta", "repository": accept.BETA_WORK_REPOSITORY} + + calls = _install_offline_mcp_session(monkeypatch, handler) + report = asyncio.run(accept.run_authenticated(fixture(), original, "beta-source", mutate=True, item_number=0, resume_original=True)) + assert report.verdict == "PASS" and report.positive_deployment_proven is True + assert report.operation_id == "deployment-original" + assert all(name not in accept.MUTATIONS.values() and name != "auto_coder_operation_get" for name, _ in calls) + assert calls[2] == ("auto_coder_operation_inspect", {"target_id": fixture().target_ids["beta"], "operation_id": "deployment-original"}) + persisted = json.loads(ticket.read_text()) + assert persisted["idempotency_key"] == original.idempotency_key + assert persisted["effect_state"] == "SUCCEEDED" + assert persisted["terminal_evidence"]["source_sha"] == original.source_sha + + +def test_unknown_admission_cannot_resume_or_change_intent(monkeypatch, tmp_path): + original = checkpoint() + ticket = tmp_path / "dot-fixture-beta-source-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + arguments = {"target_id": fixture().target_ids["beta"], "idempotency_key": original.idempotency_key, "selected_git_sha": original.source_sha} + accept.persist_original_ticket(ticket, fixture(), original, accept.MUTATIONS["beta-source"], arguments) + with pytest.raises(accept.Blocked, match="lacks its operation ID"): + accept.load_original_ticket(fixture(), original, "beta-source", resume=True) + accept.update_original_ticket(ticket, fixture(), "original-source", "ADMITTED") + for changed in (replace(original, idempotency_key="another-key"), replace(original, stage_revision="another-revision"), replace(original, source_sha="d" * 40), replace(original, pending_operation_id="another-operation")): + with pytest.raises(accept.Blocked): + accept.load_original_ticket(fixture(), changed, "beta-source", resume=True) + assert accept.load_original_ticket(fixture(), original, "beta-source", resume=True)["operation_id"] == "original-source" + + +def _producer_record(action, operation_id, target_id, receipt_source, result): + from auto_coder.management.operations import AcceptedAuthority, ActionParameter, EffectAttempt, EffectReceipt, EffectState, ManagedTarget, OperationIntent, OperationPhase, OperationRecord + + intent = OperationIntent(ManagedTarget(target_id, "beta" if action == "run_beta_acceptance" else "release", accept.BETA_WORK_REPOSITORY if action == "run_beta_acceptance" else accept.TEST_REPOSITORY), action, (ActionParameter("target_id", target_id),), "original-key", expected_artifact="b" * 40) + authority = AcceptedAuthority("http://127.0.0.1:8081", fixture().endpoint, fixture().firebase_project_id, "fixture-owner", "dot-disposable-operator", "original-grant", ("auto-coder.read", "auto-coder.deploy"), 1, 2) + receipt = EffectReceipt(operation_id, receipt_source, 3, json.dumps(result, sort_keys=True, separators=(",", ":"))) + return OperationRecord(operation_id, intent, intent.fingerprint(), authority, OperationPhase.SUCCEEDED, 3, effect=EffectAttempt(state=EffectState.CONFIRMED, correlation_identity=operation_id, receipt=receipt)) + + +def test_beta_acceptance_consumes_actual_producer_projection_and_rejects_unpublished_local_pass(): + original = "beta-acceptance-original" + record = _producer_record("run_beta_acceptance", original, fixture().target_ids["beta"], "github-source-acceptance-readback", {"status": "PASS_PUBLISHED", "tag_ref": "refs/tags/auto-coder-beta-acceptance-" + original, "tag_object_oid": "c" * 40, "direct_commit_sha": "b" * 40}) + runtime = object.__new__(ManagementRuntime) + runtime.beta_acceptance = None + projected = runtime._project_beta_acceptance(record) + result = accept.verified_acceptance_receipt(projected, operation_id=original, target_id=fixture().target_ids["beta"], source_sha="b" * 40) + assert result["acceptance_oid"] == "c" * 40 + for change in ({"status": "PASS"}, {"effect_state": "sent_unknown"}, {"receipt": None}, {"operation_id": "different-original"}, {"target_id": fixture().target_ids["release"]}): + with pytest.raises((accept.Blocked, accept.Failed)): + accept.verified_acceptance_receipt({**projected, **change}, operation_id=original, target_id=fixture().target_ids["beta"], source_sha="b" * 40) + + +def _producer_promotion_projection(): + from auto_coder.management.source_release_dispatch import SourceReleaseDispatch + + original = "promotion-original" + run = {"id": 42, "run_attempt": 1, "workflow_id": 100} + record = _producer_record("promote_accepted_source_release", original, fixture().target_ids["release"], "github-source-release-readback", {"outcome": "SOURCE_PROMOTION_CONFIRMED", "run_id": 42, "run_attempt": 1, "catalog_oid": "d" * 40, "source_sha": "b" * 40, "worker_deployed": False}) + runtime = SimpleNamespace(_project_operation=ManagementRuntime._project_operation, settings=None, journal=SimpleNamespace(source_release_run=lambda _original: run)) + return SourceReleaseDispatch(runtime).project(record) + + +def test_promotion_producer_receipt_never_proves_worker_deployment(): + projected = _producer_promotion_projection() + result = accept.verified_promotion_receipt(projected, operation_id="promotion-original", target_id=fixture().target_ids["release"], source_sha="b" * 40) + assert result["release_worker_deployed"] is False + assert result["promotion_run_id"] == 42 + for change in ({"source_promotion": "COMPLETED_BUT_SUPERSEDED"}, {"worker_deployed": True}, {"workflow_run": {"id": 43, "run_attempt": 1}}, {"effect_state": "sent_unknown"}): + with pytest.raises((accept.Blocked, accept.Failed)): + accept.verified_promotion_receipt({**projected, **change}, operation_id="promotion-original", target_id=fixture().target_ids["release"], source_sha="b" * 40) + + +def test_effectful_original_query_is_denied_by_default_and_bound_to_one_operation(): + calls = [] + + class Session: + async def call_tool(self, name, args): + calls.append((name, args)) + return SimpleNamespace(isError=False, structuredContent={"status": "NEEDS_ATTENTION"}) + + live = accept.Live(Session(), {"auto_coder_operation_get"}) + args = {"target_id": fixture().target_ids["release"], "operation_id": "promotion-original"} + with pytest.raises((accept.Blocked, ValueError)): + asyncio.run(live.call("auto_coder_operation_get", args)) + with pytest.raises(accept.Blocked): + asyncio.run(live.call("auto_coder_operation_get", args, mutation=True)) + live.authorize_original_reconciliation(args["target_id"], args["operation_id"]) + with pytest.raises(accept.Blocked): + asyncio.run(live.call("auto_coder_operation_get", {**args, "operation_id": "another-operation"}, mutation=True)) + asyncio.run(live.call("auto_coder_operation_get", args, mutation=True)) + assert calls == [("auto_coder_operation_get", args)] + + +@pytest.mark.parametrize("status", ["CONFLICT", "BUSY", "DENIED", "INVALID_REQUEST", "UNAVAILABLE"]) +def test_admission_refusal_cannot_adopt_an_occupying_operation(monkeypatch, tmp_path, status): + original = checkpoint() + ticket = tmp_path / "dot-fixture-reevaluate-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + + async def prepare(*_args): + return {"target_id": fixture().target_ids["beta"], "idempotency_key": original.idempotency_key, "target_type": "issue", "number": 14, "expected_process_run_id": "original-worker", "expected_deployment_generation": "original-generation"} + + monkeypatch.setattr(accept, "prepare_arguments", prepare) + + def handler(name, _arguments): + assert name == "auto_coder_reevaluate_target" + # The production admission handlers use this actual response helper + # for both conflict and occupied-lane replies, sometimes with an ID. + return ManagementRuntime._response(status, "journal_" + status.lower(), operation_id="occupying-operation") + + calls = _install_offline_mcp_session(monkeypatch, handler) + with pytest.raises(accept.Blocked, match="Admission was refused"): + asyncio.run(accept.run_authenticated(fixture(), original, "reevaluate", mutate=True, item_number=14)) + record = json.loads(ticket.read_text()) + assert record["effect_state"] == "ADMISSION_UNKNOWN" + assert record["operation_id"] == "" + assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_targets", "auto_coder_reevaluate_target"] + + +@pytest.mark.parametrize("observed_source", ["a" * 40, "b" * 40]) +def test_release_activation_needs_current_installed_source_after_promotion(monkeypatch, tmp_path, observed_source): + from auto_coder.management.worker_lifecycle import WorkerLifecycle + + original = checkpoint(stage="full", release_target=fixture().target_ids["release"], idempotency_key="original-release-key") + ticket = tmp_path / "dot-fixture-release-source-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + arguments = { + "target_id": fixture().target_ids["release"], + "idempotency_key": original.idempotency_key, + "selected_git_sha": original.source_sha, + "promotion_operation_id": "promotion-original", + "catalog_ref": "refs/tags/auto-coder-source-release-promotion-original", + "catalog_oid": "d" * 40, + "acceptance_ref": "refs/tags/auto-coder-beta-acceptance-beta-original", + "acceptance_oid": "e" * 40, + } + accept.persist_original_ticket(ticket, fixture(), original, accept.MUTATIONS["release-source"], arguments) + accept.update_original_ticket(ticket, fixture(), "release-original", "ADMITTED") + record = _producer_record("deploy_release_source", "release-original", fixture().target_ids["release"], "supervisor-and-worker-lifecycle-journal", "source_deployed") + runtime = SimpleNamespace(_params=ManagementRuntime._params) + lifecycle = WorkerLifecycle(runtime) + identity = {key: arguments[key] for key in ("promotion_operation_id", "catalog_ref", "catalog_oid", "acceptance_ref", "acceptance_oid")} + projected = lifecycle.project_snapshot(record, {"stage": "SUCCEEDED", "source_sha": original.source_sha, "new_worker": _real_worker_sample(1)["worker"], "release_identity": {**identity, "source_sha": original.source_sha}}) + sequence = 10 + + async def promoted(*_args, **_kwargs): + return {**identity, "release_worker_deployed": False} + + monkeypatch.setattr(accept, "promotion_evidence", promoted) + + def handler(name, args): + nonlocal sequence + if name == "auto_coder_operation_inspect": + return projected + assert name == "auto_coder_deployment_status" + sequence += 1 + target = fixture().target_ids["release"] + return {**_real_worker_sample(sequence, source_sha=observed_source), "target_id": target, "instance_id": target, "channel": "release", "repository": accept.TEST_REPOSITORY} + + calls = _install_offline_mcp_session(monkeypatch, handler) + if observed_source != original.source_sha: + with pytest.raises(accept.Failed, match="Installed source binding"): + asyncio.run(accept.run_authenticated(fixture(), original, "release-source", mutate=True, item_number=0, resume_original=True)) + assert json.loads(ticket.read_text())["effect_state"] == "ADMITTED" + else: + report = asyncio.run(accept.run_authenticated(fixture(), original, "release-source", mutate=True, item_number=0, resume_original=True)) + assert report.verdict == "PASS" + assert report.positive_deployment_proven is True + assert report.details["release_worker_deployed"] is True + assert json.loads(ticket.read_text())["effect_state"] == "SUCCEEDED" + assert all(name not in accept.MUTATIONS.values() for name, _ in calls) + + +def test_lifecycle_success_without_confirmed_effect_remains_blocked(): + original = checkpoint() + value = {"operation_id": "original-beta", "target_id": fixture().target_ids["beta"], "action": "deploy_beta_source", "source_sha": original.source_sha, "status": "SUCCEEDED", "phase": "succeeded"} + for state in (None, "not_sent", "sent_unknown"): + with pytest.raises(accept.Blocked, match="confirmed terminal"): + accept.verified_deployment({**value, "effect_state": state}, fixture(), original, "beta-source", "original-beta") + accept.verified_deployment({**value, "effect_state": "confirmed"}, fixture(), original, "beta-source", "original-beta") + + +def test_pending_effect_cannot_be_ignored_by_worker_only_observe(): + original = checkpoint(beta_operation_id="original-deploy", pending_operation_id="original-deploy", pending_effect_state="SENT_UNKNOWN") + with pytest.raises(accept.Blocked, match="Inspect the original unresolved"): + accept.original_stage(original, "observe", "", "", mutation=False) + accept.original_stage(original, "inspect-original", "", "", mutation=False) + + +@pytest.mark.parametrize( + "section,changed", + [ + ("lifecycle", {"state": "draining"}), + ("lifecycle", {"daemon_serving": False}), + ("lifecycle", {"startup_reconciliation_error": "reconciliation_failed"}), + ("lifecycle", {"startup_failure": {"event_id": "original-failure"}}), + ("invocations", {"state": "closing"}), + ("heartbeat", {"stage": "bootstrap-ready"}), + ("heartbeat", {"sequence": 0}), + ], +) +def test_advancing_counter_cannot_mask_nonserving_or_nonproducer_worker(section, changed): + before, after = _real_worker_sample(10), _real_worker_sample(11) + after["worker_observation"][section].update(changed) + with pytest.raises(accept.Blocked): + accept.require_advancing_worker_heartbeat(before, after, source_sha="b" * 40) + + +def test_original_promotion_run_and_catalog_cannot_be_replaced_by_readback(): + evidence = accept.verified_promotion_receipt(_producer_promotion_projection(), operation_id="promotion-original", target_id=fixture().target_ids["release"], source_sha="b" * 40) + original = checkpoint(stage="full", release_target=fixture().target_ids["release"], promotion_operation_id="promotion-original", promotion_run_id="42", promotion_catalog_ref=str(evidence["catalog_ref"]), promotion_catalog_oid=str(evidence["catalog_oid"])) + accept.confirm_saved_promotion(original, evidence) + for changed in (replace(original, promotion_run_id="43"), replace(original, promotion_operation_id="other-promotion"), replace(original, promotion_catalog_oid="e" * 40), replace(original, promotion_catalog_ref="")): + with pytest.raises(accept.Failed): + accept.confirm_saved_promotion(changed, evidence) diff --git a/tests/test_dot_mcp_negative_acceptance.py b/tests/test_dot_mcp_negative_acceptance.py new file mode 100644 index 000000000..c34dcff05 --- /dev/null +++ b/tests/test_dot_mcp_negative_acceptance.py @@ -0,0 +1,303 @@ +"""Offline negative consumer regressions, never live end-to-end acceptance. + +The HTTP cases exercise actual OAuth/grant, current policy and SQLite producers; +the existing Firebase network-edge fixture supplies offline identity inputs. +""" + +import asyncio +import json +import os +import subprocess +import sys +from dataclasses import asdict, replace +from pathlib import Path +from types import SimpleNamespace + +import pytest +from scripts import dot_mcp_acceptance as accept +from scripts import dot_mcp_negative_acceptance as negative +from scripts import management_mcp_rollout_smoke as smoke +from starlette.testclient import TestClient + +from auto_coder.management.app import create_management_app +from auto_coder.management.mcp_runtime_tools import ManagementRuntime +from auto_coder.management.operations import AcceptedAuthority, ActionParameter, EffectAttempt, EffectState, ManagedTarget, OperationIntent, OperationPhase, OperationRecord +from auto_coder.management.worker_lifecycle import WorkerLifecycle +from tests.test_dot_mcp_acceptance import checkpoint as saved_checkpoint +from tests.test_dot_mcp_acceptance import fixture as disposable_fixture +from tests.test_management_mcp import _issue, real_boundary # noqa: F401 +from tests.test_management_runtime_mcp import _call, runtime_boundary # noqa: F401 + + +def checkpoint(**changes): + return saved_checkpoint(release_target=disposable_fixture().target_ids["release"], **changes) + + +def policy_text(fixture, profile): + return f"profile: {profile}\ntargets:\n" f" {fixture.target_ids['beta']}:\n channel: beta\n repositories: [{accept.BETA_WORK_REPOSITORY}]\n" f" {fixture.target_ids['release']}:\n channel: release\n repositories: [{accept.TEST_REPOSITORY}]\n" + + +def test_default_without_fixture_is_not_run_and_creates_only_rotating_log(tmp_path): + completed = subprocess.run( + [sys.executable, str(Path(negative.__file__))], + capture_output=True, + text=True, + env={**os.environ, "XDG_STATE_HOME": str(tmp_path), "AUTO_CODER_MCP_ACCESS_TOKEN": "unused"}, + timeout=15, + ) + assert completed.returncode == 3 + output = json.loads(completed.stdout.splitlines()[-1]) + assert output["verdict"] == "NOT_RUN" + assert output["effect_dispatched"] is False + assert output["negative_boundary_verified"] is False + assert output["positive_deployment_proven"] is False + assert (tmp_path / "auto-coder/dot-mcp-negative-acceptance.log").is_file() + + +@pytest.mark.parametrize("pending", ["SENT_UNKNOWN", "NEEDS_ATTENTION"]) +def test_refusal_probe_cannot_bypass_original_unknown_with_another_key(pending): + original = checkpoint(beta_operation_id="original", pending_operation_id="original", pending_effect_state=pending, idempotency_key="different-key") + with pytest.raises(accept.Blocked, match="original unresolved"): + negative.selected_checkpoint(disposable_fixture(), original, "observe-refusal", "beta", "operator-v1", probe=True) + + +def test_refusal_probe_requires_explicit_flag_and_operator_revision(): + original = checkpoint() + with pytest.raises(accept.Blocked, match="explicit operator selection"): + negative.selected_checkpoint(disposable_fixture(), original, "observe-refusal", "beta", "operator-v1", probe=False) + with pytest.raises(accept.Blocked, match="revision"): + negative.selected_checkpoint(disposable_fixture(), original, "observe-refusal", "beta", "different", probe=True) + with pytest.raises(accept.Blocked, match="strictly read-only"): + negative.selected_checkpoint(disposable_fixture(), original, "unknown-effect", "beta", "operator-v1", probe=True) + + +def test_observe_policy_requires_exact_two_fixture_targets(tmp_path): + fixture = disposable_fixture() + path = tmp_path / "operator.yaml" + path.write_text(policy_text(fixture, "observe")) + assert negative.observe_policy_revision(fixture, path).startswith("sha256:") + path.write_text(policy_text(fixture, "full")) + with pytest.raises(accept.Blocked, match="observe policy"): + negative.observe_policy_revision(fixture, path) + path.write_text(policy_text(fixture, "observe").replace(accept.TEST_REPOSITORY, "fixture/other", 1)) + with pytest.raises(accept.Blocked, match="exact two-target"): + negative.observe_policy_revision(fixture, path) + + +class HttpSession: + """Adapter to actual management HTTP; no fabricated MCP result.""" + + def __init__(self, client, bearer, *, before=None, lose_response=False): + self.client = client + self.bearer = bearer + self.before = before + self.lose_response = lose_response + self.calls = [] + + async def call_tool(self, name, arguments): + self.calls.append(name) + if name == "auto_coder_reevaluate_target" and self.before: + self.before() + value = _call(self.client, self.bearer, name, arguments) + if name == "auto_coder_reevaluate_target" and self.lose_response: + raise TimeoutError("original response lost after actual producer call") + return SimpleNamespace(isError=False, structuredContent=value) + + +def fixture_app(boundary, profile): + settings, grants, signed, policy, beta, release = boundary + fixture = disposable_fixture() + for role, worker in (("beta", beta), ("release", release)): + worker.config = replace(worker.config, instance_id=fixture.target_ids[role], repository=accept.BETA_WORK_REPOSITORY if role == "beta" else accept.TEST_REPOSITORY) + policy.write_text(policy_text(fixture, profile)) + app = create_management_app(settings, grants, worker_clients={"beta": beta, "release": release}) + app.state.management_runtime.journal.get("initialize-empty-journal") + return fixture, settings, signed, policy, beta, app + + +def live_session(session): + return accept.Live(session, {"auto_coder_operation_inspect", "auto_coder_deployment_status", "auto_coder_reevaluate_target"}) + + +def test_actual_observe_refusal_has_no_admission_and_preserves_terminal_refused_ticket(runtime_boundary, tmp_path): + fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "observe") + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + token = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.operate")["access_token"] + session = HttpSession(client, token) + original = checkpoint() + report = asyncio.run(negative.refusal_probe(live_session(session), fixture, original, "observe-refusal", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + assert report.verdict == "PASS" and report.negative_boundary_verified + assert report.effect_dispatched is False and report.positive_deployment_proven is False + assert beta.send_calls == 0 + assert negative.journal_original_key(settings.operation_state_root / "management-operations.sqlite3", fixture.target_ids["beta"], original.idempotency_key) == () + ticket = json.loads(negative.refusal_ticket("observe-refusal", tmp_path).read_text()) + assert ticket["effect_state"] == "REFUSED" and ticket["operation_id"] == "" + assert ticket["refusal_evidence"]["operation_absent"] is True + assert session.calls == ["auto_coder_deployment_status", "auto_coder_reevaluate_target"] + with pytest.raises(accept.Blocked, match="previous admission"): + asyncio.run(negative.refusal_probe(live_session(session), fixture, original, "observe-refusal", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + assert beta.send_calls == 0 + + +def test_policy_downgrade_requires_actual_prior_reevaluation_receipt(runtime_boundary, tmp_path): + fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "beta") + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + token = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.operate")["access_token"] + prior = checkpoint(idempotency_key="prior-allowed") + arguments = {"target_id": fixture.target_ids["beta"], "target_type": "issue", "number": 14, "idempotency_key": prior.idempotency_key, "expected_process_run_id": beta.run, "expected_deployment_generation": beta.deployment} + path = tmp_path / "dot-fixture-reevaluate-original-send.json" + accept.persist_original_ticket(path, fixture, prior, "auto_coder_reevaluate_target", arguments) + admitted = _call(client, token, "auto_coder_reevaluate_target", arguments) + assert admitted["status"] == "accepted_for_reevaluation" + accept.update_original_ticket(path, fixture, admitted["operation_id"], "ADMITTED") + accept.update_original_ticket(path, fixture, admitted["operation_id"], "SUCCEEDED") + policy.write_text(policy_text(fixture, "observe")) + session = HttpSession(client, token) + original = checkpoint(idempotency_key="separate-denial-probe") + report = asyncio.run(negative.refusal_probe(live_session(session), fixture, original, "policy-downgrade", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + assert report.verdict == "PASS" and beta.send_calls == 1 + assert session.calls == ["auto_coder_operation_inspect", "auto_coder_deployment_status", "auto_coder_reevaluate_target"] + assert json.loads(path.read_text())["operation_id"] == admitted["operation_id"] + + +def test_unexpected_actual_admission_is_failed_and_original_ticket_remains_held(runtime_boundary, tmp_path): + fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "observe") + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + token = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.operate")["access_token"] + session = HttpSession(client, token, before=lambda: policy.write_text(policy_text(fixture, "beta"))) + with pytest.raises(accept.Failed, match="admitted an operation"): + asyncio.run(negative.refusal_probe(live_session(session), fixture, checkpoint(), "observe-refusal", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + record = json.loads(negative.refusal_ticket("observe-refusal", tmp_path).read_text()) + assert record["effect_state"] == "ADMITTED" and record["operation_id"] + report = negative.exception_report("observe-refusal", "unexpected admission", verdict="FAIL", root=tmp_path) + assert report.operation_id == record["operation_id"] and report.effect_dispatched is None + assert beta.send_calls == 1 + + +def test_lost_actual_refusal_response_never_reports_no_send(runtime_boundary, tmp_path): + fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "observe") + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + token = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.operate")["access_token"] + with pytest.raises(TimeoutError): + asyncio.run(negative.refusal_probe(live_session(HttpSession(client, token, lose_response=True)), fixture, checkpoint(), "observe-refusal", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + report = negative.exception_report("observe-refusal", "lost", root=tmp_path) + assert report.effect_dispatched is None and report.verdict == "BLOCKED" + assert json.loads(negative.refusal_ticket("observe-refusal", tmp_path).read_text())["effect_state"] == "ADMISSION_UNKNOWN" + assert beta.send_calls == 0 + + +def test_busy_response_does_not_adopt_another_original_operation(runtime_boundary, tmp_path): + fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "beta") + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + token = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.operate")["access_token"] + beta.drop_before_receipt = True + arguments = {"target_id": fixture.target_ids["beta"], "target_type": "issue", "number": 14, "idempotency_key": "another-original-key", "expected_process_run_id": beta.run, "expected_deployment_generation": beta.deployment} + outstanding = _call(client, token, "auto_coder_reevaluate_target", arguments) + assert outstanding["operation_id"] and outstanding["effect_state"] == "sent_unknown" + policy.write_text(policy_text(fixture, "observe")) + session = HttpSession(client, token, before=lambda: policy.write_text(policy_text(fixture, "beta"))) + with pytest.raises(accept.Blocked, match="not this probe's original admission"): + asyncio.run(negative.refusal_probe(live_session(session), fixture, checkpoint(), "observe-refusal", 14, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + ticket = json.loads(negative.refusal_ticket("observe-refusal", tmp_path).read_text()) + assert ticket["effect_state"] == "ADMISSION_UNKNOWN" and ticket["operation_id"] == "" + report = negative.exception_report("observe-refusal", "busy", root=tmp_path) + assert report.operation_id == "" and report.effect_dispatched is None + assert beta.send_calls == 1 + + +def original_record(fixture): + target = ManagedTarget(fixture.target_ids["beta"], "beta", accept.BETA_WORK_REPOSITORY) + intent = OperationIntent(target, "deploy_beta_source", (ActionParameter("target_id", target.instance_id),), "original-key", "old-process", "old-generation") + authority = AcceptedAuthority("https://fixture.example", "https://fixture.example/mcp", fixture.firebase_project_id, "owner", "dot-disposable-operator", "original-grant", ("auto-coder.read", "auto-coder.deploy"), 1.0, 1.0) + return OperationRecord("original-operation", intent, intent.fingerprint(), authority, OperationPhase.NEEDS_ATTENTION, 1, safe_error_category="lifecycle_deadline_exceeded", effect=EffectAttempt(state=EffectState.SENT_UNKNOWN)) + + +class ProjectedOriginal: + """Offline real lifecycle projection with no claim of physical worker evidence.""" + + def __init__(self, record, snapshot, current=None): + self.record = record + self.snapshot = snapshot + self.current = current + self.observations = [] + self.calls = [] + self.producer = WorkerLifecycle(SimpleNamespace(_params=ManagementRuntime._params)) + + async def call(self, name, arguments, *, mutation=False): + assert mutation is False + self.calls.append((name, arguments)) + assert arguments.get("target_id") == self.record.intent.target.instance_id + if name == "auto_coder_operation_inspect": + assert arguments.get("operation_id") == self.record.operation_id + return self.producer.project_snapshot(self.record, self.snapshot) + assert name == "auto_coder_deployment_status" and self.current is not None + return self.current + + +def test_unknown_original_remains_blocked_and_never_uses_effectful_get(tmp_path): + fixture = disposable_fixture() + record = original_record(fixture) + live = ProjectedOriginal(record, {"stage": "NEEDS_ATTENTION", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 0}) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="SENT_UNKNOWN") + report = asyncio.run(negative.original_scenario(live, fixture, saved, "unknown-effect", root=tmp_path)) + assert report.verdict == "BLOCKED" and report.negative_boundary_verified + assert report.operation_id == record.operation_id and report.effect_dispatched is False + assert report.positive_deployment_proven is False + assert [call[0] for call in live.calls] == ["auto_coder_operation_inspect", "auto_coder_operation_inspect"] + + +def test_true_held_drain_requires_same_actual_worker_and_no_start(tmp_path): + fixture = disposable_fixture() + record = original_record(fixture) + incumbent = {"process_run_id": "old-process", "deployment_generation": "old-generation", "runtime_id": "old-runtime", "supervisor_generation": "original-supervisor"} + snapshot = {"stage": "DRAINING", "source_sha": fixture.source_fixture_sha, "incumbent": incumbent, "steps": {"drain": {"operation_id": "d-" + record.operation_id, "state": "SENT_UNKNOWN"}}, "recovery_attempts": 0} + current = {"status": "OK", "target_id": fixture.target_ids["beta"], "instance_id": fixture.target_ids["beta"], "repository": accept.BETA_WORK_REPOSITORY, "channel": "beta", "alive": True, "worker": incumbent, "process_state": "DRAINING", "supervisor_generation": "original-supervisor"} + live = ProjectedOriginal(record, snapshot, current) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") + report = asyncio.run(negative.original_scenario(live, fixture, saved, "held-drain", root=tmp_path)) + assert report.verdict == "BLOCKED" and report.negative_boundary_verified + current["worker"] = {**incumbent, "process_run_id": "replacement"} + with pytest.raises(accept.Blocked, match="lifetime"): + asyncio.run(negative.original_scenario(live, fixture, saved, "held-drain", root=tmp_path)) + snapshot["steps"]["start"] = {"operation_id": "s-" + record.operation_id} + with pytest.raises(accept.Blocked, match="held graceful drain"): + asyncio.run(negative.original_scenario(live, fixture, saved, "held-drain", root=tmp_path)) + + +def test_consumed_producer_recovery_is_durable_and_cannot_be_rebound(tmp_path): + fixture = disposable_fixture() + record = original_record(fixture) + live = ProjectedOriginal(record, {"stage": "NEEDS_ATTENTION", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 1, "recovery_state": "UNKNOWN"}) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") + report = asyncio.run(negative.original_scenario(live, fixture, saved, "repeated-failure", root=tmp_path)) + assert report.verdict == "BLOCKED" and report.negative_boundary_verified + path = tmp_path / f"dot-recovery-{report.failure_signature}.json" + original_bytes = path.read_bytes() + assert path.stat().st_mode & 0o077 == 0 + assert asyncio.run(negative.original_scenario(live, fixture, saved, "repeated-failure", root=tmp_path)).failure_signature == report.failure_signature + assert path.read_bytes() == original_bytes + identity = negative.FailureIdentity(record.intent.action, record.intent.target.instance_id, fixture.source_fixture_sha, record.safe_error_category) + with pytest.raises(accept.Blocked, match="cannot replace"): + negative.record_exhausted_failure(tmp_path, fixture, identity, "invented-retry") + assert path.read_bytes() == original_bytes + + +def test_missing_recovery_evidence_does_not_create_exhaustion_pass(tmp_path): + fixture = disposable_fixture() + record = original_record(fixture) + live = ProjectedOriginal(record, {"stage": "NEEDS_ATTENTION", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 0}) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") + with pytest.raises(accept.Blocked, match="one consumed recovery"): + asyncio.run(negative.original_scenario(live, fixture, saved, "repeated-failure", root=tmp_path)) + assert list(tmp_path.iterdir()) == [] + + +def test_runtime_receipt_cannot_claim_persistent_data_rollback(tmp_path): + fixture = disposable_fixture() + record = original_record(fixture) + live = ProjectedOriginal(record, {"stage": "RECOVERED_PREVIOUS", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 1, "recovery_state": "CONFIRMED", "restored_worker": {"runtime_id": "previous"}}) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") + report = asyncio.run(negative.original_scenario(live, fixture, saved, "data-rollback", root=tmp_path)) + assert report.verdict == "BLOCKED" and "unsupported" in report.reason + assert not report.positive_deployment_proven and not report.negative_boundary_verified + assert live.calls == [("auto_coder_operation_inspect", {"target_id": fixture.target_ids["beta"], "operation_id": record.operation_id})] diff --git a/tests/test_management_worker_lifecycle.py b/tests/test_management_worker_lifecycle.py index fcbf16e59..b57dfb370 100644 --- a/tests/test_management_worker_lifecycle.py +++ b/tests/test_management_worker_lifecycle.py @@ -287,6 +287,15 @@ def unreadable_until_restart(operation_id: str) -> WorkerNetworkResult: bridge.supervisor_generation = "supervisor-restarted" _drive(app, operation_id) completed = _call(client, bearer, "auto_coder_operation_get", {"target_id": "beta-worker", "operation_id": operation_id}) + # Consumers must retain the durable effect's uncertainty even when a + # lifecycle snapshot has advanced or a terminal reply was interrupted. + original_record = runtime.journal.get(operation_id).record + assert original_record is not None + assert completed["effect_state"] == original_record.effect.state.value + if completed["status"] == "SUCCEEDED": + assert completed["effect_state"] == "confirmed" + if intervention == "supervisor_changed_after_unknown": + assert completed["effect_state"] == "sent_unknown" if intervention == "selection_changed": assert completed["status"] == "SUPERSEDED" assert [name for name, _id in bridge.actions] == ["PREPARE"] From 7330957b2aeec028226fd2bd59daf4d55f4cd719 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 12 Nov 2026 23:30:17 +0900 Subject: [PATCH 39/42] fix: enforce durable rollout recovery budget and beta release refusal --- ...dot-autonomous-source-worker-operations.md | 12 + .../dot-negative-acceptance.md | 72 +++++- scripts/dot_mcp_acceptance.py | 51 ++++ scripts/dot_mcp_negative_acceptance.py | 147 ++++++++--- tests/test_dot_mcp_acceptance.py | 120 +++++++++ tests/test_dot_mcp_negative_acceptance.py | 231 +++++++++++++++++- 6 files changed, 583 insertions(+), 50 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index f09f8430b..aca2cbe91 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -353,6 +353,18 @@ original send ticket cannot report `effect_dispatched=false`: the report carries `effect_dispatched=null`, the original ticket state and verified original operation ID when available. Pre-send refusal without a ticket reports false; corrupt, replaced or unreadable tickets report UNKNOWN, not a guessed absence. +Before a new send, the client also reads the companion's durable +`dot-recovery-.json` records in the same management state +directory. For the same action, target and source, it inspects the exact retained +original operation without reconciliation and verifies the producer's consumed +recovery count and original failure. A confirmed terminal `FAILED` operation +may have released the service's target lane, but it does not reset this client +budget. A new checkpoint, operator revision or idempotency key cannot bypass it, +even when the original was admitted outside this client and has no send ticket. +Missing or changed original evidence remains blocked; the retained record is +never cleared automatically. An unrelated action, target or source does not +inherit that exhausted signature. Observation and explicit original resumption +remain available without admitting another operation. Live producer observations are hashed for origin tracking. A real reevaluation journal projects terminal acceptance as `status=accepted_for_reevaluation`, `phase=succeeded` and `effect_state=confirmed` with a JSON-encoded diff --git a/docs/client-features/dot-negative-acceptance.md b/docs/client-features/dot-negative-acceptance.md index b68d2bc23..00f2de931 100644 --- a/docs/client-features/dot-negative-acceptance.md +++ b/docs/client-features/dot-negative-acceptance.md @@ -22,8 +22,9 @@ rotating file; the last stdout line is the JSON report. | --- | --- | --- | | `observe-refusal` | The externally selected exact two-target `observe` policy, current real worker fence, authenticated `DENIED/profile_denies_mutation`, and an independent read-only SQLite lookup proving the original request key was never admitted | Negative boundary `PASS`; no deployment claim. The one fixed reevaluation probe requires explicit `--enable-refusal-probe`. | | `policy-downgrade` | All observe-refusal evidence plus the existing successful reevaluation ticket and its real owned `accepted_for_reevaluation` worker receipt | Negative boundary `PASS`; proves earlier acceptance and current refusal. A saved stage name alone cannot prove a downgrade. | +| `beta-release-refusal` | Current operator stage and exact two-target policy both remain `beta`; genuine tested-source and published beta acceptance prerequisites are independently read before actual `DENIED/release_policy_denied`; the original release request key is absent from the admission journal | Negative boundary `PASS`; the fixed promotion probe requires `--enable-refusal-probe`. It does not construct a release catalog or claim release activation. | | `held-drain` | Exact original lifecycle operation remains `DRAINING`, its original drain step exists with no start, and current signed Supervisor projection still identifies the same live incumbent process/runtime | Operational `BLOCKED` with `negative_boundary_verified=true`; no start, retry or recovery. | -| `unknown-effect` | Two non-dispatching reads of the same retained original operation and target continue to show the unresolved producer outcome | Operational `BLOCKED` with the negative boundary verified; an unknown original effect is never reported completed or unsent. | +| `unknown-effect` | Two non-dispatching reads of the same retained original worker, beta acceptance or source promotion operation and target continue to show the unresolved producer outcome | Operational `BLOCKED` with the negative boundary verified; an unknown original effect is never reported completed or unsent. | | `repeated-failure` | The original producer reports one consumed recovery attempt and a held/failed/restored-prior-runtime result; its action, target, exact source and failure category are retained durably | Operational `BLOCKED`. The original failure signature remains exhausted across restarts and cannot be rebound to an invented retry operation. | | `data-rollback` | Non-dispatching original runtime/lifecycle evidence | `BLOCKED`. No supported producer certifies persistent-data compatibility or restoration. Even a confirmed prior Python runtime restoration cannot establish that databases, files, migrations or external effects were rolled back. Escalate to the operator and keep admission suspended. | @@ -42,14 +43,23 @@ The immutable Python source and release workload use repository ownership. The source proof is not a grant to process that source repository concurrently in both channels. -An external operator first selects the fixture's `observe` policy. The client -reads that policy but cannot change it. An explicit refusal probe may attempt -only a fixed beta Issue reevaluation on `kitamura-tetsuo/auto-coder-test-beta`; it -cannot request release activation, arbitrary commands or Docker operations. +For `observe-refusal` and `policy-downgrade`, an external operator first selects +the fixture's `observe` policy. The client reads that policy but cannot change +it. These explicit probes attempt only a fixed beta Issue reevaluation on +`kitamura-tetsuo/auto-coder-test-beta`. The request uses current original worker fences and a persisted idempotency key. A pending original operation or consumed recovery budget prevents even this negative probe. +The separate `beta-release-refusal` probe requires an explicitly selected +`beta` stage and current `beta` policy. It reuses the positive client's genuine +tested-source and published beta-acceptance checks, then sends one fixed +`auto_coder_promote_accepted_source_release` request to the configured release +target. This tests the actual producer's beta-only policy boundary after valid +prerequisites; it needs no fabricated release catalog or release-worker fence. +The exact expected refusal is `DENIED/release_policy_denied`. None of these +probes requests release-worker activation, arbitrary commands or Docker control. + Before sending, the client independently opens the management admission SQLite database in `mode=ro` and verifies that the original key is absent. It then fsyncs a normal fixture original-send ticket. Only the exact producer @@ -62,6 +72,11 @@ request, the original operation ID is persisted and the scenario fails for operator inspection. It is never retried to obtain an expected rejection. An operation ID attached to `BUSY`, `CONFLICT` or an unbound response can name another intent; it is never adopted as this probe's original admission. +The source-promotion journal uses a canonical source lane, so absence checking +also checks the immutable physical `target_id` parameter instead of confusing +that lane name with the selected release target. Durable recovery ledgers are +consulted before a probe can create a new ticket; clearing checkpoint fields +does not reset the matching action, target and source budget. For example, inside the already authorized disposable management container: @@ -81,6 +96,12 @@ For downgrade testing, retain the positive client's successful policy to `observe`, select `policy-downgrade` with a separate original probe key. A known completed reevaluation is not an unresolved-operation retry. +After genuine beta acceptance, select `--scenario beta-release-refusal +--enable-refusal-probe --operator-stage beta` with its original beta-stage +revision and separate fixed probe key. The checkpoint must preserve the real +tested-source CI/ref identity and beta acceptance ref/OID. The Issue-number +argument is not used by this source-promotion probe. + ## Original-operation and recovery protocol Read-only scenarios require `pending_operation_id` and its original @@ -90,6 +111,17 @@ saved beta or release operation field. They request only `auto_coder_operation_get`. Inspecting the original operation precedes any worker health dependency, so a dead child does not suppress journal evidence. +An unknown source promotion must use the saved `promotion_operation_id` and +the fixture release target; its real `selected_source_sha` must equal the +original source. An unknown beta acceptance must use the fixture beta target. +Its non-dispatching MCP projection does not expose source parameters, so the +client additionally checks the original SQLite intent in `mode=ro` for the +exact action, beta workload, source and fixed idempotency key. A separate +negative checkpoint may bind that acceptance ID as its selected +`beta_operation_id`; preserve the original deployment checkpoint and ticket +unchanged. Publication operations are accepted only by `unknown-effect`, not +as held drains or worker recovery attempts. + The repeated-failure scenario records the actual producer's consumed attempt in `dot-recovery-.json`, where the signature hashes the immutable action, target, source SHA and producer failure category. The file @@ -97,6 +129,9 @@ retains the original operation and is created exclusively and fsynced. Repeated reads preserve its bytes. Another operation cannot replace that failure's original proof. This companion never consumes a new recovery attempt or circumvents the producer's normal retry, slot, exhaustion or merge gates. +The real prior-runtime restoration projects `status=FAILED` for the requested +candidate deployment with `recovery_state=CONFIRMED`; restoration does not +convert the failed candidate into a successful deployment or reset its budget. For `data-rollback`, the explicit unsupported outcome is the safety contract. There is no instruction to implement persistent-data rollback, manufacture an @@ -104,6 +139,33 @@ incompatibility oracle or turn an operator assertion into producer evidence. An operator must assess restoration and compatibility outside the runtime rollout loop before resuming admission. +## External physical fault prerequisites + +Fresh OAuth consent and the beta canary repository do not create recovery +evidence. The external test orchestrator must provision the following before +running the corresponding scenario. These are disposable bootstrap and fault +insertion steps; they never grant Docker or policy authority to this client. + +| Physical scenario | External setup and actual oracle | +| --- | --- | +| Refusal and downgrade | Start a real installed `process-issues` worker with the scoped workload read credential, enabled idle backend, empty Issue/PR author allowlists and advancing producer heartbeat. Exercise observe refusal, then successful beta reevaluation, then an operator-owned downgrade to observe with a separate denial key. | +| Beta-only release refusal | First obtain actual exact-source CI/ref proof and published beta acceptance. Keep the operator stage and current policy at beta, preserve that original evidence and run only the fixed denial probe. | +| Held drain | In a fresh fixture, independently install and start a prior Python runtime. Enable `AUTO_CODER_TEST_HOLD_INSTALLED_TERMINAL=1`, place its exact environment path in `fixture-hold-selected-runtime`, and create `fixture-hold-terminal-exit` under the worker runtime root. The existing wrapper holds the real OS lifetime after the production worker writes its terminal receipt. Admit one candidate source update and verify original `DRAINING`, same live incumbent, the original drain step and no start step. Record before its finite lifecycle deadline expires. | +| Durable unknown | Retain the admitted original ID and key before externally interrupting the client/manager or losing a bridge response. Preserve the original journal, Supervisor and worker volumes. Inspect that same operation without `operation_get`; compare the real request/launch audit before and after reconstruction. A missing original admission ID remains held and is never repaired by another send. | +| One prior-runtime recovery and exhausted budget | Start a genuinely installed prior runtime with verified binding and advancing heartbeat, then admit one candidate update in a separate fixture. Enable `AUTO_CODER_FIXTURE_FAIL_STAGED_ONCE=1` and create `fixture-crash-next-staged` before the candidate starts. The real child exits, the Supervisor proves that OS lifetime ended, and the original lifecycle may attempt one `RECOVER_PREVIOUS`. Require actual prior-runtime binding and advancing heartbeat for `FAILED` plus `recovery_state=CONFIRMED`, or retain `NEEDS_ATTENTION` if restoration is ambiguous. Run `repeated-failure`, preserve its durable signature and original journal, and confirm later invocations cannot send a new attempt. | +| Unsupported data rollback | Use that genuine failed/restored original runtime operation. Run `data-rollback`; retain `BLOCKED`, suspend admission and escalate compatibility assessment to the operator. Do not alter a database or fabricate a compatibility receipt to manufacture this test result. | + +Prior and candidate installations must come from pinned real source revisions +through `WorkerVersionPreparer` and the production Supervisor; copying a READY +file is not preparation. The existing staging/installed-worker integration +fixtures show this bootstrap pattern. Each fault fixture retains its own source +identity, targets and original records. The one-shot positive harness does not +provide a second `beta-source` admission after its first successful ticket; +prepare the prior runtime externally before the candidate's first admission. +Do not delete tickets, reset a held lane or change keys to reuse an ambiguous +fixture for another scenario. Release terminal holds only as an explicit +external test action after recording the negative boundary. + ## Verification provenance `tests/test_dot_mcp_negative_acceptance.py` is an **offline** regression suite. diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index 2f285cd55..e49806f1b 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -217,6 +217,56 @@ def ticket_path(scenario: str) -> Path: return Path("/fixture/management") / f"dot-fixture-{scenario}-original-send.json" +async def check_retained_recovery(live: Live, fixture: Fixture, checkpoint: smoke.DotCheckpoint, action: str, target: str, *, root: Path) -> None: + """Keep a consumed original budget across checkpoint/key replacement. + + Terminal FAILED lifecycle operations release the producer's target lane. + The companion ledger therefore remains relevant even without a positive + send ticket. Inspect only its exact original; never reconcile or retry it. + """ + expected = (action.removeprefix("auto_coder_"), target, checkpoint.source_sha) + for path in sorted(root.glob("dot-recovery-*.json")): + try: + if path.is_symlink(): + raise ValueError("replaced recovery record") + retained = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_json_pairs) + except (OSError, ValueError, UnicodeError) as error: + raise Blocked("The retained original recovery budget cannot be checked") from error + failure = retained.get("failure") if isinstance(retained, dict) else None + if not isinstance(failure, dict): + raise Blocked("The retained original recovery identity is unavailable") + if (failure.get("action"), failure.get("target"), failure.get("source_sha")) != expected: + continue + signature = hashlib.sha256(json.dumps(failure, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + operation_id = retained.get("original_operation_id") + if ( + type(retained.get("schema")) is not int + or retained["schema"] != 1 + or retained.get("fixture_nonce") != fixture.fixture_nonce + or set(failure) != {"action", "target", "source_sha", "reason"} + or not isinstance(failure.get("reason"), str) + or smoke.SAFE_ID.fullmatch(failure["reason"]) is None + or retained.get("signature") != signature + or path.name != f"dot-recovery-{signature}.json" + or not isinstance(operation_id, str) + or smoke.SAFE_ID.fullmatch(operation_id) is None + or type(retained.get("recovery_attempts")) is not int + or retained["recovery_attempts"] != 1 + or retained.get("outcome") != "BLOCKED" + ): + raise Blocked("The retained original recovery budget has changed or is incomplete") + original = await live.call("auto_coder_operation_inspect", {"target_id": target, "operation_id": operation_id}) + if (original.get("operation_id"), original.get("action"), original.get("target_id"), original.get("reason")) != (operation_id, expected[0], target, failure["reason"]): + raise Blocked("The retained exhausted original cannot be independently verified; reconcile that original before any new send") + if expected[0].startswith("deploy_") and original.get("source_sha") != checkpoint.source_sha: + raise Blocked("The retained exhausted original source cannot be independently verified") + if type(original.get("recovery_attempts")) is not int or original["recovery_attempts"] != 1 or (original.get("status"), original.get("phase")) not in {("FAILED", "failed"), ("NEEDS_ATTENTION", "needs_attention")}: + raise Blocked("The retained recovery outcome changed; reconcile its original operation before any new send") + if original["status"] == "FAILED" and original.get("effect_state") != "confirmed": + raise Blocked("The retained failed recovery has no confirmed original producer outcome") + raise Blocked(f"Original operation {operation_id} exhausted the recovery budget for this action, target and source; a new key cannot retry failure {signature}") + + def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotCheckpoint, action: str, args: dict[str, object]) -> None: """Fsync original nonsecret intent before an effect, refusing all replays.""" if path.exists() or path.is_symlink(): @@ -1056,6 +1106,7 @@ async def run_authenticated(fixture: Fixture, checkpoint: smoke.DotCheckpoint, s return Result("PASS", "observe", "Two independent installed workers advanced real heartbeat sequences; no effect sent", tuple(live.observations)) if retained is not None: return await complete_original(live, fixture, checkpoint, scenario, str(retained["operation_id"]), retained["args"], wait_seconds=wait_seconds) + await check_retained_recovery(live, fixture, checkpoint, MUTATIONS[scenario], scenario_target(fixture, scenario), root=ticket_path(scenario).parent) arguments = await prepare_arguments(live, fixture, checkpoint, scenario, item_number) original_ticket = ticket_path(scenario) persist_original_ticket(original_ticket, fixture, checkpoint, MUTATIONS[scenario], arguments) diff --git a/scripts/dot_mcp_negative_acceptance.py b/scripts/dot_mcp_negative_acceptance.py index f7c22563c..f651f1509 100644 --- a/scripts/dot_mcp_negative_acceptance.py +++ b/scripts/dot_mcp_negative_acceptance.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Observe real disposable refusal/recovery boundaries without granting authority. -Only the two explicit refusal probes send a fixed reevaluation request. They -require an operator-selected observe policy and a durable pre-send ticket. +Explicit refusal probes send a fixed reevaluation or source-promotion request. +They require an operator-selected denying policy and a durable pre-send ticket. All other scenarios inspect the original operation and never retry or recover it. """ @@ -26,8 +26,9 @@ import dot_mcp_acceptance as accept smoke = accept.smoke -SCENARIOS = frozenset({"observe-refusal", "policy-downgrade", "held-drain", "unknown-effect", "repeated-failure", "data-rollback"}) -REFUSAL_PROBES = frozenset({"observe-refusal", "policy-downgrade"}) +SCENARIOS = frozenset({"observe-refusal", "policy-downgrade", "beta-release-refusal", "held-drain", "unknown-effect", "repeated-failure", "data-rollback"}) +REFUSAL_PROBES = frozenset({"observe-refusal", "policy-downgrade", "beta-release-refusal"}) +WORKER_ACTIONS = frozenset({"reevaluate_target", "deploy_beta_source", "deploy_release_source", "start_worker", "stop_worker", "restart_worker"}) ROOT = Path("/fixture/management") POLICY = Path("/fixture/policy/operating-policy.yaml") JOURNAL = ROOT / "operations/management-operations.sqlite3" @@ -77,6 +78,8 @@ def selected_checkpoint(fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint raise accept.Blocked("The checkpoint must retain both original disposable targets") if checkpoint.source_sha != fixture.source_fixture_sha or not fixture.source_fixture_available: raise accept.Blocked("The negative scenario must retain the exact disposable source") + if scenario == "beta-release-refusal" and checkpoint.stage != "beta": + raise accept.Blocked("The beta-only release refusal requires the current operator-selected beta stage") if scenario in REFUSAL_PROBES: if not probe: raise accept.Blocked("An actual refusal probe requires explicit operator selection") @@ -91,13 +94,17 @@ def selected_checkpoint(fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint def observe_policy_revision(fixture: accept.Fixture, path: Path = POLICY) -> str: + return denying_policy_revision(fixture, "observe", path) + + +def denying_policy_revision(fixture: accept.Fixture, profile: str, path: Path = POLICY) -> str: """Read the mounted operator policy without modifying or granting anything.""" - from auto_coder.operating_policy import PolicyProfile, load_operating_policy + from auto_coder.operating_policy import load_operating_policy loaded = load_operating_policy(path) policy = loaded.policy - if policy is None or policy.profile is not PolicyProfile.OBSERVE: - raise accept.Blocked("The external operator has not selected the required observe policy") + if profile not in {"observe", "beta"} or policy is None or policy.profile.value != profile: + raise accept.Blocked("The external operator has not selected the required " + profile + " policy") expected = {(fixture.target_ids["beta"], "beta", (accept.BETA_WORK_REPOSITORY,)), (fixture.target_ids["release"], "release", (accept.TEST_REPOSITORY,))} actual = {(target.target_id, target.channel.value, target.repositories) for target in policy.targets} if actual != expected or policy.management_self_update is not None: @@ -116,7 +123,15 @@ def journal_original_key(path: Path, target: str, key: str) -> tuple[str, ...]: intent = json.loads(encoded) if not isinstance(intent, dict) or not isinstance(intent.get("target"), list) or len(intent["target"]) != 3: raise ValueError("invalid original journal intent") - if intent["target"][0] == target and intent.get("idempotency_key") == key: + pairs = intent.get("parameters") + if not isinstance(pairs, list) or any(not isinstance(pair, list) or len(pair) != 2 or not isinstance(pair[0], str) for pair in pairs): + raise ValueError("invalid original journal parameters") + parameters = dict(pairs) + if len(parameters) != len(pairs): + raise ValueError("duplicate original journal parameter") + # Source promotion owns a canonical source lane while retaining the + # physical release target in its immutable parameters. + if (intent["target"][0] == target or parameters.get("target_id") == target) and intent.get("idempotency_key") == key: found.append(str(operation_id)) return tuple(sorted(found)) except (OSError, sqlite3.Error, ValueError, TypeError) as error: @@ -154,48 +169,62 @@ async def prior_reevaluation(live: accept.Live, fixture: accept.Fixture, root: P async def refusal_probe(live: accept.Live, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, issue_number: int, *, root: Path = ROOT, policy_path: Path = POLICY, journal_path: Path = JOURNAL) -> NegativeReport: """Prove current policy refusal at the producer and independent admission DB.""" - if scenario not in REFUSAL_PROBES or type(issue_number) is not int or not 1 <= issue_number <= 999999999: + release_probe = scenario == "beta-release-refusal" + if scenario not in REFUSAL_PROBES or (not release_probe and (type(issue_number) is not int or not 1 <= issue_number <= 999999999)): raise accept.Blocked("Select one fixed refusal scenario and a test-repository Issue number") - policy_revision = observe_policy_revision(fixture, policy_path) + if release_probe and checkpoint.stage != "beta": + raise accept.Blocked("The beta-only release refusal requires the current operator-selected beta stage") + profile = "beta" if release_probe else "observe" + policy_revision = denying_policy_revision(fixture, profile, policy_path) if scenario == "policy-downgrade": await prior_reevaluation(live, fixture, root) - target = fixture.target_ids["beta"] + target = fixture.target_ids["release" if release_probe else "beta"] before = journal_original_key(journal_path, target, checkpoint.idempotency_key) if before: raise accept.Blocked("A refusal probe cannot replay an existing original journal intent") - beta = await accept.observed_target(live, target, "beta", accept.BETA_WORK_REPOSITORY) - worker = beta.get("worker") - if beta.get("alive") is not True or not isinstance(worker, dict): - raise accept.Blocked("No genuine current worker fence is available for the refusal probe") - arguments = { - "target_id": target, - "target_type": "issue", - "number": issue_number, - "idempotency_key": checkpoint.idempotency_key, - "expected_process_run_id": worker.get("process_run_id"), - "expected_deployment_generation": worker.get("deployment_generation"), - } - if any(not isinstance(value, (str, int)) or not value for value in arguments.values()): - raise accept.Blocked("The current refusal-probe process fence is unavailable") - if observe_policy_revision(fixture, policy_path) != policy_revision: + if release_probe: + # This validates the original tested source and independently published + # beta acceptance. No release catalog or worker fence is fabricated. + arguments = await accept.prepare_arguments(live, fixture, checkpoint, "promote-source", 0) + tool = accept.MUTATIONS["promote-source"] + denied_reason = "release_policy_denied" + else: + beta = await accept.observed_target(live, target, "beta", accept.BETA_WORK_REPOSITORY) + worker = beta.get("worker") + if beta.get("alive") is not True or not isinstance(worker, dict): + raise accept.Blocked("No genuine current worker fence is available for the refusal probe") + arguments = { + "target_id": target, + "target_type": "issue", + "number": issue_number, + "idempotency_key": checkpoint.idempotency_key, + "expected_process_run_id": worker.get("process_run_id"), + "expected_deployment_generation": worker.get("deployment_generation"), + } + if any(not isinstance(value, (str, int)) or not value for value in arguments.values()): + raise accept.Blocked("The current refusal-probe process fence is unavailable") + tool = accept.MUTATIONS["reevaluate"] + denied_reason = "profile_denies_mutation" + if denying_policy_revision(fixture, profile, policy_path) != policy_revision: raise accept.Blocked("The operator policy changed before the refusal probe") ticket = refusal_ticket(scenario, root) - accept.persist_original_ticket(ticket, fixture, checkpoint, "auto_coder_reevaluate_target", arguments) - response = await live.call("auto_coder_reevaluate_target", arguments, mutation=True) + await accept.check_retained_recovery(live, fixture, checkpoint, tool, target, root=root) + accept.persist_original_ticket(ticket, fixture, checkpoint, tool, arguments) + response = await live.call(tool, arguments, mutation=True) operation_id = response.get("operation_id") - if isinstance(operation_id, str) and operation_id and response.get("action") == "reevaluate_target" and response.get("target_id") == target and response.get("status") not in {"BUSY", "CONFLICT", "NOT_FOUND", "DENIED", "UNAVAILABLE", "INVALID_REQUEST"}: + if isinstance(operation_id, str) and operation_id and response.get("action") == tool.removeprefix("auto_coder_") and response.get("target_id") == target and response.get("status") not in {"BUSY", "CONFLICT", "NOT_FOUND", "DENIED", "UNAVAILABLE", "INVALID_REQUEST"}: accept.update_original_ticket(ticket, fixture, operation_id, "ADMITTED") raise accept.Failed("The expected refusal admitted an operation; preserve its original ticket and stop") if operation_id is not None: raise accept.Blocked("A busy, conflicting or unbound operation is not this probe's original admission; preserve uncertainty") - if response.get("status") != "DENIED" or response.get("reason") != "profile_denies_mutation": + if response.get("status") != "DENIED" or response.get("reason") != denied_reason: raise accept.Blocked("The producer did not prove the expected policy refusal; the send remains held") after = journal_original_key(journal_path, target, checkpoint.idempotency_key) if after: raise accept.Failed("The producer refusal conflicts with an admitted original journal intent") digest = _journal_absence_digest(target, checkpoint.idempotency_key, after) - accept.record_refused_ticket(ticket, fixture, reason="profile_denies_mutation", journal_sha256=digest) - if observe_policy_revision(fixture, policy_path) != policy_revision: + accept.record_refused_ticket(ticket, fixture, reason=denied_reason, journal_sha256=digest) + if denying_policy_revision(fixture, profile, policy_path) != policy_revision: raise accept.Blocked("The refusal is retained, but the operator policy changed during the scenario") live.observations.append(accept.Evidence("management-operation-journal", digest, "fixture SQLite mode=ro original-key read", "ABSENT")) return NegativeReport("PASS", scenario, "The current producer denied the fixed request and its original admission key is absent; no worker effect", tuple(live.observations), negative_boundary_verified=True) @@ -214,13 +243,42 @@ def original_binding(checkpoint: smoke.DotCheckpoint, fixture: accept.Fixture) - return target, operation_id -def check_original(value: dict[str, object], target: str, operation_id: str, source_sha: str) -> None: +def check_original(value: dict[str, object], target: str, operation_id: str, source_sha: str, *, publication_action: str = "") -> None: if value.get("operation_id") != operation_id or value.get("target_id") != target: raise accept.Blocked("The original journal operation is unavailable or differently bound") - if value.get("action") not in {"reevaluate_target", "deploy_beta_source", "deploy_release_source", "start_worker", "stop_worker", "restart_worker"}: + if publication_action == "promote_accepted_source_release" and value.get("action") != publication_action: + raise accept.Blocked("The saved original promotion is not a source-promotion operation") + if value.get("action") not in WORKER_ACTIONS and (not publication_action or value.get("action") != publication_action): raise accept.Blocked("The original action has no supported recovery acceptance scenario") if str(value.get("action")).startswith("deploy_") and value.get("source_sha") != source_sha: raise accept.Failed("Original source operation differs from the immutable selected source") + if value.get("action") == "promote_accepted_source_release" and value.get("selected_source_sha") != source_sha: + raise accept.Failed("Original source promotion differs from the immutable selected source") + + +def original_acceptance_intent(path: Path, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, operation_id: str) -> str: + """Bind acceptance source from its real journal, absent from MCP inspection.""" + try: + with sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=5) as connection: + connection.execute("PRAGMA query_only=ON") + row = connection.execute("SELECT intent_json FROM operations WHERE operation_id=?", (operation_id,)).fetchone() + if row is None: + raise ValueError("original acceptance is absent") + intent = json.loads(row[0]) + pairs = intent.get("parameters") if isinstance(intent, dict) else None + if not isinstance(pairs, list) or any(not isinstance(pair, list) or len(pair) != 2 or not isinstance(pair[0], str) for pair in pairs): + raise ValueError("original acceptance parameters are malformed") + parameters = dict(pairs) + if len(parameters) != len(pairs): + raise ValueError("duplicate original acceptance parameter") + except (OSError, sqlite3.Error, ValueError, TypeError) as error: + raise accept.Blocked("The original acceptance source intent cannot be independently checked") from error + target = fixture.target_ids["beta"] + if intent.get("action") != "run_beta_acceptance" or intent.get("target") != [target, "beta", accept.BETA_WORK_REPOSITORY] or parameters.get("target_id") != target or not checkpoint.idempotency_key or intent.get("idempotency_key") != checkpoint.idempotency_key: + raise accept.Blocked("The original acceptance action, target or fixed key differs from its checkpoint") + if parameters.get("source_sha") != checkpoint.source_sha: + raise accept.Failed("Original beta acceptance differs from the immutable selected source") + return hashlib.sha256(json.dumps(intent, sort_keys=True, separators=(",", ":")).encode()).hexdigest() def record_exhausted_failure(root: Path, fixture: accept.Fixture, identity: FailureIdentity, operation_id: str) -> str: @@ -251,11 +309,20 @@ def record_exhausted_failure(root: Path, fixture: accept.Fixture, identity: Fail return signature -async def original_scenario(live: accept.Live, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, root: Path = ROOT) -> NegativeReport: +async def original_scenario(live: accept.Live, fixture: accept.Fixture, checkpoint: smoke.DotCheckpoint, scenario: str, *, root: Path = ROOT, journal_path: Path = JOURNAL) -> NegativeReport: target, operation_id = original_binding(checkpoint, fixture) arguments = {"target_id": target, "operation_id": operation_id} original = await live.call("auto_coder_operation_inspect", arguments) - check_original(original, target, operation_id, checkpoint.source_sha) + publication_action = "" + if scenario == "unknown-effect": + if operation_id == checkpoint.promotion_operation_id and target == fixture.target_ids["release"]: + publication_action = "promote_accepted_source_release" + elif operation_id == checkpoint.beta_operation_id and target == fixture.target_ids["beta"]: + publication_action = "run_beta_acceptance" + check_original(original, target, operation_id, checkpoint.source_sha, publication_action=publication_action) + if original.get("action") == "run_beta_acceptance": + digest = original_acceptance_intent(journal_path, fixture, checkpoint, operation_id) + live.observations.append(accept.Evidence("management-operation-journal", digest, "fixture SQLite mode=ro original acceptance intent", "BOUND")) signature = "" if scenario == "held-drain": steps = original.get("steps") @@ -284,7 +351,7 @@ async def original_scenario(live: accept.Live, fixture: accept.Fixture, checkpoi raise accept.Blocked("The original operation is not currently an unresolved-effect case") reason = "The original effect remains unresolved; only its non-dispatching journal was inspected" elif scenario == "repeated-failure": - if type(original.get("recovery_attempts")) is not int or original["recovery_attempts"] != 1 or original.get("status") not in {"NEEDS_ATTENTION", "FAILED", "RECOVERED_PREVIOUS"}: + if type(original.get("recovery_attempts")) is not int or original["recovery_attempts"] != 1 or original.get("status") not in {"NEEDS_ATTENTION", "FAILED"}: raise accept.Blocked("The producer has not proven one consumed recovery attempt for this failure") failure_reason = original.get("reason") if not isinstance(failure_reason, str) or not failure_reason or smoke.SAFE_ID.fullmatch(failure_reason) is None: @@ -302,8 +369,8 @@ async def original_scenario(live: accept.Live, fixture: accept.Fixture, checkpoi else: raise accept.Blocked("Select a supported read-only original-operation scenario") after = await live.call("auto_coder_operation_inspect", arguments) - check_original(after, target, operation_id, checkpoint.source_sha) - if any(after.get(key) != original.get(key) for key in ("action", "status", "phase", "source_sha", "recovery_attempts", "recovery_state")): + check_original(after, target, operation_id, checkpoint.source_sha, publication_action=publication_action) + if any(after.get(key) != original.get(key) for key in ("action", "status", "phase", "effect_state", "source_sha", "selected_source_sha", "recovery_attempts", "recovery_state")): raise accept.Blocked("The original producer state changed during bounded negative observation") return NegativeReport("BLOCKED", scenario, reason, tuple(live.observations), operation_id, negative_boundary_verified=True, failure_signature=signature) @@ -320,7 +387,7 @@ async def run_authenticated(fixture: accept.Fixture, checkpoint: smoke.DotCheckp await session.initialize() live = accept.Live(session, {tool.name for tool in (await session.list_tools()).tools}) identity = await live.call("auto_coder_whoami", {}) - accept.verify_current_operator(identity, fixture, "reevaluate", mutation=scenario in REFUSAL_PROBES) + accept.verify_current_operator(identity, fixture, "promote-source" if scenario == "beta-release-refusal" else "reevaluate", mutation=scenario in REFUSAL_PROBES) accept.verify_current_targets(await live.call("auto_coder_targets", {}), fixture) if scenario in REFUSAL_PROBES: return await refusal_probe(live, fixture, checkpoint, scenario, issue_number) diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 279cd7ec2..73ae25f08 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -847,3 +847,123 @@ def test_original_promotion_run_and_catalog_cannot_be_replaced_by_readback(): for changed in (replace(original, promotion_run_id="43"), replace(original, promotion_operation_id="other-promotion"), replace(original, promotion_catalog_oid="e" * 40), replace(original, promotion_catalog_ref="")): with pytest.raises(accept.Failed): accept.confirm_saved_promotion(changed, evidence) + + +def _retained_actual_exhaustion(tmp_path): + """Produce and retain a real terminal journal outcome, without live claims.""" + from scripts import dot_mcp_negative_acceptance as negative + + from tests.test_dot_mcp_negative_acceptance import ProjectedOriginal, confirmed_prior_runtime_recovery + + record, snapshot = confirmed_prior_runtime_recovery(fixture(), tmp_path) + original = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") + producer = ProjectedOriginal(record, snapshot) + result = asyncio.run(negative.original_scenario(producer, fixture(), original, "repeated-failure", root=tmp_path)) + assert result.negative_boundary_verified is True and result.verdict == "BLOCKED" + producer.calls.clear() + return record, producer, tmp_path / f"dot-recovery-{result.failure_signature}.json" + + +def test_terminal_exhaustion_without_send_ticket_blocks_new_checkpoint_and_key(monkeypatch, tmp_path): + """The real producer releases FAILED lanes; the retained client budget does not.""" + from auto_coder.management.operation_store import ManagementOperationStore + from auto_coder.management.operations import AdmissionStatus + + record, producer, ledger = _retained_actual_exhaustion(tmp_path) + original_bytes = ledger.read_bytes() + journal = ManagementOperationStore(tmp_path / "journal") + assert journal.list_operations(outstanding_only=True, target=record.intent.target).records == () + fresh = checkpoint(idempotency_key="new-checkpoint-and-key", stage_revision="operator-v2") + ticket = tmp_path / "dot-fixture-beta-source-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + + def handler(name, arguments): + assert name == "auto_coder_operation_inspect" + assert arguments == {"target_id": fixture().target_ids["beta"], "operation_id": record.operation_id} + return producer.producer.project_snapshot(record, producer.snapshot) + + calls = _install_offline_mcp_session(monkeypatch, handler) + with pytest.raises(accept.Blocked, match="exhausted the recovery budget"): + asyncio.run(accept.run_authenticated(fixture(), fresh, "beta-source", mutate=True, item_number=0)) + assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_targets", "auto_coder_operation_inspect"] + assert not ticket.exists() + assert ledger.read_bytes() == original_bytes + # This counterfactual admission occurs only in the isolated offline journal. + # It demonstrates why the server's lane check alone cannot enforce REQ-004. + counterfactual = journal.admit(replace(record.intent, idempotency_key=fresh.idempotency_key), record.authority) + assert counterfactual.status is AdmissionStatus.CREATED + assert counterfactual.record.operation_id != record.operation_id + + +@pytest.mark.parametrize("changed", ["action", "target", "source"]) +def test_retained_exhaustion_does_not_fence_an_unrelated_identity(tmp_path, changed): + _, producer, ledger = _retained_actual_exhaustion(tmp_path) + before = ledger.read_bytes() + selected = checkpoint(source_sha="f" * 40) if changed == "source" else checkpoint() + action = accept.MUTATIONS["beta-acceptance" if changed == "action" else "beta-source"] + target = fixture().target_ids["release" if changed == "target" else "beta"] + asyncio.run(accept.check_retained_recovery(producer, fixture(), selected, action, target, root=tmp_path)) + assert producer.calls == [] and ledger.read_bytes() == before + + +@pytest.mark.parametrize( + ("changed", "message"), + [ + ({"status": "UNAVAILABLE"}, "recovery outcome changed"), + ({"operation_id": "another-operation"}, "cannot be independently verified"), + ({"source_sha": "f" * 40}, "original source"), + ({"recovery_attempts": 0}, "recovery outcome changed"), + ({"effect_state": "sent_unknown"}, "no confirmed original producer outcome"), + ], +) +def test_retained_exhaustion_requires_current_exact_original_evidence(tmp_path, changed, message): + record, producer, ledger = _retained_actual_exhaustion(tmp_path) + before = ledger.read_bytes() + calls = [] + + async def read(name, arguments, *, mutation=False): + assert name == "auto_coder_operation_inspect" and mutation is False + calls.append(arguments) + return {**producer.producer.project_snapshot(record, producer.snapshot), **changed} + + with pytest.raises(accept.Blocked, match=message): + asyncio.run(accept.check_retained_recovery(SimpleNamespace(call=read), fixture(), checkpoint(), accept.MUTATIONS["beta-source"], fixture().target_ids["beta"], root=tmp_path)) + assert calls == [{"target_id": fixture().target_ids["beta"], "operation_id": record.operation_id}] + assert ledger.read_bytes() == before + + +def test_changed_retained_failure_signature_cannot_authorize_new_admission(tmp_path): + _, producer, ledger = _retained_actual_exhaustion(tmp_path) + value = json.loads(ledger.read_text()) + value["signature"] = "0" * 64 + ledger.write_text(json.dumps(value)) + with pytest.raises(accept.Blocked, match="changed or is incomplete"): + asyncio.run(accept.check_retained_recovery(producer, fixture(), checkpoint(), accept.MUTATIONS["beta-source"], fixture().target_ids["beta"], root=tmp_path)) + assert producer.calls == [] + + +def test_retained_exhaustion_keeps_exact_original_inspection_and_resumption(tmp_path, monkeypatch): + record, producer, ledger = _retained_actual_exhaustion(tmp_path) + before = ledger.read_bytes() + saved = checkpoint(idempotency_key=record.intent.idempotency_key, beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION", recovery_attempts=1) + ticket = tmp_path / "dot-fixture-beta-source-original-send.json" + monkeypatch.setattr(accept, "ticket_path", lambda _scenario: ticket) + + def handler(name, arguments): + assert name == "auto_coder_operation_inspect" + assert arguments == {"target_id": fixture().target_ids["beta"], "operation_id": record.operation_id} + return producer.producer.project_snapshot(record, producer.snapshot) + + calls = _install_offline_mcp_session(monkeypatch, handler) + inspected = asyncio.run(accept.run_authenticated(fixture(), saved, "inspect-original", mutate=False, item_number=0)) + assert inspected.verdict == "BLOCKED" and inspected.operation_id == record.operation_id + assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_operation_inspect"] + arguments = {"target_id": fixture().target_ids["beta"], "idempotency_key": saved.idempotency_key, "selected_git_sha": saved.source_sha} + accept.persist_original_ticket(ticket, fixture(), saved, accept.MUTATIONS["beta-source"], arguments) + accept.update_original_ticket(ticket, fixture(), record.operation_id, "ADMITTED") + calls.clear() + with pytest.raises(accept.Blocked, match="Original operation failed"): + asyncio.run(accept.run_authenticated(fixture(), saved, "beta-source", mutate=True, item_number=0, resume_original=True)) + assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_targets", "auto_coder_operation_inspect"] + assert ledger.read_bytes() == before + assert json.loads(ticket.read_text())["operation_id"] == record.operation_id diff --git a/tests/test_dot_mcp_negative_acceptance.py b/tests/test_dot_mcp_negative_acceptance.py index c34dcff05..36e3c642a 100644 --- a/tests/test_dot_mcp_negative_acceptance.py +++ b/tests/test_dot_mcp_negative_acceptance.py @@ -21,7 +21,10 @@ from auto_coder.management.app import create_management_app from auto_coder.management.mcp_runtime_tools import ManagementRuntime +from auto_coder.management.oauth_grants import OAuthGrantService +from auto_coder.management.operation_store import ManagementOperationStore from auto_coder.management.operations import AcceptedAuthority, ActionParameter, EffectAttempt, EffectState, ManagedTarget, OperationIntent, OperationPhase, OperationRecord +from auto_coder.management.source_release_dispatch import SourceReleaseDispatch from auto_coder.management.worker_lifecycle import WorkerLifecycle from tests.test_dot_mcp_acceptance import checkpoint as saved_checkpoint from tests.test_dot_mcp_acceptance import fixture as disposable_fixture @@ -104,9 +107,15 @@ async def call_tool(self, name, arguments): return SimpleNamespace(isError=False, structuredContent=value) -def fixture_app(boundary, profile): +def fixture_app(boundary, profile, *, selected_fixture=None, deploy_scope=False): settings, grants, signed, policy, beta, release = boundary - fixture = disposable_fixture() + if deploy_scope: + scopes = (*settings.oauth.enabled_scopes, "auto-coder.deploy") + clients = tuple(replace(client, allowed_scopes=(*client.allowed_scopes, "auto-coder.deploy")) for client in settings.oauth.clients) + oauth = replace(settings.oauth, clients=clients, enabled_scopes=scopes) + settings = replace(settings, oauth=oauth) + grants = OAuthGrantService(oauth, grants.identity, clock=grants.clock) + fixture = selected_fixture or disposable_fixture() for role, worker in (("beta", beta), ("release", release)): worker.config = replace(worker.config, instance_id=fixture.target_ids[role], repository=accept.BETA_WORK_REPOSITORY if role == "beta" else accept.TEST_REPOSITORY) policy.write_text(policy_text(fixture, profile)) @@ -116,7 +125,7 @@ def fixture_app(boundary, profile): def live_session(session): - return accept.Live(session, {"auto_coder_operation_inspect", "auto_coder_deployment_status", "auto_coder_reevaluate_target"}) + return accept.Live(session, {"auto_coder_operation_inspect", "auto_coder_deployment_status", "auto_coder_beta_acceptance_get", "auto_coder_reevaluate_target", "auto_coder_promote_accepted_source_release"}) def test_actual_observe_refusal_has_no_admission_and_preserves_terminal_refused_ticket(runtime_boundary, tmp_path): @@ -160,6 +169,103 @@ def test_policy_downgrade_requires_actual_prior_reevaluation_receipt(runtime_bou assert json.loads(path.read_text())["operation_id"] == admitted["operation_id"] +def test_actual_beta_policy_refuses_independently_published_original_source(runtime_boundary, monkeypatch, tmp_path): + """Real HTTP policy refusal with offline Git/worker producer prerequisites.""" + from auto_coder.management.source_acceptance import BetaAcceptanceHandler, SourceAcceptancePublisher + from tests import test_beta_runtime_smoke as upstream + from tests.test_source_release_promotion import _harness + + # The shared deterministic Git edge uses these synthetic namespaces. The + # actual fixture CLI restrictions are tested separately; no remote is used. + monkeypatch.setattr(accept, "TEST_REPOSITORY", "owner/repo") + monkeypatch.setattr(accept, "BETA_WORK_REPOSITORY", "fixture/beta") + original_intent = upstream.OperationIntent + + def targeted_intent(target, action, parameters, *args): + return original_intent(target, action, (*parameters, ActionParameter("target_id", target.instance_id)), *args) + + monkeypatch.setattr(upstream, "OperationIntent", targeted_intent) + remote, producer, request, release_policy = _harness(tmp_path / "upstream") + publisher = SourceAcceptancePublisher(remote, "owner/repo") + handler = BetaAcceptanceHandler(None, producer.producer(), publisher) + original = producer.operation + begun = producer.journal.begin_effect(original.operation_id, original.revision, owner_attempt_id="publish-owner", attempt_id="publish-original", correlation_identity="publication-correlation").record + assert begun is not None + published = publisher.read(original.operation_id) + assert published is not None + completed = handler._complete(begun, published) + assert completed.phase is OperationPhase.SUCCEEDED and completed.effect.state is EffectState.CONFIRMED + projection_runtime = SimpleNamespace(_project_operation=ManagementRuntime._project_operation, beta_acceptance=handler) + + fixture = replace(disposable_fixture(), repository="owner/repo", source_fixture_sha=request.source_sha, target_ids={"beta": "beta-worker", "release": "release-worker"}) + original = replace( + checkpoint(), + source_sha=request.source_sha, + beta_target=fixture.target_ids["beta"], + release_target=fixture.target_ids["release"], + ci_run_id=str(request.ci_run_id), + ci_run_attempt=str(request.ci_run_attempt), + tested_source_ref=request.tested_source_ref, + tested_source_oid=request.tested_source_oid, + beta_acceptance_ref=request.acceptance_ref, + beta_acceptance_oid=request.acceptance_oid, + idempotency_key="original-beta-denied-promotion", + ) + release_policy_path = tmp_path / "source-release-policy.json" + release_policy_path.write_text(json.dumps(asdict(release_policy))) + token_path = tmp_path / "offline-release-token" + token_path.write_text("unused-offline-placeholder") + settings, grants, signed, policy, beta, release = runtime_boundary + settings = replace(settings, source_release_policy_path=release_policy_path, source_release_token_file=token_path, source_release_target_id=fixture.target_ids["release"], source_release_workflow_id=321) + fixture, settings, signed, policy, beta, app = fixture_app((settings, grants, signed, policy, beta, release), "beta", selected_fixture=fixture, deploy_scope=True) + monkeypatch.setattr(accept, "_source_client", lambda: remote) + + class PublishedPrerequisiteSession(HttpSession): + async def call_tool(self, name, arguments): + if name == "auto_coder_beta_acceptance_get": + self.calls.append(name) + assert arguments == {"target_id": fixture.target_ids["beta"], "operation_id": completed.operation_id} + value = ManagementRuntime._project_beta_acceptance(projection_runtime, completed) + return SimpleNamespace(isError=False, structuredContent=value) + return await super().call_tool(name, arguments) + + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + bearer = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.deploy")["access_token"] + session = PublishedPrerequisiteSession(client, bearer) + remote.calls.clear() + report = asyncio.run(negative.refusal_probe(live_session(session), fixture, original, "beta-release-refusal", 0, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + assert report.verdict == "PASS" and report.negative_boundary_verified + assert report.effect_dispatched is False and report.positive_deployment_proven is False + assert session.calls == ["auto_coder_beta_acceptance_get", "auto_coder_promote_accepted_source_release"] + retained = json.loads(negative.refusal_ticket("beta-release-refusal", tmp_path).read_text()) + assert retained["target"] == fixture.target_ids["release"] + assert retained["effect_state"] == "REFUSED" and retained["operation_id"] == "" + assert retained["refusal_evidence"]["reason"] == "release_policy_denied" + assert negative.journal_original_key(settings.operation_state_root / "management-operations.sqlite3", fixture.target_ids["release"], original.idempotency_key) == () + assert remote.calls and all(method == "GET" for method, _ in remote.calls) + assert beta.send_calls == release.send_calls == 0 + + +def test_beta_release_refusal_requires_real_source_before_ticket_or_request(runtime_boundary, monkeypatch, tmp_path): + from tests.test_source_acceptance import Git + + fixture, settings, signed, policy, _beta, app = fixture_app(runtime_boundary, "beta", deploy_scope=True) + monkeypatch.setattr(accept, "_source_client", lambda: Git()) + with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: + bearer = _issue(client, settings, signed, "alice", "alice-client", "auto-coder.read auto-coder.deploy")["access_token"] + session = HttpSession(client, bearer) + with pytest.raises(accept.Blocked, match="exact immutable tested-source"): + asyncio.run(negative.refusal_probe(live_session(session), fixture, checkpoint(), "beta-release-refusal", 0, root=tmp_path, policy_path=policy, journal_path=settings.operation_state_root / "management-operations.sqlite3")) + assert session.calls == [] + assert not negative.refusal_ticket("beta-release-refusal", tmp_path).exists() + + +@pytest.mark.parametrize("stage", ["observe", "full"]) +def test_beta_release_probe_requires_explicit_current_beta_stage(stage): + with pytest.raises(accept.Blocked, match="operator-selected beta stage"): + negative.selected_checkpoint(disposable_fixture(), checkpoint(stage=stage), "beta-release-refusal", stage, "operator-v1", probe=True) + + def test_unexpected_actual_admission_is_failed_and_original_ticket_remains_held(runtime_boundary, tmp_path): fixture, settings, signed, policy, beta, app = fixture_app(runtime_boundary, "observe") with TestClient(app, base_url=settings.oauth.canonical_issuer) as client: @@ -234,6 +340,92 @@ async def call(self, name, arguments, *, mutation=False): return self.current +class ProjectedPublication: + """Actual original journal/projections, with no network or publication claim.""" + + def __init__(self, journal, record): + self.journal = journal + self.record = record + self.observations = [] + self.calls = [] + self.producer = SourceReleaseDispatch(SimpleNamespace(journal=journal, settings=None, _project_operation=ManagementRuntime._project_operation)) + + async def call(self, name, arguments, *, mutation=False): + assert mutation is False and name == "auto_coder_operation_inspect" + self.calls.append((name, arguments)) + assert arguments == {"target_id": ManagementRuntime._params(self.record)["target_id"], "operation_id": self.record.operation_id} + record = self.journal.get(self.record.operation_id).record + assert record is not None + if record.intent.action == "promote_accepted_source_release": + return self.producer.project(record) + return ManagementRuntime._project_operation(record) + + +def publication_original(tmp_path, action, *, source_sha=None): + fixture = disposable_fixture() + role = "release" if action == "promote_accepted_source_release" else "beta" + target = ManagedTarget("accepted-source-release" if role == "release" else fixture.target_ids[role], role, accept.TEST_REPOSITORY if role == "release" else accept.BETA_WORK_REPOSITORY) + source_sha = source_sha or fixture.source_fixture_sha + intent = OperationIntent(target, action, (ActionParameter("target_id", fixture.target_ids[role]), ActionParameter("source_sha", source_sha)), "original-publication-key", "original-process", source_sha) + journal = ManagementOperationStore(tmp_path / "journal") + record = journal.admit(intent, original_record(fixture).authority).record + assert record is not None + record = journal.begin_effect(record.operation_id, record.revision, owner_attempt_id="original-owner", attempt_id="original-attempt", correlation_identity="original-correlation").record + assert record is not None and record.effect.state is EffectState.SENT_UNKNOWN + binding = {"promotion_operation_id" if role == "release" else "beta_operation_id": record.operation_id} + saved = checkpoint(**binding, idempotency_key=intent.idempotency_key, pending_operation_id=record.operation_id, pending_effect_state="SENT_UNKNOWN") + return fixture, journal, record, saved + + +def test_release_absence_query_includes_actual_canonical_source_lane(tmp_path): + fixture, _journal, record, saved = publication_original(tmp_path, "promote_accepted_source_release") + assert negative.journal_original_key(tmp_path / "journal/management-operations.sqlite3", fixture.target_ids["release"], saved.idempotency_key) == (record.operation_id,) + + +@pytest.mark.parametrize("action", ["promote_accepted_source_release", "run_beta_acceptance"]) +def test_unknown_publication_uses_real_original_projection_without_dispatch(tmp_path, action): + fixture, journal, record, saved = publication_original(tmp_path, action) + live = ProjectedPublication(journal, record) + report = asyncio.run(negative.original_scenario(live, fixture, saved, "unknown-effect", root=tmp_path, journal_path=tmp_path / "journal/management-operations.sqlite3")) + assert report.verdict == "BLOCKED" and report.negative_boundary_verified + assert report.operation_id == record.operation_id and report.effect_dispatched is False + assert report.positive_deployment_proven is False + assert [call[0] for call in live.calls] == ["auto_coder_operation_inspect", "auto_coder_operation_inspect"] + assert journal.get(record.operation_id).record == record + if action == "run_beta_acceptance": + assert report.observations[-1].status == "BOUND" + + +def test_unknown_promotion_cannot_use_release_deployment_binding(tmp_path): + fixture, journal, record, saved = publication_original(tmp_path, "promote_accepted_source_release") + saved = replace(saved, promotion_operation_id="", release_operation_id=record.operation_id) + with pytest.raises(accept.Blocked, match="original action"): + asyncio.run(negative.original_scenario(ProjectedPublication(journal, record), fixture, saved, "unknown-effect", root=tmp_path)) + + +@pytest.mark.parametrize("action", ["promote_accepted_source_release", "run_beta_acceptance"]) +def test_original_publication_requires_actual_selected_source(tmp_path, action): + fixture, journal, record, saved = publication_original(tmp_path, action, source_sha="f" * 40) + with pytest.raises(accept.Failed, match="immutable selected source"): + asyncio.run(negative.original_scenario(ProjectedPublication(journal, record), fixture, saved, "unknown-effect", root=tmp_path, journal_path=tmp_path / "journal/management-operations.sqlite3")) + + +def test_original_acceptance_requires_independent_fixed_key_intent(tmp_path): + fixture, journal, record, saved = publication_original(tmp_path, "run_beta_acceptance") + live = ProjectedPublication(journal, record) + with pytest.raises(accept.Blocked, match="independently checked"): + asyncio.run(negative.original_scenario(live, fixture, saved, "unknown-effect", root=tmp_path, journal_path=tmp_path / "missing.sqlite3")) + with pytest.raises(accept.Blocked, match="fixed key"): + asyncio.run(negative.original_scenario(live, fixture, replace(saved, idempotency_key="replacement-key"), "unknown-effect", root=tmp_path, journal_path=tmp_path / "journal/management-operations.sqlite3")) + + +@pytest.mark.parametrize("scenario", ["held-drain", "repeated-failure", "data-rollback"]) +def test_publication_does_not_become_a_worker_recovery_scenario(tmp_path, scenario): + fixture, journal, record, saved = publication_original(tmp_path, "promote_accepted_source_release") + with pytest.raises(accept.Blocked, match="original action"): + asyncio.run(negative.original_scenario(ProjectedPublication(journal, record), fixture, saved, scenario, root=tmp_path)) + + def test_unknown_original_remains_blocked_and_never_uses_effectful_get(tmp_path): fixture = disposable_fixture() record = original_record(fixture) @@ -292,10 +484,39 @@ def test_missing_recovery_evidence_does_not_create_exhaustion_pass(tmp_path): assert list(tmp_path.iterdir()) == [] +def confirmed_prior_runtime_recovery(fixture, tmp_path): + journal = ManagementOperationStore(tmp_path / "journal") + original = original_record(fixture) + snapshot = {"schema_version": 1, "stage": "RECONCILING_PRIOR", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 1, "recovery_state": "CONFIRMED", "restored_worker": {"runtime_id": "previous"}, "heartbeats": [1, 2]} + record = journal.admit(original.intent, original.authority, lifecycle_snapshot=snapshot).record + assert record is not None + record = journal.begin_effect(record.operation_id, record.revision, owner_attempt_id="original-owner", attempt_id="original-attempt", correlation_identity="original-correlation").record + assert record is not None + producer = WorkerLifecycle(SimpleNamespace(journal=journal, _params=ManagementRuntime._params)) + version, stored = journal.lifecycle_snapshot(record.operation_id) + record = producer._finish(record, version, stored, "FAILED", "new_beta_worker_failed_previous_runtime_restored") + _, stored = journal.lifecycle_snapshot(record.operation_id) + assert record.phase is OperationPhase.FAILED and record.effect.state is EffectState.CONFIRMED + return record, stored + + +def test_confirmed_prior_runtime_still_exhausts_original_failure_budget(tmp_path): + fixture = disposable_fixture() + record, snapshot = confirmed_prior_runtime_recovery(fixture, tmp_path) + live = ProjectedOriginal(record, snapshot) + saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="SENT_UNKNOWN") + report = asyncio.run(negative.original_scenario(live, fixture, saved, "repeated-failure", root=tmp_path)) + assert report.verdict == "BLOCKED" and report.negative_boundary_verified + retained = json.loads((tmp_path / f"dot-recovery-{report.failure_signature}.json").read_text()) + assert retained["failure"]["reason"] == "new_beta_worker_failed_previous_runtime_restored" + assert retained["original_operation_id"] == record.operation_id + assert retained["recovery_attempts"] == 1 + + def test_runtime_receipt_cannot_claim_persistent_data_rollback(tmp_path): fixture = disposable_fixture() - record = original_record(fixture) - live = ProjectedOriginal(record, {"stage": "RECOVERED_PREVIOUS", "source_sha": fixture.source_fixture_sha, "recovery_attempts": 1, "recovery_state": "CONFIRMED", "restored_worker": {"runtime_id": "previous"}}) + record, snapshot = confirmed_prior_runtime_recovery(fixture, tmp_path) + live = ProjectedOriginal(record, snapshot) saved = checkpoint(beta_operation_id=record.operation_id, pending_operation_id=record.operation_id, pending_effect_state="NEEDS_ATTENTION") report = asyncio.run(negative.original_scenario(live, fixture, saved, "data-rollback", root=tmp_path)) assert report.verdict == "BLOCKED" and "unsupported" in report.reason From 8fd6f5046b434fed2afc651691bccbec44059cbf Mon Sep 17 00:00:00 2001 From: kitamura-tetsuo Date: Thu, 12 Nov 2026 23:58:20 +0900 Subject: [PATCH 40/42] fix(dot): serialize original-send intent across concurrent fixture actions --- ...dot-autonomous-source-worker-operations.md | 9 +- scripts/dot_mcp_acceptance.py | 28 +++++- tests/test_dot_mcp_acceptance.py | 85 +++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index aca2cbe91..a8424c2b3 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -305,8 +305,13 @@ needed by the separate release activation. A green promotion cannot report Each scenario has one private original-send ticket. The next permitted action uses its own immutable idempotency key; it cannot reuse a key from another action on the same target. Any unresolved same-target ticket blocks a new action even -with a different key. A negative probe can release that local fence only after -the actual refusal plus independent real-journal absence has been recorded as +with a different key. Independent fixture clients serialize original-ticket +admission through one private, stable Linux file lock in the management fixture +directory, covering the prior-ticket scan and durable ticket creation across +different action names. A missing, replaced or unprotected lock refuses before +a mutation; the lock is not a management grant or a substitute for the producer +journal. A negative probe can release that local fence only after the actual +refusal plus independent real-journal absence has been recorded as `REFUSED`; a refusal response by itself is insufficient. BUSY/CONFLICT responses can include an occupying operation ID, which is never adopted as this send's ID. diff --git a/scripts/dot_mcp_acceptance.py b/scripts/dot_mcp_acceptance.py index e49806f1b..707b4ec67 100644 --- a/scripts/dot_mcp_acceptance.py +++ b/scripts/dot_mcp_acceptance.py @@ -9,6 +9,7 @@ import argparse import asyncio +import fcntl import hashlib import ipaddress import json @@ -268,7 +269,32 @@ async def check_retained_recovery(live: Live, fixture: Fixture, checkpoint: smok def persist_original_ticket(path: Path, fixture: Fixture, checkpoint: smoke.DotCheckpoint, action: str, args: dict[str, object]) -> None: - """Fsync original nonsecret intent before an effect, refusing all replays.""" + """Serialize action-specific original admission across independent clients. + + O_EXCL protects a single action path, not two different actions racing + on the same target. Hold the stable, cross-process directory lock through + scanning prior intent records and fsyncing the new original ticket. + """ + lock_path = path.parent / ".dot-fixture-original-send.lock" + try: + descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + except OSError as error: + raise Blocked("The disposable original admission lock is unavailable") from error + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077: + raise Blocked("The disposable original admission lock is untrusted") + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + except OSError as error: + raise Blocked("Cannot establish exclusive original admission custody") from error + _persist_original_ticket_locked(path, fixture, checkpoint, action, args) + finally: + os.close(descriptor) + + +def _persist_original_ticket_locked(path: Path, fixture: Fixture, checkpoint: smoke.DotCheckpoint, action: str, args: dict[str, object]) -> None: + """Fsync original nonsecret intent before a send, under the directory lock.""" if path.exists() or path.is_symlink(): raise Blocked("A previous admission may have been sent; reconcile its original ticket before retry") target = args.get("target_id") diff --git a/tests/test_dot_mcp_acceptance.py b/tests/test_dot_mcp_acceptance.py index 73ae25f08..add581fcc 100644 --- a/tests/test_dot_mcp_acceptance.py +++ b/tests/test_dot_mcp_acceptance.py @@ -967,3 +967,88 @@ def handler(name, arguments): assert [name for name, _ in calls] == ["auto_coder_whoami", "auto_coder_targets", "auto_coder_operation_inspect"] assert ledger.read_bytes() == before assert json.loads(ticket.read_text())["operation_id"] == record.operation_id + + +def test_concurrent_distinct_action_tickets_cannot_both_admit_one_target(monkeypatch, tmp_path): + """Two independent fixture clients must not bypass the same-target original fence.""" + import concurrent.futures + import threading + + selected_fixture = fixture() + beta = selected_fixture.target_ids["beta"] + first_seen = threading.Event() + second_seen = threading.Event() + real_open = accept.os.open + + def simultaneous_original_ticket_open(path, flags, mode=0o777, **kwargs): + if str(path).endswith("-original-send.json") and flags & os.O_CREAT: + if not first_seen.is_set(): + first_seen.set() + second_seen.wait(timeout=0.6) + else: + second_seen.set() + return real_open(path, flags, mode, **kwargs) + + monkeypatch.setattr(accept.os, "open", simultaneous_original_ticket_open) + + def reserve(scenario, key): + path = tmp_path / f"dot-fixture-{scenario}-original-send.json" + source = checkpoint(idempotency_key=key) + try: + accept.persist_original_ticket( + path, + selected_fixture, + source, + accept.MUTATIONS[scenario], + {"target_id": beta, "idempotency_key": key}, + ) + except accept.Blocked: + return "BLOCKED" + return "RESERVED" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + a = pool.submit(reserve, "reevaluate", "first-original-key") + b = pool.submit(reserve, "beta-source", "second-original-key") + outcomes = [a.result(timeout=5), b.result(timeout=5)] + + assert sorted(outcomes) == ["BLOCKED", "RESERVED"] + assert len(list(tmp_path.glob("dot-fixture-*-original-send.json"))) == 1 + + +@pytest.mark.parametrize("invalid_lock", ["symlink", "group-writable"]) +def test_original_admission_refuses_replaced_or_unprotected_lock(tmp_path, invalid_lock): + """An unsafe original-admission guard cannot authorize a new effect.""" + lock = tmp_path / ".dot-fixture-original-send.lock" + if invalid_lock == "symlink": + target = tmp_path / "replacement" + target.write_text("") + lock.symlink_to(target) + else: + lock.write_text("") + lock.chmod(0o660) + + path = tmp_path / "dot-fixture-reevaluate-original-send.json" + with pytest.raises(accept.Blocked, match="admission lock"): + accept.persist_original_ticket( + path, + fixture(), + checkpoint(), + accept.MUTATIONS["reevaluate"], + {"target_id": fixture().target_ids["beta"], "idempotency_key": "original-key"}, + ) + assert not path.exists() + + +def test_distinct_target_original_intents_remain_independently_admissible(tmp_path): + """The fixture lock must not classify two physical targets as one effect.""" + selected = fixture() + for role, scenario in (("beta", "reevaluate"), ("release", "release-source")): + path = tmp_path / f"dot-fixture-{scenario}-original-send.json" + accept.persist_original_ticket( + path, + selected, + checkpoint(idempotency_key=f"original-key-{role}"), + accept.MUTATIONS[scenario], + {"target_id": selected.target_ids[role], "idempotency_key": f"original-key-{role}"}, + ) + assert path.is_file() From 40338a87f0e24e1b1d39bdf4a8fd164ee13c253a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 13 Nov 2026 07:27:20 +0900 Subject: [PATCH 41/42] fix(dot): reject source checkouts with unmounted Git storage --- ...dot-autonomous-source-worker-operations.md | 10 ++- scripts/dot_disposable_fixture.py | 8 +++ tests/test_dot_disposable_fixture.py | 71 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index a8424c2b3..4d3033398 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -220,7 +220,15 @@ fixture before any live acceptance. The generated image names are pinned fixture requirements, not automatically verified runtime identities. The Firebase ID must start with `fixture-`, and authenticated `whoami` must return that **exact** project ID. The test-source checkout must be a fully clean `main` of the fixed -test repository, including no untracked files. The fixture starts with an empty +test repository, including no untracked files. Each worker receives only that +checkout at `/source:ro`, so it must contain its own ordinary `.git` directory +and Git object database. Gitfiles, linked worktrees and nonempty object +alternates, such as those created by `git clone --shared`, are rejected before +fixture files are written: their host-visible metadata or borrowed objects may +be absent inside the worker. Use a standalone clone, or explicitly materialize +the borrowed objects and remove the alternate dependency while preserving the +exact clean `main`. Successful Git reads on the host alone do not establish +that the single mounted checkout is usable. The fixture starts with an empty UID allowlist and intentionally held worker bootstrap; both require separate operator authorization and real test-only setup to proceed. diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index 4f787de06..26838ea25 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -49,6 +49,14 @@ def _source_fixture_revision(source: Path | None) -> str: return "" if not (source / "pyproject.toml").is_file() or not (source / "uv.lock").is_file(): raise ValueError("disposable tested source must contain the real locked Python project") + # The worker receives only this checkout at /source:ro. Host-visible + # worktree metadata or borrowed object stores do not cross that mount. + git_directory = source / ".git" + if git_directory.is_symlink() or not git_directory.is_dir(): + raise ValueError("disposable tested source must be an independent checkout with its own .git directory") + alternates = git_directory / "objects/info/alternates" + if alternates.exists() and alternates.read_text(encoding="utf-8").strip(): + raise ValueError("disposable tested source must be an independent checkout without borrowed Git objects") def git(*arguments: str) -> str: try: diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index ed5a83702..67d21bf62 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -386,6 +386,77 @@ def git(*arguments): fixture._source_fixture_revision(test_source) +@pytest.mark.parametrize("checkout_kind", ["worktree", "shared"]) +def test_source_with_unmounted_git_storage_is_rejected_before_fixture_writes(tmp_path, _use_real_commands, checkout_kind): + source = source_tree(tmp_path) + donor = _git_source_tree(tmp_path / "donor") + candidate = tmp_path / "candidate" + if checkout_kind == "worktree": + subprocess.run(["git", "-C", str(donor), "worktree", "add", "--detach", str(candidate), "main"], check=True, capture_output=True) + assert (candidate / ".git").is_file() + else: + subprocess.run(["git", "clone", "--shared", str(donor), str(candidate)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(candidate), "remote", "set-url", "origin", "https://github.com/kitamura-tetsuo/auto-coder-test.git"], check=True, capture_output=True) + assert (candidate / ".git/objects/info/alternates").read_text(encoding="utf-8").strip() == str(donor / ".git/objects") + + command = ["git", "-C", str(candidate), "cat-file", "-p", "HEAD:uv.lock"] + assert subprocess.run(command, check=True, capture_output=True, text=True).stdout == "version = 1\n" + assert subprocess.run(["git", "-C", str(candidate), "status", "--porcelain", "--untracked-files=all"], check=True, capture_output=True, text=True).stdout == "" + + # The fixture mounts only the candidate. Make its external donor path + # unavailable to exercise real Git's read, without fabricating a result. + unavailable = donor.with_name("unmounted-donor") + donor.rename(unavailable) + try: + isolated = subprocess.run(command, capture_output=True, text=True) + assert isolated.returncode != 0 + assert isolated.stdout == "" + finally: + unavailable.rename(donor) + + # The donor is visible again, so a host-only identity check would pass. + with pytest.raises(ValueError, match="independent checkout"): + fixture.provision( + source=source, + fixture_id="pr2835-test-unmounted", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=candidate, + ) + assert not (source / ".agent-tmp").exists() + + +def test_materialized_source_remains_readable_without_its_original_donor(tmp_path, _use_real_commands): + source = source_tree(tmp_path) + donor = _git_source_tree(tmp_path / "donor") + candidate = tmp_path / "candidate" + subprocess.run(["git", "clone", "--shared", str(donor), str(candidate)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(candidate), "remote", "set-url", "origin", "https://github.com/kitamura-tetsuo/auto-coder-test.git"], check=True, capture_output=True) + revision = subprocess.run(["git", "-C", str(candidate), "rev-parse", "HEAD"], check=True, capture_output=True, text=True).stdout.strip() + subprocess.run(["git", "-C", str(candidate), "repack", "-a", "-d"], check=True, capture_output=True) + (candidate / ".git/objects/info/alternates").unlink() + + unavailable = donor.with_name("unmounted-donor") + donor.rename(unavailable) + try: + assert subprocess.run(["git", "-C", str(candidate), "cat-file", "-p", f"{revision}:uv.lock"], check=True, capture_output=True, text=True).stdout == "version = 1\n" + root = fixture.provision( + source=source, + fixture_id="pr2835-test-independent", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=candidate, + ) + finally: + unavailable.rename(donor) + + manifest = json.loads((root / "fixture.json").read_text(encoding="utf-8")) + assert manifest["source_fixture_sha"] == revision + assert manifest["source_fixture_available"] is True + + def test_wrong_test_source_remote_refused_before_fixture_creation(tmp_path, _use_real_commands): source = source_tree(tmp_path) test_source = source_tree(tmp_path / "candidate") From f53589759a621e8333b00df8bd0645fa831c7156 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 13 Nov 2026 08:40:22 +0900 Subject: [PATCH 42/42] fix(dot): keep management credentials outside worker code mounts --- ...dot-autonomous-source-worker-operations.md | 8 ++ scripts/dot_disposable_fixture.py | 13 ++- tests/test_dot_disposable_fixture.py | 100 ++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/docs/client-features/dot-autonomous-source-worker-operations.md b/docs/client-features/dot-autonomous-source-worker-operations.md index 4d3033398..798a477d3 100644 --- a/docs/client-features/dot-autonomous-source-worker-operations.md +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -232,6 +232,14 @@ that the single mounted checkout is usable. The fixture starts with an empty UID allowlist and intentionally held worker bootstrap; both require separate operator authorization and real test-only setup to proceed. +Worker bootstrap code is mounted from only the generator checkout's `src` +directory at `/code/src:ro` (`/source/src:ro` without a test-source checkout), +alongside the explicit `/fixtures:ro` scripts. Its checkout root contains the +fixture's management credentials and peer state and is not mounted into workers. +A test-source checkout that would contain the fixture state directory is +rejected before any fixture files are written. Management publication and +dispatch credentials remain available only through management's selected mounts. + Both workers install source from `kitamura-tetsuo/auto-coder-test`. Workload ownership stays distinct: beta processes only the fixed `kitamura-tetsuo/auto-coder-test-beta` canary repository, while release processes diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py index 26838ea25..5a755efd1 100644 --- a/scripts/dot_disposable_fixture.py +++ b/scripts/dot_disposable_fixture.py @@ -100,6 +100,8 @@ def provision( source = source.resolve(strict=True) if not (source / "pyproject.toml").is_file() or not (source / "tests/fixtures/supervisor_offline_pid1.py").is_file(): raise ValueError("source root must be the checked-out Auto-Coder test fixture source") + if not (source / "src").is_dir(): + raise ValueError("source root must contain its Python src directory") if SAFE_FIXTURE.fullmatch(fixture_id) is None: raise ValueError("fixture ID must be an exact lowercase disposable identifier") if not isinstance(firebase_project, str) or not re.fullmatch(r"fixture-[a-z0-9-]{6,50}", firebase_project): @@ -107,6 +109,10 @@ def provision( if owner_uid < 1 or owner_gid < 1: raise ValueError("fixture worker cannot run as root") test_source = test_source.resolve(strict=True) if test_source is not None else None + scratch = source / ".agent-tmp" + root = scratch / fixture_id + if test_source is not None and root.is_relative_to(test_source): + raise ValueError("disposable tested source must not contain the fixture state directory") test_sha = _source_fixture_revision(test_source) release_configuration = source_release_workflow_id is not None or accepted_profile_revision is not None if release_configuration: @@ -116,11 +122,9 @@ def provision( raise ValueError("source release requires the exact accepted runtime profile revision") if not test_sha: raise ValueError("source release requires a clean test-repository source checkout") - scratch = source / ".agent-tmp" if scratch.exists() and (scratch.is_symlink() or not scratch.is_dir()): raise ValueError("fixture scratch root must be an ordinary directory") scratch.mkdir(mode=0o700, exist_ok=True) - root = scratch / fixture_id root.mkdir(mode=0o700) for directory in ( "secrets", @@ -244,12 +248,13 @@ def provision( for role in ("beta", "release"): role_id = beta_id if role == "beta" else release_id read_token = "beta-work-read" if role == "beta" else "source-public-read" + # The checkout's sibling fixture state contains management credentials. fixture_mounts = [ - f"{source}:/source:ro", + f"{source}/src:/source/src:ro", f"{source}/tests/fixtures:/fixtures:ro", ] if test_source is not None: - fixture_mounts = [f"{source}:/code:ro", f"{source}/tests/fixtures:/fixtures:ro", f"{test_source}:/source:ro"] + fixture_mounts = [f"{source}/src:/code/src:ro", f"{source}/tests/fixtures:/fixtures:ro", f"{test_source}:/source:ro"] services[role] = { "image": WORKER_IMAGE, "entrypoint": ["python", "/fixtures/supervisor_offline_pid1.py"], diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py index 67d21bf62..3247c42ca 100644 --- a/tests/test_dot_disposable_fixture.py +++ b/tests/test_dot_disposable_fixture.py @@ -23,6 +23,7 @@ def source_tree(tmp_path: Path) -> Path: root = tmp_path / "source" (root / "tests" / "fixtures").mkdir(parents=True) + (root / "src").mkdir() (root / "pyproject.toml").write_text("[project]\nname='fixture'\n", encoding="utf-8") (root / "tests" / "fixtures" / "supervisor_offline_pid1.py").write_text("# Fixture sentinel\n", encoding="utf-8") return root @@ -43,6 +44,105 @@ def _git_source_tree(tmp_path: Path) -> Path: return root +def _mount_case(tmp_path: Path, source_available: bool) -> tuple[Path, Path, dict]: + source = source_tree(tmp_path) + root = fixture.provision( + source=source, + fixture_id="pr2835-test-mounts", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=_git_source_tree(tmp_path / "candidate") if source_available else None, + source_release_workflow_id=12345 if source_available else None, + accepted_profile_revision=SmokeProfile().content_revision if source_available else None, + ) + return source, root, yaml.safe_load((root / "compose.yaml").read_text(encoding="utf-8")) + + +def _private_mount_exposures(root: Path, compose: dict) -> list[str]: + """Find private host subtrees exposed by any worker bind, including aliases.""" + exposures = [] + for role in ("beta", "release"): + peer = "release" if role == "beta" else "beta" + other_read_token = "source-public-read" if role == "beta" else "beta-work-read" + protected = ( + root / "management", + root / "secrets/source-acceptance-publish", + root / "secrets/source-release-dispatch", + root / "secrets" / (peer + "-bridge"), + root / "secrets" / other_read_token, + *(root / peer / name for name in ("runtime", "routing", "workspace")), + ) + for volume in compose["services"][role]["volumes"]: + host, destination, *_ = volume.split(":", 2) + origin = Path(host).resolve() + for private in protected: + private = private.resolve() + if private.is_relative_to(origin) or origin.is_relative_to(private): + exposures.append(f"{role}: {origin} -> {destination} exposes {private.relative_to(root)}") + return exposures + + +@pytest.mark.parametrize("source_available", [False, True], ids=["no-source", "real-source"]) +def test_worker_bind_mounts_do_not_expose_private_fixture_ancestors(tmp_path, _use_real_commands, source_available): + _, root, compose = _mount_case(tmp_path, source_available) + # Read-only mounts and 0600 files still permit reads by this same worker UID. + # Inspect host ancestry, not just secret names in individual volume strings. + exposures = _private_mount_exposures(root, compose) + assert not exposures, "Worker bind mounts expose private fixture state: " + "; ".join(exposures) + + +@pytest.mark.parametrize("source_available", [False, True], ids=["no-source", "real-source"]) +def test_worker_code_mounts_are_src_only(tmp_path, _use_real_commands, source_available): + source, _, compose = _mount_case(tmp_path, source_available) + destination = "/code/src" if source_available else "/source/src" + for role in ("beta", "release"): + worker = compose["services"][role] + assert f"{source}/src:{destination}:ro" in worker["volumes"] + assert f"{source}/tests/fixtures:/fixtures:ro" in worker["volumes"] + assert all(Path(volume.split(":", 1)[0]).resolve() != source for volume in worker["volumes"]) + if source_available: + assert f"{tmp_path}/candidate/source:/source:ro" in worker["volumes"] + assert "PYTHONPATH" not in worker["environment"] + else: + assert worker["environment"]["PYTHONPATH"] == "/source/src" + + +def test_missing_worker_code_src_is_rejected_before_fixture_writes(tmp_path): + source = source_tree(tmp_path) + (source / "src").rmdir() + with pytest.raises(ValueError, match="src directory"): + fixture.provision(source=source, fixture_id="pr2835-test-nosrc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000) + assert not (source / ".agent-tmp").exists() + + +@pytest.mark.parametrize("relationship", ["same", "ancestor"]) +def test_source_containing_fixture_is_rejected_before_writes(tmp_path, _use_real_commands, relationship): + test_source = _git_source_tree(tmp_path / "candidate") + if relationship == "same": + source = test_source + else: + source = source_tree(test_source / "nested") + subprocess.run(["git", "-C", str(test_source), "add", "-A"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(test_source), "commit", "-qm", "nested fixture code"], check=True, capture_output=True) + # Both candidates have genuine clean, independent main and the exact TEST + # remote. Only their future fixture-containing mount makes them unsuitable. + status = subprocess.run(["git", "-C", str(test_source), "status", "--porcelain", "--untracked-files=all"], check=True, capture_output=True, text=True) + assert status.stdout == "" + assert fixture._source_fixture_revision(test_source) + assert (source / ".agent-tmp").is_relative_to(test_source) + with pytest.raises(ValueError, match="fixture"): + fixture.provision( + source=source, + fixture_id="pr2835-test-containing", + firebase_project="fixture-test-123", + owner_uid=1000, + owner_gid=1000, + test_source=test_source, + ) + assert not (source / ".agent-tmp").exists() + + def test_provision_is_dockerless_private_and_observe_only(tmp_path): source = source_tree(tmp_path) root = fixture.provision(source=source, fixture_id="pr2835-test-abc", firebase_project="fixture-test-123", owner_uid=1000, owner_gid=1000)