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..798a477d3 --- /dev/null +++ b/docs/client-features/dot-autonomous-source-worker-operations.md @@ -0,0 +1,410 @@ +# 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 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 + +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. + +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, +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. 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. +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 +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 +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 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). 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 +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 +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 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 +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 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 +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: + +```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 \ + --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 +``` + +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 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 +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. 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 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 + +`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. 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. 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. + +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 +`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 \ + --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. 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. +A worker restart with a larger counter is not continuous heartbeat progress; +the client refuses it rather than claiming installed-source acceptance. The +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` 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 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. 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. + +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 +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 +`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 +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..00f2de931 --- /dev/null +++ b/docs/client-features/dot-negative-acceptance.md @@ -0,0 +1,179 @@ +# 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. | +| `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 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. | + +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. + +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 +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. +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: + +```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. + +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 +`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. + +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 +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 +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. +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 aa6e48c20..bac86f70e 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*. @@ -112,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/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..f45a0065b --- /dev/null +++ b/docs/examples/management-mcp/dot-rollout-checkpoint.example.json @@ -0,0 +1,33 @@ +{ + "schema": 1, + "stage": "observe", + "task_id": "", + "repository": "", + "issue_number": "", + "pr_number": "", + "stage_revision": "", + "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": "", + "pending_operation_id": "", + "failure_signature": "", + "recovery_attempts": 0 +} diff --git a/scripts/dot_disposable_fixture.py b/scripts/dot_disposable_fixture.py new file mode 100644 index 000000000..5a755efd1 --- /dev/null +++ b/scripts/dot_disposable_fixture.py @@ -0,0 +1,408 @@ +#!/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" +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") +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") + # 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: + 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=all"): + raise ValueError("disposable tested source must have no tracked or untracked changes") + return revision + + +def provision( + *, + source: Path, + fixture_id: str, + firebase_project: str, + 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 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") + 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): + 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 + 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: + 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") + 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.mkdir(mode=0o700) + for directory in ( + "secrets", + "policy", + "management/oauth", + "management/operations", + "beta/runtime/home", + "beta/runtime/logs", + "beta/workspace", + "beta/routing", + "release/runtime/home", + "release/runtime/logs", + "release/workspace", + "release/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" + 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 / "secrets/beta-work-read", "") + for role in ("beta", "release"): + # 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", + _yaml( + { + "profile": "observe", + "targets": { + 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( + { + "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-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}], + } + ), + ) + + 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": BETA_WORK_REPOSITORY if role == "beta" else TEST_REPOSITORY, + "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 + 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}/src:/source/src:ro", + f"{source}/tests/fixtures:/fixtures:ro", + ] + if test_source is not None: + 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"], + "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": 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", + "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/{role}-bridge:/fixture/secrets/{role}-bridge:ro", + # 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", + } + 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/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", + "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, + "firebase_project_id": firebase_project, + "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("--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() + 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, + 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__) + 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..707b4ec67 --- /dev/null +++ b/scripts/dot_mcp_acceptance.py @@ -0,0 +1,1213 @@ +#!/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 fcntl +import hashlib +import ipaddress +import json +import os +import re +import secrets +import stat +import sys +import time +from dataclasses import asdict, dataclass, field +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" +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") +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) + + +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.""" + + +@dataclass(frozen=True) +class Fixture: + schema: int + fixture_nonce: str + firebase_project_id: 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 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": + 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 + 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: + """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", BETA_WORK_REPOSITORY), + fixture.target_ids["release"]: ("release", TEST_REPOSITORY), + } + 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, 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 + 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") + + +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" + + +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: + """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") + 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")) + 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") == 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": target, + "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 _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, + *, + 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, *, 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") + 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} + 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(record, 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 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") + 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") + 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 or not str(heartbeat.get("stage", "")).startswith("producer:"): + raise Blocked("A running container is not a positive heartbeat producer") + 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 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. + + 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") + + try: + 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 ( + 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 _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 + + 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") + 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) + 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: + 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 identity.get("project_id") != fixture.firebase_project_id + 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") + + +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", 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") + 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 + 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: + 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) + 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 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: + 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) + + +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 + + 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) + 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: + 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 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) + 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") + return await complete_original(live, fixture, checkpoint, scenario, original_id, arguments, wait_seconds=wait_seconds) + + +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("--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) + 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: + fixture: Fixture | None = None + 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, 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, 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: + 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) + except Exception as error: + logger.warning("Untrusted transport 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) + 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/scripts/dot_mcp_negative_acceptance.py b/scripts/dot_mcp_negative_acceptance.py new file mode 100644 index 000000000..f651f1509 --- /dev/null +++ b/scripts/dot_mcp_negative_acceptance.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""Observe real disposable refusal/recovery boundaries without granting authority. + +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. +""" + +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", "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" + + +@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 == "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") + 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: + 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 load_operating_policy + + loaded = load_operating_policy(path) + policy = loaded.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: + 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") + 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: + 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.""" + 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") + 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["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") + 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) + 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") == 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") != 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=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) + + +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, *, 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 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: + """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, 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) + 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") + 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"}: + 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, 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) + + +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, "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) + 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/scripts/management_mcp_rollout_smoke.py b/scripts/management_mcp_rollout_smoke.py new file mode 100644 index 000000000..6faa3281b --- /dev/null +++ b/scripts/management_mcp_rollout_smoke.py @@ -0,0 +1,391 @@ +#!/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_inspect", + "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") +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): + """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.""" + + 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 = "" + 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 = "" + pending_operation_id: 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["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") + 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: + 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"]: + 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.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") + 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") + 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 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": + 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") + + +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": + 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_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") + + +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: + 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: + 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") + 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_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: + # Original-operation reconciliation precedes current deployment diagnostics. + plan: tuple[tuple[str, dict[str, object]], ...] = ( + ("auto_coder_whoami", {}), + original_read, + status_read, + deployment_read, + ) + else: + plan = ( + ("auto_coder_whoami", {}), + status_read, + deployment_read, + ) + if operation_id: + plan += (original_read,) + for name, arguments in plan: + checked_tool(name, arguments) + 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: + if name == "auto_coder_operation_get": + 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: + """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 + + 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") + 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) + 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: + 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 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") + 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 {"PASS": 0, "FAIL": 1, "BLOCKED": 2, "NOT_RUN": 3}[report.verdict] + + +if __name__ == "__main__": + raise SystemExit(main()) 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/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/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/fixtures/supervisor_offline_process_issues.py b/tests/fixtures/supervisor_offline_process_issues.py index f64fd910e..cf52ba17b 100644 --- a/tests/fixtures/supervisor_offline_process_issues.py +++ b/tests/fixtures/supervisor_offline_process_issues.py @@ -15,6 +15,24 @@ 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 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") + 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": @@ -41,7 +59,7 @@ 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,13 +67,24 @@ def main() -> None: "process-issues", "--repo", os.environ["AUTO_CODER_REPOSITORY"], - "--github-token", - token, "--skip-codex-auth-secret-sync", "--disable-webhook", "--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", + "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"]) 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 diff --git a/tests/test_dot_disposable_fixture.py b/tests/test_dot_disposable_fixture.py new file mode 100644 index 000000000..3247c42ca --- /dev/null +++ b/tests/test_dot_disposable_fixture.py @@ -0,0 +1,575 @@ +"""Fail-closed disposable Compose fixture configuration tests.""" + +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 + + +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 + + +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 _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) + 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["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" + 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")) + 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(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"]) + 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 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() + + +@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, 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="fixed test repositories"): + offline_worker.activate_scoped_fixture_token(token_path, "kitamura-tetsuo/auto-coder") + 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, 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, repository) + + +@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), + ("nonfunctional-disposable-worker-token", 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("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) + 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) + 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() + + +def test_clean_test_only_git_main_is_required_for_installed_source(tmp_path, _use_real_commands): + source = source_tree(tmp_path) + 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() + + 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"] + (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) + + +@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") + (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..add581fcc --- /dev/null +++ b/tests/test_dot_mcp_acceptance.py @@ -0,0 +1,1054 @@ +"""Strict offline safety contracts, not fabricated end-to-end producer PASS.""" + +import asyncio +import json +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( + 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", + 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": 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", + } + 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, "firebase_project_id": "auto-coder-production"}, + {**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": {"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) + 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 _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": {"state": "running", "startup_reconciled": True, "startup_reconciliation_error": None, "daemon_serving": True}, + "invocations": {"state": "running"}, + "heartbeat": {"available": True, "sequence": sequence, "stage": "producer:maintenance-sleep"}, + }, + } + + +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_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="")) + + +def test_authenticated_identity_is_bound_to_fixture_resource_client_and_current_scopes(): + identity = { + "project_id": fixture().firebase_project_id, + "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": ""}, + {"project_id": "fixture-production-other"}, + ): + with pytest.raises(accept.Blocked, match="OAuth"): + 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": 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 ( + {"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 { + "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") + + +@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" + + +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 + + +@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) + + +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 + + +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() diff --git a/tests/test_dot_mcp_negative_acceptance.py b/tests/test_dot_mcp_negative_acceptance.py new file mode 100644 index 000000000..36e3c642a --- /dev/null +++ b/tests/test_dot_mcp_negative_acceptance.py @@ -0,0 +1,524 @@ +"""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.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 +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, *, selected_fixture=None, deploy_scope=False): + settings, grants, signed, policy, beta, release = boundary + 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)) + 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_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): + 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_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: + 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 + + +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) + 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 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, 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 + 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_dot_source_rollout_contract.py b/tests/test_dot_source_rollout_contract.py new file mode 100644 index 000000000..5d2224fd8 --- /dev/null +++ b/tests/test_dot_source_rollout_contract.py @@ -0,0 +1,759 @@ +"""Offline policy/transport planning only; NOT a fake live MCP acceptance test.""" + +import asyncio +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, + ) + assert result.returncode == 3 + 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", + 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="pending original operation ID"): + smoke.read_plan(original, "beta-worker", "unrelated-operation") + plan = smoke.read_plan(original, "beta-worker", "original-operation") + assert plan[1] == ( + "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_inspect"] + + +@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", pending_operation_id=operation) + 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", + pending_operation_id="ambiguous-op", + ) + 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", pending_operation_id=operation) + plan = smoke.read_plan(original, target, operation) + assert plan[1] == ("auto_coder_operation_inspect", {"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_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_inspect", + {"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_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" + + +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"}, + {"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): + 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", + 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", + 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", + promotion_catalog_oid="c" * 40, + release_target="fixture-release", + 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( + "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", + [ + "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("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", + ) + plan = smoke.read_plan(original, "fixture-beta", "operation-original") + assert plan[1][0] == "auto_coder_operation_inspect" + assert smoke.checked_live_plan(plan) is None + + +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", 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(): + 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", + [ + "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(): + 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", + "auto_coder_deployment_status", + "auto_coder_operation_inspect", + ] + 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", "") + + +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", + tested_source_oid="a" * 40, + beta_target="fixture-beta", + 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 + + +@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_inspect", + {"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 + + +@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 + + +@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_inspect", + {"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"] 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") 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..47ff23bdb 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,17 @@ 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 + # 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" 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 +722,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_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_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 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" 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"] 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)) diff --git a/tests/test_release_worker_acceptance_container.py b/tests/test_release_worker_acceptance_container.py index 472f3a29c..9725876d0 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, *, timeout: float = 45) -> 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=timeout) + 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() @@ -159,21 +178,6 @@ def _ready(client: WorkerNetworkClient, source_sha: str) -> dict[str, object] | return result.data if result.data.get("binding_status") == "VERIFIED" and binding.get("source_sha") == source_sha and lifecycle.get("startup_reconciled") is True and lifecycle.get("daemon_serving") is True else None -def _wait_initial_release_inspection(client: WorkerNetworkClient, *, timeout: float = 30) -> WorkerNetworkResult: - """Wait for the signed bridge, which starts after the control socket exists.""" - last_response: WorkerNetworkResult | None = None - - def inspect() -> WorkerNetworkResult | None: - nonlocal last_response - last_response = client.inspect_supervisor() - return last_response if last_response.result_code == "OK" else None - - try: - return _wait_for(inspect, timeout=timeout) - except AssertionError as error: - raise AssertionError(f"Release Supervisor signed inspection did not become ready; last_response={last_response!r}") from error - - @contextmanager def _installed_release( system: _SmokeSystem, @@ -302,8 +306,8 @@ 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 = _wait_initial_release_inspection(client) - assert stopped.result_code == "OK" and stopped.data["alive"] is False, stopped + 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. started = _control(name, {"operation_id": "fixture-prior-runtime", "operation": "START", "runtime_id": str(previous["installed_runtime_id"])}) 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) 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 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 diff --git a/tests/test_supervisor_integration_workflow.py b/tests/test_supervisor_integration_workflow.py index 55d8cb33b..2592dd86f 100644 --- a/tests/test_supervisor_integration_workflow.py +++ b/tests/test_supervisor_integration_workflow.py @@ -510,7 +510,13 @@ 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 + # 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