diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51617fc..b91d5c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,9 +16,8 @@ permissions: id-token: write jobs: - # v0.1.45 ships fresh-install-only: in-place upgrade from 0.1.44 is not a - # supported path, so publication is gated on the exact clean-install lane - # that operators actually follow, not on the unsupported upgrade chain. + # Publication is gated on current clean-install evidence. Historical upgrade + # chains remain release records, not mandatory workflows for current changes. clean-install-chain: if: github.repository == 'misunders2d/agentnet' uses: ./.github/workflows/server-setup-e2e.yml diff --git a/.github/workflows/server-setup-upgrade-e2e.yml b/.github/workflows/server-setup-upgrade-e2e.yml index 0cc2735..0356773 100644 --- a/.github/workflows/server-setup-upgrade-e2e.yml +++ b/.github/workflows/server-setup-upgrade-e2e.yml @@ -1,4 +1,4 @@ -name: Ordinary server 0.1.45 to 0.1.46 timer upgrade E2E +name: Ordinary server 0.1.50 to 0.1.51 installed-host upgrade E2E on: workflow_call: @@ -37,7 +37,7 @@ permissions: jobs: released-upgrade-chain: - name: Ubuntu 24.04 / PostgreSQL 18 / recurring credential renewal + name: Ubuntu 24.04 / PostgreSQL 18 / installed-host state preservation runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -51,11 +51,6 @@ jobs: shell: bash run: install -m 0755 scripts/ci/ordinary-server-upgrade-e2e.sh "$RUNNER_TEMP/ordinary-server-upgrade-e2e.sh" - - name: Check out frozen v0.1.46 evidence source - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - ref: 9620e24c74e3720ad90790da037b013dd26bfbab - name: Configure Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 @@ -91,7 +86,7 @@ jobs: sudo pg_createcluster 18 main --port 5432 --start sudo -u postgres psql -Atq --dbname=postgres -c "SHOW server_version" | grep -E '^18\.' - - name: Prove exact released-to-corrected timer upgrade + - name: Prove exact v0.1.50-to-v0.1.51 installed-host upgrade shell: bash run: "$RUNNER_TEMP/ordinary-server-upgrade-e2e.sh" diff --git a/PUBLIC_RELEASE_STATUS.md b/PUBLIC_RELEASE_STATUS.md index 72d84eb..d52908f 100644 --- a/PUBLIC_RELEASE_STATUS.md +++ b/PUBLIC_RELEASE_STATUS.md @@ -11,32 +11,52 @@ requirements, gate ledgers, or accountable-owner evidence. Reads of the public npm registry and immutable Git tag returned: - package: `@misunders2d/agentnet` -- latest published version: `0.1.45` -- published source commit: `e8a49671481767078551f677599f51af051c3d5a` -- immutable tag: `v0.1.45` -- registry shasum: `06f4775ecf63097068e1f3583fe84a2c66c64096` +- latest published version: `0.1.50` +- published source commit: `c7c5659055884b3a6fa4ad05f1e43b7e7e844436` +- immutable tag: `v0.1.50` +- registry shasum: `4875fd0e37b8a12689a6a50772b89e118931e7d7` - provenance: SLSA statement signed by the trusted GitHub Actions publisher and approved by the accountable npm owner -`0.1.45` is a **fresh-install-only** release. In-place upgrade from `0.1.44` is -not a supported path: the packaged upgrade and rollback lane is preserved as -non-green, so operators must install `0.1.45` on a clean host and re-enroll the -server and each harness. Publication is gated on the ordinary-server -clean-install lane, which is the exact path operators follow. +`0.1.50` is the published setup-usability release. It supports the exact +allowlisted schema-v7 five-unit upgrade paths documented by that immutable +package. Publication does not prove installed-host convergence, production +durability, or any must-not-ship gate. Package availability does not authorize deployment and does not establish production readiness. No must-not-ship gate is promoted by publication. -## Setup-usability `0.1.50` candidate +## Corrective `0.1.51` candidate + +Candidate `0.1.51` retains the published `0.1.50` setup and laptop protocols +and adds package-owned convergence for the exact ordinary-onboarding +placeholder Approval owner and for a committed communication scope missing +its schema-v7 collaboration projection. The target owner is derived from +enrolled Core identity plus Approval's pinned OIDC binding; signer and Core +policy cutover is journaled and resumable. New scope activation writes its +projection atomically, and legacy repair derives it from the existing +committed scope without replacing authority. + +The retained one-hour v0.1.50 Approval hotfix is accepted only when its +reconstructed published form matches the setup marker, then journaled and +normalized to separate ordinary and communication-scope deadlines. When that +hotfix and an already-completed canonical-owner repair coexist while the marker +still records the pre-repair source, setup accepts the host only by validating +the terminal recovery journal and reconstructing both marker-era Approval and +Core documents. Both digests must match before journaling or managed writes; +incomplete evidence or unrelated drift fails closed. + +A lost PostgreSQL runtime heartbeat publishes fail-closed state before another +protected operation can enter; work remains unavailable until a later operation +verifies a fresh connection and acquires a strictly higher same-owner fence. + +The candidate accepts only exact five-unit schema-v7 markers from v0.1.45 +through v0.1.50. Ambiguous, drifted, incomplete, or unsupported state fails +closed. Installed-host, cross-platform, and disposable PostgreSQL 18.4 evidence +for the preceding exact commit remains retained but does not bind this +corrective source delta. Fresh same-commit CI and server PostgreSQL validation +remain pending before live convergence; no production or gate claim is made. -Candidate `0.1.50` wraps the existing strict server and laptop protocols in -one resumable command per machine. It adds authenticated discovery defaults, -content-free named phases, bounded deadlines, exact blocker/recovery output, -and direct allowlisted upgrades from exact v0.1.45–v0.1.49 schema-v7 five-unit -markers. Packed-package verification requires an installed separate-process -message/obligation roundtrip. Release artifacts, complete validation, fresh CI, -and publication remain pending. This local implementation does not promote a -requirement, production claim, or must-not-ship gate. ## Corrective `0.1.49` candidate diff --git a/README.md b/README.md index 2308478..d4b541a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,13 @@ Live subscriptions wake connected agents immediately. Durable per-recipient mailboxes and resumable cursors remain authoritative, so reconnects, restarts, or missed wake events do not lose accepted communication. +The PostgreSQL runtime lease is fenced and renewable. If its background +heartbeat fails, Core publishes the failure under the storage lock before +another protected operation can enter. The next operation opens a fresh +verified connection and may resume only after acquiring a strictly higher fence +for the same runtime owner; otherwise Core remains unavailable. The superseded +connection is closed, so a stale process cannot resume writing. + ## Security is the product boundary AgentNet treats every harness, relay, external agent, file, model output, and @@ -287,6 +294,17 @@ agree. The command performs no authority grant or restart. Exact retry reconciles interruption; missing, stale, edited, or unaudited provenance fails closed. After completion, rerun the same digest-bound setup apply/start. +An expired laptop or peer harness that still owns an active collaboration-scope +membership is replaced only through the root-only +`server-agent replace-expired-scope-harness` command. An active managed-server +harness of the same verified principal may open the ceremony because the +expired member cannot authenticate; the exact scope-owning principal must then +approve the complete transaction through Approval. The atomic commit +tombstones the former member, activates the named same-principal replacement as +`member`, advances the scope revision and membership sequence once, and makes +current schema-v7 membership authoritative immediately. It changes no managed +identity/configuration file and restarts no service. + Plan and apply bind exact Node/uv/launcher/`systemctl`/`useradd` paths and content hashes plus the canonical full AgentNet package-tree content hash to the request-versioned approval digest. Apply @@ -441,7 +459,7 @@ always-on deployment—see the [implementation guide](docs/implementation-guide. ## Project status AgentNet is an early public implementation; the latest published package is -`0.1.45`. Its publication does not promote any requirement or gate. +`0.1.50`. Its publication does not promote any requirement or gate. Published `0.1.29` repaired owner/enrollment OIDC callback parsing after real Google owner login exposed rejection of valid unique response extensions; published `0.1.30` repaired installed-verifier package custody; published @@ -543,6 +561,46 @@ addition to the existing installed journey. These are local and CI evidence, not production certification, and no requirement or must-not-ship gate is promoted. +Candidate `0.1.51` adds the package-owned corrective recovery path for the +exact ordinary-onboarding state where Approval still names the setup +placeholder owner after Core enrolled the canonical human. Managed setup +derives the target only from the enrolled Core identity and Approval's pinned +OIDC binding, rotates current Approval signing authority to that principal, +and journals resumable Core policy replacement. It rejects identity, domain, +OIDC, signer, database, configuration, or journal ambiguity. New communication +activation writes its complete schema-v7 collaboration projection in the same +transaction; an already committed scope missing that projection is repaired +from its immutable scope rows without replacing the scope or minting broader +authority. Exact five-unit schema-v7 markers from v0.1.45 through published +v0.1.50 may upgrade directly to v0.1.51. + +The v0.1.50→v0.1.51 transition preserves the exact published v0.1.50 +Approval TTL policy (`request_ttl_seconds=300` with no separate +communication-scope field). It separately recognizes the retained one-hour +hotfix shape. If that operational hotfix was not written into the setup marker, +setup reconstructs the exact published 300-second form and requires its +canonical digest to equal the marker before creating a journal. It then +journals the realized configuration, restores the ordinary approval deadline +to 600 seconds, keeps the communication-scope ceremony ceiling at 3600 +seconds, and atomically replaces the file. A crash resumes from the journal; +any additional drift fails closed, and pre-commit rollback restores the exact +source bytes. + +If the canonical-owner repair already completed while that one-hour hotfix +remained outside the marker, setup accepts the combined state only after the +completed recovery evidence reconstructs the marker-era Approval and Core +documents. It reverses only the evidence-bound owner and signer fields in +memory, then requires both reconstructed canonical digests to equal the +marker. Historical marker matching treats only the fixed mandatory +approval-purpose set as order-insensitive; it may reconstruct that set's +serialized order to reproduce the retained digest, but any added, removed, +duplicated, or changed purpose fails closed. The realized current documents +are journaled, the TTL policy is normalized, and owner/Core convergence is +rechecked idempotently. Missing Core-OIDC agreement, incomplete evidence, or +unrelated drift fails before setup creates its upgrade journal or changes +managed state. + + Git tag `v0.1.23` reached the staging workflow, but CI stopped before npm staging because one hermetic interruption test mocked `/usr/bin/useradd` on a runner where that path did not exist. No `0.1.23` package was staged or diff --git a/RELEASE_MANIFEST.json b/RELEASE_MANIFEST.json index 6b9d4da..e7112a9 100644 --- a/RELEASE_MANIFEST.json +++ b/RELEASE_MANIFEST.json @@ -218,7 +218,7 @@ "path": "uv.lock", "pyproject": { "path": "pyproject.toml", - "sha256": "95c9effe37951f5ab012aa00743ecc52b44ca0ac0b62ab08eddafa3ab7559e7a" + "sha256": "6aebcdea17d2715c3136c2b6b3179a424a3eed990f54d65a4cccbf67c715c5da" }, "resolution": { "a2a-sdk": "1.1.0", @@ -285,7 +285,7 @@ "webauthn": "3.0.0" }, "revision": 3, - "sha256": "afffa3558290939ca4f3efae1b04559db3299084f4a62c0d93ccdd4ab66e69c8" + "sha256": "c971fdea8d3782a9d7349b5723f4e036740d314f19f7c3553dc6ba1780c9f84d" }, "external_release_evidence": { "installer_lifecycle": { @@ -529,86 +529,103 @@ "release": { "name": "agentnet", "production_ready": false, - "profile": "setup_usability_candidate", + "profile": "canonical_owner_recovery_candidate", "reason": "All 19 must-not-ship gates remain non-PASSED; local implementation evidence is partial and required external, privileged, production-topology, or owner evidence remains absent.", "ship_eligible": false, "status": "BLOCKED", - "version": "0.1.50" + "version": "0.1.51" }, "release_inputs": { "README.md": { "path": "README.md", - "sha256": "f6839282457daeeb8cad2b76621b384725cb6caac15e92ad3e09f72633fcbbe3" + "sha256": "fb8b10a622b64f024462047ba82708cc090270b27f13e9f94e045c8208ad99b8", + "size": 46552 }, "REQUIREMENTS_STATUS.md": { "path": "REQUIREMENTS_STATUS.md", - "sha256": "59491a96c8697c41c47d29756144768f5b62da0df04c3d75c9579dc762dae0d8" + "sha256": "3afd7cfecd7199ccb142f2701aa9a8920f7542ffa52b36f8157cfd177e2e88e2", + "size": 106532 }, "deploy/Dockerfile": { "path": "deploy/Dockerfile", - "sha256": "e0e6d42368772ecb420625d36555400e7b15ee0a4b250a2c7e6ded107f27cd37" + "sha256": "e0e6d42368772ecb420625d36555400e7b15ee0a4b250a2c7e6ded107f27cd37", + "size": 1188 }, "deploy/compose.production.json": { "path": "deploy/compose.production.json", - "sha256": "a66803887f965dc86862d8a7ef66e44e98f201743f200d826c270a4af1cab822" + "sha256": "a66803887f965dc86862d8a7ef66e44e98f201743f200d826c270a4af1cab822", + "size": 21622 }, "deploy/nginx-agent.conf": { "path": "deploy/nginx-agent.conf", - "sha256": "1873ee423f222c9f2efbc18c324d2be1fc2ce911dcf8848e2853d5d8fb1288be" + "sha256": "1873ee423f222c9f2efbc18c324d2be1fc2ce911dcf8848e2853d5d8fb1288be", + "size": 857 }, "deploy/render_and_run.py": { "path": "deploy/render_and_run.py", - "sha256": "11f812ce645ee612ef5c3f2ae07e0d987a516f7f474bd0b89c435fab8432a0e0" + "sha256": "11f812ce645ee612ef5c3f2ae07e0d987a516f7f474bd0b89c435fab8432a0e0", + "size": 24256 }, "docs/GATE_EVIDENCE.md": { "path": "docs/GATE_EVIDENCE.md", - "sha256": "1d690b65756169e1954c1a37c0436bc3dc053e903e61dd4a647a0a0f786be6e2" + "sha256": "3ed1f1795f4b02b104d80583d79758b0e8ee654af52022b3d18b329378bfc7d6", + "size": 85207 }, "docs/RELEASE_MANIFEST.md": { "path": "docs/RELEASE_MANIFEST.md", - "sha256": "99fb7ad44dfb05766c59ac69c1decb1df9e951984c7196acb53c37179d2d99ad" + "sha256": "c04c1a93d52121f84c15f81859ecdefe85ae11d5f67ddf57e925e58a8e1c4cf6", + "size": 27861 }, "evidence/gates/G01/2026-07-13-installed-harnesses/manifest.json": { "path": "evidence/gates/G01/2026-07-13-installed-harnesses/manifest.json", - "sha256": "f691b3a676a518f2bc8c7ee7a099e544c884ebd1f3d8bab7e7d031aa4c86946b" + "sha256": "f691b3a676a518f2bc8c7ee7a099e544c884ebd1f3d8bab7e7d031aa4c86946b", + "size": 1921 }, "evidence/gates/G04/2026-07-13-alpha2-http-json/REVIEW.md": { "path": "evidence/gates/G04/2026-07-13-alpha2-http-json/REVIEW.md", - "sha256": "3ff08c0a0eb296ce2181f634277c4037d7278349b25a9033557dbe50dc52dd42" + "sha256": "3ff08c0a0eb296ce2181f634277c4037d7278349b25a9033557dbe50dc52dd42", + "size": 3931 }, "evidence/gates/G04/2026-07-13-alpha2-http-json/manifest.json": { "path": "evidence/gates/G04/2026-07-13-alpha2-http-json/manifest.json", - "sha256": "5d7e6526c6da7368046684243502c42a8a31cfee5152affad01e45bdf0b23ead" + "sha256": "5d7e6526c6da7368046684243502c42a8a31cfee5152affad01e45bdf0b23ead", + "size": 10657 }, "evidence/gates/G09/2026-07-13-postgresql-18.4-local/manifest.json": { "path": "evidence/gates/G09/2026-07-13-postgresql-18.4-local/manifest.json", - "sha256": "fc99d7d2600f031eab99b7bffefd59eabdedfe72be91209dcd0afcd847b2ceeb" + "sha256": "fc99d7d2600f031eab99b7bffefd59eabdedfe72be91209dcd0afcd847b2ceeb", + "size": 2977 }, "scripts/ci/exact_endpoint_routing_e2e.py": { "path": "scripts/ci/exact_endpoint_routing_e2e.py", - "sha256": "60347eb321bebed08971c24ece0be1d2a72bb32f3c12070bf5b0509606b90e80" + "sha256": "60347eb321bebed08971c24ece0be1d2a72bb32f3c12070bf5b0509606b90e80", + "size": 16983 }, "scripts/ci/packaged_local_communication_e2e.py": { "path": "scripts/ci/packaged_local_communication_e2e.py", - "sha256": "096bbc1a551c9bd313aa4e0024c40a7cb8b619a63417a7c5b1ef33776c66d27f" + "sha256": "096bbc1a551c9bd313aa4e0024c40a7cb8b619a63417a7c5b1ef33776c66d27f", + "size": 36776 }, "scripts/ci/packaged_v0145_user_journey.py": { "path": "scripts/ci/packaged_v0145_user_journey.py", - "sha256": "3ac405c76e2b095537c34c9c0bafaf10c0bf9b0e887adcd51647fe99596f2d50" + "sha256": "3ac405c76e2b095537c34c9c0bafaf10c0bf9b0e887adcd51647fe99596f2d50", + "size": 38600 }, "scripts/export_schemas.py": { "path": "scripts/export_schemas.py", - "sha256": "f8d8cf238c4ce1318331ae60efb1c8db5388cbbd558e2d1d355c80dd870e2ff9" + "sha256": "f8d8cf238c4ce1318331ae60efb1c8db5388cbbd558e2d1d355c80dd870e2ff9", + "size": 2831 }, "scripts/verify_release.py": { "path": "scripts/verify_release.py", - "sha256": "b559a98eb5050f54a9d4d222ef83e2b6bda16af5c79860699d0eace2c1661d00" + "sha256": "64ee6b0d96f4d63e6ddc785fb26a783ff6a510c9df4b23266701b103404019b9", + "size": 56697 } }, "release_source_tree": { "algorithm": "sha256(path NUL bytes NUL)", "path": "src", - "sha256": "740adfb28e27641c3ec0bab470036c4690fde2c70a53ab0568fde644aecddb7e" + "sha256": "a3ae6ae3d35dcc6f2252f6aeb1e663ac05cbf5f8f234c4898a4f802d54796b2b" }, "runtime": { "implementation": "CPython", @@ -746,5 +763,5 @@ }, "version": "1.0" }, - "snapshot_date": "2026-08-07" + "snapshot_date": "2026-08-09" } diff --git a/REQUIREMENTS_STATUS.md b/REQUIREMENTS_STATUS.md index d60d348..7648487 100644 --- a/REQUIREMENTS_STATUS.md +++ b/REQUIREMENTS_STATUS.md @@ -1,6 +1,6 @@ # Requirements Status -Snapshot: 2026-08-07. This is an implementation/evidence ledger, not a release +Snapshot: 2026-08-09. This is an implementation/evidence ledger, not a release certificate. It contains the exact 85 stable requirement IDs from the preserved requirements reference. PD-001 through PD-011 are listed separately because they are accountable policy decisions, not additional requirements. @@ -428,6 +428,85 @@ published and historical release evidence: `UX-001`, `UX-002`, `SEC-003`, `SEC-005`, `OPS-003`, and `OPS-006`. No requirement or must-not-ship gate is promoted. +- Corrective v0.1.51 work replaces the manually demonstrated live recovery + with a package-owned, bounded path for the exact ordinary-onboarding + placeholder-owner state. Approval owner/passkey custody and current signer + authority converge to the enrolled Core principal only when exact domain, + OIDC, credential, row-shape, configuration, and journal evidence agrees. + Setup journals the corresponding Core policy cutover and resumes exact + partial writes; unsupported or ambiguous state fails closed. Communication + activation now materializes the complete schema-v7 collaboration projection + atomically with scope commitment, while a committed legacy scope is repaired + idempotently from its server-held source rows without replacement or generic + entitlement minting. The exact v0.1.50→v0.1.51 path preserves the published + 300-second generic Approval policy without a rewrite and separately journals + and atomically normalizes only the retained one-hour generic Approval hotfix + to the bounded 600-second ordinary and 3600-second communication-scope TTL + split. An unrecorded hotfix is accepted only when deriving the published + 300-second form reproduces the marker’s exact canonical config digest; + retries resume, pre-commit rollback restores the source bytes, and any + additional drift fails closed. The same marker gate composes the exact + completed canonical-owner repair with the retained TTL hotfix: it validates + terminal recovery and signer evidence, reconstructs both marker-era Approval + and Core documents, and requires both canonical digests to match before + creating an upgrade journal or changing managed state. If that exact + terminal live repair lost its recovery journal, setup may reconstruct one + only from the retained source marker, the unique historical and current + signer keys, current typed Approval/Core configuration, the exact active + owner binding, target credential state, and one matching immutable adoption + audit. It first writes a strict terminal reconstruction journal without + changing authority; missing, extra, or conflicting evidence fails closed. + Every initial or resumed attempt revalidates that terminal journal and its + marker/current digests. A resumed upgrade journal rechecks the exact + two-signer custody and matching current Core/Core-OIDC policy immediately + before the Approval TTL compare-and-swap, so intervening drift leaves + Approval unchanged. The retained 3600-second source shape is parsed without + writing and only its exact allowlisted TTL delta is accepted. Setup then + documents, normalizes the TTL policy, and verifies idempotent owner/Core + convergence; incomplete, cross-principal, signer-drifted, ambiguous-owner, + or independently drifted state remains fail-closed. A lost + PostgreSQL lease heartbeat publishes reconnect-required state while protected + operations remain excluded. A later operation opens a fresh verified + connection and reacquires a strictly higher same-owner fence; + different-owner contention or unverifiable recovery remains unavailable. + The focused recovery lane reports **261 passed**; the broad + releasable-source lane reports **2249 passed and 22 expected + platform/dedicated-PostgreSQL skips**; and source plus two recursive packed + generations each report **2276 passed and 22 expected skips**. Two independent + builds are byte-identical, packaged local message/obligation processing + reaches `recipient_committed`, and a fresh installed npm package passes exact + endpoint routing with zero sibling reactions or residue. Exact-commit GitHub + installed-host and disposable PostgreSQL 18.4 evidence for predecessor + commit `4e8ebd72d6b5e39550e438816abb1e2d30a5326a` remains retained but does not + bind this corrective source delta. Fresh same-commit CI and server PostgreSQL + validation are required before owner-authorized live convergence. Live + server/laptop convergence and two-machine reliability evidence remain pending. + Affected IDs are `ID-001`, `ID-002`, `ID-005`, + `ID-006`, `AUTH-001`, `AUTH-002`, `AUTH-003`, `AUTH-004`, `AUTH-005`, + `COM-001`, `COM-002`, `COM-009`, `AVL-003`, `AVL-005`, `SEC-003`, `SEC-005`, + `SEC-007`, `OPS-003`, and `OPS-006`. No requirement or must-not-ship gate is + promoted. + +- The same corrective v0.1.51 candidate adds a bounded recovery for an expired + same-principal `member` in an active schema-v7 collaboration scope. A current + managed-server harness of the exact scope-owning principal may initiate the + ceremony because the expired member cannot authenticate, but the exact human + principal remains the source of positive authority through a fresh, + single-use Approval receipt bound to the complete scope/member/credential + pre-state. One atomic transaction tombstones the old member, activates the + exact replacement with the preserved role, advances counters, recomputes + digests, and records audit evidence. Current projection membership is the + authorization source; schema-v6 provenance remains immutable. All mismatch, + stale, replay, partial-projection, and unsupported transfer paths fail closed; + crash recovery reuses owner-only pending state. This changes no managed + identity/configuration and restarts no service. Affected IDs are `ID-005`, + `ID-006`, `ID-009`, `AUTH-001`, `AUTH-002`, `AUTH-003`, `AUTH-004`, + `AUTH-005`, `COM-001`, `COM-002`, `COM-009`, `AVL-003`, `SEC-003`, + `SEC-005`, `SEC-007`, `OPS-003`, and `OPS-006`. `PD-002` and all + owner/external release gates remain blocked; no requirement or must-not-ship + gate is promoted. + + - S5/S6 directly exercise `ID-006`, `AUTH-001`, `AUTH-002`, `AUTH-003`, @@ -579,7 +658,7 @@ stable requirement's status or remaining external/owner boundary. | OPS-004 — privacy-safe health/queue/latency/error/denial/security observability | partial-external | `src/agentnet/operations/telemetry.py`; `src/agentnet/audit/service.py`; `src/agentnet/product_http.py` | Fixed-label counters, bounded latency buckets, gauges, outage denials, scanner/audit/cost/adapter results, and protected content-free operator status: `tests/operations/test_privacy_budgets_attention.py`; `tests/integration/test_product_http_api.py`; `tests/production/test_postgres_runtime.py` | Production dashboard, alert delivery, retention, and load-SLO evidence remain external/owner-governed. | | OPS-005 — quotas, rate limits, backpressure, abuse and loop controls | partial-external | `src/agentnet/operations/quotas.py`; `src/agentnet/supervisor/model_egress.py`; `src/agentnet/operations/policy_defaults.py` | Persistent multidimensional fairness, atomic authoritative pressure reservations, circuit-breaker CAS/reclaim, loop fencing, safety reserve, relay/effect composition, and real-PostgreSQL one-winner race: `tests/operations/test_privacy_budgets_attention.py`; `tests/relay/test_server_agent_relay.py`; `tests/effects/test_effect_reservation.py`; `tests/production/test_postgres_runtime.py` | Production flood/soak/capacity tuning remains external and owner-governed. | | OPS-006 — portable self-hosted install/config/credentials/deployment | partial-external | `npm/bin/agentnet.mjs`; `src/agentnet/host.py`; `src/agentnet/host_security.py`; `src/agentnet/windows_security.py`; `src/agentnet/_terminal_handoff.py`; `src/agentnet/cli.py`; `src/agentnet/operations/server_setup.py`; `src/agentnet/storage/sqlite.py`; `src/agentnet/security/distribution.py`; `deploy/compose.production.json` | Linux/macOS/Windows package install/launch; fixed Linux setup plan/apply/resume/redaction; canonical state roots; owner-mode/protected-DACL state; reparse/link rejection; portable SQLite reopen/replay; signed HTTP client availability; config rebinding; backup/restore; anti-rollback; POSIX private-TTY behavior; and one ephemeral Ubuntu 24.04/PostgreSQL 18 installed-artifact setup lifecycle with always-run cleanup: `tests/platform/test_host_support.py`; `tests/operations/test_server_setup.py`; `tests/conformance/test_npm_package.py`; `tests/cli/test_terminal_handoff.py`; `tests/production/test_deployment_config.py`; `tests/operations/test_backup_restore.py`; `tests/security/test_distribution_lifecycle.py`; GitHub run `30155733937` | Current real-host setup proof is one ephemeral Linux/systemd runner, not a privileged hostile-path or persistent-host trial. Windows terminal mode intentionally fails closed; signed native installer/update/uninstall/rollback, privileged hostile-path trials, live Google/WebAuthn, PostgreSQL locked restore, KMS/off-host custody, and independently signed production artifacts remain external. | -| OPS-007 — conformance/security/recovery/federation/harness tests and reuse bake-off | partial-external | `src/agentnet/components/bakeoff.py`; `src/agentnet/components/registry.py`; `scripts/verify_release.py`; `.github/workflows/cross-platform.yml`; `.github/workflows/server-setup-e2e.yml`; `.github/workflows/server-setup-upgrade-e2e.yml`; `docs/BAKEOFF_PLAN.md` | Historical cross-platform evidence remains bound to its named immutable releases. For current candidate `0.1.39`, the setup/recovery lane reports `224 passed`; focused release lane reports `645 passed/7 expected dedicated-PostgreSQL skips`; source-only lane reports `1615 passed/16 expected skips`; source and both clean recursive installed npm generations each report `1642 passed/16 expected skips`; generation 2 additionally passes the installed-byte real-loopback multiprocess local communication gate with `accepted_local`, exact attribution, idempotency, `recipient_committed`, typed obligation completion across four Core starts (three restarts), lab-fixture credential refusal, listener release, empty workspace, and no package-tree residue; direct disposable-package verification, release verification, complete package-tree equality, no-residue checks, and deterministic byte-identical archives pass. Those lanes exclude installed-live-inference, subprocess-lifecycle, and bake-off-evidence files; the two installed-harness pin failures remain non-green and unwaived. Published `0.1.38` retains ordinary 30-attempt probes, uses the existing finite 90-attempt startup bound only for public Approval/Core health and Core readiness, and admits only exact forward-only `0.1.37→0.1.38` five-unit recovery. Candidate `0.1.39` changes to explicit GET plus product User-Agent and JSON Accept headers and repairs only the deterministic-lab local policy/recipient/custody seams; production policy still rejects those harnesses. It adds no migration edge and rejects an exact 0.1.38 marker. Its same-commit gate must realize that exact public marker from fresh state, require two identical fail-closed refusals, and prove marker, attempt, managed files, database schema, unit states, identity, and authority remain unchanged. The earlier clean Ubuntu 24.04/PostgreSQL 18 setup/cleanup and cross-platform package results remain bound to named `0.1.26` commit/runs: `tests/platform/test_host_support.py`; `tests/operations/test_server_setup.py`; `tests/conformance/test_npm_package.py`; `tests/conformance/test_release_manifest.py`; `tests/production/test_postgres_runtime.py`; `tests/components/test_bakeoff_evidence.py` | Official A2A remains non-green. Exact public `0.1.39` root-installed Hub verification and fresh clean-state setup, same-commit CI, mutation-authorized PostgreSQL evidence, completed live ceremony, independent component bake-offs, adaptive red-team, privileged host trials, and production chaos remain external. | +| OPS-007 — conformance/security/recovery/federation/harness tests and reuse bake-off | partial-external | `src/agentnet/components/bakeoff.py`; `src/agentnet/components/registry.py`; `scripts/verify_release.py`; `.github/workflows/cross-platform.yml`; `.github/workflows/server-setup-e2e.yml`; `docs/BAKEOFF_PLAN.md` | Historical cross-platform evidence remains bound to its named immutable releases. For current candidate `0.1.39`, the setup/recovery lane reports `224 passed`; focused release lane reports `645 passed/7 expected dedicated-PostgreSQL skips`; source-only lane reports `1615 passed/16 expected skips`; source and both clean recursive installed npm generations each report `1642 passed/16 expected skips`; generation 2 additionally passes the installed-byte real-loopback multiprocess local communication gate with `accepted_local`, exact attribution, idempotency, `recipient_committed`, typed obligation completion across four Core starts (three restarts), lab-fixture credential refusal, listener release, empty workspace, and no package-tree residue; direct disposable-package verification, release verification, complete package-tree equality, no-residue checks, and deterministic byte-identical archives pass. Those lanes exclude installed-live-inference, subprocess-lifecycle, and bake-off-evidence files; the two installed-harness pin failures remain non-green and unwaived. Published `0.1.38` retains ordinary 30-attempt probes, uses the existing finite 90-attempt startup bound only for public Approval/Core health and Core readiness, and admits only exact forward-only `0.1.37→0.1.38` five-unit recovery. Candidate `0.1.39` changes to explicit GET plus product User-Agent and JSON Accept headers and repairs only the deterministic-lab local policy/recipient/custody seams; production policy still rejects those harnesses. It adds no migration edge and rejects an exact 0.1.38 marker. Its same-commit gate must realize that exact public marker from fresh state, require two identical fail-closed refusals, and prove marker, attempt, managed files, database schema, unit states, identity, and authority remain unchanged. The earlier clean Ubuntu 24.04/PostgreSQL 18 setup/cleanup and cross-platform package results remain bound to named `0.1.26` commit/runs: `tests/platform/test_host_support.py`; `tests/operations/test_server_setup.py`; `tests/conformance/test_npm_package.py`; `tests/conformance/test_release_manifest.py`; `tests/production/test_postgres_runtime.py`; `tests/components/test_bakeoff_evidence.py` | Official A2A remains non-green. Exact public `0.1.39` root-installed Hub verification and fresh clean-state setup, same-commit CI, mutation-authorized PostgreSQL evidence, completed live ceremony, independent component bake-offs, adaptive red-team, privileged host trials, and production chaos remain external. | ## Accountable policy decisions (separate from the 85 requirements) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3c2e7f..c3400df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,6 +116,18 @@ authority and restarts no service. Inode-checked owner/mode-preserving CAS updates config, identity, and journal, and exact retry reconciles crashes or response loss without creating another credential. +Canonical-owner setup recovery treats the exact source/target approver pair as +an unordered trust set. It still verifies both principals, signer keys, public +keys, authority kinds, purpose sets, sidecar policy, and all non-trust OIDC +fields. The live source policy must equal the sole frozen target policy except +for the journal-bound source principal and signer references; setup proves that +equivalence before any recovery-journal write. A retained marker may bind +either that source policy or the exact target policy from the frozen setup +request; acceptance requires one candidate to reproduce the marker digest. +The observed trust order is retained through the prepared journal and +compare-and-swap migration so recovery never rewrites unjournaled bytes. + + ## Canonical state and evidence - Canonical implementation state: this repository. @@ -124,6 +136,12 @@ response loss without creating another credential. - Production target: one PostgreSQL transaction for identity/policy/event/ recipient/receipt/audit-intent/outbox authority plus an immutable artifact backend. Startup remains blocked until that backend and evidence are wired. + The runtime lease heartbeat fails closed: its failure state is published + while protected operations remain excluded. A later storage operation + replaces the connection and resumes only after the writable primary, exact + schema, and a strictly higher same-owner fence are verified. Different-owner + contention, standby/future schema, or a non-increasing fence remains + unavailable; the superseded connection is closed. - Evidence ledger: `REQUIREMENTS_STATUS.md` and `docs/GATE_EVIDENCE.md`. - Generated schemas: `schemas/v1/*.json`. @@ -895,4 +913,104 @@ revalidated before commit. Packed-package verification additionally executes the separate-process local message/obligation roundtrip; it remains H/L local evidence and does not prove production durability. +The corrective v0.1.51 recovery remains inside this composition root. After an +exact supported upgrade has established the enrolled Core identity, setup may +classify only the known placeholder-owner, partial-repair, live-repair, or +already-converged Approval shapes. A service-private command adopts the +canonical owner and replacement P-256 receipt signer under Approval's OS +identity. Setup then replaces the corresponding Core OIDC and approver policy +through the existing root-owned upgrade journal. Exact journal phases make an +interruption resumable; mismatched identity, OIDC subject, domain, signer, +configuration, database shape, revision, or digest blocks startup. + +The prepared journal temporarily holds the replacement signer secret under +root-only file custody before the Approval-owned signer file is created. The +secret is removed from the journal immediately after the signer file is +durably installed and before authority mutation; either durable copy can +resume the prepared phase, while a missing or mismatched copy fails closed. + +One exact pre-adoption split state is also resumable when the original recovery +journal was lost. In that state Approval still names the source owner and +signer, signer custody contains exactly the source and staged target keys, the +embedded Core policy contains source-plus-target trust, and the standalone Core +OIDC sidecar contains only the canonical target. Setup reconstructs a +`prepared` journal only when the retained v0.1.50 marker, current typed files, +key bindings, and active source-owner state reproduce that exact transition. +The reconstruction records marker and realized Approval/Core/Core-OIDC digests +before any authority mutation. Ordinary journaled adoption then resumes; extra +keys, reversed or incomplete trust, target-only Approval state, ambiguous +owner state, or any unrelated drift fails closed. + +If the exact terminal live repair completed but its recovery journal was never +retained, setup does not repeat adoption or infer authority from the current +principal alone. It reconstructs a terminal journal only when one retained +v0.1.50 marker, exactly two fixed-custody signer keys, current typed +Approval/Core policy, one active canonical owner binding, target credential +state, and one immutable adoption audit jointly reproduce the exact marker-era +source and current realized digests. Reconstructing the marker-era source +reverses only the exact evidence-bound Approval and Core owner/signer policy +fields. Because the historical policy writer serialized a `frozenset`, marker +matching may search only permutations of the exact mandatory approval-purpose +set to recover its former byte order. Added, removed, duplicated, or changed +purposes cannot enter that path. The reconstruction write is the first +mutation and changes no authority. Its strict evidence is revalidated after +process loss. A resumed upgrade journal rechecks the exact two-signer custody +and matching current Core/Core-OIDC policy immediately before the Approval TTL +compare-and-swap; missing, additional, ambiguous, or drifted evidence leaves +Approval unchanged and fails closed. + +That same v0.1.50→v0.1.51 boundary preserves the exact published v0.1.50 +Approval policy with its 300-second generic request TTL and absent +communication-scope field. It separately recognizes the retained hotfix that +raised the generic request TTL to one hour with either an absent or explicit +3600-second communication-scope field. +When the setup marker still records +the published configuration, setup derives that exact 300-second form from the +realized hotfix and requires its canonical digest to match the marker. Only +then does it record the realized Approval configuration in upgrade journal v3, +restore the ordinary request ceiling to 600 seconds, and preserve the distinct +communication-scope ceiling at 3600 seconds. The write uses compare-and-swap; +interruption resumes from the journal, pre-commit rollback restores the exact +source bytes, and any additional configuration drift fails closed. + +Marker validation also handles the exact composition of those two corrective +states. When the live owner/Core policy is already canonical, the Approval +recovery journal is terminal, the one-hour hotfix remains realized, and the +marker still records the pre-repair source, setup reconstructs both marker-era +documents from the current typed documents plus proof-bound source identity and +signer fields. It reverses only the allowlisted TTL delta for comparison and +requires both canonical digests to equal the marker before creating the setup +journal. Every initial or resumed attempt first revalidates the terminal +recovery journal, exact single active owner, adoption audit, target credential, +and signer custody. The retained 3600-second source policy is parsed through an +exact no-write compatibility path; only after that preflight may the normal +journal capture the realized current files and TTL normalization proceed. +Owner/Core convergence is then verified idempotently. Unknown, incomplete, +cross-principal, signer-mismatched, ambiguous-owner, or additionally drifted +combinations fail before a managed write. + +Communication-scope completion and legacy-scope repair share one schema-v7 +single-scope materializer. Completion invokes it inside the transaction that +commits the scope, entitlements, revoke powers, members, audit record, and +idempotent result. Repair derives the same projection only from committed +server-held scope rows. Existing exact rows are a no-op; partial, extra, or +conflicting rows roll back. Thus no success can expose a terminal active scope +without its canonical collaboration authority, and recovery never replaces +the active scope or grants generic access. + +Expired schema-v7 member recovery is a separate root-managed Approval +transaction. A current non-lab managed-server harness may initiate it only when +it belongs to the exact scope-owning human principal; this permits recovery +when the former owner/member harness can no longer authenticate without +transferring authority to a sibling harness. Approval by that exact principal +binds the scope owner, former and replacement harnesses/current credentials, +preserved `member` role, scope digest/revision/membership sequence, policy and +revocation epochs, and finite request lifetime. Core consumes the receipt and +atomically tombstones the former row, inserts the active same-principal +replacement, advances counters, recomputes digests, and appends audit evidence. +Current schema-v7 membership becomes the authorization source; immutable +schema-v6 rows remain provenance. Managed identity/configuration files and +service state are outside the transaction and are never changed or restarted. + + `agentnet server-agent reset` is destructive server-manager-only package recovery. It acquires the same permanent root-only setup lock before inventory, rejects state without pre-existing lock custody, stops/disables and proves all five managed units inactive, removes only allowlisted package deployment units/state, and preserves the lock/root so a concurrent or later setup cannot lock a different inode. It always reloads systemd, including exact response-loss retry, and retains PostgreSQL, runtimes, package installation, proxy/TLS/DNS/firewall inputs, and locked service identities. Reset is not a browser action, onboarding step, or secret-rotation path. Exact AgentNet database/role reinitialization is a separate destructive operator boundary requiring sanitized target inventory, explicit named approval, an explicit backup/rollback decision, and redacted audit evidence; unrelated/shared/valuable targets fail closed. diff --git a/docs/GATE_EVIDENCE.md b/docs/GATE_EVIDENCE.md index 6164c5a..9be6006 100644 --- a/docs/GATE_EVIDENCE.md +++ b/docs/GATE_EVIDENCE.md @@ -1,6 +1,6 @@ # Must-Not-Ship Gate Evidence Ledger -Current ledger update: 2026-08-07. Overall release posture: **blocked**. Older +Current ledger update: 2026-08-09. Overall release posture: **blocked**. Older rows retain their exact run dates and remain historical evidence. This ledger separates checked-in historical release evidence from an explicitly labeled uncommitted worktree candidate, records only commands actually run for the stated @@ -84,6 +84,8 @@ Gate statuses used here: | Candidate npm `0.1.48` canonical post-C0 credential correction | Completed-C0 terminal credentials now resolve through the exact domain/principal-bound harness plus credential epoch; unknown or mismatched identity state remains fail-closed. Adds only the exact forward-only `0.1.47→0.1.48` five-unit marker edge and no database migration. | Focused lane: `547 passed, 5 skipped`; broad releasable-source lane: `2155 passed, 21 skipped`; recursive packed-package and installed-host upgrade evidence pending. | H-only correction evidence; no gate promotion. Affected IDs: `ID-006`, `ID-009`, `SEC-007`, `OPS-003`. | | Candidate npm `0.1.49` completed-C0 communication recovery | Permanent communication activation resolves the exact completed C0 pair; only the authenticated ordinary server harness may advance to its current active credential, while the C0 peer remains pinned to its enrolled credential absent separately verified succession. Terminal pre-commit retries converge without replacing committed authority. Signed message send, inbox, and acknowledgement requests bind the exact collaboration scope. Adds only the forward-only `0.1.48→0.1.49` five-unit marker edge and no database migration. | Focused lane: `645 passed, 5 skipped`; broad releasable-source lane: `2166 passed, 21 skipped`; recursive packed-package and installed-host upgrade evidence pending. | H-only correction evidence; no gate promotion. Affected IDs: `ID-001`, `ID-002`, `ID-004`, `ID-006`, `AUTH-001`, `AUTH-002`, `AUTH-003`, `AUTH-004`, `AUTH-007`, `AUTH-009`, `COM-001`, `COM-002`, `COM-003`, `COM-006`, `COM-009`, `COM-011`, `AVL-003`, `AVL-005`, `AVL-006`, `SEC-003`, `SEC-005`. | | Candidate npm `0.1.50` setup-usability, direct-upgrade, and communication-scope approval path | One guided server command over the strict request/plan/apply protocol; one server-origin-only guided laptop command with authenticated discovery defaults; content-free named phases; ten-minute server and five-minute laptop bounds; resumable blocker output; exact direct allowlist from v0.1.45–v0.1.49 schema-v7 five-unit markers; installed separate-process local communication/obligation roundtrip added to packed verification; explicit one-hour Approval request ceiling limited to `authorization.communication_scope.approve`, with all other approvals retaining the five-minute ceiling and short-lived WebAuthn challenges | Focused release lane: `681 passed, 5 skipped`; broad releasable-source lane: `2180 passed, 21 skipped`; source and two recursive packed generations: `2207 passed, 21 skipped` each; two byte-identical release builds; release manifest verifier passed; installed tarball journey passed from an unrelated prefix; installed-host, fresh-machine, and same-commit CI evidence pending | H/L-shaped local candidate evidence only. No production, owner-policy, external, privileged-host, or gate promotion. Affected IDs: `ID-006`, `AUTH-004`, `AUTH-007`, `COM-001`, `COM-002`, `COM-003`, `COM-006`, `COM-009`, `AVL-003`, `AVL-005`, `AVL-006`, `UX-001`, `UX-002`, `SEC-003`, `SEC-005`, `OPS-003`, `OPS-006`. | +| Corrective v0.1.51 canonical-owner, schema-v7 projection, retained-TTL, and expired-member replacement recovery | Exact enrolled-Core plus pinned-Approval-OIDC owner selection; transactional Approval owner/passkey adoption; current receipt-signer replacement; strict resumable signer/config journal; journaled Core OIDC/approver cutover; exact published v0.1.50 300-second Approval policy preservation plus marker-relative proof, exact composition of a completed canonical-owner repair with the retained one-hour hotfix, and normalization of that generic Approval hotfix to separate 600-second ordinary and 3600-second communication-scope ceilings under compare-and-swap, resume, and rollback; every initial or resumed combined-recovery attempt revalidates terminal journal, single-active-owner, adoption-audit, credential, and signer state before journal preparation or TTL migration; runtime-heartbeat failure publishes fail-closed state while protected operations remain excluded and requires a fresh verified connection and a strictly higher same-owner fence before protected work resumes; single-scope schema-v7 projection shared by atomic activation and idempotent legacy repair; separately approved same-principal expired-member replacement requires the exact scope owner, preserves role, tombstones the former harness, activates the enrolled replacement, increments membership and scope revisions once, recomputes canonical digests, cuts authorization over to current schema-v7 membership, and permits exact committed replay after request expiry; ambiguity, cross-principal/domain, replay, tamper, drift, partial-row, conflicting-row, already-shortened TTL, recovery-journal identity/path/digest drift, and Approval owner-binding drift rejection | Recovery-focused lane: `261 passed`; replacement-focused SQLite lane: `62 passed`; PostgreSQL contract collection: `82 passed, 8 dedicated-database skips`; broad releasable-source lane: `2249 passed, 22 skipped`; `npm run check` source plus two recursively packed generations: `2276 passed, 22 skipped` each; release manifest, installed-byte local communication, exact-endpoint routing, and packaged v0.1.45 journey passed; predecessor exact commit `4e8ebd72d6b5e39550e438816abb1e2d30a5326a` retains its GitHub installed-host and disposable PostgreSQL evidence but does not bind this corrective source delta; fresh same-commit CI and server PostgreSQL validation are required | Current source/package H/L-shaped evidence; predecessor exact-service and installed-host records remain retained, including `evidence/local/2026-08-11-v0.1.51-server-postgres/manifest.json`, but fresh same-commit CI and server validation are pending. Live server/laptop convergence, two-machine reliability, multi-node durability, and higher required tiers remain pending. No requirement or gate promotion. Affected IDs: `ID-001`, `ID-002`, `ID-005`, `ID-006`, `ID-009`, `AUTH-001`, `AUTH-002`, `AUTH-003`, `AUTH-004`, `AUTH-005`, `COM-001`, `COM-002`, `COM-009`, `AVL-003`, `AVL-005`, `AVL-006`, `SEC-003`, `SEC-005`, `SEC-007`, `OPS-003`, `OPS-006`. | + | OIDC validated-address transport repair | `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/identity/test_oidc_enrollment.py tests/operations/test_fail_closed_config.py tests/production/test_deployment_config.py` | 53 passed, 0 failed on 2026-07-15 | H only: includes the real `_PinnedHTTPSConnection` socket path under a validation-to-connect DNS-answer change, exact TCP address/SNI/Host assertions, proxy/tunnel and redirect denial, response bounds, unsafe address-class rejection, invalid resolver type handling, private IPv4/IPv6 pins, configuration, and deployment wiring. No real IdP/TLS service or independent approval boundary is claimed. | | Independent WebAuthn-UV approval component | `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/approval`; approval-consumer lane; non-gate full regression below | 12 focused passed; 144 approval/consumer passed; included in 1049-pass broad lane on 2026-07-15 | H only: strict owner-only config/key custody, exact SQLite catalog/tamper rejection, UV-required maintained-library call contract, exact display/digest, bounded duplicate-rejecting HTTP, one-receipt response-loss retry, committed denial/expiry audits, rejection, credential revocation, loopback serving, and non-authorizing provisioning. WebAuthn verification is seam-controlled in hermetic tests; no real authenticator, independent host/device/operator, TLS proxy, rotation/recovery drill, or owner decision is claimed. | | Prior `0.1.8` candidate, unfiltered local run | `UV_CACHE_DIR=/tmp/uv-cache uv run --extra test pytest -q` | `1087 passed, 2 failed, 7 expected PostgreSQL skips` on 2026-07-16 | Both failures were preserved environmental G01 gates. This was not a passing unfiltered release run and is not current `0.1.9` evidence. | diff --git a/docs/OWNER_DECISIONS.md b/docs/OWNER_DECISIONS.md index dd3c3a7..ba2eb0e 100644 --- a/docs/OWNER_DECISIONS.md +++ b/docs/OWNER_DECISIONS.md @@ -65,6 +65,17 @@ usability and threat-model tradeoff: identity-set drift permanently invalidates the guard. This bounded pilot instruction is not general elevation, relationship, messaging-administration, task, file, room, federation, A2A, server-agent, or wildcard authority. +- **Updated 2026-08-09:** the owner approved automatic reconciliation only for + the exact ordinary-onboarding placeholder Approval owner after Core has + enrolled the canonical OIDC issuer-plus-subject principal. Recovery must + derive the target from enrolled Core evidence and Approval's pinned OIDC + binding, accept only the bounded known source shapes, replace current signer + authority without dual trust, preserve immutable historical receipts, and + fail closed on ambiguity or drift. This is not a general principal merge, + alias migration, appeal, account recovery, cross-domain migration, or + production policy decision. The authenticated coding-session record is not + independent signed O-tier evidence and does not close PD-001. + This decision does not approve production certification, the optional independent-administration tier, high-impact/break-glass elevation, company diff --git a/docs/RELEASE_MANIFEST.md b/docs/RELEASE_MANIFEST.md index 704a645..7927c2d 100644 --- a/docs/RELEASE_MANIFEST.md +++ b/docs/RELEASE_MANIFEST.md @@ -1,13 +1,13 @@ # Release Manifest -Snapshot: 2026-08-07 -Candidate: `agentnet 0.1.50` -Latest published package: `agentnet 0.1.45` -Evidence profile: setup usability around the strict ordinary-server and guided-laptop protocols, bounded phase/deadline/recovery behavior, installed-byte communication, and direct allowlisted 0.1.45→0.1.50 through 0.1.49→0.1.50 package/config/unit transitions +Snapshot: 2026-08-09 +Candidate: `agentnet 0.1.51` +Latest published package: `agentnet 0.1.50` +Evidence profile: package-owned combined canonical-owner/retained-TTL recovery, atomic schema-v7 communication-scope projection, installed-byte communication, direct allowlisted 0.1.45→0.1.51 through 0.1.50→0.1.51 package/config/unit transitions, with fresh exact-commit installed-host CI and disposable PostgreSQL 18.4 runtime verification pending This is not a production release. It is the human projection of -`RELEASE_MANIFEST.json`; local evidence cannot promote external, privileged, -owner, installer, or production-topology gates. +`RELEASE_MANIFEST.json`; local and exact-service evidence cannot promote +external, privileged, owner, installer, or production-topology gates. ## Release claim @@ -24,8 +24,8 @@ owner, installer, or production-topology gates. |---|---| | Runtime | CPython `3.13.13` | | Python range | `>=3.13, <3.15` | -| `uv.lock` | format `1`, revision `3`, SHA-256 `afffa3558290939ca4f3efae1b04559db3299084f4a62c0d93ccdd4ab66e69c8` | -| `pyproject.toml` | SHA-256 `95c9effe37951f5ab012aa00743ecc52b44ca0ac0b62ab08eddafa3ab7559e7a` | +| `uv.lock` | format `1`, revision `3`, SHA-256 `c971fdea8d3782a9d7349b5723f4e036740d314f19f7c3553dc6ba1780c9f84d` | +| `pyproject.toml` | SHA-256 `6aebcdea17d2715c3136c2b6b3179a424a3eed990f54d65a4cccbf67c715c5da` | | Build backend | `hatchling==1.28.0` and editable-build helper `editables==0.5`, both in the `build` dependency group and frozen lock | The production Docker recipe installs the locked build group, then installs diff --git a/docs/SCHEMAS_INTERFACES.md b/docs/SCHEMAS_INTERFACES.md index d4a8d29..50fbdf2 100644 --- a/docs/SCHEMAS_INTERFACES.md +++ b/docs/SCHEMAS_INTERFACES.md @@ -152,6 +152,12 @@ purposes must collectively cover all six mandatory approval consumers, and every approver must cover enrollment. Signer private keys remain file references; load verifies each key's configured thumbprint. +`OIDCEnrollmentConfig.trusted_approvers` serialization order does not grant or +remove authority. Canonical-owner recovery accepts either ordering only for the +exact proof-bound source/target pair, verifies every trust record and all other +OIDC fields, and preserves the observed order until journaled replacement. + + The approval SQLite catalog is version 4 and is checked on every open against both exact `sqlite_master` objects, stored catalog SHA-256, and immutable migration names/checksums. The default self-hosted profile may run this service @@ -353,6 +359,29 @@ only when this audited chain reaches it exactly. Exact same-request replay reconciles; same ID with drift, stale/missing links, skipped epochs, changed actor/key/files, or missing/conflicting audit rows fails closed. +`agentnet server-agent replace-expired-scope-harness` uses strict owner-only +`agentnet.managed-scope-harness-replacement-state.v1` local state and an +`agentnet.scope-harness-replacement.v1` canonical transaction. The local state +binds exact managed config/identity digests, scope ID, former/replacement +harness IDs, fixed `member` role, receipt-possession secret, and Approval +request ID. The signed transaction additionally binds the scope owner +principal/harness, current credential IDs/epochs, scope +revision/digest/membership sequence, and a ten-minute maximum lifetime. + +Only a current non-lab harness of the exact scope-owning principal may initiate +the ceremony; the former expired harness need not authenticate. The Approval +receipt uses the existing `identity.credential.recover.approve` purpose but is +digest-bound to this distinct complete transaction. Commit consumes that +single-use receipt in the same transaction that changes the old member to +`removed`, inserts the exact active same-principal replacement as `member`, +advances membership sequence and scope revision once, recomputes member/scope +digests, and appends `collaboration_scope.harness_replaced` audit evidence. +Exact retained-request replay returns the committed result; drift, replay with +different bytes, unexpired old credential, inactive new credential, +principal/domain/role mismatch, partial projection, or stale scope state fails +closed. No schema-v6 provenance, identity/configuration file, or service +lifecycle is changed. + The signed persistent communication-scope routes are: | Route | Exact request/effect | @@ -685,6 +714,14 @@ migration, table, index, trigger/constraint, noncontiguous history, future version, or unsupported older version. The only current N/N-1 Core migration is an exact catalog/checksum-verified v6→v7 transition. +`PostgreSQLStore` binds protected transactions to one renewable runtime lease +and its fence. A heartbeat failure publishes reconnect-required state while the +storage lock still excludes protected operations, then stops the keeper. The +next operation opens a fresh connection, revalidates writable primary and exact +schema, and reacquires only a strictly higher fence before restarting the +keeper; it closes the superseded connection. Contention by a different owner or +any unverifiable recovery remains unavailable. + No pre-release or differently named database is accepted as an authority source, and no unilateral relationship can be converted into consent. Import requires a reviewed non-authority export into a fresh current store followed by @@ -797,6 +834,103 @@ five-unit schema-v7 markers from v0.1.45 through v0.1.49 are distinct allowlisted forward-only sources to v0.1.50. They use journal v4 and do not imply an intermediate package install or database migration. +Corrective v0.1.51 setup recognizes only the bounded canonical-owner recovery +states. Its internal Approval subprocess receives the expected source and +target principal, exact OIDC issuer/subject, verified alias, recovery ID, and +approved timestamp; Approval verifies those values against its active +store-bound owner record before mutation. The strict recovery result and +root-owned journal are schema-v1 objects. Current Approval configuration, +passkey ownership, and receipt signing move to the canonical principal while +terminal historical evidence remains immutable. Core OIDC and approver policy +files are replaced through journaled compare-and-swap writes. These interfaces +are setup-owned recovery mechanics, not generic principal migration APIs. + +Only the prepared journal phase may contain the replacement signer private PEM, +under root-only file custody. Setup removes that field immediately after the +Approval-owned signer file is durably installed and before owner authority is +mutated; a retry reconstructs the file from the journal or verifies the exact +existing signer. + +The same journal schema may carry a strict +`agentnet.canonical-owner-partial-recovery.v1` record only in its `prepared` +phase. This record binds observation time, retained marker Approval/Core +digests, realized Approval/Core/Core-OIDC digests, and the fixed +`active_owner_binding` source classification. It represents only the exact +pre-adoption split state: source-owned Approval, exactly two source/target +signers, dual-trust embedded Core OIDC, and target-only standalone Core OIDC. +The retained Approval marker may encode either the source policy or the exact +target policy from the frozen request and target signer; one reconstructed +policy must reproduce the marker digest exactly. Before writing the journal, +setup requires exactly one frozen target approver and proves that the live +source policy differs only in the source principal and signer references. +Setup derives the recovery request from store-bound source authority and the +enrolled target identity, writes the journal atomically without changing +authority, and then uses the ordinary recovery command. Unknown fields, +additional signers or approvers, nonmatching key material, alternate trust +topology, or digest/state drift are rejected. + +A journal-less terminal live repair may create the same schema-v1 terminal +journal only from exact marker-relative evidence. Its nested strict +`agentnet.canonical-owner-recovery-reconstruction.v1` record binds the +observation time, retained marker Approval/Core digests, realized current +Approval/Core digests, and the fixed `approval_audit_marker_digest` source +classification. Reconstruction additionally requires exactly two +fixed-custody signer keys, one active canonical owner binding, target +credential state, and one matching immutable adoption audit. The journal write +changes no authority and is atomic; every retry revalidates the journal and its +marker/current digest bindings before TTL migration. Unknown fields, missing +or extra signers, ambiguous historical principals, tampered audit/binding +state, or any digest mismatch fail closed. + +Upgrade journal v3 covers the exact v0.1.50→v0.1.51 Approval TTL boundary. It +extends the standard forward-only journal with one +`previous_configs.approval_config` payload under the existing root-only +custody and size bounds. The exact published source with +`request_ttl_seconds=300` and no +`communication_scope_request_ttl_seconds` is preserved byte-for-byte. A +retained-hotfix source with `request_ttl_seconds=3600` and an absent or +3600-second communication-scope field may differ from its marker only when +replacing those fields with the published 300-second form reproduces the +marker’s canonical Approval-config digest. The realized hotfix is then +journaled and normalized to the target 600/3600 split. Compare-and-swap +replacement, exact resume, and exact rollback use that retained payload; +already-shortened, unknown, or any additionally drifted shape fails closed. + +The marker gate composes journal-v1 canonical-owner evidence with upgrade +journal v3 only for one exact already-recovered v0.1.50 shape. It validates the +terminal recovery record, target signer private/public binding, current target +Approval and Core schemas, and the exact source/target identity and signer +fields needed to reconstruct both marker-era documents. The inverse TTL +comparison may remove only the known communication-scope field and restore the +published 300-second generic value. The reconstructed Approval and Core +digests must equal the marker before upgrade journal v3 is created. Historical +`allowed_purposes` ordering is not authority-bearing: marker matching may +permute only the exact fixed mandatory set to reproduce either retained +digest. Set membership, cardinality, all other fields, and the digest remain +exact; any added, removed, duplicated, or changed purpose fails closed. Every +initial or resumed attempt repeats the terminal journal, +exact-single-active-owner, adoption-audit, target-credential, and +signer-custody checks. An upgrade-journal resume also requires the current Core +OIDC sidecar to remain identical to Core and rechecks exact two-signer custody +immediately before the Approval TTL compare-and-swap. The retained +3600-second source policy has one exact no-write compatibility parse; it +accepts the absent released field or the explicit 3600-second default +materialized by canonical-owner configuration replacement, and no other +invalid current schema. The v3 journal stores the realized current documents, +not synthetic source bytes; subsequent owner and Core recovery checks are +idempotent. No incomplete recovery phase, caller-selected identity, ambiguous +owner state, or independently unexplained Approval/Core drift is accepted. + +The schema-v7 communication projection has one canonical single-scope mapping. +Both normal activation and repair write the exact collaboration scope, two +active member rows, five entitlements, and five revoke powers from server-held +communication-scope rows. Normal activation does so in the same transaction as +terminal scope commitment and its idempotent result. Exact existing rows are +accepted; missing rows are inserted only by repair; partial, extra, or +conflicting rows fail closed. Neither interface accepts caller-supplied member +identity or authority fields. + + Schema v7 and the lifecycle journal are implementation mechanisms only. Signed installer/update evidence, hostile-host qualification, independent approval, HA/restore, production durability, and every other production/high-tier gate diff --git a/docs/implementation-guide.md b/docs/implementation-guide.md index 1692fcf..05775d6 100644 --- a/docs/implementation-guide.md +++ b/docs/implementation-guide.md @@ -292,6 +292,16 @@ writes exact units, then commits the request-versioned marker through same-request, prior-byte compare-and-swap. Manual marker/config/unit surgery is unsupported. +For the proof-bound canonical-owner split-state recovery, the embedded Core +source/target trust pair may appear in either serialized order. Setup verifies +the exact two trust records and target-only sidecar. It also requires the +retained Approval marker to match either the source policy or the exact target +policy derived from the frozen setup request and target signer. Setup records +current config digests before mutation and carries the observed trust order +into rollback and replacement checks. Added, duplicated, altered, or unrelated +trust remains a fail-closed conflict. + + Released five-unit correction upgrades are a narrow forward-only exception: only an explicitly allowlisted exact predecessor marker is accepted, and the target marker is committed before service quiescence and bootstrap so an @@ -382,6 +392,87 @@ runtime before commit. Unsupported, ambiguous, or downgraded markers fail closed. This is package/config/unit provenance replacement, not a database migration or rollback promise. +If v0.1.51 setup reports `canonical_owner_recovery`, do not edit Approval or +Core databases, identity files, approver files, or scopes manually. Keep the +managed recovery journal and rerun the same approved +`agentnet server-agent setup --apply --start` request. The package derives the +canonical owner from the enrolled Core identity, verifies Approval's pinned +OIDC owner binding, performs the service-private owner/signer adoption, resumes +the journaled Core policy cutover, and verifies both services before reporting +success. Unsupported identity, OIDC, signer, row-shape, revision, or file drift +requires operator investigation; email equality alone is never recovery +authority. + +One interrupted live repair may have no recovery journal even though the +target signer and Core policy staging completed before Approval owner adoption. +Do not delete either signer, copy Core OIDC files over one another, or create a +journal manually. Rerun the same approved setup command. Setup accepts only the +exact source-owned Approval / dual-trust embedded Core / target-only standalone +Core OIDC shape with exactly two matching signer keys and one active source +owner. Its retained marker must match either the source Approval policy or the +exact target policy from the frozen request and target signer. The live source +policy must otherwise equal that sole frozen target policy; setup checks this +before recording a strict prepared recovery journal, then resumes normal +adoption. Any extra signer or approver, target-owned live Approval, +incomplete/reversed trust, ambiguous owner, or unrelated policy/digest drift +remains blocked. + +The exact published v0.1.50 Approval configuration has +`request_ttl_seconds: 300` and no separate communication-scope field; setup +preserves it without rewriting. A retained live hotfix may instead have +`request_ttl_seconds: 3600` with the separate communication-scope field absent +or explicitly set to `3600`; canonical-owner replacement materializes the +explicit default. The setup marker may still contain the digest of the +published 300-second form; +setup accepts that difference only when reconstructing the published form from +the realized file reproduces the marker digest exactly. Do not edit either file +manually. For that hotfix only, the exact v0.1.50→v0.1.51 setup path journals +the realized Approval configuration before loading the service, changes the +generic deadline to 600 seconds, and writes +`communication_scope_request_ttl_seconds: 3600`. Rerun the same approved +command after interruption. Any additional TTL/configuration drift blocks as +`setup_upgrade_conflict`; a failure before marker commit restores the journaled +source bytes. + +A live host may contain both corrections: canonical-owner recovery is complete, +Core and Approval contain the canonical target, the one-hour Approval hotfix is +still realized, and the retained setup marker still names the pre-repair +source. Do not repair that marker or create a recovery journal manually. If the +terminal recovery journal is absent, setup reconstructs it only when the +retained marker, exactly two signer keys, current typed Approval/Core files, +single active owner binding, target credential state, and matching adoption +audit identify one exact completed repair. It writes that terminal journal +without changing authority, then validates the journal, signer custody, and +marker/current digests before any setup journal or managed-state write. A +process loss after this write is safe: rerun the same approved command. If an +upgrade journal was already retained, setup rechecks exact two-signer custody +and requires the current Core OIDC sidecar to remain identical to Core +immediately before the Approval TTL compare-and-swap; intervening drift leaves +Approval unchanged. + +Setup reconstructs the exact marker-era Approval and Core documents by +reversing only the recovery-evidence-bound owner and signer fields, applies the +bounded inverse TTL comparison, and requires both reconstructed digests to +equal the marker. The old writer emitted the fixed mandatory +`allowed_purposes` set in process-dependent order. Setup may therefore try only +permutations of that exact set when reproducing the retained Approval/Core +digests. It never accepts different membership, duplicates, or unrelated +policy drift. Setup parses the retained 3600-second source policy without +writing and admits only that exact legacy TTL shape. Only then does it journal +the realized current documents, normalize the TTL policy, and recheck the +owner command and Core policy as idempotent +`already_exact`/`already_satisfied` operations. Missing or extra signer +evidence, incomplete adoption evidence, mixed principals, signer drift, +ambiguous owner state, Core/Core-OIDC disagreement, or unrelated +Approval/Core changes fail closed. + +The same setup pass repairs an already committed communication scope that lacks +its schema-v7 collaboration projection. It materializes only the exact +committed scope and member authority from server-held rows. It does not replace +the scope, re-enroll either endpoint, or mint a generic entitlement. Repeating +the recovery is a verified no-op. + + ### Communication-only request-v2 Use @@ -732,6 +823,33 @@ reconstruct the request state, terminal, journal, config, or identity to force recovery; absent or conflicting provenance requires a separate owner-approved recovery path. +If a freshly enrolled laptop harness cannot use an existing collaboration +scope because the former same-principal member credential is expired, keep Core +stopped and run: + +```bash +sudo -- \ + server-agent replace-expired-scope-harness \ + --scope-id \ + --old-harness-id \ + --new-harness-id +``` + +The managed server harness may initiate this recovery only as a current, +non-lab harness of the exact scope-owning principal. The first call persists the +exact scope/member/credential/revision/digest transaction before asking +Approval and reports `waiting_owner_approval`. After the exact principal +approves with WebAuthn UV, rerun the identical command. One PostgreSQL +transaction consumes the single-use receipt, tombstones the old `member`, +activates the replacement `member`, advances membership sequence and revision, +recomputes member/scope digests, and appends audit evidence. A crash before +commit changes nothing; a crash after commit is reconciled by the retained +request. Rejected or expired ceremonies require explicit +`--replace-terminal-state`. The command never edits identity/configuration, +restarts services, changes roles, transfers principals/domains, or accepts an +unexpired former credential. Never hand-edit scope rows or the private pending +state. + Guided command defaults to local system browser without printing authorization URL. Explicit server-only `--browser remote` stores authorization URL encrypted inside Core continuation custody, opens/discloses nothing, and waits. Owner opens @@ -1221,6 +1339,13 @@ v7 open. Unknown, missing, altered, prototype, noncontiguous, future, or unsupported older state fails closed before use. +Core also requires a current fenced PostgreSQL runtime lease. If its background +heartbeat fails or expires, failure state is published before another protected +operation can enter. A later request recovers without a service restart only by +opening a fresh verified connection and acquiring a strictly higher fence for +the same runtime owner. A different owner, standby, schema mismatch, or +non-increasing fence remains blocked. + Do not edit version metadata or infer authority from unilateral/prototype records. Transition from exploratory data requires reviewed export of non-authority content into a fresh current store and fresh exact bilateral diff --git a/docs/superpowers/plans/2026-08-09-agentnet-canonical-owner-recovery.md b/docs/superpowers/plans/2026-08-09-agentnet-canonical-owner-recovery.md new file mode 100644 index 0000000..dba28b9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-agentnet-canonical-owner-recovery.md @@ -0,0 +1,249 @@ +# AgentNet canonical owner and scope recovery implementation plan + +> **Execution:** Continue in this isolated worktree with test-driven implementation. Do not mutate the live server until all package and upgrade evidence is green and the owner separately authorizes deployment. + +**Goal:** Replace the v0.1.50 manual owner/signer/database repairs with a bounded package-owned recovery path and make every new communication-scope activation atomically materialize schema-v7 messaging authority. + +**Architecture:** Reuse one strict single-scope projection function for migration, runtime completion, and repair. Add a focused canonical-owner recovery module for state classification, Approval SQLite adoption, signer-history evidence, and journal validation. Keep root orchestration and service lifecycle in `server_setup.py`, while Approval mutation runs under the Approval service identity through an internal, fixed-shape package command. + +**Requirements:** ID-001, ID-002, ID-005, ID-006, AUTH-001..005, COM-001, COM-002, COM-009, AVL-003, AVL-005, SEC-003, SEC-005, SEC-007, OPS-003, OPS-006. + +**Owner boundary:** The 2026-08-09 authenticated owner approval covers only exact placeholder-to-enrolled-owner recovery. It is not general migration/appeal policy and is not O-tier evidence. + +--- + +## Task 1: Make schema-v7 projection reusable + +**Files:** +- Modify: `src/agentnet/storage/release_v7_schema.py` +- Modify: `tests/production/test_release_v7_schema.py` + +1. Add failing tests for one committed scope materialized by exact `scope_id` on an already-v7 database. +2. Cover exact idempotent retry, partial target rejection, extra member rejection, wrong source row rejection, and SQLite transaction rollback. +3. Extract `materialize_v6_communication_scope(connection, *, scope_id, postgres=False) -> Literal["created", "already_exact"]` from the existing batch loop. +4. Keep `migrate_v6_communication_scopes` as the batch wrapper used by schema 6→7 migration. +5. Ensure the materializer resolves every field from committed source rows and verifies the exact target projection before accepting an existing row. +6. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/production/test_release_v7_schema.py +``` + +7. Commit: `refactor: reuse communication scope projection` + +## Task 2: Materialize projection inside scope completion + +**Files:** +- Modify: `src/agentnet/authorization/communication_scope_service.py` +- Modify: `tests/authorization/test_communication_scope_service.py` +- Modify: `tests/integration/test_collaboration_scope_messaging.py` + +1. Add failing completion tests proving one canonical collaboration scope and exactly two active members exist when `complete()` returns `communication_active`. +2. Add fault injection at projection insertion and prove the communication scope, entitlements, items, approval consumption, audit record, and terminal result all roll back. +3. Add retry/response-loss coverage proving no duplicate projection or authority rows. +4. Call the shared single-scope materializer after the source scope is marked committed but before returning, inside the existing final transaction. +5. Pass the backend dialect only when using a raw migration connection; runtime store connections already translate qmark SQL. +6. Exercise the actual collaboration-scope messaging lookup after completion. +7. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q \ + tests/authorization/test_communication_scope_service.py \ + tests/integration/test_collaboration_scope_messaging.py \ + tests/production/test_release_v7_schema.py +``` + +8. Commit: `fix: project communication scope atomically` + +## Task 3: Define canonical-owner recovery contracts + +**Files:** +- Create: `src/agentnet/operations/canonical_owner_recovery.py` +- Create: `tests/operations/test_canonical_owner_recovery.py` + +1. Write failing model and classifier tests for: + - unmodified v0.1.50 placeholder state; + - exact partial live repair; + - exact operational live repair; + - already-converged state; + - wrong domain/OIDC subject, email-only match, duplicate owner, duplicate target, revoked passkey, unknown signer, unexpected active request/session, and arbitrary drift. +2. Define strict Pydantic models for the recovery request, journal, phase, historical signer record, source classification, and sanitized result. +3. Require canonical JSON, exact key sets, lowercase digests, bounded identifiers, fixed package/source versions, and monotonic phases. +4. Implement pure classification from typed Approval config, exact bounded SQLite facts, Core config/trust facts, setup marker, and verified enrolled identity facts. +5. Never accept email equality as canonical identity proof. Require exact OIDC issuer and subject plus the enrolled principal derived by setup. +6. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/operations/test_canonical_owner_recovery.py +``` + +7. Commit: `feat: define bounded owner recovery state` + +## Task 4: Implement Approval owner/passkey adoption + +**Files:** +- Modify: `src/agentnet/operations/canonical_owner_recovery.py` +- Modify: `src/agentnet/approval/store.py` only if a reusable transaction/backup primitive is required +- Modify: `src/agentnet/cli.py` +- Modify: `tests/operations/test_canonical_owner_recovery.py` +- Modify: `tests/approval/test_webauthn_service.py` + +1. Add failing tests for one exact owner binding and active passkey migrating to the canonical principal while credential ID, public key, sign count, device/backup state, and creation time remain unchanged. +2. Prove the deterministic user handle is updated and the existing passkey still verifies a new Approval request. +3. Prove terminal requests, receipts, and historical audit records are not rewritten. +4. Prove nonterminal requests/sessions are rejected or explicitly terminated according to the fixed recovery contract before adoption. +5. Implement an internal fixed-shape command invoked only by managed setup. It consumes a root-orchestrated, digest-bound recovery request file; it is not a generic principal-edit command. +6. Under the Approval service identity: + - open the exact configured SQLite store; + - create a complete SQLite backup using the SQLite backup API after quiescence; + - run `BEGIN IMMEDIATE`; + - recheck source facts and recovery-request digests; + - update the binding, active credentials, and user handle; + - append a new adoption audit record without changing historical rows; + - run foreign-key and exact postcondition checks; + - commit once. +7. Make replay return the same target state and reject a different request digest. +8. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q \ + tests/operations/test_canonical_owner_recovery.py \ + tests/approval/test_webauthn_service.py +``` + +9. Commit: `feat: adopt canonical approval owner` + +## Task 5: Implement signer history and current-authority cutover + +**Files:** +- Modify: `src/agentnet/operations/canonical_owner_recovery.py` +- Modify: `src/agentnet/approval/config.py` +- Modify: `src/agentnet/operations/config.py` +- Modify: `src/agentnet/operations/server_setup.py` +- Modify: `tests/operations/test_canonical_owner_recovery.py` +- Modify: `tests/operations/test_server_setup_recovery.py` +- Modify: `tests/approval/test_webauthn_service.py` + +1. Add failing tests for replacement signer generation, exact canonical principal binding, current Core trust replacement, and placeholder signer denial for new requests. +2. Add historical verification tests proving a receipt issued before cutover remains verifiable only within its recorded authority interval. +3. Store historical public signer evidence in an owner-only, append-only, digest-chained file. Do not include private key bytes or feed historical keys into current `trusted_approvers`. +4. Stage a replacement signer in Approval custody and replacement Approval/Core configs while services are stopped. +5. Replace current Approval approver and Core trusted approver/service principal together under the recovery journal. The target contains no current placeholder authority and no permanent dual trust. +6. Reject unknown historical signer intervals, key reuse across principals, or a signer/config mismatch. +7. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q \ + tests/operations/test_canonical_owner_recovery.py \ + tests/operations/test_server_setup_recovery.py \ + tests/approval/test_webauthn_service.py +``` + +8. Commit: `feat: cut over canonical approval signer` + +## Task 6: Add crash-safe setup orchestration + +**Files:** +- Modify: `src/agentnet/operations/server_setup.py` +- Modify: `src/agentnet/cli.py` +- Modify: `tests/operations/test_server_setup_recovery.py` +- Modify: `tests/operations/test_server_setup.py` + +1. Add failing phase-interruption tests for `prepared`, `quiesced`, `approval_committed`, `core_committed`, `marker_committed`, `services_verified`, and `completed`. +2. Add source fixtures for the four supported states and unsupported drift. +3. Add response-loss and concurrent setup-lock tests. +4. Introduce a dedicated root-owned recovery journal path and strict compare-and-swap writer, reusing existing setup journal durability primitives rather than inventing a generic framework. +5. Before mutation, validate package/setup custody, enrolled identity proof, exact OIDC binding, current credentials, Approval source state, Core trust/config, signer custody, schema versions, and complete backup. +6. Stop Approval and Core and verify them inactive before database or signer/config mutation. +7. Before marker commit, restore the complete Approval backup, signer/config files, Core config, marker, and prior service state on failure. +8. After marker commit, resume forward only. Verify each phase from durable state before advancing. +9. Invoke the shared projection materializer for any exact committed scope missing its schema-v7 projection. +10. Restart only managed required services, then verify health, readiness, canonical current approval authority, active communication scope, exact members, and current credential. +11. Return redacted blockers and sanitized evidence only. +12. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q \ + tests/operations/test_server_setup.py \ + tests/operations/test_server_setup_recovery.py \ + tests/operations/test_canonical_owner_recovery.py +``` + +13. Commit: `feat: recover canonical owner during setup` + +## Task 7: Prove PostgreSQL parity + +**Files:** +- Modify: `tests/production/test_postgres_store.py` or the existing PostgreSQL communication-scope test module selected by repository convention +- Modify: `tests/production/test_release_v7_schema.py` +- Modify: `scripts/ci/ordinary-server-upgrade-e2e.sh` + +1. Add a dedicated PostgreSQL test that completes a new communication scope on schema v7 and observes one exact collaboration scope and two exact members in the same commit. +2. Inject a projection conflict and prove no terminal communication scope or entitlement rows commit. +3. Add exact missing-projection repair and idempotent retry coverage. +4. Extend installed upgrade E2E with the supported v0.1.50 source fixture and verify package-owned convergence without re-enrollment. +5. Run only against the dedicated mutation-authorized database: + +```bash +AGENTNET_TEST_POSTGRES_URL='postgresql:///agentnet_test_governance_final?host=/tmp/agentnet-pgsocket&port=55432' \ +AGENTNET_TEST_POSTGRES_ALLOW_MUTATION=1 \ +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q \ + tests/production/test_release_v7_schema.py \ + tests/production -k 'postgres and communication_scope' +``` + +6. Run the installed upgrade lane in its documented isolated environment. +7. Commit: `test: prove owner recovery across stores` + +## Task 8: Update owner, architecture, operator, and evidence records + +**Files:** +- Modify: `docs/OWNER_DECISIONS.md` +- Modify: `docs/ARCHITECTURE.md` +- Modify: `docs/SCHEMAS_INTERFACES.md` +- Modify: `README.md` +- Modify: `docs/implementation-guide.md` +- Modify: `REQUIREMENTS_STATUS.md` +- Modify: `docs/GATE_EVIDENCE.md` + +1. Record the bounded 2026-08-09 authenticated owner instruction under PD-001 without calling it O-tier or general migration policy. +2. Document the owner-adoption trust boundary, historical/current signer separation, journal phases, recovery boundary, and atomic runtime projection. +3. Document supported operator behavior and redacted blockers. Do not expose internal identifiers in ordinary output. +4. Update implementation evidence only from test artifacts produced in this change. Keep all owner, production, HA, and external gates non-green. +5. Preserve the unrelated main-worktree edit to `docs/SCHEMAS_INTERFACES.md`; merge intentionally rather than overwriting it. +6. Commit: `docs: document canonical owner recovery` + +## Task 9: Package and release verification + +**Files:** +- Modify version/release metadata only if the repository release workflow requires the next candidate version after implementation stabilizes. + +1. Run focused tests from Tasks 1–7 again after documentation integration. +2. Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv sync --extra test +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q +UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/verify_release.py +UV_CACHE_DIR=/tmp/uv-cache uv run agentnet verify +npm run check +``` + +3. Run the server setup upgrade E2E and confirm no process, socket, temporary key, credential, or test server remains. +4. Review the stabilized diff once for identity spoofing, stale trust, rollback uncertainty, partial projection, cross-store mismatch, and evidence overclaim. +5. Correct real findings and rerun affected evidence. +6. Commit any final corrections with a narrow conventional commit. + +## Task 10: Authorized live convergence and messaging proof + +**Prerequisite:** Separate explicit owner authorization after all package evidence is green. + +1. Inventory the live server through the approved protected server operations path. Do not use A2A. +2. Install the immutable verified package through the supported upgrade path. +3. Run managed setup recovery once and retain sanitized journal/result evidence. +4. Verify no modified installed source, current placeholder authority, temporary dual trust, or incomplete rollback claim remains. +5. Verify Core and Approval health/readiness, current credential, active communication scope, and exact projection. +6. Send one server-to-laptop and one laptop-to-server AgentNet message. Each must reach durable `recipient_committed` acknowledgement. +7. Record exact evidence in MEL-251 and relevant evidence documents without promoting unrelated gates. +8. Mark MEL-251 complete only after this live proof. +9. Begin MEL-250, then confirm the dashboard implementation scope with the owner. diff --git a/docs/superpowers/plans/2026-08-09-expired-scope-harness-replacement.md b/docs/superpowers/plans/2026-08-09-expired-scope-harness-replacement.md new file mode 100644 index 0000000..e6fb197 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-expired-scope-harness-replacement.md @@ -0,0 +1,233 @@ +# Expired Scope Harness Replacement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one Approval-backed, atomic, idempotent operation that replaces an expired same-principal `member` harness in an active collaboration scope. + +**Architecture:** A focused authorization service prepares and commits a strict canonical replacement transaction against Core state. The managed server command owns the resumable Approval ceremony, while runtime policy resolves current peers from schema-v7 membership and retains schema-v6 records only as provenance. + +**Tech Stack:** Python 3.13, Pydantic v2, SQLite, PostgreSQL adapter, existing AgentNet Approval client/verifier, argparse, pytest. + +## Global Constraints + +- Preserve verified actor context; caller-supplied identifiers never establish identity. +- Reuse the existing Approval service and receipt-consumption ledger; add no dependency. +- Support only exact same-principal, same-domain `member` replacement where the old current credential is expired and the new current credential is active. +- Make the membership, digest, revision, audit, and receipt-consumption changes one database transaction. +- Preserve schema-v6 communication records as immutable source evidence. +- Fail closed on ambiguity, drift, replay mismatch, stale policy, stale revocation epoch, or partial state. +- Keep owner, guest, role-change, cross-principal, cross-domain, and active-credential migration unsupported. +- Do not promote any release, owner, privileged-host, or external evidence gate. + +--- + +### Task 1: Canonical replacement service + +**Files:** +- Create: `src/agentnet/authorization/scope_harness_replacement.py` +- Create: `tests/authorization/test_scope_harness_replacement.py` +- Modify: `src/agentnet/authorization/__init__.py` + +**Interfaces:** +- Produces: `ScopeHarnessReplacementRequest`, `ScopeHarnessReplacementResult`, and `ScopeHarnessReplacementService`. +- `ScopeHarnessReplacementService.prepare(*, actor, scope_id, old_harness_id, new_harness_id, role, request_id, issued_at, expires_at) -> ScopeHarnessReplacementRequest` reads and binds the exact pre-state. +- `ScopeHarnessReplacementService.replace(*, actor, request, approval) -> ScopeHarnessReplacementResult` verifies Approval and commits or returns an exact idempotent replay. + +- [ ] **Step 1: Write the failing success test** + +Create a real SQLite fixture with one active direct scope, active owner, expired member credential, active replacement credential, and a real signed Approval receipt. Assert that `replace` tombstones the old row, inserts the replacement, increments `membership_sequence` and `revision` once, changes `scope_digest`, binds a new `audit_record_hash`, consumes the receipt, and returns `idempotent_repeat=False`. + +- [ ] **Step 2: Run the success test and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_scope_harness_replacement.py::test_replacement_atomically_tombstones_old_member_and_activates_new_member +``` + +Expected: collection or import failure because `scope_harness_replacement` does not exist. + +- [ ] **Step 3: Implement strict models and preparation** + +Implement a request whose canonical transaction binds request/purpose, scope and owner identity, old/new harness and exact current credentials, old expiry/new validity, current scope digest/revision/membership sequence/policy/revocation epoch, proposed next counters, and ceremony times. `prepare` must validate the caller and every narrow precondition from the approved design. + +- [ ] **Step 4: Implement atomic replacement** + +Inside one `store.transaction()` call: revalidate the request, verify and consume the receipt, update the old member to `removed`, insert the new active member, recompute both member digests and the aggregate scope digest, append audit, and compare-and-swap the scope row on the old digest/revision/membership sequence. Return a strict result containing request ID, scope ID, old/new harness IDs, role, new sequence/revision/digest, audit hash, and `idempotent_repeat`. + +- [ ] **Step 5: Run the success test and verify GREEN** + +Run the exact command from Step 2. Expected: one passed test. + +- [ ] **Step 6: Add denial, concurrency, rollback, and replay tests** + +Add parameterized tests for wrong caller, non-owner caller, cross-domain/principal harness, wrong role, unexpired old credential, inactive/expired new credential, absent or duplicate member, stale scope digest/revision/sequence, wrong approver, wrong purpose/domain/transaction, consumed unrelated receipt, compare-and-swap loss, and injected failure after tombstoning. Add an exact replay test asserting unchanged counters/digests and `idempotent_repeat=True`. + +- [ ] **Step 7: Run the service suite** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_scope_harness_replacement.py +``` + +Expected: all tests pass with no partial rows after negative cases. + +### Task 2: Membership digest and authorization cutover + +**Files:** +- Modify: `src/agentnet/authorization/communication_scope_service.py` +- Modify: `src/agentnet/authorization/policy.py` +- Modify: `tests/authorization/test_communication_scope_service.py` +- Modify: `tests/authorization/test_policy.py` + +**Interfaces:** +- Consumes: removed and active member rows written by Task 1. +- Produces: validated collaboration scope snapshots containing both active members and tombstones, while `require` and peer authorization use only active members. + +- [ ] **Step 1: Write failing scope-validation tests** + +Create a replaced scope with one removed old row and one active replacement row. Assert `CommunicationScopeService.require` accepts the valid aggregate digest for the replacement and denies the removed harness. + +- [ ] **Step 2: Verify scope tests fail** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_communication_scope_service.py -k replacement +``` + +Expected: failure because `_members` currently rejects every non-active row. + +- [ ] **Step 3: Validate tombstones without weakening active checks** + +Update member parsing to validate both legal states, exact removal sequence/time invariants, authority identity, digest, and ordering. Include active and removed rows in the aggregate scope digest; filter to active rows only when determining membership and recipients. + +- [ ] **Step 4: Write failing runtime-policy tests** + +Using a migrated schema-v6 communication scope plus its schema-v7 projection, assert the old removed harness receives `communication_scope_harness_mismatch`, the new active harness receives the existing principal entitlement, and its peer set contains exactly the two current active schema-v7 members. + +- [ ] **Step 5: Verify policy tests fail** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_policy.py -k collaboration_projection +``` + +Expected: the replacement remains denied because policy uses frozen schema-v6 harness IDs. + +- [ ] **Step 6: Cut policy to current schema-v7 membership** + +When an entitlement is linked to a communication scope with a collaboration projection, validate the active projection, current policy/revocation state, exact principal/domain, and allowed action, then derive the peer set from active member rows. Retain the existing schema-v6 path only when no projection exists. + +- [ ] **Step 7: Run scope and policy suites** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_communication_scope_service.py tests/authorization/test_policy.py tests/integration/test_collaboration_scope_messaging.py +``` + +Expected: all current and replacement cases pass. + +### Task 3: Resumable managed server command + +**Files:** +- Modify: `src/agentnet/cli.py` +- Modify: `tests/cli/test_server_agent_cli.py` +- Create or modify: `tests/operations/test_scope_harness_replacement_cli.py` + +**Interfaces:** +- Consumes: `ScopeHarnessReplacementService` and the existing managed Approval client/verifier/configuration helpers. +- Produces: `agentnet server-agent replace-expired-scope-harness` with `--scope-id`, `--old-harness-id`, `--new-harness-id`, fixed `--role member`, managed defaults for config/identity/state, and `--replace-terminal-state`. + +- [ ] **Step 1: Write failing parser and pending-ceremony tests** + +Assert the parser exposes the exact command and that the first invocation writes owner-only resumable state before creating one Approval request, returns status `waiting_owner_approval`, does not mutate membership, and does not restart services. + +- [ ] **Step 2: Verify command tests fail** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/cli/test_server_agent_cli.py tests/operations/test_scope_harness_replacement_cli.py +``` + +Expected: command/parser missing. + +- [ ] **Step 3: Implement the minimal managed command** + +Under the existing managed-server setup/recovery lock, load exact config and server identity, open the configured Core store, prepare or reload a canonical request, create/status/retrieve the Approval receipt, call `replace`, remove private pending state only after verified completion, and emit content-minimized JSON. Never edit managed identity/configuration or restart a unit. + +- [ ] **Step 4: Add completion, exact replay, crash, and terminal replacement tests** + +Cover approval pending, issued completion, crash after Approval creation, crash after database commit, exact rerun, rejected/expired ceremony, explicit `--replace-terminal-state`, managed-file drift, wrong state-file ownership/mode, and concurrent setup-lock refusal. + +- [ ] **Step 5: Run command suites** + +Run the command from Step 2. Expected: all tests pass. + +### Task 4: PostgreSQL parity and operator contracts + +**Files:** +- Modify: `tests/production/test_postgres_runtime.py` +- Modify: `docs/ARCHITECTURE.md` +- Modify: `docs/SCHEMAS_INTERFACES.md` +- Modify: `docs/implementation-guide.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: service and command behavior from Tasks 1–3. +- Produces: dedicated PostgreSQL evidence and exact operator instructions. + +- [ ] **Step 1: Add the PostgreSQL replacement contract test** + +Use the mutation-authorized dedicated test database to execute preparation, approval, replacement, replay, and stale concurrent update behavior through the PostgreSQL adapter. Assert membership rows, counters, digests, audit chain, and receipt consumption match SQLite behavior. + +- [ ] **Step 2: Run the dedicated PostgreSQL test** + +```bash +AGENTNET_TEST_POSTGRES_URL='postgresql:///agentnet_test_governance_final?host=/tmp/agentnet-pgsocket&port=55432' AGENTNET_TEST_POSTGRES_ALLOW_MUTATION=1 PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/production/test_postgres_runtime.py -k scope_harness_replacement +``` + +Expected: pass when the dedicated server is available; otherwise report the infrastructure blocker without claiming PostgreSQL evidence. + +- [ ] **Step 3: Update architecture, schema, and operator docs** + +Document the strict transaction, tombstone semantics, schema-v7 authorization source, Approval boundary, exact command/rerun flow, crash recovery, no-restart behavior, and unsupported cases. Do not imply production certification. + +### Task 5: Evidence, release inputs, review, and delivery + +**Files:** +- Modify: `REQUIREMENTS_STATUS.md` +- Modify: `docs/GATE_EVIDENCE.md` +- Modify: `RELEASE_MANIFEST.json` +- Modify as required by verifier: `docs/RELEASE_MANIFEST.json`, `evidence/local/2026-08-09-v0.1.51/manifest.json` + +**Interfaces:** +- Consumes: exact test outputs and final source tree. +- Produces: honest H/L evidence, rebound release artifacts, and updated PR/Linear records. + +- [ ] **Step 1: Run focused SQLite suites** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_scope_harness_replacement.py tests/authorization/test_communication_scope_service.py tests/authorization/test_policy.py tests/integration/test_collaboration_scope_messaging.py tests/cli/test_server_agent_cli.py tests/operations/test_scope_harness_replacement_cli.py +``` + +- [ ] **Step 2: Run broad release validation** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run --extra test pytest -q --ignore=tests/adapters/test_installed_live_inference.py --ignore=tests/adapters/test_subprocess_lifecycle.py +npm run check +UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/verify_release.py +UV_CACHE_DIR=/tmp/uv-cache uv run agentnet verify +``` + +Record exact counts and expected skips. Do not waive failures. + +- [ ] **Step 3: Update evidence ledgers honestly** + +Record only observed H/L evidence for the affected IDs. Keep all owner, privileged, production HA, external, and must-not-ship gates blocked. + +- [ ] **Step 4: Rebind release inputs and reproducible artifacts** + +Follow `agentnet-release-candidate-assembly`: update source hashes, rebuild source/wheel twice under fixed environment, update artifact digests, and rerun package/release checks until clean. + +- [ ] **Step 5: Review the stabilized diff once** + +Review the final candidate for authorization bypass, incomplete rollback, digest drift, replay weakness, legacy privilege retention, SQLite/PostgreSQL divergence, and documentation overclaim. Fix only concrete findings and rerun affected verification. + +- [ ] **Step 6: Commit, push, and update tracked work** + +Commit the stabilized candidate on `fix/mel-251-recovery`, push it, watch GitHub Actions for the exact commit, update PR #3 with exact evidence, and update MEL-251 with implementation status, remaining gates, and the exact server command. Do not publish npm or mutate the live server. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-11-combined-live-recovery.md b/docs/superpowers/plans/2026-08-11-combined-live-recovery.md new file mode 100644 index 0000000..d0bd468 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-combined-live-recovery.md @@ -0,0 +1,132 @@ +# Combined Live Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow the exact retained v0.1.50 marker to safely recognize the already-applied canonical-owner repair plus the known one-hour Approval TTL hotfix before the existing v0.1.51 journaled upgrade runs. + +**Architecture:** Add one marker-relative reconstruction helper in `server_setup.py`. It reads the current Approval/Core configuration and exact canonical-owner evidence under existing private-file custody checks, reverses only the evidence-bound Approval and Core owner/signer fields plus the known TTL hotfix in memory, and accepts the source marker only when both reconstructed digests match. All mutation remains in the existing recovery/upgrade journals, TTL migration, canonical-owner recovery command, and Core policy cutover. + +**Tech Stack:** Python 3.13, Pydantic v2, pytest, AgentNet setup journals and canonical JSON digests. + +## Global Constraints + +- Only the exact `0.1.50 -> 0.1.51` transition may use this reconstruction. +- Caller identity and target authority remain derived from the fixed setup request, enrolled identity, and signed recovery journal; payload prose grants nothing. +- Missing, incomplete, malformed, cross-domain, wrong-target, signer-mismatched, or digest-mismatched state fails closed before host mutation. +- Do not add a generic migration or manual database/config rewrite command. +- Recovery and rollback continue through the existing root-owned setup upgrade journal. +- Affected IDs: `ID-001`, `ID-002`, `ID-005`, `ID-006`, `ID-009`, `AUTH-001..005`, `COM-001`, `COM-002`, `COM-009`, `AVL-003`, `AVL-005`, `AVL-006`, `SEC-003`, `SEC-005`, `SEC-007`, `OPS-003`, `OPS-006`. + +--- + +### Task 1: Combined-state marker reconstruction + +**Files:** +- Modify: `src/agentnet/operations/server_setup.py:4680-4720,5541-5745,6504-6522` +- Test: `tests/operations/test_server_setup_recovery.py` + +**Interfaces:** +- Consumes: retained setup marker; managed Approval/Core paths and accounts; Approval state path; fixed `ServerSetupRequest`. +- Produces: a private helper returning the pair `(approval_digest, core_digest)` used by `_require_marker_realized_state`. + +- [ ] **Step 1: Write the failing combined-state test** + +Create an exact v0.1.50 harness state, retain its marker, materialize the known one-hour TTL hotfix, then materialize a completed canonical-owner recovery journal and target owner/signer fields in Approval while Core remains marker-identical. Assert v0.1.51 apply succeeds, normalizes ordinary request TTL to `600`, retains communication-scope TTL `3600`, verifies canonical authority as already exact, advances the marker, and clears the setup upgrade journal. + +- [ ] **Step 2: Write fail-closed tests** + +Parameterize changes to the completed journal and realized documents: incomplete phase, wrong domain/target, source signer mismatch, extra Approval field drift, and extra Core OIDC drift. Assert `ServerSetupError`, unchanged managed bytes, unchanged v0.1.50 marker, and no new setup upgrade journal. + +- [ ] **Step 3: Run tests and verify the new positive test fails before mutation** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/operations/test_server_setup_recovery.py -k 'combined_owner_ttl or combined_recovery' +``` + +Expected: the positive case fails at `setup_upgrade_conflict`; negative cases remain fail-closed. + +- [ ] **Step 4: Implement minimal reconstruction** + +Add a helper that: + +1. reads Approval and Core JSON through `_read_private_managed_file`; +2. returns realized digests immediately when both equal the marker; +3. requires the exact package edge and a completed canonical-owner journal bound to the fixed request; +4. validates the realized target approver and Core trust entry against the journal target signer; +5. replaces only target Approval principal/key/path fields with journal source values in a copy; +6. reverses only the exact one-hour TTL shape to the published v0.1.50 shape; +7. validates the reconstructed Approval and Core models; and +8. returns marker digests only when both reconstructed canonical digests match, otherwise fails closed so the existing gate rejects. + +Pass the fixed request and Approval state into `_prepare_supported_upgrade`; do not mutate files in this helper. + +- [ ] **Step 5: Run focused positive and negative tests** + +Run the command from Step 3. Expected: all selected tests pass. + +### Task 2: Authority and recovery documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-09-agentnet-canonical-owner-recovery-design.md` +- Modify: `docs/implementation-guide.md` +- Modify: `docs/SCHEMAS_INTERFACES.md` +- Modify: `REQUIREMENTS_STATUS.md` +- Modify: `docs/GATE_EVIDENCE.md` + +**Interfaces:** +- Consumes: verified runtime behavior from Task 1. +- Produces: exact operator-facing and evidence-ledger wording without gate promotion. + +- [ ] **Step 1: Document the combined source state** + +State that only a completed canonical-owner journal can explain target owner/signer drift relative to the retained marker, and only the exact known TTL hotfix can explain TTL drift. Both Approval and Core reconstructed digests must match before journal creation. + +- [ ] **Step 2: Document fail-closed and rollback behavior** + +State that extra drift, incomplete journals, identity mismatches, or signer mismatches block before mutation; after journal creation, existing compare-and-swap resume/rollback semantics apply. + +- [ ] **Step 3: Update evidence wording without promotion** + +Record focused hermetic coverage only. Keep PostgreSQL/live convergence and every must-not-ship gate non-green until exact external evidence exists. + +### Task 3: Verification and delivery + +**Files:** +- Modify generated release artifacts only through the repository's release assembly procedure. + +**Interfaces:** +- Consumes: Tasks 1-2. +- Produces: a reproducible exact candidate commit for server validation. + +- [ ] **Step 1: Run focused recovery suites** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/operations/test_server_setup_recovery.py tests/operations/test_canonical_owner_recovery.py +``` + +- [ ] **Step 2: Run the broad source gate** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run --extra test pytest -q --ignore=tests/adapters/test_installed_live_inference.py --ignore=tests/adapters/test_subprocess_lifecycle.py --ignore=tests/components/test_bakeoff_evidence.py --ignore=tests/conformance/test_release_manifest.py +``` + +- [ ] **Step 3: Reassemble release evidence in repository order** + +Update verifier counts and release-input hashes, build two isolated package generations with `SOURCE_DATE_EPOCH`, bind artifact hashes, then run: + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/verify_release.py +npm run check +``` + +- [ ] **Step 4: Run AgentNet self-verification** + +```bash +PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run agentnet verify +``` + +- [ ] **Step 5: Review, commit, push, and request disposable server validation** + +Commit only after focused and release gates pass. Push the exact commit and instruct the server operator to validate it in a new disposable PostgreSQL environment before any live apply. diff --git a/docs/superpowers/specs/2026-08-09-agentnet-canonical-owner-recovery-design.md b/docs/superpowers/specs/2026-08-09-agentnet-canonical-owner-recovery-design.md new file mode 100644 index 0000000..10ec441 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-agentnet-canonical-owner-recovery-design.md @@ -0,0 +1,230 @@ +# AgentNet canonical owner and communication-scope recovery design + +**Date:** 2026-08-09 +**Status:** Approved for implementation in the authenticated owner session; not production-certified +**Issue:** MEL-251 + +## 1. Purpose + +AgentNet v0.1.50 can enroll an ordinary server agent yet leave two package-owned authority representations inconsistent: + +1. Approval may remain bound to the pre-enrollment placeholder owner identifier while Core uses the canonical enrolled principal. +2. A communication scope created after the database reached schema v7 may commit only its legacy authority rows, without the collaboration-scope projection required by messaging. + +The live server was repaired manually. This design replaces those interventions with one bounded, fail-closed package path. It preserves the enrolled server and laptop, passkey, active communication scope, messages, and receipts. It does not broaden communication authority or claim a production gate. + +## 2. Requirements and trust boundaries + +Affected requirements are ID-001, ID-002, ID-005, ID-006, AUTH-001, AUTH-002, AUTH-003, AUTH-004, AUTH-005, COM-001, COM-002, COM-009, AVL-003, AVL-005, SEC-003, SEC-005, SEC-007, OPS-003, and OPS-006. + +The change crosses these boundaries: + +- authenticated Core enrollment identity; +- Approval's owner binding, passkey custody, and receipt signer; +- Core's trusted approver configuration; +- SQLite Approval state; +- SQLite or PostgreSQL Core authority state; +- root-owned setup journal, setup marker, and managed service lifecycle. + +Positive authority continues to come from the verified human principal. The setup process may reconcile already-approved deployment state, but it cannot invent a principal, OIDC binding, passkey, signer, scope, or entitlement. + +## 3. Owner-decision boundary + +PD-001 records OIDC issuer plus subject as the canonical principal source, but its previous bounded default excluded general migration and appeal semantics. On 2026-08-09 the accountable owner explicitly approved this design in the authenticated coding session. + +That approval is limited to automatic reconciliation of the exact ordinary-onboarding placeholder with the exact enrolled canonical owner when all proof, row-count, domain, OIDC, credential, signer, configuration, and revision checks succeed. It is not a general principal merge, alias migration, appeal, account recovery, cross-domain migration, or production policy decision. The path remains unavailable for ambiguous or unsupported states. The repository record is not independent signed O-tier evidence and does not pass PD-001 or any release gate. + +## 4. Considered approaches + +### 4.1 Keep the placeholder as an alias + +Rejected. It would preserve two authority identities and weaken the invariant that the approval receipt principal equals the authenticated canonical actor. + +### 4.2 Derive deterministic principal identifiers before enrollment + +Rejected for this repair. It would alter the global identity model and require a much broader migration of existing principals, credentials, events, and references. + +### 4.3 Online permanent dual trust + +Rejected. It avoids a short maintenance window but leaves the placeholder signer able to authorize new approvals and complicates exact recovery. + +### 4.4 Managed offline cutover with journaled resume + +Selected. Setup derives the canonical target from the enrolled identity, stops the two managed services, stages complete replacement state, commits a bounded cutover, and starts only after exact postconditions hold. The old public key may remain only in immutable historical verification evidence, never current approval authority. + +## 5. Canonical owner adoption + +### 5.1 Trigger + +The adoption check runs during managed setup or supported upgrade after Core has an enrolled harness and credential. It is not exposed as a generic database-editing command. + +The target principal is read from the package-owned server identity profile and must match the current Core enrollment binding. The OIDC issuer and exact subject must match both the enrolled identity evidence and the configured Approval owner binding. Message text, CLI principal arguments, email equality, or an unverified payload cannot select the target. + +### 5.2 Supported source states + +The reconciler accepts only: + +- an unmodified v0.1.50 placeholder state; +- the exact known partial repair state; +- the exact known operational live repair state; +- the already-converged target state. + +Each state is identified by typed configuration, exact signer thumbprints, database schema versions, bounded row shapes, current/revoked state, and digests. Package source hashes are handled by the existing immutable package-upgrade mechanism, not trusted as authority evidence. + +For the exact v0.1.50→v0.1.51 edge, a retained marker may precede both the +known one-hour Approval TTL hotfix and a completed canonical-owner repair. +Before creating the setup upgrade journal, setup may reverse only the +documented recovery transformations in memory: the TTL fields are restored to +the published v0.1.50 shape, while the Approval and Core owner/signer policy +fields are restored from the exact recovery evidence. The reconstructed +Approval and Core digests must equal the retained marker. The historical writer +serialized the fixed mandatory approval-purpose `frozenset` in +process-dependent order. Digest reconstruction may therefore try only +permutations of that exact set; membership, cardinality, every other field, and +both retained digests remain exact. The current Core OIDC sidecar must equal +Core, and the realized target signer, domain, target principal, and fixed setup +request must agree with the recovery evidence. This reconstruction writes only +a strict terminal evidence journal when the original journal is absent; it +does not change authority. + +An incomplete or malformed recovery journal, a wrong domain or target, +unverifiable signer state, extra configuration drift, Core/Core-OIDC +disagreement, or either required digest mismatch blocks before upgrade-journal +creation. Once the exact source is recognized, the existing setup upgrade +journal owns compare-and-swap mutation, resume, and rollback; the TTL +normalization runs before current-model Approval loading, and canonical-owner +convergence must return an exact recovered or already-converged result. + +Anything else returns a redacted `canonical_owner_recovery` or +`setup_upgrade_conflict` blocker before protected mutation. + +### 5.3 Approval database transition + +Inside one SQLite `BEGIN IMMEDIATE` transaction, the reconciler: + +1. verifies one active owner binding for the configured domain and OIDC identity; +2. verifies the source identifier is either the exact placeholder or exact canonical target; +3. rejects revoked, duplicate, cross-domain, or ambiguous bindings; +4. verifies every active passkey row belongs to that owner and no target collision exists; +5. expires or rejects any nonterminal owner session, registration ceremony, or approval request that cannot remain valid across the identity change; +6. rewrites the owner binding and active passkey ownership to the canonical principal; +7. updates the deterministic passkey user-handle value while preserving the credential ID, public key, sign count, device type, backup state, and creation time; +8. leaves terminal requests, receipts, and historical audit rows immutable under their original recorded principal, then binds that historical identifier to the canonical adoption only through new evidence; +9. appends a privacy-minimized adoption audit record bound to source-state digest, target-state digest, OIDC-binding digest, signer transition digest, and setup-journal identifier. + +The transaction commits only after exact postconditions and SQLite foreign-key checks pass. A repeated run returns the same converged result without another migration. + +### 5.4 Signer cutover + +Setup generates a replacement P-256 key under Approval's existing owner-only signer custody. The new key is bound only to the canonical principal. + +Core's current trusted approver set and Approval's active approver configuration are replaced while both services are stopped. The target state contains only the canonical principal and replacement signer for new approvals. The placeholder signer is absent from current Core trust and absent from Approval's active approver configuration. + +Historical receipt verification stores the old public key and its authority interval in append-only historical verification evidence. That evidence has no route into current approval selection. Unknown keys, use outside the recorded interval, or use for a new request fail closed. + +## 6. Recovery journal and cutover + +### 6.1 Journal + +A root-owned, mode `0600` journal records only bounded metadata and encrypted or owner-only rollback bytes: + +- schema and package version; +- source-state classification; +- request, configuration, database, signer, and setup-marker digests; +- canonical identity and OIDC-binding digests; +- previous and replacement managed-file bytes; +- complete pre-mutation Approval SQLite backup plus WAL/SHM handling evidence; +- phase and compare-and-swap revision; +- service states before cutover. + +No private key material, passkey material, token, raw identity, or protected content appears in command output or ordinary logs. + +### 6.2 Phases + +The phases are: + +1. `prepared`: exact state classified, services not yet changed, complete backup durable; +2. `quiesced`: Approval and Core stopped and verified inactive; +3. `approval_committed`: SQLite owner migration and replacement signer/config committed; +4. `core_committed`: Core current trust/config committed; +5. `marker_committed`: setup marker binds the target configuration and disarms rollback; +6. `services_verified`: services restarted, health/readiness and authority postconditions verified; +7. `completed`: rollback bytes removed, historical public verification evidence retained. + +Before `marker_committed`, failure restores the complete pre-change state and original service state. At or after `marker_committed`, retry resumes forward from exact journal evidence. It never guesses whether a phase completed. + +The cutover has a bounded maintenance window. This is preferable to temporarily authorizing both signers. + +## 7. Atomic schema-v7 projection + +The existing v6-to-v7 mapping becomes a reusable single-scope materializer plus the existing batch migration wrapper. + +Communication-scope completion calls the single-scope materializer inside the same database transaction that writes: + +- terminal communication-scope commitment; +- exact allowed items; +- five communication entitlements and five revoke powers; +- canonical collaboration scope; +- exact owner and peer active member rows; +- audit and idempotent completion result. + +The materializer resolves all inputs from server-held scope rows. It does not accept caller-supplied member identity or authority fields. Existing exact projection rows are accepted only when every digest and value matches. Partial, extra, or conflicting projection rows fail the transaction. + +Any mapping, insertion, constraint, audit, or result-persistence failure rolls back the entire activation. The service cannot return `communication_active` without the projection. PostgreSQL and SQLite use the same mapping and differ only in parameter syntax. + +For a legacy scope already committed without projection, the setup recovery path invokes the same single-scope materializer in one transaction. Repeated repair is a verified no-op. + +## 8. Compatibility and rollback + +The supported upgrade preserves schema-v7 data and does not create a new public schema version solely for derived runtime rows. Existing v0.1.50 stores remain readable after exact recovery. N/N-1 compatibility remains governed by the existing release catalog. + +Rollback before the marker boundary restores the complete Approval database, signer/config files, Core config, setup marker, and original managed-service state. Rollback after new approvals could be externally observable is forbidden; forward recovery is required. + +The active communication scope is never replaced. Missing projection is repaired from its committed source rows. No endpoint is re-enrolled and no generic entitlement is minted. + +## 9. Fail-closed behavior + +Recovery stops before mutation on: + +- wrong or missing enrolled identity evidence; +- OIDC issuer or subject mismatch; +- email-only identity match; +- wrong domain; +- revoked canonical credential or owner passkey; +- unexpected active sessions or requests; +- duplicate or ambiguous owner/passkey rows; +- signer, configuration, revision, marker, or digest drift; +- incomplete backup or journal durability; +- unsupported live-repair shape; +- projection source or target mismatch. + +After mutation starts, uncertain file, database, or service outcomes retain the journal and require exact resume or pre-boundary restoration. No branch reports operational until managed health, readiness, canonical approval authority, active scope, projection membership, and current credentials are verified. + +## 10. Verification + +Required evidence includes: + +- focused TTL tests proving only communication-scope requests use one hour; +- clean and post-enrollment canonical adoption tests; +- wrong identity/domain/OIDC, revoked passkey, duplicate, stale revision, race, response-loss, and interruption tests; +- schema-v7 activation and repair tests on SQLite and dedicated PostgreSQL; +- projection fault injection proving no partial `communication_active` result; +- upgrade fixtures for the four supported source states and unsupported drift; +- signer historical-verification tests proving the old key cannot authorize current requests; +- installed-package setup/upgrade restart evidence; +- a new server-to-laptop and laptop-to-server message reaching `recipient_committed` after authorized live deployment. + +Hermetic tests do not prove production readiness. PostgreSQL, installed-package, live-server, owner, and external evidence retain their actual tiers. No gate status changes without reproducible evidence. + +## 11. Documentation + +The implementation must update: + +- `docs/OWNER_DECISIONS.md` with the bounded 2026-08-09 owner instruction and its non-O-tier limit; +- `docs/ARCHITECTURE.md` and `docs/SCHEMAS_INTERFACES.md` for adoption, signer history, journal, and atomic projection boundaries; +- `README.md` and `docs/implementation-guide.md` for supported operator recovery behavior; +- `REQUIREMENTS_STATUS.md` and `docs/GATE_EVIDENCE.md` only to describe verified implementation evidence without promoting blocked gates. + +## 12. Non-goals + +This work does not implement the selector-free background inbox receiver, dashboard changes, general identity migration, appeals, account merging, federation, A2A changes, artifact changes, business effects, or production certification. Live deployment requires separate explicit authorization after package verification. diff --git a/docs/superpowers/specs/2026-08-09-expired-scope-harness-replacement-design.md b/docs/superpowers/specs/2026-08-09-expired-scope-harness-replacement-design.md new file mode 100644 index 0000000..491cf27 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-expired-scope-harness-replacement-design.md @@ -0,0 +1,118 @@ +# Expired Collaboration-Scope Harness Replacement Design + +## Purpose + +Add one package-owned, independently approved operation that replaces an expired same-principal harness in an active collaboration scope without hand-editing Core state. The operation preserves the member role, records the former membership as a tombstone, activates the exact replacement harness, and immediately makes current schema-v7 membership authoritative for communication authorization. + +## Scope + +The first supported case is deliberately narrow: + +- the caller is the exact active owner harness of an active direct collaboration scope; +- the former member and replacement harness belong to the scope owner's exact principal and domain; +- the former member has role `member` and an expired current credential; +- the replacement harness has an active current credential and is not already a scope member; +- the replacement preserves role `member`; +- an independent Approval receipt from the exact scope-owning principal authorizes the transaction. + +Owner replacement, cross-principal transfer, cross-domain transfer, guest replacement, role changes, active-credential migration, and generic membership administration are rejected. + +## Affected requirements and boundaries + +Affected stable requirements: `ID-005`, `ID-006`, `ID-009`, `AUTH-001`, `AUTH-002`, `AUTH-003`, `AUTH-004`, `AUTH-005`, `COM-001`, `COM-002`, `COM-009`, `AVL-003`, `SEC-003`, `SEC-005`, `SEC-007`, `OPS-003`, and `OPS-006`. + +Trust boundaries: + +1. The managed server CLI reads the installed Core configuration and exact server identity under package-owned custody. +2. Core state is authoritative for scope ownership, membership, harness identity, credential state, policy revision, and revocation epoch. +3. Approval remains a separate signer and store. Core accepts only a verified, current, single-use receipt bound to the exact canonical replacement transaction. +4. Legacy schema-v6 communication records remain immutable source evidence. Current schema-v7 collaboration membership becomes the authorization membership source. + +No new third-party component is needed. The implementation reuses the existing Approval client, verifier, single-use receipt ledger, audit chain, PostgreSQL/SQLite transaction abstractions, and managed private-state custody. + +## Canonical transaction + +The replacement transaction is a strict canonical object that binds: + +- request ID and approval purpose; +- scope ID, domain ID, owner principal ID, and owner harness ID; +- former harness ID, replacement harness ID, and preserved role; +- exact former and replacement credential IDs and epochs; +- former credential expiry and replacement credential validity window; +- current scope digest, revision, membership sequence, policy revision, and domain revocation epoch; +- proposed next membership sequence and revision; +- request creation and expiry times. + +The managed command writes this request and a receipt-possession secret to an owner-only resumable state file before creating the Approval request. Approval request idempotency is keyed by the replacement request ID. + +## Validation + +Before requesting approval and again immediately before commit, the operation requires all of the following: + +- the scope exists, is active, is not expired, and the caller is a current non-lab harness of the exact scope-owning principal; +- the caller is an active verified human harness with a current active credential; +- scope policy revision and domain revocation epoch are current; +- exactly one active former member row exists with role `member`; +- the former harness shares the owner principal and domain; +- the former harness current credential matches the transaction and is expired; +- the replacement harness shares the owner principal and domain; +- the replacement harness current credential matches the transaction and is active; +- the replacement is not already present in the scope; +- all stored digests and sequence values match the canonical pre-state; +- the Approval receipt is current, has the exact `identity.credential.recover.approve` purpose/domain/transaction digest, and names the exact owner principal. + +Any mismatch fails closed without mutation. + +## Atomic mutation + +One Core database transaction performs the complete cutover: + +1. Reverify the canonical transaction against current state. +2. Verify and consume the single-use independent Approval receipt. +3. Change the former member to `removed`, set its removal sequence/time, and recompute its member digest. +4. Insert the replacement as an active `member` at the same next membership sequence and compute its member digest. +5. Increment the scope membership sequence and revision exactly once. +6. Recompute the scope digest over the complete active and removed membership set. +7. Append a tamper-evident audit record containing the exact transaction and receipt digests. +8. Bind the resulting audit hash and new scope digest to the updated scope row. + +A compare-and-swap update on the previous scope digest, revision, and membership sequence prevents concurrent or stale replacement. + +## Authorization cutover + +Schema-v6 communication scope rows and item records are retained unchanged as approval provenance. When an entitlement originates from a communication scope that has a schema-v7 collaboration projection, authorization obtains its permitted peer harness set from the projection's current active membership rows. It rejects removed, expired, revoked, cross-principal, digest-invalid, or incomplete projections. + +This prevents the former harness from retaining access and grants the replacement harness only the same principal-scoped communication actions already approved for the scope. It does not create new actions, data access, roles, or generic authority. + +## Idempotency and recovery + +The Approval store makes request creation idempotent for the exact canonical transaction. The Core audit record and exact post-state make commit retry idempotent: + +- retry before approval returns the same pending ceremony; +- retry after approval but before commit performs the single transaction; +- crash before commit leaves membership unchanged; +- crash after commit returns the recorded result without incrementing sequences again; +- reuse of a request ID with different bytes fails; +- a different replacement request against the changed pre-state fails stale. + +Rejected or expired ceremonies remain terminal. A separate explicit terminal-state replacement flag creates a fresh request only after Approval proves the previous ceremony is rejected or expired and the managed inputs have not drifted. + +## Operator interface + +The managed operation is: + +```text +sudo agentnet server-agent replace-expired-scope-harness \ + --scope-id \ + --old-harness-id \ + --new-harness-id \ + --role member +``` + +The command defaults to package-owned managed config, identity, and private-state paths. The first run prints a content-minimized owner approval action and exits nonzero while pending. Rerunning the exact command after approval commits the replacement. The command does not restart services or mutate identity/configuration files. + +## Verification + +Required hermetic evidence covers success, exact idempotent replay, request conflict, wrong caller, wrong owner approval, cross-domain/principal mismatch, incorrect role, unexpired former credential, inactive replacement credential, existing replacement membership, stale scope revision/digest, concurrent compare-and-swap failure, rollback after injected mutation failure, removed-member denial, replacement-member authorization, SQLite behavior, and dedicated PostgreSQL behavior. + +No production, owner-policy, or external gate is promoted by these tests. \ No newline at end of file diff --git a/evidence/local/2026-08-09-v0.1.51/artifacts/.gitignore b/evidence/local/2026-08-09-v0.1.51/artifacts/.gitignore new file mode 100644 index 0000000..a10f730 --- /dev/null +++ b/evidence/local/2026-08-09-v0.1.51/artifacts/.gitignore @@ -0,0 +1,5 @@ +* +!/.gitignore +!/RETENTION.md +!/*.whl +!/*.tar.gz diff --git a/evidence/local/2026-08-09-v0.1.51/artifacts/RETENTION.md b/evidence/local/2026-08-09-v0.1.51/artifacts/RETENTION.md new file mode 100644 index 0000000..c416a93 --- /dev/null +++ b/evidence/local/2026-08-09-v0.1.51/artifacts/RETENTION.md @@ -0,0 +1,3 @@ +# Retained release archives + +This directory intentionally retains only `.gitignore`, `*.whl`, and `*.tar.gz` release-evidence files. diff --git a/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl b/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl new file mode 100644 index 0000000..6dccbd2 Binary files /dev/null and b/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl differ diff --git a/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz b/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz new file mode 100644 index 0000000..0ac0f93 Binary files /dev/null and b/evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz differ diff --git a/evidence/local/2026-08-09-v0.1.51/manifest.json b/evidence/local/2026-08-09-v0.1.51/manifest.json new file mode 100644 index 0000000..a397815 --- /dev/null +++ b/evidence/local/2026-08-09-v0.1.51/manifest.json @@ -0,0 +1,95 @@ +{ + "artifacts": [ + { + "path": "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz", + "sha256": "dea7758577c394f43da85daef84a945dcd754ca22a60cdb51db7228ace936fcb" + }, + { + "path": "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl", + "sha256": "597c7f2b7521bd88af0d7cb9d0e3e18e90794f33a1bfff4468fc3c059cae3bad" + } + ], + "change_boundary": "Candidate 0.1.51 recognizes two exact marker-relative canonical-owner recovery states without broadening authority: (1) a pre-adoption split state with source-owned Approval, exactly two source/target signer keys, dual-trust embedded Core OIDC, target-only standalone Core OIDC, and no retained recovery journal; and (2) the already-completed canonical-owner repair with its recovery journal absent or terminal. The partial path reconstructs a strict prepared journal bound to marker and realized Approval/Core/Core-OIDC digests plus active source-owner evidence before authority mutation. The terminal path requires one active canonical owner binding, target credential state, one matching immutable adoption audit, and exact signer custody. Both compose only with the retained v0.1.50 one-hour Approval TTL hotfix when inverse reconstruction reproduces the marker, then normalize to the 600/3600 target under the ordinary upgrade journal. Unknown identity, signer, trust topology, row shape, digest, or additional file drift fails closed. This focused correction treats the two already-bound embedded source/target trust records as an unordered authority set while preserving their observed serialized order through journaled migration; every trust field, sidecar field, marker digest, signer, and non-trust OIDC field remains exact.", + "commands": [ + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/approval/test_approval_cli.py tests/approval/test_webauthn_service.py tests/operations/test_canonical_owner_recovery.py tests/operations/test_server_setup_recovery.py tests/authorization/test_communication_scope_service.py tests/production/test_release_v7_schema.py tests/integration/test_collaboration_scope_messaging.py tests/production/test_postgres_runtime.py::test_keeper_failure_blocks_protected_work_until_recovery_state_is_published tests/production/test_postgres_runtime.py::test_expired_keeper_lease_recovers_on_next_operation_with_higher_fence tests/production/test_postgres_runtime.py::test_expired_keeper_recovery_starts_new_background_keeper", + "result": "PASS: 302 passed" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/authorization/test_scope_harness_replacement.py tests/authorization/test_communication_scope_service.py tests/integration/test_collaboration_scope_messaging.py tests/cli/test_server_agent_activation.py", + "result": "PASS: 62 passed" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run --extra test pytest -q --ignore=tests/adapters/test_installed_live_inference.py --ignore=tests/adapters/test_subprocess_lifecycle.py --ignore=tests/components/test_bakeoff_evidence.py --ignore=tests/conformance/test_release_manifest.py", + "result": "PASS: 2290 passed and 22 expected platform/dedicated-PostgreSQL skips" + }, + { + "command": "SOURCE_DATE_EPOCH=1580601600 PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache UV_LINK_MODE=copy uv build --offline --no-build-isolation twice", + "result": "PASS: two independent builds are byte-identical; sdist sha256 dea7758577c394f43da85daef84a945dcd754ca22a60cdb51db7228ace936fcb; wheel sha256 597c7f2b7521bd88af0d7cb9d0e3e18e90794f33a1bfff4468fc3c059cae3bad" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run python -B -I scripts/ci/packaged_local_communication_e2e.py run --package-root --launcher --workspace ", + "result": "PASS: accepted_fact=accepted_local; approved_revocation_proven=false; bounded_c0_pilot_proven=false; core_process_starts=4; core_restarts=3; credential_refusal_fixture=true; exact_attribution=true; idempotency=true; non_production=true; obligation_state=completed; production_durability_proven=false; recipient_fact=recipient_committed; release_certified=false; separate_process_loopback=true" + }, + { + "command": "fresh npm pack; install tarball into unrelated temporary prefix; run installed scripts/ci/exact_endpoint_routing_e2e.py with the installed interpreter and empty workspace", + "result": "PASS: target_harness_id=processing_harness_id; sibling_reactions=0; offline_queue_owner=target_harness_id; offline_processing_harness_ids=[]; endpoint_processes_remaining=0; capability_roots_remaining=0; workspace_fallback_used=false" + }, + { + "command": "Fresh GitHub ubuntu-24.04 installed-package lane: npm pack, install into unrelated prefix, run scripts/ci/packaged_local_communication_e2e.py from installed bytes", + "result": "NOT RERUN FOR THIS CORRECTIVE DELTA: prior exact-commit result binds predecessor 4e8ebd72d6b5e39550e438816abb1e2d30a5326a only" + }, + { + "command": "Ad-hoc live trial on two isolated Ubuntu 24.04.4 VMs with separate qcow2 disks and exact v0.1.51 wheel: send authenticated messages bidirectionally; stop and restart VM A Core; inspect recovered delivery, duplicate safety, exact attribution, and recipient acknowledgement", + "result": "NOT RERUN FOR CORRECTIVE DELTA: retained JSON observes the preceding v0.1.51 candidate only and is not attributed to this source tree" + }, + { + "command": "Fresh Ubuntu 24.04 installed-host upgrade from published v0.1.50 to candidate v0.1.51", + "result": "NOT RERUN FOR THIS CORRECTIVE DELTA: prior exact-commit result binds predecessor 4e8ebd72d6b5e39550e438816abb1e2d30a5326a only" + }, + { + "command": "AGENTNET_TEST_POSTGRES_URL='' AGENTNET_TEST_POSTGRES_ALLOW_MUTATION=1 PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/production/test_postgres_runtime.py", + "result": "NOT RERUN FOR THIS CORRECTIVE DELTA: prior 93-pass PostgreSQL result binds predecessor 4e8ebd72d6b5e39550e438816abb1e2d30a5326a only; current same-commit server validation is required" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/verify_release.py", + "result": "PASS: release manifest verification" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/conformance/test_release_manifest.py", + "result": "PASS: 27 passed" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run agentnet verify", + "result": "PASS: 2317 passed and 22 expected platform/dedicated-PostgreSQL skips" + }, + { + "command": "npm run check", + "result": "PASS: source plus generations 1 and 2 each reported 2317 passed and 22 expected platform/dedicated-PostgreSQL skips; exact-endpoint routing gate and packaged v0.1.45 user journey passed; excludes installed-live-inference, subprocess-lifecycle, and bake-off-evidence; two installed-harness pin failures remain non-green and were not rerun or waived" + } + ], + "evaluated_at": "2026-08-13T11:06:41Z", + "execution_context": { + "callback_incident": "Exact-commit GitHub workflow and server PostgreSQL results are included; no callback result is claimed", + "local_retry_incident": "The final exact focused lane reported 302 passed and the broad lane reported 2290 passed with 22 expected skips. Release verifier, manifest contract, source agentnet verify, and recursive npm check are recorded only after their final green runs.", + "packed_generations": "Local npm check validates the source package and two clean recursively packed and installed generations", + "root_installed_external_host": "Prior exact commit 4e8ebd72d6b5e39550e438816abb1e2d30a5326a passed GitHub Ubuntu 24.04 PostgreSQL 18 clean setup and v0.1.50 to v0.1.51 installed-host upgrade; those results do not bind this corrective source delta, whose exact-commit CI and server PostgreSQL validation remain required", + "source": "AgentNet v0.1.51 proof-bound partial and terminal canonical-owner recovery candidate on Linux x86_64 with locked CPython 3.13.13 dependencies; exact trust records are order-independent while observed bytes remain journal-bound.", + "two_vm_source_proof": "Observed exact packaged v0.1.51 behavior on two live isolated Ubuntu 24.04.4 VMs, but excluded from formal release evidence because the ad-hoc provisioning driver was not retained; production durability and must-not-ship gates remain unproven" + }, + "external_evidence": { + "applies_to_current_source_tree": false, + "manifest": "evidence/local/2026-08-11-v0.1.51-server-postgres/manifest.json", + "pr_comment": "https://github.com/misunders2d/agentnet/pull/3#issuecomment-5250983948", + "source_revision": "4e8ebd72d6b5e39550e438816abb1e2d30a5326a" + }, + "release_certified": false, + "release_source_tree_sha256": "a3ae6ae3d35dcc6f2252f6aeb1e663ac05cbf5f8f234c4898a4f802d54796b2b", + "run_id": "2026-08-09-v0.1.51-local-package-candidate", + "scope": "v0_1_51_partial_and_terminal_owner_recovery_candidate", + "source_revision": "Working-tree corrective candidate bound by release_source_tree_sha256; exact-commit CI, disposable PostgreSQL evidence, and live convergence must be refreshed after commit", + "source_revision_kind": "working_tree_candidate", + "supersedes_package_evidence": "evidence/local/2026-08-07-v0.1.50/manifest.json", + "verification_scope": "PASS applies to the selected local source, focused owner-recovery/communication tests, broad hermetic suite, reproducible package, and recursive installed-byte commands recorded below; it is not release certification and does not include same-commit GitHub installed-host lanes, same-commit disposable PostgreSQL runtime evidence, live convergence proof, installed-harness pin gates, multi-node durability, or any owner/privileged/external must-not-ship gate", + "verification_status": "PASS" +} diff --git a/evidence/local/2026-08-09-v0.1.51/two-vm-communication.json b/evidence/local/2026-08-09-v0.1.51/two-vm-communication.json new file mode 100644 index 0000000..c1ce257 --- /dev/null +++ b/evidence/local/2026-08-09-v0.1.51/two-vm-communication.json @@ -0,0 +1,65 @@ +{ + "schema": "agentnet.two-vm-communication-observation.v1", + "evaluated_at": "2026-08-09T12:08:14.219Z", + "trial_result": "PASS", + "release_evidence_eligible": false, + "release_certified": false, + "exclusion_reason": "Live commands and outputs were observed, but the ad-hoc provisioning driver was not retained as a repository script; this record cannot promote a release gate.", + "package": { + "version": "0.1.51", + "wheel_sha256": "8030d8cbd62ef05f6b3134994ccbd884f343356d16203d02484bbefde59af10e" + }, + "hosts": [ + { + "vm": "A", + "observed": [ + "host=agentnet-vm-a", + "kernel=6.8.0-136-generic", + "os=Ubuntu 24.04.4 LTS", + "agentnet=agentnet 0.1.51" + ] + }, + { + "vm": "B", + "observed": [ + "host=agentnet-vm-b", + "kernel=6.8.0-136-generic", + "os=Ubuntu 24.04.4 LTS", + "agentnet=agentnet 0.1.51" + ] + } + ], + "launch_commands": { + "vm_a": "qemu-system-x86_64 -enable-kvm -machine q35 -cpu host -m 1536 -smp 2 -drive file=/tmp/agentnet-vm-e2e/vm-a.qcow2,format=qcow2,if=virtio -drive file=/tmp/agentnet-vm-e2e/seed-a.iso,format=raw,media=cdrom,readonly=on -netdev user,id=uplink,hostfwd=tcp:127.0.0.1:22221-:22 -device virtio-net-pci,netdev=uplink,mac=52:54:00:12:34:01 -netdev socket,id=agentnet,listen=127.0.0.1:24500 -device virtio-net-pci,netdev=agentnet,mac=52:54:00:12:50:01 -display none -serial file:/tmp/agentnet-vm-e2e/vm-a.serial.log -monitor none", + "vm_b": "qemu-system-x86_64 -enable-kvm -machine q35 -cpu host -m 1536 -smp 2 -drive file=/tmp/agentnet-vm-e2e/vm-b.qcow2,format=qcow2,if=virtio -drive file=/tmp/agentnet-vm-e2e/seed-b.iso,format=raw,media=cdrom,readonly=on -netdev user,id=uplink,hostfwd=tcp:127.0.0.1:22222-:22 -device virtio-net-pci,netdev=uplink,mac=52:54:00:12:34:02 -netdev socket,id=agentnet,connect=127.0.0.1:24500 -device virtio-net-pci,netdev=agentnet,mac=52:54:00:12:50:02 -display none -serial file:/tmp/agentnet-vm-e2e/vm-b.serial.log -monitor none" + }, + "scope": { + "domain_id": "two-vm.example", + "collaboration_scope_id": "scope:two-vm-v0151", + "conversation_id": "conversation:two-vm-v0151" + }, + "observations": { + "a_to_b": { + "status": 202, + "event_id": "f66de4d4-e993-505f-8fe3-d9969e85f83e", + "accepted_fact": "accepted_local", + "duplicate": true, + "offline_request_failed": true, + "recovered_after_restart": true, + "sender_harness_id": "synthetic-harness-6676033caa7c48e09cd10df1f8b8fc58", + "recipient_fact": "recipient_committed", + "acknowledgement_receipt_id": "0c38e37d-a80a-49b6-aebc-c49f1658da05", + "acknowledgement_duplicate": true + }, + "b_to_a": { + "status": 202, + "event_id": "8f5f7fa8-dfb0-587a-9c91-784c716e66fe", + "accepted_fact": "accepted_local", + "duplicate": true, + "sender_harness_id": "synthetic-harness-ee8130787fe14f06921fff47ce01adfb", + "recipient_fact": "recipient_committed", + "acknowledgement_receipt_id": "24ed7807-ad08-4474-9464-f6b9226c3969" + } + }, + "claim_boundary": "Observed exact packaged v0.1.51 bidirectional communication, duplicate safety, A-to-B recovery after Core offline interval, exact sender attribution, and recipient acknowledgement on two live local VMs. Does not prove production durability, multi-node HA, public-network operation, owner policy, privileged-host isolation, or any must-not-ship gate." +} diff --git a/evidence/local/2026-08-11-v0.1.51-server-postgres/manifest.json b/evidence/local/2026-08-11-v0.1.51-server-postgres/manifest.json new file mode 100644 index 0000000..db3b4e8 --- /dev/null +++ b/evidence/local/2026-08-11-v0.1.51-server-postgres/manifest.json @@ -0,0 +1,74 @@ +{ + "run_id": "2026-08-11-v0.1.51-server-postgres", + "evaluated_at": "2026-08-11T08:47:54Z", + "scope": "exact_candidate_disposable_postgresql_runtime_and_release_verifier", + "verification_status": "PASS_NARROW_POSTGRESQL_ONLY", + "release_certified": false, + "production_ready": false, + "ship_eligible": false, + "gate_status_changed": false, + "source_revision": "4e8ebd72d6b5e39550e438816abb1e2d30a5326a", + "release_source_tree_sha256": "78724e76457a479ff62b4473e5ee6a87f9b019034b39310d089eaff174b64ad5", + "package": { + "version": "0.1.51", + "published": false + }, + "affected_requirement_ids": [ + "ID-001", + "ID-002", + "ID-005", + "ID-006", + "ID-009", + "AUTH-001", + "AUTH-002", + "AUTH-003", + "AUTH-004", + "AUTH-005", + "COM-001", + "COM-002", + "COM-009", + "AVL-003", + "AVL-005", + "AVL-006", + "SEC-003", + "SEC-005", + "SEC-007", + "OPS-003", + "OPS-006" + ], + "execution_context": { + "runner": "isolated detached checkout on the ordinary server host", + "postgresql_image": "postgres:18.4", + "postgresql_version": "PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1), x86_64, gcc 14.2.0, 64-bit", + "database": "fresh disposable PostgreSQL container using --rm, no host or named-volume mount", + "dependency_sync": "56 locked packages installed into the isolated virtual environment", + "live_boundary": "live Core unchanged at PID 18355 with NRestarts=0; healthz=200 and unchanged fail-closed readyz=503; no live install, apply, start, restart, or managed-state mutation", + "privacy": "credentials, DSN secrets, private keys, protected message content, and unnecessary identity identifiers are omitted" + }, + "commands": [ + { + "command": "UV_CACHE_DIR=/tmp/uv-cache uv sync --extra test", + "result": "PASS: 56 packages installed into the isolated virtual environment" + }, + { + "command": "AGENTNET_TEST_POSTGRES_URL='' AGENTNET_TEST_POSTGRES_ALLOW_MUTATION=1 PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/production/test_postgres_runtime.py", + "result": "PASS: 93 passed, 0 skipped, 0 failed in 36.14s" + }, + { + "command": "PYTHONDONTWRITEBYTECODE=1 UV_CACHE_DIR=/tmp/uv-cache uv run python scripts/verify_release.py", + "result": "PASS: release manifest verification" + } + ], + "cleanup": { + "postgresql_process_exit": 0, + "postgresql_restart_count": 0, + "container_removed": true, + "persistent_volume_present": false, + "validation_checkout_removed": true + }, + "evidence_source": { + "url": "https://github.com/misunders2d/agentnet/pull/3#issuecomment-5250983948", + "record": "sanitized exact commands, environment, results, cleanup proof, and live-boundary recheck posted by the server operator" + }, + "limitations": "This proves the exact candidate PostgreSQL runtime test file and release verifier on one disposable PostgreSQL 18.4 service. It does not prove live convergence, two-machine delivery, multi-node HA, failover, PITR/restore, production durability, or any owner/privileged/external must-not-ship gate." +} diff --git a/package-lock.json b/package-lock.json index be080f8..ad90541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@misunders2d/agentnet", - "version": "0.1.50", + "version": "0.1.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@misunders2d/agentnet", - "version": "0.1.50", + "version": "0.1.51", "license": "Apache-2.0", "os": [ "linux", diff --git a/package.json b/package.json index 5e46afc..c4c8fbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@misunders2d/agentnet", - "version": "0.1.50", + "version": "0.1.51", "description": "Self-hosted, agent-agnostic secure communication network for Claude, Codex, Pi, Antigravity, and A2A agents", "license": "Apache-2.0", "type": "module", @@ -121,6 +121,9 @@ "evidence/local/2026-08-07-v0.1.50/artifacts/RETENTION.md", "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50-py3-none-any.whl", "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50.tar.gz", + "evidence/local/2026-08-09-v0.1.51/artifacts/RETENTION.md", + "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl", + "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz", "evidence/**/*.json", "npm/**/*.mjs", "npm/**/*.ps1", diff --git a/pyproject.toml b/pyproject.toml index d155736..108de97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentnet" -version = "0.1.50" +version = "0.1.51" description = "Self-hosted, agent-agnostic secure communication conformance kernel" readme = "README.md" requires-python = ">=3.13,<3.15" diff --git a/scripts/ci/ordinary-server-upgrade-e2e.sh b/scripts/ci/ordinary-server-upgrade-e2e.sh index 3bb1d94..6231c1e 100755 --- a/scripts/ci/ordinary-server-upgrade-e2e.sh +++ b/scripts/ci/ordinary-server-upgrade-e2e.sh @@ -2,8 +2,8 @@ set -Eeuo pipefail # Destructive only inside a fresh GitHub-hosted Ubuntu 24.04 runner. The lane -# installs two independent npm tarballs, realizes the released 0.1.45 service, -# and proves the supported state-preserving 0.1.45 -> 0.1.46 timer repair. +# installs two independent npm tarballs, realizes the released 0.1.50 service, +# and proves the supported state-preserving 0.1.50 -> 0.1.51 upgrade. if [[ "${CI:-}" != "true" || "${GITHUB_ACTIONS:-}" != "true" || -z "${RUNNER_TEMP:-}" ]]; then echo "ordinary server upgrade E2E requires an ephemeral GitHub Actions runner" >&2 exit 2 @@ -29,8 +29,8 @@ done WORK="$RUNNER_TEMP/agentnet-ordinary-server-upgrade-e2e" INPUTS="$WORK/inputs" PACK="$WORK/pack" -PREFIX_0144="/opt/agentnet-upgrade-e2e-0.1.45" -PREFIX_0145="/opt/agentnet-upgrade-e2e-0.1.46" +PREFIX_0144="/opt/agentnet-upgrade-e2e-0.1.50" +PREFIX_0145="/opt/agentnet-upgrade-e2e-0.1.51" NO_PROXY_VALUE="127.0.0.1,localhost,.agentnet.test,core.agentnet.test,approval.agentnet.test" OPT_UID="$(stat -c '%u' /opt)" OPT_GID="$(stat -c '%g' /opt)" @@ -288,17 +288,17 @@ assert_schema_seven_source() { # Install exact released and candidate packed bytes into independent immutable -# roots. The candidate must be the packed 0.1.46 tree, never an implicit cwd. -[[ "$(node -p "require('./package.json').version")" == "0.1.46" ]] +# roots. The candidate must be the packed 0.1.51 tree, never an implicit cwd. +[[ "$(node -p "require('./package.json').version")" == "0.1.51" ]] sudo chown root:root /opt sudo chmod 0755 /opt -RELEASED_TARBALL="$(npm pack @misunders2d/agentnet@0.1.45 --ignore-scripts --pack-destination "$PACK" --silent)" +RELEASED_TARBALL="$(npm pack @misunders2d/agentnet@0.1.50 --ignore-scripts --pack-destination "$PACK" --silent)" CANDIDATE_TARBALL="$(npm pack --ignore-scripts --pack-destination "$PACK" --silent)" -[[ "$RELEASED_TARBALL" == "misunders2d-agentnet-0.1.45.tgz" ]] -[[ "$CANDIDATE_TARBALL" == "misunders2d-agentnet-0.1.46.tgz" ]] +[[ "$RELEASED_TARBALL" == "misunders2d-agentnet-0.1.50.tgz" ]] +[[ "$CANDIDATE_TARBALL" == "misunders2d-agentnet-0.1.51.tgz" ]] [[ "$(sha256sum "$PACK/$RELEASED_TARBALL" | cut -d' ' -f1)" != "$(sha256sum "$PACK/$CANDIDATE_TARBALL" | cut -d' ' -f1)" ]] -install_runtime "$PREFIX_0144" "$PACK/$RELEASED_TARBALL" "0.1.45" -install_runtime "$PREFIX_0145" "$PACK/$CANDIDATE_TARBALL" "0.1.46" +install_runtime "$PREFIX_0144" "$PACK/$RELEASED_TARBALL" "0.1.50" +install_runtime "$PREFIX_0145" "$PACK/$CANDIDATE_TARBALL" "0.1.51" # Operator-owned disposable local TLS routes. echo '127.0.0.1 core.agentnet.test approval.agentnet.test # agentnet-upgrade-e2e' | sudo tee -a /etc/hosts >/dev/null @@ -410,17 +410,17 @@ sudo -u postgres psql -Atq --dbname=postgres -c \ "SELECT count(*) FROM pg_hba_file_rules WHERE type='local' AND database=ARRAY['agentnet'] AND user_name=ARRAY['agentnet'] AND auth_method='peer' AND error IS NULL" \ | grep -qx '1' -# Realize exact public 0.1.45 bytes and its schema-v7 five-unit marker. -PLAN_0144="$WORK/plan-0.1.45.json" -APPLY_0144="$WORK/apply-0.1.45.json" -APPLY_BOUND_0144="$WORK/apply-bound-0.1.45.json" +# Realize exact public 0.1.50 bytes and its schema-v7 five-unit marker. +PLAN_0144="$WORK/plan-0.1.50.json" +APPLY_0144="$WORK/apply-0.1.50.json" +APPLY_BOUND_0144="$WORK/apply-bound-0.1.50.json" plan_setup "$PREFIX_0144" "$PLAN_0144" jq -e '.status == "planned" and .identity_enrolled == false' "$PLAN_0144" >/dev/null DIGEST_0144="$(jq -r '.request_digest' "$PLAN_0144")" [[ "$DIGEST_0144" =~ ^[a-f0-9]{64}$ ]] apply_setup "$PREFIX_0144" "$DIGEST_0144" "$APPLY_0144" jq -e '.status == "waiting_owner_oidc_or_passkey" and .identity_enrolled == false' "$APPLY_0144" >/dev/null -sudo jq -e '.package_version == "0.1.45" and .artifact_mode == "disabled" and (.units | length) == 5' /var/lib/agentnet-setup/setup.json >/dev/null +sudo jq -e '.package_version == "0.1.50" and .artifact_mode == "disabled" and (.units | length) == 5' /var/lib/agentnet-setup/setup.json >/dev/null assert_schema_seven_source sudo test ! -e /var/lib/agentnet-setup/upgrade.json @@ -611,21 +611,16 @@ if [[ "$LEASE_RELEASED" != "true" ]]; then fi run_as_core_service "$AGENTNET_0144" server-agent activate \ --config /var/lib/agentnet/agentnet.json \ - --identity /var/lib/agentnet/server-agent-identity.json >"$WORK/activate-0.1.45.json" + --identity /var/lib/agentnet/server-agent-identity.json >"$WORK/activate-0.1.50.json" jq -e --arg harness "$HARNESS_ID" --arg credential "$CREDENTIAL_ID" \ '.activated == true and .harness_id == $harness and .credential_id == $credential and .authority_granted == false' \ - "$WORK/activate-0.1.45.json" >/dev/null -# Public 0.1.45 uses Persistent=true. Seed the fresh runner's standard systemd -# timer stamp so enabling the historical timer does not fabricate a missed -# activation from before this disposable host existed and race setup's exact -# postcondition check. -sudo install -d -o root -g root -m 0755 /var/lib/systemd/timers -sudo touch /var/lib/systemd/timers/stamp-agentnet-credential-renew.timer + "$WORK/activate-0.1.50.json" >/dev/null +# Public 0.1.50 already uses the recurring post-completion timer contract. apply_setup "$PREFIX_0144" "$DIGEST_0144" "$APPLY_BOUND_0144" jq -e '.status == "operational" and .identity_enrolled == true and .authority_granted == false' "$APPLY_BOUND_0144" >/dev/null -sudo grep -Fxq 'OnUnitActiveSec=1h' /etc/systemd/system/agentnet-credential-renew.timer -sudo grep -Fxq 'Persistent=true' /etc/systemd/system/agentnet-credential-renew.timer -! sudo grep -Fq 'OnUnitInactiveSec=' /etc/systemd/system/agentnet-credential-renew.timer +sudo grep -Fxq 'OnActiveSec=5min' /etc/systemd/system/agentnet-credential-renew.timer +sudo grep -Fxq 'OnUnitInactiveSec=1h' /etc/systemd/system/agentnet-credential-renew.timer +! sudo grep -Fq 'Persistent=' /etc/systemd/system/agentnet-credential-renew.timer # Freeze the exact released identity, schema catalog, marker, and private # identity material before the candidate changes any package-owned bytes. @@ -642,8 +637,8 @@ OLD_APPROVAL_PID="$(sudo systemctl show agentnet-approval.service --property=Mai [[ "$OLD_CORE_PID" =~ ^[1-9][0-9]*$ ]] [[ "$OLD_APPROVAL_PID" =~ ^[1-9][0-9]*$ ]] -PLAN_0145="$WORK/plan-0.1.46.json" -APPLY_0145="$WORK/apply-0.1.46.json" +PLAN_0145="$WORK/plan-0.1.51.json" +APPLY_0145="$WORK/apply-0.1.51.json" plan_setup "$PREFIX_0145" "$PLAN_0145" DIGEST_0145="$(jq -r '.request_digest' "$PLAN_0145")" [[ "$DIGEST_0145" =~ ^[a-f0-9]{64}$ ]] @@ -656,7 +651,7 @@ jq -e ' and .endpoint_lifecycle == null ' "$APPLY_0145" >/dev/null sudo jq -e --arg previous "$MARKER_0144" --argjson revision "$REVISION_0144" ' - .package_version == "0.1.46" + .package_version == "0.1.51" and .artifact_mode == "disabled" and (.units | length) == 5 and .revision == ($revision + 1) @@ -708,4 +703,4 @@ rm -rf "$INPUTS" "$WORK/seed-released-state.py" "$WORK/root-ca.key" "$WORK/tls.k [[ ! -e "$INPUTS" && ! -e "$WORK/root-ca.key" && ! -e "$WORK/tls.key" ]] unset TOKEN -echo "ordinary server 0.1.45 to 0.1.46 timer upgrade E2E: PASS" +echo "ordinary server 0.1.50 to 0.1.51 installed-host upgrade E2E: PASS" diff --git a/scripts/verify_release.py b/scripts/verify_release.py index ad96590..ba244de 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -310,7 +310,7 @@ def _expected_sdist_files(root: Path, source_files: dict[str, bytes]) -> dict[st def _verify_built_artifacts(root: Path, artifacts: Any, failures: list[str]) -> None: - base = "evidence/local/2026-08-07-v0.1.50/artifacts" + base = "evidence/local/2026-08-09-v0.1.51/artifacts" ignore_path = root / base / ".gitignore" retention_path = root / base / "RETENTION.md" expected_ignore = "*\n!/.gitignore\n!/RETENTION.md\n!/*.whl\n!/*.tar.gz\n" @@ -327,8 +327,8 @@ def _verify_built_artifacts(root: Path, artifacts: Any, failures: list[str]) -> elif (root / ".git").exists(): failures.append("final package artifact ignore policy does not retain its archives") expected_paths = { - f"{base}/agentnet-0.1.50.tar.gz", - f"{base}/agentnet-0.1.50-py3-none-any.whl", + f"{base}/agentnet-0.1.51.tar.gz", + f"{base}/agentnet-0.1.51-py3-none-any.whl", } if not isinstance(artifacts, list) or len(artifacts) != 2: failures.append("final package evidence must contain exactly the sdist and wheel") @@ -381,8 +381,8 @@ def _verify_built_artifacts(root: Path, artifacts: Any, failures: list[str]) -> names = set(listed_names) if len(names) != len(listed_names): failures.append("wheel contains duplicate archive member names") - dist_info = "agentnet-0.1.50.dist-info" - shared = "agentnet-0.1.50.data/data/share/agentnet" + dist_info = "agentnet-0.1.51.dist-info" + shared = "agentnet-0.1.51.data/data/share/agentnet" expected_payloads = dict(source_files) expected_payloads.update( { @@ -437,7 +437,7 @@ def _verify_built_artifacts(root: Path, artifacts: Any, failures: list[str]) -> wheel_metadata = archive.read(metadata_name) if ( b"\nName: agentnet\n" not in b"\n" + wheel_metadata - or b"\nVersion: 0.1.50\n" not in b"\n" + wheel_metadata + or b"\nVersion: 0.1.51\n" not in b"\n" + wheel_metadata or b"\nRequires-Python: <3.15,>=3.13\n" not in b"\n" + wheel_metadata ): failures.append("wheel core metadata differs from the release identity/runtime") @@ -464,7 +464,7 @@ def _verify_built_artifacts(root: Path, artifacts: Any, failures: list[str]) -> failures.append(f"final wheel is unreadable or malformed: {exc}") sdist_path = paths[next(path for path in expected_paths if path.endswith(".tar.gz"))] - prefix = "agentnet-0.1.50/" + prefix = "agentnet-0.1.51/" try: with tarfile.open(sdist_path, mode="r:gz") as archive: all_members = archive.getmembers() @@ -730,8 +730,8 @@ def _verify_public_readme(root: Path, failures: list[str]) -> None: return normalized = re.sub(r"\s+", " ", readme_path.read_text(encoding="utf-8")) required_claims = ( - "latest published package is `0.1.45`", - "Candidate `0.1.50`", + "latest published package is `0.1.50`", + "Candidate `0.1.51`", "two installed-harness pin failures remain non-green and are not waived", "provider error without token exchange", "fresh-laptop enrollment", @@ -998,14 +998,14 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis ): failures.append("final clean-install status does not preserve the local blocked-release boundary") package_evidence = _load_json( - root / "evidence/local/2026-08-07-v0.1.50/manifest.json", + root / "evidence/local/2026-08-09-v0.1.51/manifest.json", failures, - "0.1.50 package evidence manifest", + "0.1.51 package evidence manifest", ) if package_evidence.get("release_source_tree_sha256") != _source_tree_sha256(root): - failures.append("0.1.50 package evidence is not bound to the current source tree") + failures.append("0.1.51 package evidence is not bound to the current source tree") if package_evidence.get("verification_status") != "PASS": - failures.append("0.1.50 package evidence must record completed PASS verification") + failures.append("0.1.51 package evidence must record completed PASS verification") command_records = package_evidence.get("commands") if not isinstance(command_records, list) or any( not isinstance(record, dict) @@ -1013,7 +1013,7 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis or not isinstance(record.get("result"), str) for record in command_records ): - failures.append("0.1.50 package evidence commands are malformed") + failures.append("0.1.51 package evidence commands are malformed") else: command_results = { record["command"]: record["result"] @@ -1022,7 +1022,7 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis npm_result = command_results.get("npm run check", "") if ( not npm_result.startswith("PASS:") - or "2207 passed and 21 expected" not in npm_result + or "2317 passed and 22 expected" not in npm_result or "source plus generations 1 and 2" not in npm_result or "exact-endpoint routing gate" not in npm_result or "packaged v0.1.45 user journey" not in npm_result @@ -1030,7 +1030,7 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis or "two installed-harness pin failures remain non-green" not in npm_result or "not rerun or waived" not in npm_result ): - failures.append("0.1.50 npm source and recursive packed evidence is incomplete") + failures.append("0.1.51 npm source and recursive packed evidence is incomplete") routing_result = next( ( result @@ -1053,38 +1053,15 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis "workspace_fallback_used=false", ) if not all(marker in routing_result for marker in required_routing_markers): - failures.append("0.1.50 packaged exact-endpoint routing evidence is incomplete") + failures.append("0.1.51 packaged exact-endpoint routing evidence is incomplete") required_focused_paths = ( - "tests/operations/test_endpoint_lifecycle.py", - "tests/operations/test_client_setup.py", - "tests/bindings/test_endpoint_binding.py", - "tests/bindings/test_local_binding_composition.py", - "tests/adapters/test_supervisor_core_composition.py", - "tests/supervisor/test_host_endpoints.py", - "tests/adapters/test_all_harnesses.py", - "tests/authorization/test_collaboration_scope.py", - "tests/discovery/test_recipient_resolver.py", - "tests/integration/test_collaboration_scope_messaging.py", - "tests/integration/test_obligation_background_wakeup.py", - "tests/production/test_release_v7_schema.py", - "tests/artifacts/test_backend_parity.py", - "tests/artifacts/test_clamav_scanner.py", - "tests/artifacts/test_transfer_service.py", - "tests/artifacts/test_safe_download.py", - "tests/integration/test_file_tools_e2e.py", - "tests/operations/test_server_setup.py", - "tests/identity/test_invitation_links.py", - "tests/identity/test_invitation_redemption.py", - "tests/integration/test_invitation_browser_journey.py", - "tests/operations/test_server_setup_recovery.py", - "tests/operations/test_c0_credential_supersession.py", - "tests/identity/test_credential_rotation.py", - "tests/approval/test_transaction_summary.py", + "tests/approval/test_approval_cli.py", "tests/approval/test_webauthn_service.py", - "tests/cli/test_server_agent_activation.py", - "tests/core/test_synthetic_lane.py", - "tests/integration/test_enrollment_http.py", - "tests/cli/test_bootstrap_plan_cli.py", + "tests/operations/test_canonical_owner_recovery.py", + "tests/operations/test_server_setup_recovery.py", + "tests/authorization/test_communication_scope_service.py", + "tests/production/test_release_v7_schema.py", + "tests/integration/test_collaboration_scope_messaging.py", ) if not any( command.startswith( @@ -1092,18 +1069,18 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis "uv run pytest -q " ) and all(path in command.split() for path in required_focused_paths) - and result == "PASS: 681 passed, 5 expected dedicated-PostgreSQL skips" + and result == "PASS: 302 passed" for command, result in command_results.items() ): - failures.append("0.1.50 focused release-blocker evidence is incomplete") + failures.append("0.1.51 focused release-blocker evidence is incomplete") if not any( command.startswith("SOURCE_DATE_EPOCH=1580601600 ") and result.startswith("PASS: two independent builds") for command, result in command_results.items() ): - failures.append("0.1.50 reproducible build evidence is incomplete") + failures.append("0.1.51 reproducible build evidence is incomplete") if any("PENDING" in result for result in command_results.values()): - failures.append("0.1.50 package evidence cannot retain pending command results") + failures.append("0.1.51 package evidence cannot retain pending command results") execution_context = package_evidence.get("execution_context") if not isinstance(execution_context, dict) or not all( isinstance(execution_context.get(key), str) and execution_context[key] @@ -1114,7 +1091,7 @@ def _verify_evidence_ledgers(manifest: dict[str, Any], root: Path, failures: lis "root_installed_external_host", ) ): - failures.append("0.1.50 package evidence execution context is incomplete") + failures.append("0.1.51 package evidence execution context is incomplete") _verify_built_artifacts(root, package_evidence.get("artifacts", []), failures) diff --git a/src/agentnet/__init__.py b/src/agentnet/__init__.py index 2b5f427..d06d6fb 100644 --- a/src/agentnet/__init__.py +++ b/src/agentnet/__init__.py @@ -9,4 +9,4 @@ from .operations.config import ExtensionConfig, RuntimeProfile __all__ = ["CommunicationCore", "ExtensionConfig", "RuntimeProfile"] -__version__ = "0.1.50" +__version__ = "0.1.51" diff --git a/src/agentnet/approval/cli_commands.py b/src/agentnet/approval/cli_commands.py index bd04888..14fde64 100644 --- a/src/agentnet/approval/cli_commands.py +++ b/src/agentnet/approval/cli_commands.py @@ -35,6 +35,10 @@ from agentnet.approval.owner_session import OwnerSessionService from agentnet.approval.store import ApprovalStore from agentnet.approval.webauthn_uv import WebAuthnApprovalService +from agentnet.operations.canonical_owner_recovery import ( + CanonicalOwnerAdoptionRequest, + converge_canonical_approval_owner, +) from agentnet.errors import GateBlocked, ValidationError from agentnet.identity.oidc import OIDCProvider, OIDCProviderConfig from agentnet.operations.config import OIDCTokenEndpointAuthMethod @@ -560,6 +564,57 @@ def command_approval_status(args: argparse.Namespace) -> int: return 0 +def command_approval_recover_canonical_owner(args: argparse.Namespace) -> int: + config, store, _service = _open_service(Path(args.config)) + try: + binding = store.fetch_one( + """SELECT oidc_issuer,oidc_subject,verified_email,pinned_at + FROM approval_owner_bindings + WHERE domain_id=? AND approver_principal_id=? AND status='active'""", + (args.domain, args.source_principal), + ) + if binding is None: + binding = store.fetch_one( + """SELECT oidc_issuer,oidc_subject,verified_email,pinned_at + FROM approval_owner_bindings + WHERE domain_id=? AND approver_principal_id=? AND status='active'""", + (args.domain, args.target_principal), + ) + if binding is None: + raise GateBlocked( + "canonical_owner_recovery", + "exact Approval owner binding is unavailable", + ) + if binding["oidc_issuer"] != args.oidc_issuer: + raise GateBlocked( + "canonical_owner_recovery", + "exact Approval owner OIDC issuer does not match recovery request", + ) + request = CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=args.recovery_id, + domain_id=args.domain, + source_principal_id=args.source_principal, + target_principal_id=args.target_principal, + oidc_issuer=str(binding["oidc_issuer"]), + oidc_subject=str(binding["oidc_subject"]), + verified_email=str(binding["verified_email"]), + verifier_id=config.verifier_id, + approved_at=int(binding["pinned_at"]), + ) + result = converge_canonical_approval_owner( + store, + config_path=Path(args.config), + journal_path=config.data_dir / "canonical-owner-recovery.json", + request=request, + now=int(time.time()), + ) + finally: + store.close() + print(json.dumps(result, sort_keys=True)) + return 0 + + def configure_approval_parser(commands: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: approval = commands.add_parser( "approval", @@ -646,5 +701,17 @@ def configure_approval_parser(commands: argparse._SubParsersAction[argparse.Argu status.add_argument("--config", default=".agentnet-approval/config.json") status.set_defaults(func=command_approval_status) + recover = sub.add_parser( + "recover-canonical-owner", + help=argparse.SUPPRESS, + ) + recover.add_argument("--config", required=True) + recover.add_argument("--recovery-id", required=True) + recover.add_argument("--domain", required=True) + recover.add_argument("--source-principal", required=True) + recover.add_argument("--target-principal", required=True) + recover.add_argument("--oidc-issuer", required=True) + recover.set_defaults(func=command_approval_recover_canonical_owner) + __all__ = ["configure_approval_parser"] diff --git a/src/agentnet/approval/config.py b/src/agentnet/approval/config.py index a5c8dca..2977d93 100644 --- a/src/agentnet/approval/config.py +++ b/src/agentnet/approval/config.py @@ -312,6 +312,16 @@ def load_approval_service_config(path: Path) -> ApprovalServiceConfig: ) if not isinstance(value, dict): raise ValueError("not an object") + if ( + value.get("request_ttl_seconds") == 3_600 + and "communication_scope_request_ttl_seconds" not in value + and value.get("challenge_ttl_seconds") == 180 + and value.get("receipt_ttl_seconds") == 300 + and value.get("registration_ttl_seconds") == 600 + ): + value = dict(value) + value["request_ttl_seconds"] = 600 + value["communication_scope_request_ttl_seconds"] = 3_600 config = ApprovalServiceConfig.model_validate(value) except Exception as exc: raise ValidationError("approval service configuration is invalid") from exc diff --git a/src/agentnet/approval/owner_session.py b/src/agentnet/approval/owner_session.py index 6f5433c..4d89642 100644 --- a/src/agentnet/approval/owner_session.py +++ b/src/agentnet/approval/owner_session.py @@ -534,6 +534,7 @@ def begin_approval( or matches[0].get("approval_purpose") not in { "identity.enrollment.approve", + "identity.credential.recover.approve", "authorization.bootstrap_plan.approve", "authorization.communication_scope.approve", } diff --git a/src/agentnet/approval/store.py b/src/agentnet/approval/store.py index 2bc8a56..a6a432c 100644 --- a/src/agentnet/approval/store.py +++ b/src/agentnet/approval/store.py @@ -15,6 +15,7 @@ from agentnet.errors import AuthenticationError, GateBlocked from agentnet.security.envelope import LocalEnvelopeCipher +from agentnet.security.signatures import canonical_json APPROVAL_STORE_SCHEMA_VERSION = 4 @@ -400,6 +401,21 @@ def _require_private_database(path: Path) -> os.stat_result: ): raise GateBlocked("approval_store", "approval database must be owner-only") return metadata +def approval_user_handle(*, verifier_id: str, principal_id: str, domain_id: str) -> bytes: + """Derive the stable WebAuthn user handle for one exact approval principal.""" + + return hashlib.sha256( + canonical_json( + { + "schema": "agentnet.approval.webauthn-user.v1", + "verifier_id": verifier_id, + "domain_id": domain_id, + "approver_principal_id": principal_id, + } + ) + ).digest() + + class ApprovalStore: @@ -734,6 +750,7 @@ def close(self) -> None: "APPROVAL_STORE_SCHEMA_V3", "APPROVAL_STORE_SCHEMA_V4", "APPROVAL_STORE_SCHEMA_VERSION", + "approval_user_handle", "ApprovalStore", "expected_catalog", "expected_catalog_digest", diff --git a/src/agentnet/approval/webauthn_uv.py b/src/agentnet/approval/webauthn_uv.py index b589771..41c276f 100644 --- a/src/agentnet/approval/webauthn_uv.py +++ b/src/agentnet/approval/webauthn_uv.py @@ -27,7 +27,7 @@ from agentnet.approval.config import ApprovalServiceConfig, require_owner_only_file from agentnet.approval.service import TrustedApprover, create_independent_approval_receipt -from agentnet.approval.store import ApprovalStore +from agentnet.approval.store import ApprovalStore, approval_user_handle from agentnet.approval.transaction_summary import validate_and_summarize_approval_transaction from agentnet.authorization.communication_scope import COMMUNICATION_SCOPE_APPROVAL_PURPOSE from agentnet.errors import AuthenticationError, ConflictError, GateBlocked, ValidationError @@ -123,16 +123,11 @@ def _core_request_digest( def _user_handle(config: ApprovalServiceConfig, principal_id: str, domain_id: str) -> bytes: - return hashlib.sha256( - canonical_json( - { - "schema": "agentnet.approval.webauthn-user.v1", - "verifier_id": config.verifier_id, - "domain_id": domain_id, - "approver_principal_id": principal_id, - } - ) - ).digest() + return approval_user_handle( + verifier_id=config.verifier_id, + principal_id=principal_id, + domain_id=domain_id, + ) def _active_fingerprint( diff --git a/src/agentnet/authorization/communication_scope_service.py b/src/agentnet/authorization/communication_scope_service.py index 468ec63..51121fb 100644 --- a/src/agentnet/authorization/communication_scope_service.py +++ b/src/agentnet/authorization/communication_scope_service.py @@ -40,6 +40,7 @@ from agentnet.security.signatures import canonical_digest, canonical_json from agentnet.storage.backend import StoreBackend from agentnet.storage.communication_scope_schema import COMMUNICATION_SCOPE_TABLE_DDL +from agentnet.storage.release_v7_schema import materialize_v6_communication_scope from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -937,6 +938,10 @@ def complete( self._require_committed_current( connection, row=row, actor=actor, now=now ) + materialize_v6_communication_scope( + connection, + scope_id=str(row["scope_id"]), + ) return self._stored_complete(row, reservation_digest) self._require_row_actor(row, actor) if row["state"] in { @@ -1019,6 +1024,10 @@ def complete( self._require_committed_current( connection, row=row, actor=actor, now=int(self.clock()) ) + materialize_v6_communication_scope( + connection, + scope_id=str(row["scope_id"]), + ) return self._stored_complete(row, reservation_digest) if ( row["state"] != "completion_reserved" @@ -1159,6 +1168,10 @@ def complete( scope_id, ), ) + materialize_v6_communication_scope( + connection, + scope_id=scope_id, + ) return self._stored_complete( self._row_for_begin(connection, begin_hash), reservation_digest, @@ -1496,19 +1509,25 @@ def _member_digest( harness_id: str, role: str, joined_at: int, + state: str = "active", + joined_sequence: int = 1, + removed_sequence: int | None = None, + removed_at: int | None = None, ) -> str: - return canonical_digest( - { - "scope_id": scope_id, - "authority_kind": authority_kind, - "authority_id": authority_id, - "harness_id": harness_id, - "role": role, - "state": "active", - "joined_sequence": 1, - "joined_at": joined_at, - } - ) + member: dict[str, object] = { + "scope_id": scope_id, + "authority_kind": authority_kind, + "authority_id": authority_id, + "harness_id": harness_id, + "role": role, + "state": state, + "joined_sequence": joined_sequence, + "joined_at": joined_at, + } + if state == "removed": + member["removed_sequence"] = removed_sequence + member["removed_at"] = removed_at + return canonical_digest(member) @staticmethod def _scope_digest( @@ -1605,25 +1624,50 @@ def _members( if not member_rows: raise AuthorizationError("collaboration scope is unavailable") members: list[dict[str, object]] = [] - harness_ids: list[str] = [] + active_harness_ids: list[str] = [] owner_members = 0 + membership_sequence = int(row["membership_sequence"]) for member in member_rows: authority_kind = str(member["authority_kind"]) authority_id = str(member["authority_id"]) harness_id = str(member["harness_id"]) role = str(member["role"]) + state = str(member["state"]) + joined_sequence = int(member["joined_sequence"]) + removed_sequence = ( + int(member["removed_sequence"]) + if member["removed_sequence"] is not None + else None + ) + removed_at = ( + int(member["removed_at"]) if member["removed_at"] is not None else None + ) if ( authority_kind not in {"principal", "guest"} or not authority_id or not harness_id - or member["state"] != "active" - or int(member["joined_sequence"]) != 1 - or member["removed_sequence"] is not None - or member["removed_at"] is not None + or state not in {"active", "removed"} + or joined_sequence < 1 + or joined_sequence > membership_sequence or (authority_kind == "guest") != (role == "guest") + or ( + state == "active" + and (removed_sequence is not None or removed_at is not None) + ) + or ( + state == "removed" + and ( + removed_sequence is None + or removed_sequence < joined_sequence + or removed_sequence > membership_sequence + or removed_at is None + ) + ) ): raise AuthorizationError("collaboration scope membership is unavailable") if role == "owner": + if state != "active": + raise AuthorizationError("collaboration scope membership is unavailable") owner_members += 1 if ( authority_kind != "principal" @@ -1640,24 +1684,35 @@ def _members( harness_id=harness_id, role=role, joined_at=int(member["joined_at"]), + state=state, + joined_sequence=joined_sequence, + removed_sequence=removed_sequence, + removed_at=removed_at, ) if not secrets.compare_digest(expected, str(member["member_digest"])): raise AuthorizationError("collaboration scope membership is unavailable") - harness_ids.append(harness_id) - members.append( - { - "authority_kind": authority_kind, - "authority_id": authority_id, - "harness_id": harness_id, - "role": role, - "state": "active", - "joined_sequence": 1, - "joined_at": int(member["joined_at"]), - } - ) - if owner_members != 1 or harness_ids != sorted(set(harness_ids)): + value: dict[str, object] = { + "authority_kind": authority_kind, + "authority_id": authority_id, + "harness_id": harness_id, + "role": role, + "state": state, + "joined_sequence": joined_sequence, + "joined_at": int(member["joined_at"]), + } + if state == "removed": + value["removed_sequence"] = removed_sequence + value["removed_at"] = removed_at + else: + active_harness_ids.append(harness_id) + members.append(value) + if ( + owner_members != 1 + or active_harness_ids != sorted(set(active_harness_ids)) + or not active_harness_ids + ): raise AuthorizationError("collaboration scope membership is unavailable") - return tuple(harness_ids), members + return tuple(active_harness_ids), members def _scope_from_row(self, connection: Any, row: Any) -> CollaborationScope: actions = self._load_canonical_tuple( diff --git a/src/agentnet/authorization/scope_harness_replacement.py b/src/agentnet/authorization/scope_harness_replacement.py new file mode 100644 index 0000000..307d6b0 --- /dev/null +++ b/src/agentnet/authorization/scope_harness_replacement.py @@ -0,0 +1,543 @@ +"""Owner-approved replacement of one expired collaboration-scope harness.""" + +from __future__ import annotations + +import hashlib +import json +import secrets +import time +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from agentnet.approval import IndependentApprovalVerifier, consume_independent_approval +from agentnet.authorization.communication_scope_service import ( + CollaborationScope, + CollaborationScopeService, +) +from agentnet.errors import AuthenticationError, AuthorizationError, ConflictError, ValidationError +from agentnet.identity.actors import VerifiedActor +from agentnet.security.signatures import canonical_json +from agentnet.storage.backend import StoreBackend + +SCOPE_HARNESS_REPLACEMENT_SCHEMA = "agentnet.scope-harness-replacement.v1" +SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE = "identity.credential.recover.approve" +_REQUEST_TTL_SECONDS = 600 +_STRICT = ConfigDict(extra="forbid", frozen=True) + + +class ScopeHarnessReplacementRequest(BaseModel): + """Exact owner-approved membership cutover request.""" + + model_config = _STRICT + + schema_version: Literal["agentnet.scope-harness-replacement.v1"] = ( + SCOPE_HARNESS_REPLACEMENT_SCHEMA + ) + request_id: str = Field(min_length=16, max_length=128) + domain_id: str = Field(min_length=1, max_length=256) + owner_principal_id: str = Field(min_length=1, max_length=256) + owner_harness_id: str = Field(min_length=1, max_length=256) + scope_id: str = Field(min_length=16, max_length=256) + expected_scope_revision: int = Field(ge=1) + expected_scope_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + expected_membership_sequence: int = Field(ge=1) + expected_policy_revision: int = Field(ge=1) + expected_domain_revocation_epoch: int = Field(ge=1) + old_harness_id: str = Field(min_length=1, max_length=256) + old_credential_id: str = Field(min_length=1, max_length=256) + old_credential_epoch: int = Field(ge=1) + new_harness_id: str = Field(min_length=1, max_length=256) + new_credential_id: str = Field(min_length=1, max_length=256) + new_credential_epoch: int = Field(ge=1) + role: Literal["member"] + issued_at: int = Field(ge=1) + expires_at: int = Field(ge=1) + + @property + def canonical_transaction(self) -> bytes: + return canonical_json(self.model_dump(mode="json")) + + @property + def digest(self) -> str: + return hashlib.sha256(self.canonical_transaction).hexdigest() + + +class ScopeHarnessReplacementResult(BaseModel): + """Durable result of one exact membership cutover.""" + + model_config = _STRICT + + schema_version: Literal["agentnet.scope-harness-replacement.result.v1"] = ( + "agentnet.scope-harness-replacement.result.v1" + ) + request_id: str + request_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + scope_id: str + old_harness_id: str + new_harness_id: str + role: Literal["member"] + membership_sequence: int = Field(ge=2) + scope_revision: int = Field(ge=2) + scope_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + audit_record_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + idempotent_repeat: bool + + +class ScopeHarnessReplacementService: + """Replace an expired same-principal member under exact independent approval.""" + + def __init__( + self, + store: StoreBackend, + approval_verifier: IndependentApprovalVerifier, + *, + clock: Callable[[], float] = time.time, + ) -> None: + self.store = store + self.approval_verifier = approval_verifier + self.clock = clock + self.scopes = CollaborationScopeService(store, clock=clock) + + @staticmethod + def _current_credential(connection: Any, harness_id: str) -> tuple[Any, Any]: + harness = connection.execute( + "SELECT * FROM harnesses WHERE harness_id=?", + (harness_id,), + ).fetchone() + if harness is None: + raise AuthorizationError("scope replacement harness is unavailable") + credentials = connection.execute( + """SELECT * FROM credentials + WHERE harness_id=? AND epoch=? AND status='active' + ORDER BY credential_id""", + (harness_id, int(harness["credential_epoch"])), + ).fetchall() + if len(credentials) != 1: + raise AuthorizationError("scope replacement current credential is ambiguous") + return harness, credentials[0] + + @staticmethod + def _require_request_window( + *, + issued_at: int, + expires_at: int, + now: int, + ) -> None: + if ( + issued_at > now + or now >= expires_at + or expires_at <= issued_at + or expires_at - issued_at > _REQUEST_TTL_SECONDS + ): + raise AuthenticationError("scope replacement request is expired or overlong") + + def _require_owner_and_scope( + self, + connection: Any, + *, + actor: VerifiedActor, + scope_id: str, + when: datetime, + ) -> CollaborationScope: + self.scopes._require_actor(connection, actor=actor, when=when) + row = self.scopes._scope_row(connection, scope_id) + if row is None: + raise AuthorizationError("collaboration scope is unavailable") + scope = self.scopes._scope_from_row(connection, row) + if ( + scope.state != "active" + or actor.domain_id != scope.domain_id + or actor.principal_id != scope.owner_principal_id + ): + raise AuthorizationError( + "scope replacement requires a current harness of the exact scope-owning principal" + ) + return scope + + def _require_harness_pair( + self, + connection: Any, + *, + scope: CollaborationScope, + old_harness_id: str, + new_harness_id: str, + role: str, + now: int, + ) -> tuple[Any, Any]: + if old_harness_id == new_harness_id or role != "member": + raise ValidationError("scope replacement harness pair is invalid") + member = connection.execute( + """SELECT * FROM collaboration_scope_members + WHERE scope_id=? AND harness_id=?""", + (scope.scope_id, old_harness_id), + ).fetchone() + if ( + member is None + or member["state"] != "active" + or member["role"] != role + or member["authority_kind"] != "principal" + or member["authority_id"] != scope.owner_principal_id + ): + raise AuthorizationError("expired scope member does not match the owner-approved target") + old_harness, old_credential = self._current_credential(connection, old_harness_id) + new_harness, new_credential = self._current_credential(connection, new_harness_id) + if any( + harness["domain_id"] != scope.domain_id + or harness["principal_id"] != scope.owner_principal_id + or harness["status"] != "active" + for harness in (old_harness, new_harness) + ): + raise AuthorizationError("scope replacement requires active same-principal harnesses") + if int(old_credential["expires_at"]) > now: + raise AuthorizationError("scope replacement requires the old current credential to be expired") + if ( + int(new_credential["not_before"]) > now + or int(new_credential["expires_at"]) <= now + ): + raise AuthorizationError("scope replacement requires a current replacement credential") + existing_new = connection.execute( + """SELECT state FROM collaboration_scope_members + WHERE scope_id=? AND harness_id=?""", + (scope.scope_id, new_harness_id), + ).fetchone() + if existing_new is not None: + raise ConflictError("replacement harness already has scope membership history") + return old_credential, new_credential + + def prepare( + self, + *, + actor: VerifiedActor, + scope_id: str, + old_harness_id: str, + new_harness_id: str, + role: Literal["member"], + request_id: str, + issued_at: int, + expires_at: int, + ) -> ScopeHarnessReplacementRequest: + now = int(self.clock()) + self._require_request_window(issued_at=issued_at, expires_at=expires_at, now=now) + when = datetime.fromtimestamp(now, UTC) + with self.store.transaction() as connection: + scope = self._require_owner_and_scope( + connection, + actor=actor, + scope_id=scope_id, + when=when, + ) + old_credential, new_credential = self._require_harness_pair( + connection, + scope=scope, + old_harness_id=old_harness_id, + new_harness_id=new_harness_id, + role=role, + now=now, + ) + return ScopeHarnessReplacementRequest( + request_id=request_id, + domain_id=scope.domain_id, + owner_principal_id=scope.owner_principal_id, + owner_harness_id=scope.owner_harness_id, + scope_id=scope.scope_id, + expected_scope_revision=scope.revision, + expected_scope_digest=scope.scope_digest, + expected_policy_revision=scope.policy_revision, + expected_domain_revocation_epoch=scope.domain_revocation_epoch, + expected_membership_sequence=scope.membership_sequence, + old_harness_id=old_harness_id, + old_credential_id=str(old_credential["credential_id"]), + old_credential_epoch=int(old_credential["epoch"]), + new_harness_id=new_harness_id, + new_credential_id=str(new_credential["credential_id"]), + new_credential_epoch=int(new_credential["epoch"]), + role=role, + issued_at=issued_at, + expires_at=expires_at, + ) + + def _completed_result( + self, + connection: Any, + *, + scope: CollaborationScope, + request: ScopeHarnessReplacementRequest, + ) -> ScopeHarnessReplacementResult | None: + if ( + scope.revision != request.expected_scope_revision + 1 + or scope.membership_sequence != request.expected_membership_sequence + 1 + ): + return None + audit_row = connection.execute( + "SELECT record_json FROM audit_log WHERE record_hash=?", + (connection.execute( + "SELECT audit_record_hash FROM collaboration_scopes WHERE scope_id=?", + (scope.scope_id,), + ).fetchone()["audit_record_hash"],), + ).fetchone() + if audit_row is None: + return None + try: + audit = json.loads(str(audit_row["record_json"])) + except (TypeError, ValueError): + return None + if ( + audit.get("action") != "collaboration_scope.harness_replaced" + or audit.get("request_digest") != request.digest + or audit.get("old_harness_id") != request.old_harness_id + or audit.get("new_harness_id") != request.new_harness_id + or audit.get("scope_digest") != scope.scope_digest + ): + return None + return ScopeHarnessReplacementResult( + request_id=request.request_id, + request_digest=request.digest, + scope_id=request.scope_id, + old_harness_id=request.old_harness_id, + new_harness_id=request.new_harness_id, + role=request.role, + membership_sequence=scope.membership_sequence, + scope_revision=scope.revision, + scope_digest=scope.scope_digest, + audit_record_hash=str(audit.get("record_hash", "")) or str( + connection.execute( + "SELECT audit_record_hash FROM collaboration_scopes WHERE scope_id=?", + (scope.scope_id,), + ).fetchone()["audit_record_hash"] + ), + idempotent_repeat=True, + ) + + def replace( + self, + *, + actor: VerifiedActor, + request: ScopeHarnessReplacementRequest, + approval: Mapping[str, Any], + ) -> ScopeHarnessReplacementResult: + now = int(self.clock()) + when = datetime.fromtimestamp(now, UTC) + with self.store.transaction() as connection: + scope = self._require_owner_and_scope( + connection, + actor=actor, + scope_id=request.scope_id, + when=when, + ) + if ( + request.domain_id != scope.domain_id + or request.owner_principal_id != scope.owner_principal_id + or request.owner_harness_id != scope.owner_harness_id + ): + raise AuthenticationError("scope replacement owner binding changed") + completed = self._completed_result(connection, scope=scope, request=request) + if completed is not None: + return completed + self._require_request_window( + issued_at=request.issued_at, + expires_at=request.expires_at, + now=now, + ) + verified = self.approval_verifier.verify( + canonical_transaction=request.canonical_transaction, + approval=approval, + expected_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + expected_domain_id=request.domain_id, + when=when, + ) + if ( + verified.approver_authority_kind != "human" + or verified.approver_principal_id != request.owner_principal_id + ): + raise AuthorizationError("scope replacement requires exact owner approval") + if ( + scope.revision != request.expected_scope_revision + or scope.membership_sequence != request.expected_membership_sequence + or scope.policy_revision != request.expected_policy_revision + or scope.domain_revocation_epoch + != request.expected_domain_revocation_epoch + or not secrets.compare_digest(scope.scope_digest, request.expected_scope_digest) + ): + raise ConflictError("scope replacement revision conflict") + old_credential, new_credential = self._require_harness_pair( + connection, + scope=scope, + old_harness_id=request.old_harness_id, + new_harness_id=request.new_harness_id, + role=request.role, + now=now, + ) + if ( + old_credential["credential_id"] != request.old_credential_id + or int(old_credential["epoch"]) != request.old_credential_epoch + or new_credential["credential_id"] != request.new_credential_id + or int(new_credential["epoch"]) != request.new_credential_epoch + ): + raise AuthenticationError("scope replacement credential binding changed") + consume_independent_approval( + connection, + receipt=verified, + retain_until=request.expires_at, + ) + + next_membership_sequence = scope.membership_sequence + 1 + next_revision = scope.revision + 1 + old_row = connection.execute( + """SELECT * FROM collaboration_scope_members + WHERE scope_id=? AND harness_id=?""", + (scope.scope_id, request.old_harness_id), + ).fetchone() + removed_digest = self.scopes._member_digest( + scope_id=scope.scope_id, + authority_kind="principal", + authority_id=scope.owner_principal_id, + harness_id=request.old_harness_id, + role=request.role, + joined_at=int(old_row["joined_at"]), + state="removed", + joined_sequence=int(old_row["joined_sequence"]), + removed_sequence=next_membership_sequence, + removed_at=now, + ) + cursor = connection.execute( + """UPDATE collaboration_scope_members + SET state='removed',removed_sequence=?,member_digest=?,removed_at=? + WHERE scope_id=? AND harness_id=? AND state='active' + AND member_digest=?""", + ( + next_membership_sequence, + removed_digest, + now, + scope.scope_id, + request.old_harness_id, + old_row["member_digest"], + ), + ) + if cursor.rowcount != 1: + raise ConflictError("scope replacement membership changed") + new_digest = self.scopes._member_digest( + scope_id=scope.scope_id, + authority_kind="principal", + authority_id=scope.owner_principal_id, + harness_id=request.new_harness_id, + role=request.role, + joined_at=now, + joined_sequence=next_membership_sequence, + ) + connection.execute( + """INSERT INTO collaboration_scope_members( + scope_id,authority_kind,authority_id,harness_id,role,state, + joined_sequence,removed_sequence,member_digest,joined_at,removed_at + ) VALUES(?,'principal',?,?,?,'active',?,NULL,?,?,NULL)""", + ( + scope.scope_id, + scope.owner_principal_id, + request.new_harness_id, + request.role, + next_membership_sequence, + new_digest, + now, + ), + ) + cursor = connection.execute( + """UPDATE collaboration_scopes + SET membership_sequence=?,revision=?,state_reason='harness_replaced',updated_at=? + WHERE scope_id=? AND revision=? AND membership_sequence=? + AND scope_digest=? AND state='active'""", + ( + next_membership_sequence, + next_revision, + now, + scope.scope_id, + scope.revision, + scope.membership_sequence, + scope.scope_digest, + ), + ) + if cursor.rowcount != 1: + raise ConflictError("scope replacement revision conflict") + updated_row = self.scopes._scope_row(connection, scope.scope_id) + members = self.scopes._members(connection, row=updated_row)[1] + next_scope_digest = self.scopes._scope_digest( + scope_id=scope.scope_id, + scope_kind=scope.scope_kind, + domain_id=scope.domain_id, + owner_principal_id=scope.owner_principal_id, + owner_harness_id=scope.owner_harness_id, + members=members, + allowed_actions=scope.allowed_actions, + allowed_resource_prefixes=scope.allowed_resource_prefixes, + allowed_classifications=scope.allowed_classifications, + canonical_references=scope.canonical_references, + policy_revision=scope.policy_revision, + domain_revocation_epoch=scope.domain_revocation_epoch, + control_sequence=scope.control_sequence, + membership_sequence=next_membership_sequence, + proposal_digest=scope.proposal_digest, + revision=next_revision, + state="active", + state_reason="harness_replaced", + created_at=scope.created_at, + updated_at=now, + expires_at=scope.expires_at, + revoked_at=None, + ) + audit_hash = self.store.append_audit( + connection, + { + "action": "collaboration_scope.harness_replaced", + "actor": actor.audit_view(), + "approval_receipt_id": verified.receipt_id, + "approver_principal_id": verified.approver_principal_id, + "membership_sequence": next_membership_sequence, + "new_harness_id": request.new_harness_id, + "old_harness_id": request.old_harness_id, + "previous_scope_digest": scope.scope_digest, + "request_digest": request.digest, + "request_id": request.request_id, + "role": request.role, + "scope_digest": next_scope_digest, + "scope_id": scope.scope_id, + "scope_revision": next_revision, + }, + ) + cursor = connection.execute( + """UPDATE collaboration_scopes + SET scope_digest=?,audit_record_hash=? + WHERE scope_id=? AND revision=? AND membership_sequence=? + AND state_reason='harness_replaced'""", + ( + next_scope_digest, + audit_hash, + scope.scope_id, + next_revision, + next_membership_sequence, + ), + ) + if cursor.rowcount != 1: + raise ConflictError("scope replacement finalization conflict") + return ScopeHarnessReplacementResult( + request_id=request.request_id, + request_digest=request.digest, + scope_id=scope.scope_id, + old_harness_id=request.old_harness_id, + new_harness_id=request.new_harness_id, + role=request.role, + membership_sequence=next_membership_sequence, + scope_revision=next_revision, + scope_digest=next_scope_digest, + audit_record_hash=audit_hash, + idempotent_repeat=False, + ) + + +__all__ = [ + "SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE", + "SCOPE_HARNESS_REPLACEMENT_SCHEMA", + "ScopeHarnessReplacementRequest", + "ScopeHarnessReplacementResult", + "ScopeHarnessReplacementService", +] diff --git a/src/agentnet/cli.py b/src/agentnet/cli.py index 636e728..b0e41fc 100644 --- a/src/agentnet/cli.py +++ b/src/agentnet/cli.py @@ -25,7 +25,7 @@ from contextlib import contextmanager from datetime import UTC, datetime, timedelta from pathlib import Path -from urllib.parse import urlencode, urlsplit +from urllib.parse import unquote, urlencode, urlsplit from typing import Any from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 @@ -70,6 +70,11 @@ CommunicationScopeStatusResult, ) from agentnet.authorization.c0_pilot import C0PilotResult +from agentnet.authorization.scope_harness_replacement import ( + SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + ScopeHarnessReplacementRequest, + ScopeHarnessReplacementService, +) from agentnet.bindings.remote_manager import ( resolve_packaged_pi_extension, run_manager_gateway, @@ -1029,19 +1034,43 @@ def _open_server_agent_activation_store( config.data_dir / "secrets" / "records.key", create=False, ) - return PostgreSQLStore( - database_url_override or config.resolved_database_url(), - cipher, - instance_id=config.runtime_instance_id, - lease_owner_id=f"activation-{uuid4().hex}", - connect_timeout=config.postgres_connect_timeout_seconds, - statement_timeout_ms=config.postgres_statement_timeout_ms, - lock_timeout_ms=config.postgres_lock_timeout_ms, - lease_ttl_seconds=config.postgres_lease_ttl_seconds, - run_migrations=False, - start_lease_keeper=False, - require_recovery_topology=config.postgres_recovery_topology, - ) + database_url = database_url_override or config.resolved_database_url() + + def open_store() -> PostgreSQLStore: + return PostgreSQLStore( + database_url, + cipher, + instance_id=config.runtime_instance_id, + lease_owner_id=f"activation-{uuid4().hex}", + connect_timeout=config.postgres_connect_timeout_seconds, + statement_timeout_ms=config.postgres_statement_timeout_ms, + lock_timeout_ms=config.postgres_lock_timeout_ms, + lease_ttl_seconds=config.postgres_lease_ttl_seconds, + run_migrations=False, + start_lease_keeper=False, + require_recovery_topology=config.postgres_recovery_topology, + ) + + parsed = urlsplit(database_url) + if ( + os.geteuid() == 0 + and parsed.username == CORE_USER + and parsed.password is None + and unquote(parsed.hostname or "").startswith("/") + ): + import pwd as posix_pwd + + account = posix_pwd.getpwnam(CORE_USER) + original_uid = os.geteuid() + original_gid = os.getegid() + try: + os.setegid(account.pw_gid) + os.seteuid(account.pw_uid) + return open_store() + finally: + os.seteuid(original_uid) + os.setegid(original_gid) + return open_store() def _require_server_agent_activation_binding( @@ -2639,6 +2668,281 @@ def _command_server_agent_reauthorize_expired_credential_locked( ) return 0 +def command_server_agent_replace_expired_scope_harness(args: argparse.Namespace) -> int: + """Replace one expired scope member after exact owner Approval.""" + + if os.geteuid() != 0: + raise SystemExit("managed scope harness replacement requires root") + with _managed_server_reauthorization_lock(): + return _command_server_agent_replace_expired_scope_harness_locked(args) + + +def _command_server_agent_replace_expired_scope_harness_locked( + args: argparse.Namespace, +) -> int: + config_path = Path(os.path.abspath(args.config)) + identity_path = Path(os.path.abspath(args.identity)) + state_path = Path(os.path.abspath(args.state)) + if ( + config_path != CORE_CONFIG + or identity_path != SERVER_AGENT_IDENTITY + or state_path != SETUP_ROOT / "scope-harness-replacement.json" + ): + raise SystemExit("managed scope replacement requires exact package-owned paths") + try: + import pwd as posix_pwd + + core_account = posix_pwd.getpwnam(CORE_USER) + except (KeyError, OSError) as exc: + raise SystemExit("managed Core service custody is unavailable") from exc + config_raw, config_metadata = _managed_private_file( + config_path, + label="managed server configuration", + expected_uid=core_account.pw_uid, + ) + identity_raw, identity_metadata = _managed_private_file( + identity_path, + label="managed server identity", + expected_uid=core_account.pw_uid, + ) + if ( + config_metadata.st_gid != core_account.pw_gid + or identity_metadata.st_gid != core_account.pw_gid + ): + raise SystemExit("managed server file group custody is unsafe") + try: + config = load_config_json(config_raw.decode()) + identity = json.loads(identity_raw) + actor = VerifiedActor.model_validate(identity["actor"]) + except (KeyError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise SystemExit("managed-server config or identity is invalid") from exc + if ( + config.profile is not RuntimeProfile.ALWAYS_ON_SERVER_AGENT + or actor.principal_id is None + or actor.harness_id is None + or actor.credential_id is None + or actor.binding_assurance not in {"os_bound", "hardware_bound"} + or actor.domain_id != config.domain_id + or actor.harness_id != config.enrolled_harness_id + ): + raise SystemExit("managed-server identity is not eligible for scope replacement") + key_path = Path(str(identity.get("private_key_path", ""))) + key_raw, key_metadata = _managed_private_file( + key_path, + label="managed server identity key", + expected_uid=core_account.pw_uid, + ) + if key_path != SERVER_AGENT_KEY or key_metadata.st_gid != core_account.pw_gid: + raise SystemExit("managed server identity key custody is unsafe") + key = P256KeyPair.from_private_pem(key_raw) + _require_managed_server_reauthorization_topology(config) + verifier, approval_config = _managed_server_reauthorization_verifier(config) + if approval_config.approver_principal_id != actor.principal_id: + raise SystemExit("configured Approval owner does not match the managed-server principal") + core_environment = _parse_environment_file(CORE_ENV, label="managed Core environment") + if any(name in os.environ for name in ("SSL_CERT_FILE", "SSL_CERT_DIR", "SSLKEYLOGFILE")): + raise SystemExit("ambient TLS trust overrides are forbidden for managed scope replacement") + if config.database_url_env is None or config.database_url_env not in core_environment: + raise SystemExit("managed Core database credential is unavailable") + broker_name = approval_config.service_credential_env + if broker_name not in core_environment: + raise SystemExit("managed Approval broker credential is unavailable") + database_url = core_environment[config.database_url_env] + expected_binding = { + "config_sha256": hashlib.sha256(config_raw).hexdigest(), + "identity_sha256": hashlib.sha256(identity_raw).hexdigest(), + "scope_id": args.scope_id, + "old_harness_id": args.old_harness_id, + "new_harness_id": args.new_harness_id, + "role": args.role, + } + + def prepare_request() -> ScopeHarnessReplacementRequest: + store = _open_server_agent_activation_store( + config, + database_url_override=database_url, + ) + try: + _require_server_agent_activation_binding( + store, + config=config, + actor=actor, + key=key, + ) + now = int(time.time()) + return ScopeHarnessReplacementService( + store, + verifier, + ).prepare( + actor=actor, + scope_id=args.scope_id, + old_harness_id=args.old_harness_id, + new_harness_id=args.new_harness_id, + role=args.role, + request_id=str(uuid4()), + issued_at=now, + expires_at=now + 600, + ) + finally: + store.close() + + if os.path.lexists(state_path): + try: + pending = json.loads( + _owner_only_file( + state_path, + label="managed scope replacement state", + ) + ) + if ( + not isinstance(pending, dict) + or pending.get("schema") + != "agentnet.managed-scope-harness-replacement-state.v1" + or pending.get("binding") != expected_binding + ): + raise ValueError("state binding mismatch") + request = ScopeHarnessReplacementRequest.model_validate(pending["request"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit("managed scope replacement state is invalid") from exc + else: + if args.replace_terminal_state: + raise SystemExit("no terminal scope replacement state exists") + request = prepare_request() + pending = { + "schema": "agentnet.managed-scope-harness-replacement-state.v1", + "binding": expected_binding, + "request": request.model_dump(mode="json"), + "possession_secret": secrets.token_urlsafe(32), + "approval_request_id": None, + } + _write_private_config(state_path, pending) + + client = _managed_server_reauthorization_client( + config, + broker_credential=core_environment[broker_name], + ) + try: + approval_request_id = pending["approval_request_id"] + if approval_request_id is None: + created = client.create_request( + idempotency_key=f"scope-harness-replacement:{request.request_id}", + domain_id=request.domain_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=request.canonical_transaction, + transaction_digest=request.digest, + possession_hash=hashlib.sha256( + str(pending["possession_secret"]).encode() + ).hexdigest(), + request_expires_at=request.expires_at, + ) + approval_request_id = created["request_id"] + pending["approval_request_id"] = approval_request_id + _write_private_config(state_path, pending, force=True) + status = client.request_status( + request_id=str(approval_request_id), + transaction_digest=request.digest, + ) + if status["state"] in {"rejected", "expired"}: + if not args.replace_terminal_state: + raise SystemExit( + "managed scope replacement is terminal; rerun with --replace-terminal-state" + ) + request = prepare_request() + pending = { + "schema": "agentnet.managed-scope-harness-replacement-state.v1", + "binding": expected_binding, + "request": request.model_dump(mode="json"), + "possession_secret": secrets.token_urlsafe(32), + "approval_request_id": None, + } + _write_private_config(state_path, pending, force=True) + created = client.create_request( + idempotency_key=f"scope-harness-replacement:{request.request_id}", + domain_id=request.domain_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=request.canonical_transaction, + transaction_digest=request.digest, + possession_hash=hashlib.sha256( + str(pending["possession_secret"]).encode() + ).hexdigest(), + request_expires_at=request.expires_at, + ) + approval_request_id = created["request_id"] + pending["approval_request_id"] = approval_request_id + _write_private_config(state_path, pending, force=True) + status = client.request_status( + request_id=str(approval_request_id), + transaction_digest=request.digest, + ) + if status["state"] == "pending": + print( + json.dumps( + { + "schema": "agentnet.managed-scope-harness-replacement-cli.v1", + "status": "waiting_owner_approval", + "owner_action": ( + f"Open {approval_config.public_origin}/approval and approve " + "the expired scope-harness replacement, then rerun this exact command." + ), + "membership_changed": False, + "service_restart": "not_performed", + }, + indent=2, + sort_keys=True, + ) + ) + return 2 + if status["state"] != "issued": + raise SystemExit("managed scope replacement was not approved") + receipt = client.retrieve_receipt( + request_id=str(approval_request_id), + possession_secret=str(pending["possession_secret"]), + domain_id=request.domain_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + transaction_digest=request.digest, + idempotency_key=f"scope-harness-replacement-retrieve:{request.request_id}", + ) + finally: + client.close() + + store = _open_server_agent_activation_store( + config, + database_url_override=database_url, + ) + try: + _require_server_agent_activation_binding( + store, + config=config, + actor=actor, + key=key, + ) + result = ScopeHarnessReplacementService(store, verifier).replace( + actor=actor, + request=request, + approval=receipt, + ) + finally: + store.close() + _remove_private_state(state_path) + print( + json.dumps( + { + "schema": "agentnet.managed-scope-harness-replacement-cli.v1", + "status": "completed", + "scope_id": result.scope_id, + "old_harness_id": result.old_harness_id, + "new_harness_id": result.new_harness_id, + "scope_revision": result.scope_revision, + "membership_sequence": result.membership_sequence, + "idempotent_database_repeat": result.idempotent_repeat, + "service_restart": "not_performed", + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + def command_server_agent_activate(args: argparse.Namespace) -> int: """Bind an offline server config to one exact enrolled harness credential. @@ -6416,6 +6720,35 @@ def build_parser() -> argparse.ArgumentParser: server_agent_reauthorize.set_defaults( func=command_server_agent_reauthorize_expired_credential ) + server_agent_replace_scope_harness = server_agent_commands.add_parser( + "replace-expired-scope-harness", + help="replace one expired same-principal member in an active collaboration scope", + ) + server_agent_replace_scope_harness.add_argument("--scope-id", required=True) + server_agent_replace_scope_harness.add_argument("--old-harness-id", required=True) + server_agent_replace_scope_harness.add_argument("--new-harness-id", required=True) + server_agent_replace_scope_harness.add_argument( + "--role", + choices=("member",), + default="member", + ) + server_agent_replace_scope_harness.add_argument("--config", default=str(CORE_CONFIG)) + server_agent_replace_scope_harness.add_argument( + "--identity", + default=str(SERVER_AGENT_IDENTITY), + ) + server_agent_replace_scope_harness.add_argument( + "--state", + default="/var/lib/agentnet-setup/scope-harness-replacement.json", + ) + server_agent_replace_scope_harness.add_argument( + "--replace-terminal-state", + action="store_true", + help="replace only a broker-proven rejected or expired pending ceremony", + ) + server_agent_replace_scope_harness.set_defaults( + func=command_server_agent_replace_expired_scope_harness + ) join = commands.add_parser("join", help="enroll this person and device into an AgentNet") join_commands = join.add_subparsers(dest="join_command", required=True) diff --git a/src/agentnet/core/app.py b/src/agentnet/core/app.py index 142cad8..6413717 100644 --- a/src/agentnet/core/app.py +++ b/src/agentnet/core/app.py @@ -903,7 +903,13 @@ def _require_managed_credential_supersession( "managed-server supersession cache is incomplete", ) return dict(self._verified_supersession_evidence) - journal_path = self.config.data_dir / "credential-supersessions.json" + journal_root = self.config.data_dir + if ( + getattr(self.config, "profile", None) is RuntimeProfile.ALWAYS_ON_SERVER_AGENT + and journal_root.name == "core" + ): + journal_root = journal_root.parent + journal_path = journal_root / "credential-supersessions.json" journal_exists = os.path.lexists(journal_path) terminal_credential = completed_c0_terminal_credential( self.store, diff --git a/src/agentnet/operations/canonical_owner_recovery.py b/src/agentnet/operations/canonical_owner_recovery.py new file mode 100644 index 0000000..d4320cc --- /dev/null +++ b/src/agentnet/operations/canonical_owner_recovery.py @@ -0,0 +1,1185 @@ +"""Bounded recovery from the v0.1.50 placeholder Approval owner.""" + +from __future__ import annotations + +import hashlib +import json +import os +import secrets +import stat +from pathlib import Path +from typing import Any, Literal + +from cryptography.hazmat.primitives import serialization +from pydantic import BaseModel, ConfigDict, Field, model_validator +from agentnet.approval.config import ApprovalServiceConfig + +from agentnet.approval.store import ApprovalStore, approval_user_handle +from agentnet.errors import GateBlocked +from agentnet.security.signatures import ( + P256KeyPair, + b64url_encode, + canonical_json, + load_public_key, +) + + +_RECOVERY_ID = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +_MAX_OWNER_APPROVAL_AGE_SECONDS = 31 * 24 * 60 * 60 + + +class CanonicalOwnerAdoptionRequest(BaseModel): + """Exact owner-approved placeholder-to-canonical adoption input.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal["agentnet.canonical-owner-adoption.v1"] = Field(alias="schema") + recovery_id: str = Field(pattern=_RECOVERY_ID) + domain_id: str = Field(min_length=1, max_length=256) + source_principal_id: str = Field(min_length=1, max_length=256) + target_principal_id: str = Field(min_length=1, max_length=256) + oidc_issuer: str = Field(min_length=1, max_length=512) + oidc_subject: str = Field(min_length=1, max_length=512) + verified_email: str = Field(min_length=3, max_length=320) + verifier_id: str = Field(min_length=1, max_length=128) + approved_at: int = Field(ge=0) + + @model_validator(mode="after") + def _different_principals(self) -> "CanonicalOwnerAdoptionRequest": + if self.source_principal_id == self.target_principal_id: + raise ValueError("source and target principals must differ") + return self + + +class CanonicalOwnerAdoptionResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal["agentnet.canonical-owner-adoption-result.v1"] = Field(alias="schema") + status: Literal["adopted", "already_exact"] + recovery_id: str = Field(pattern=_RECOVERY_ID) + migrated_active_credentials: int = Field(ge=0) + revoked_browser_sessions: int = Field(ge=0) + canceled_registration_ceremonies: int = Field(ge=0) + + +class CanonicalOwnerRecoveryReconstruction(BaseModel): + """Exact observation proving a journal-less terminal repair.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal[ + "agentnet.canonical-owner-recovery-reconstruction.v1" + ] = Field(alias="schema") + observed_at: int = Field(ge=0) + marker_approval_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + marker_core_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + realized_approval_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + realized_core_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + source_principal_evidence: Literal["approval_audit_marker_digest"] + + + +class CanonicalOwnerPartialRecovery(BaseModel): + """Exact pre-adoption split state captured before any recovery mutation.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal[ + "agentnet.canonical-owner-partial-recovery.v1" + ] = Field(alias="schema") + observed_at: int = Field(ge=0) + marker_approval_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + marker_core_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + realized_approval_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + realized_core_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + realized_core_oidc_config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + source_principal_evidence: Literal["active_owner_binding"] + + +class CanonicalOwnerRecoveryJournal(BaseModel): + """Strict resumable evidence for one owner/signer cutover.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal["agentnet.canonical-owner-recovery-journal.v1"] = Field( + alias="schema" + ) + recovery_id: str = Field(pattern=_RECOVERY_ID) + request_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + config_path: str = Field(min_length=1, max_length=4096) + signer_path: str = Field(min_length=1, max_length=4096) + target_signer_path: str = Field(min_length=1, max_length=4096) + source_config_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + domain_id: str = Field(min_length=1, max_length=256) + source_principal_id: str = Field(min_length=1, max_length=256) + target_principal_id: str = Field(min_length=1, max_length=256) + oidc_issuer: str = Field(min_length=1, max_length=512) + source_signer_key_id: str = Field(min_length=16, max_length=256) + source_signer_public_key_pem: str = Field(min_length=64, max_length=8192) + target_signer_key_id: str = Field(min_length=16, max_length=256) + target_signer_public_key_pem: str = Field(min_length=64, max_length=8192) + staged_target_signer_private_key_pem: str | None = Field( + default=None, + min_length=64, + max_length=65_536, + ) + phase: Literal[ + "prepared", + "authority_adopted", + "signer_replaced", + "config_replacing", + "config_replaced", + "complete", + ] + prepared_at: int = Field(ge=0) + completed_at: int | None = Field(default=None, ge=0) + authority_adoption: dict[str, Any] | None = None + authority_adoption_digest: str | None = Field( + default=None, pattern=r"^[0-9a-f]{64}$" + ) + reconstruction: CanonicalOwnerRecoveryReconstruction | None = None + partial_recovery: CanonicalOwnerPartialRecovery | None = None + + @model_validator(mode="after") + def _phase_shape(self) -> "CanonicalOwnerRecoveryJournal": + if ( + self.phase != "prepared" + and self.staged_target_signer_private_key_pem is not None + ): + raise ValueError("staged signer secret is retained after preparation") + adopted = self.phase != "prepared" + if adopted != ( + self.authority_adoption is not None + and self.authority_adoption_digest is not None + ): + raise ValueError("authority adoption evidence does not match journal phase") + if (self.phase == "complete") != (self.completed_at is not None): + raise ValueError("completion timestamp does not match journal phase") + if self.reconstruction is not None and ( + self.phase != "complete" + or self.completed_at != self.reconstruction.observed_at + ): + raise ValueError("reconstructed evidence does not match terminal phase") + if self.reconstruction is not None and self.partial_recovery is not None: + raise ValueError("recovery evidence classifications are ambiguous") + if ( + self.partial_recovery is not None + and self.prepared_at != self.partial_recovery.observed_at + ): + raise ValueError("partial recovery evidence does not match preparation") + if ( + self.authority_adoption is not None + and hashlib.sha256(canonical_json(self.authority_adoption)).hexdigest() + != self.authority_adoption_digest + ): + raise ValueError("authority adoption digest does not match") + return self + + + +def _request_digest(request: CanonicalOwnerAdoptionRequest) -> str: + return hashlib.sha256( + canonical_json(request.model_dump(by_alias=True, mode="json")) + ).hexdigest() + + +def _result( + request: CanonicalOwnerAdoptionRequest, + *, + status: Literal["adopted", "already_exact"], + migrated_active_credentials: int, + revoked_browser_sessions: int, + canceled_registration_ceremonies: int, +) -> dict[str, Any]: + return CanonicalOwnerAdoptionResult( + schema="agentnet.canonical-owner-adoption-result.v1", + status=status, + recovery_id=request.recovery_id, + migrated_active_credentials=migrated_active_credentials, + revoked_browser_sessions=revoked_browser_sessions, + canceled_registration_ceremonies=canceled_registration_ceremonies, + ).model_dump(by_alias=True) + + +def _matches_binding(row: Any, request: CanonicalOwnerAdoptionRequest, principal_id: str) -> bool: + return ( + row["domain_id"] == request.domain_id + and row["approver_principal_id"] == principal_id + and row["oidc_issuer"] == request.oidc_issuer + and row["oidc_subject"] == request.oidc_subject + and row["verified_email"] == request.verified_email + and row["status"] == "active" + and row["revoked_at"] is None + ) + + +def _active_credentials(connection: Any, principal_id: str, domain_id: str) -> list[Any]: + return list( + connection.execute( + """SELECT * FROM approval_webauthn_credentials + WHERE approver_principal_id=? AND domain_id=? AND status='active' + ORDER BY credential_id_b64""", + (principal_id, domain_id), + ).fetchall() + ) + + +def _persisted_adoption_counts( + connection: Any, + *, + request: CanonicalOwnerAdoptionRequest, + request_digest: str, +) -> tuple[int, int, int]: + rows = connection.execute( + """SELECT detail_code FROM approval_audit + WHERE action='owner.canonical_adoption' AND approver_principal_id=? + AND domain_id=? AND approval_purpose='owner.canonical_adoption' + AND transaction_digest=? AND outcome='adopted'""", + (request.target_principal_id, request.domain_id, request_digest), + ).fetchall() + if len(rows) != 1: + raise GateBlocked( + "canonical_owner_recovery", "target recovery evidence is incomplete" + ) + parts = str(rows[0]["detail_code"]).split(":") + try: + counts = tuple(int(value) for value in parts[2:]) + except ValueError as exc: + raise GateBlocked( + "canonical_owner_recovery", "target recovery evidence is invalid" + ) from exc + if ( + parts[:2] != ["canonical_owner_adopted", "v1"] + or len(counts) != 3 + or any(value < 0 for value in counts) + or parts[2:] != [str(value) for value in counts] + ): + raise GateBlocked( + "canonical_owner_recovery", "target recovery evidence is invalid" + ) + return counts + + +def _require_exact_target( + connection: Any, + *, + request: CanonicalOwnerAdoptionRequest, + binding: Any, + request_digest: str, +) -> tuple[int, int, int]: + if not _matches_binding(binding, request, request.target_principal_id): + raise GateBlocked("canonical_owner_recovery", "target authority already exists") + if connection.execute( + """SELECT 1 FROM approval_owner_bindings + WHERE domain_id=? AND approver_principal_id=? AND status='active'""", + (request.domain_id, request.source_principal_id), + ).fetchone() is not None: + raise GateBlocked("canonical_owner_recovery", "source authority remains active") + if _active_credentials(connection, request.source_principal_id, request.domain_id): + raise GateBlocked("canonical_owner_recovery", "source authority remains active") + credentials = _active_credentials(connection, request.target_principal_id, request.domain_id) + expected_handle = b64url_encode( + approval_user_handle( + verifier_id=request.verifier_id, + principal_id=request.target_principal_id, + domain_id=request.domain_id, + ) + ) + if not credentials or any(row["user_handle_b64"] != expected_handle for row in credentials): + raise GateBlocked("canonical_owner_recovery", "target authority is incomplete") + return _persisted_adoption_counts( + connection, + request=request, + request_digest=request_digest, + ) + + +def validate_canonical_owner_adoption_state( + connection: Any, + *, + request: CanonicalOwnerAdoptionRequest, +) -> tuple[int, int, int]: + """Require the exact durable authority state produced by owner adoption.""" + + active_bindings = list( + connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + ) + if len(active_bindings) != 1: + raise GateBlocked("canonical_owner_recovery", "approval owner state is ambiguous") + counts = _require_exact_target( + connection, + request=request, + binding=active_bindings[0], + request_digest=_request_digest(request), + ) + if list(connection.execute("PRAGMA foreign_key_check").fetchall()): + raise GateBlocked("canonical_owner_recovery", "approval owner adoption is inconsistent") + return counts + + +def _validated_source_state( + connection: Any, + *, + request: CanonicalOwnerAdoptionRequest, + now: int, +) -> tuple[Any, list[Any]]: + active_bindings = list( + connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + ) + if ( + len(active_bindings) != 1 + or not _matches_binding( + active_bindings[0], + request, + request.source_principal_id, + ) + ): + raise GateBlocked( + "canonical_owner_recovery", + "source state does not match", + ) + if connection.execute( + """SELECT 1 FROM approval_owner_bindings + WHERE domain_id=? AND approver_principal_id=?""", + (request.domain_id, request.target_principal_id), + ).fetchone() is not None or connection.execute( + """SELECT 1 FROM approval_webauthn_credentials + WHERE domain_id=? AND approver_principal_id=?""", + (request.domain_id, request.target_principal_id), + ).fetchone() is not None: + raise GateBlocked( + "canonical_owner_recovery", + "target authority already exists", + ) + pending_request = connection.execute( + """SELECT 1 FROM approval_requests + WHERE approver_principal_id=? AND domain_id=? AND state='pending' + LIMIT 1""", + (request.source_principal_id, request.domain_id), + ).fetchone() + pending_registration = connection.execute( + """SELECT 1 FROM approval_registration_sessions + WHERE approver_principal_id=? AND domain_id=? + AND consumed_at IS NULL AND expires_at>? LIMIT 1""", + (request.source_principal_id, request.domain_id, now), + ).fetchone() + pending_oidc = connection.execute( + """SELECT 1 FROM approval_oidc_login_transactions + WHERE state IN ('pending','callback_claimed') LIMIT 1""" + ).fetchone() + if ( + pending_request is not None + or pending_registration is not None + or pending_oidc is not None + ): + raise GateBlocked( + "canonical_owner_recovery", + "nonterminal approval state exists", + ) + credentials = _active_credentials( + connection, + request.source_principal_id, + request.domain_id, + ) + source_handle = b64url_encode( + approval_user_handle( + verifier_id=request.verifier_id, + principal_id=request.source_principal_id, + domain_id=request.domain_id, + ) + ) + if not credentials or any( + row["user_handle_b64"] != source_handle for row in credentials + ): + raise GateBlocked( + "canonical_owner_recovery", + "source credential state does not match", + ) + if connection.execute( + """SELECT 1 FROM approval_audit + WHERE action='owner.canonical_adoption' AND domain_id=? LIMIT 1""", + (request.domain_id,), + ).fetchone() is not None: + raise GateBlocked( + "canonical_owner_recovery", + "canonical owner adoption evidence already exists", + ) + return active_bindings[0], credentials + + +def validate_canonical_owner_source_state( + connection: Any, + *, + request: CanonicalOwnerAdoptionRequest, + now: int, +) -> None: + """Require the exact durable pre-adoption owner state without mutation.""" + + _validated_source_state(connection, request=request, now=now) + if list(connection.execute("PRAGMA foreign_key_check").fetchall()): + raise GateBlocked( + "canonical_owner_recovery", + "approval owner source state is inconsistent", + ) + + +def adopt_canonical_approval_owner( + store: ApprovalStore, + *, + request: CanonicalOwnerAdoptionRequest, + now: int, +) -> dict[str, Any]: + """Atomically move only live Approval authority to the enrolled principal.""" + + if ( + isinstance(now, bool) + or not isinstance(now, int) + or request.approved_at > now + or now - request.approved_at > _MAX_OWNER_APPROVAL_AGE_SECONDS + ): + raise GateBlocked("canonical_owner_recovery", "owner approval is not current") + + digest = _request_digest(request) + with store.transaction() as connection: + active_bindings = list( + connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + ) + if len(active_bindings) != 1: + raise GateBlocked("canonical_owner_recovery", "approval owner state is ambiguous") + binding = active_bindings[0] + + if binding["approver_principal_id"] == request.target_principal_id: + ( + migrated_active_credentials, + revoked_browser_sessions, + canceled_registration_ceremonies, + ) = validate_canonical_owner_adoption_state( + connection, + request=request, + ) + return _result( + request, + status="already_exact", + migrated_active_credentials=migrated_active_credentials, + revoked_browser_sessions=revoked_browser_sessions, + canceled_registration_ceremonies=canceled_registration_ceremonies, + ) + + binding, credentials = _validated_source_state( + connection, + request=request, + now=now, + ) + + ceremonies = connection.execute( + """UPDATE approval_registration_ceremonies + SET state='canceled' + WHERE owner_binding_id=? AND state='pending'""", + (binding["binding_id"],), + ) + sessions = connection.execute( + """UPDATE approval_browser_sessions + SET revoked_at=?,revocation_reason='canonical_owner_adoption' + WHERE owner_binding_id=? AND revoked_at IS NULL AND expires_at>?""", + (now, binding["binding_id"], now), + ) + connection.execute( + """UPDATE approval_owner_bindings + SET approver_principal_id=? + WHERE binding_id=? AND approver_principal_id=? AND status='active'""", + ( + request.target_principal_id, + binding["binding_id"], + request.source_principal_id, + ), + ) + target_handle = b64url_encode( + approval_user_handle( + verifier_id=request.verifier_id, + principal_id=request.target_principal_id, + domain_id=request.domain_id, + ) + ) + migrated = connection.execute( + """UPDATE approval_webauthn_credentials + SET approver_principal_id=?,user_handle_b64=? + WHERE approver_principal_id=? AND domain_id=? AND status='active'""", + ( + request.target_principal_id, + target_handle, + request.source_principal_id, + request.domain_id, + ), + ) + if migrated.rowcount != len(credentials): + raise GateBlocked("canonical_owner_recovery", "approval owner adoption raced") + adoption_counts = ( + len(credentials), + int(sessions.rowcount), + int(ceremonies.rowcount), + ) + adoption_detail = "canonical_owner_adopted:v1:" + ":".join( + str(value) for value in adoption_counts + ) + connection.execute( + """INSERT INTO approval_audit( + action,request_id,approver_principal_id,domain_id,approval_purpose, + transaction_digest,occurred_at,outcome,detail_code + ) VALUES('owner.canonical_adoption',NULL,?,?, + 'owner.canonical_adoption',?,?,'adopted',?)""", + ( + request.target_principal_id, + request.domain_id, + digest, + now, + adoption_detail, + ), + ) + persisted_counts = validate_canonical_owner_adoption_state( + connection, + request=request, + ) + if persisted_counts != adoption_counts: + raise GateBlocked( + "canonical_owner_recovery", "approval owner adoption evidence drifted" + ) + + return _result( + request, + status="adopted", + migrated_active_credentials=adoption_counts[0], + revoked_browser_sessions=adoption_counts[1], + canceled_registration_ceremonies=adoption_counts[2], + ) + + +class CanonicalOwnerRecoveryResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + schema_version: Literal["agentnet.canonical-owner-recovery-result.v1"] = Field(alias="schema") + status: Literal["recovered", "already_exact"] + recovery_id: str = Field(pattern=_RECOVERY_ID) + principal_id: str = Field(min_length=1, max_length=256) + signer_key_id: str = Field(min_length=16, max_length=256) + historical_signer_key_id: str = Field(min_length=16, max_length=256) + authority_adoption: dict[str, Any] + + +def _open_private_parent(path: Path, *, create: bool) -> tuple[Path, int, os.stat_result]: + path = path.absolute() + if create: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + descriptor = os.open(path.parent, flags) + metadata = os.fstat(descriptor) + except OSError as exc: + raise GateBlocked("canonical_owner_recovery", "recovery path is unsafe") from exc + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o077 + ): + os.close(descriptor) + raise GateBlocked("canonical_owner_recovery", "recovery directory custody is invalid") + return path, descriptor, metadata + + +def _parent_matches(path: Path, metadata: os.stat_result) -> bool: + try: + current = os.stat(path.parent, follow_symlinks=False) + except OSError: + return False + return ( + stat.S_ISDIR(current.st_mode) + and current.st_dev == metadata.st_dev + and current.st_ino == metadata.st_ino + ) + + +def _private_write(path: Path, payload: bytes) -> None: + path, directory, parent = _open_private_parent(path, create=True) + temporary_name = f".{path.name}.{secrets.token_hex(12)}" + descriptor = -1 + try: + descriptor = os.open( + temporary_name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=directory, + ) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + try: + destination = os.stat(path.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + destination = None + if destination is not None and stat.S_ISLNK(destination.st_mode): + raise GateBlocked("canonical_owner_recovery", "recovery path changed") + if not _parent_matches(path, parent): + raise GateBlocked("canonical_owner_recovery", "recovery path changed") + os.replace( + temporary_name, + path.name, + src_dir_fd=directory, + dst_dir_fd=directory, + ) + if not _parent_matches(path, parent): + raise GateBlocked("canonical_owner_recovery", "recovery path changed") + os.fsync(directory) + except BaseException: + if descriptor >= 0: + os.close(descriptor) + try: + os.unlink(temporary_name, dir_fd=directory) + except FileNotFoundError: + pass + raise + finally: + os.close(directory) + + +def _private_unlink(path: Path) -> None: + path, directory, parent = _open_private_parent(path, create=False) + try: + try: + metadata = os.stat(path.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + return + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o077 + or not metadata.st_mode & 0o600 + ): + raise GateBlocked( + "canonical_owner_recovery", "retired signer custody is invalid" + ) + if not _parent_matches(path, parent): + raise GateBlocked("canonical_owner_recovery", "recovery path changed") + os.unlink(path.name, dir_fd=directory) + if not _parent_matches(path, parent): + raise GateBlocked("canonical_owner_recovery", "recovery path changed") + os.fsync(directory) + finally: + os.close(directory) + + +def _private_read(path: Path, *, maximum: int) -> bytes: + path, directory, _parent = _open_private_parent(path, create=False) + descriptor = -1 + try: + descriptor = os.open( + path.name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=directory, + ) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o077 + or not metadata.st_mode & 0o600 + ): + raise GateBlocked("canonical_owner_recovery", "recovery state custody is invalid") + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = -1 + payload = handle.read(maximum + 1) + except GateBlocked: + raise + except OSError as exc: + raise GateBlocked("canonical_owner_recovery", "recovery state is unavailable") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + os.close(directory) + if len(payload) > maximum: + raise GateBlocked("canonical_owner_recovery", "recovery state custody is invalid") + return payload + + +def _journal_write(path: Path, value: dict[str, Any]) -> None: + _private_write( + path, + json.dumps(value, indent=2, sort_keys=True).encode("utf-8") + b"\n", + ) + + +def _configured_owner( + config: ApprovalServiceConfig, + request: CanonicalOwnerAdoptionRequest, +) -> tuple[int, Any]: + matches = [ + (index, item) + for index, item in enumerate(config.approvers) + if item.domain_id == request.domain_id + and item.oidc_issuer == request.oidc_issuer + and item.oidc_subject == request.oidc_subject + and item.principal_id + in {request.source_principal_id, request.target_principal_id} + ] + if len(matches) != 1 or len(config.approvers) != 1: + raise GateBlocked("canonical_owner_recovery", "configured owner state is ambiguous") + return matches[0] + + +def _recovery_result( + request: CanonicalOwnerAdoptionRequest, + *, + status: Literal["recovered", "already_exact"], + journal: dict[str, Any], + adoption: dict[str, Any], +) -> dict[str, Any]: + return CanonicalOwnerRecoveryResult( + schema="agentnet.canonical-owner-recovery-result.v1", + status=status, + recovery_id=request.recovery_id, + principal_id=request.target_principal_id, + signer_key_id=str(journal["target_signer_key_id"]), + historical_signer_key_id=str(journal["source_signer_key_id"]), + authority_adoption=adoption, + ).model_dump(by_alias=True) +def _validate_recovery_journal( + value: object, + *, + request: CanonicalOwnerAdoptionRequest, + request_digest: str, + config_path: Path, +) -> dict[str, Any]: + try: + journal = CanonicalOwnerRecoveryJournal.model_validate(value) + source_public = load_public_key(journal.source_signer_public_key_pem) + target_public = load_public_key(journal.target_signer_public_key_pem) + staged_target = ( + P256KeyPair.from_private_pem( + journal.staged_target_signer_private_key_pem.encode("ascii") + ) + if journal.staged_target_signer_private_key_pem is not None + else None + ) + except Exception as exc: + raise GateBlocked( + "canonical_owner_recovery", "recovery journal is invalid" + ) from exc + expected = { + "recovery_id": request.recovery_id, + "request_digest": request_digest, + "config_path": str(config_path.absolute()), + "domain_id": request.domain_id, + "source_principal_id": request.source_principal_id, + "target_principal_id": request.target_principal_id, + "oidc_issuer": request.oidc_issuer, + } + if any( + getattr(journal, key) != expected_value + for key, expected_value in expected.items() + ): + raise GateBlocked( + "canonical_owner_recovery", "recovery journal conflicts with request" + ) + source_thumbprint = b64url_encode( + hashlib.sha256( + source_public.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ).digest() + ) + target_thumbprint = b64url_encode( + hashlib.sha256( + target_public.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ).digest() + ) + if ( + source_thumbprint != journal.source_signer_key_id + or target_thumbprint != journal.target_signer_key_id + or ( + staged_target is not None + and staged_target.thumbprint != journal.target_signer_key_id + ) + ): + raise GateBlocked( + "canonical_owner_recovery", "recovery signer evidence is invalid" + ) + return journal.model_dump(by_alias=True) + + +def validate_canonical_owner_recovery_journal( + value: object, + *, + request: CanonicalOwnerAdoptionRequest, + config_path: Path, +) -> dict[str, Any]: + """Validate one persisted journal against its exact recovery request.""" + + return _validate_recovery_journal( + value, + request=request, + request_digest=_request_digest(request), + config_path=config_path, + ) + + + + +def converge_canonical_approval_owner( + store: ApprovalStore, + *, + config_path: Path, + journal_path: Path, + request: CanonicalOwnerAdoptionRequest, + now: int, + _interrupt_after: Literal[ + "prepared_journal", + "authority_committed", + "authority_adopted", + "signer_replaced", + "retired_signers_removed", + ] + | None = None, +) -> dict[str, Any]: + """Converge Approval authority, receipt signer, and config after v0.1.50.""" + + request_digest = _request_digest(request) + config = ApprovalServiceConfig.model_validate_json( + _private_read(config_path.absolute(), maximum=1_048_576) + ) + index, configured = _configured_owner(config, request) + journal_exists = journal_path.exists() or journal_path.is_symlink() + journal: dict[str, Any] = {} + if journal_exists: + try: + raw_journal = json.loads( + _private_read(journal_path.absolute(), maximum=262_144).decode("utf-8") + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GateBlocked("canonical_owner_recovery", "recovery journal is invalid") from exc + journal = _validate_recovery_journal( + raw_journal, + request=request, + request_digest=request_digest, + config_path=config_path, + ) + signer_path = Path(str(journal["signer_path"])) + target_path = Path(str(journal["target_signer_path"])) + else: + signer_path = configured.signer_private_key_path + target_path = signer_path.parent / "canonical-owner-recovery.pem" + if target_path.exists() or target_path.is_symlink(): + raise GateBlocked( + "canonical_owner_recovery", "untracked target signer state exists" + ) + signer_root = config.data_dir / "signers" + if ( + signer_path.parent != signer_root + or target_path.parent != signer_root + or signer_path == target_path + or configured.signer_private_key_path + not in {signer_path, target_path} + ): + raise GateBlocked("canonical_owner_recovery", "configured signer path is outside custody") + + backup_path = config.data_dir / "canonical-owner-recovery.backup.pem" + if not journal_exists: + if configured.principal_id != request.source_principal_id: + raise GateBlocked("canonical_owner_recovery", "target config lacks recovery journal") + source_signer = P256KeyPair.from_private_pem( + _private_read(signer_path, maximum=65_536) + ) + if source_signer.thumbprint != configured.signer_key_id: + raise GateBlocked("canonical_owner_recovery", "source signer does not match config") + target_signer = P256KeyPair.generate() + _private_write(backup_path, source_signer.private_pem) + journal = { + "schema": "agentnet.canonical-owner-recovery-journal.v1", + "recovery_id": request.recovery_id, + "request_digest": request_digest, + "config_path": str(config_path.absolute()), + "signer_path": str(signer_path), + "target_signer_path": str(target_path), + "source_config_sha256": hashlib.sha256( + _private_read(config_path.absolute(), maximum=1_048_576) + ).hexdigest(), + "domain_id": request.domain_id, + "source_principal_id": request.source_principal_id, + "target_principal_id": request.target_principal_id, + "oidc_issuer": request.oidc_issuer, + "source_signer_key_id": source_signer.thumbprint, + "source_signer_public_key_pem": source_signer.public_pem, + "target_signer_key_id": target_signer.thumbprint, + "target_signer_public_key_pem": target_signer.public_pem, + "staged_target_signer_private_key_pem": target_signer.private_pem.decode( + "ascii" + ), + "phase": "prepared", + "prepared_at": now, + "completed_at": None, + } + journal = _validate_recovery_journal( + journal, + request=request, + request_digest=request_digest, + config_path=config_path, + ) + _journal_write(journal_path, journal) + if _interrupt_after == "prepared_journal": + raise RuntimeError("injected recovery interruption") + + staged_target_pem = journal.get("staged_target_signer_private_key_pem") + if target_path.exists() or target_path.is_symlink(): + target_signer = P256KeyPair.from_private_pem( + _private_read(target_path, maximum=65_536) + ) + elif isinstance(staged_target_pem, str): + target_signer = P256KeyPair.from_private_pem(staged_target_pem.encode("ascii")) + _private_write(target_path, target_signer.private_pem) + else: + raise GateBlocked( + "canonical_owner_recovery", "journaled target signer state is unavailable" + ) + if target_signer.thumbprint != journal["target_signer_key_id"]: + raise GateBlocked( + "canonical_owner_recovery", "staged signer does not match journal" + ) + if staged_target_pem is not None: + journal.pop("staged_target_signer_private_key_pem", None) + _journal_write(journal_path, journal) + if ( + journal.get("partial_recovery") is not None + and journal["phase"] == "prepared" + ): + source_signer = P256KeyPair.from_private_pem( + _private_read(signer_path, maximum=65_536) + ) + if source_signer.thumbprint != journal["source_signer_key_id"]: + raise GateBlocked( + "canonical_owner_recovery", + "source signer state conflicts with partial recovery", + ) + if backup_path.exists() or backup_path.is_symlink(): + backup_signer = P256KeyPair.from_private_pem( + _private_read(backup_path, maximum=65_536) + ) + if backup_signer.thumbprint != source_signer.thumbprint: + raise GateBlocked( + "canonical_owner_recovery", + "source signer backup is invalid", + ) + else: + _private_write(backup_path, source_signer.private_pem) + + was_complete = journal_exists and journal.get("phase") == "complete" + if journal["phase"] in {"prepared", "authority_adopted", "signer_replaced"}: + if ( + hashlib.sha256( + _private_read(config_path.absolute(), maximum=1_048_576) + ).hexdigest() + != journal["source_config_sha256"] + ): + raise GateBlocked( + "canonical_owner_recovery", "source config changed during recovery" + ) + source_signer = P256KeyPair.from_private_pem( + _private_read(backup_path, maximum=65_536) + ) + if source_signer.thumbprint != journal["source_signer_key_id"]: + raise GateBlocked( + "canonical_owner_recovery", "source signer backup is invalid" + ) + verified_adoption = adopt_canonical_approval_owner(store, request=request, now=now) + if _interrupt_after == "authority_committed": + raise RuntimeError("injected recovery interruption") + if journal["phase"] != "prepared": + try: + recorded_adoption = CanonicalOwnerAdoptionResult.model_validate( + journal["authority_adoption"] + ) + observed_adoption = CanonicalOwnerAdoptionResult.model_validate( + verified_adoption + ) + except (TypeError, ValueError) as exc: + raise GateBlocked( + "canonical_owner_recovery", + "authority adoption evidence conflicts with recovery", + ) from exc + if ( + recorded_adoption.recovery_id != request.recovery_id + or observed_adoption.recovery_id != recorded_adoption.recovery_id + or observed_adoption.status != "already_exact" + or observed_adoption.migrated_active_credentials + != recorded_adoption.migrated_active_credentials + or observed_adoption.revoked_browser_sessions + != recorded_adoption.revoked_browser_sessions + or observed_adoption.canceled_registration_ceremonies + != recorded_adoption.canceled_registration_ceremonies + ): + raise GateBlocked( + "canonical_owner_recovery", + "authority adoption evidence conflicts with recovery", + ) + if journal["phase"] == "prepared": + journal["phase"] = "authority_adopted" + journal["authority_adoption"] = verified_adoption + journal["authority_adoption_digest"] = hashlib.sha256( + canonical_json(verified_adoption) + ).hexdigest() + _journal_write(journal_path, journal) + if _interrupt_after == "authority_adopted": + raise RuntimeError("injected recovery interruption") + + if journal["phase"] == "authority_adopted": + target_signer = P256KeyPair.from_private_pem( + _private_read(target_path, maximum=65_536) + ) + if target_signer.thumbprint != journal["target_signer_key_id"]: + raise GateBlocked("canonical_owner_recovery", "staged signer does not match journal") + journal["phase"] = "signer_replaced" + _journal_write(journal_path, journal) + if _interrupt_after == "signer_replaced": + raise RuntimeError("injected recovery interruption") + if journal["phase"] == "signer_replaced": + current = ApprovalServiceConfig.model_validate_json( + _private_read(config_path.absolute(), maximum=1_048_576) + ) + _index, configured = _configured_owner(current, request) + if configured.principal_id != request.source_principal_id: + raise GateBlocked("canonical_owner_recovery", "configured principal changed unexpectedly") + journal["phase"] = "config_replacing" + _journal_write(journal_path, journal) + + if journal["phase"] == "config_replacing": + config = ApprovalServiceConfig.model_validate_json( + _private_read(config_path.absolute(), maximum=1_048_576) + ) + index, configured = _configured_owner(config, request) + if ( + configured.principal_id == request.source_principal_id + and hashlib.sha256( + _private_read(config_path.absolute(), maximum=1_048_576) + ).hexdigest() + != journal["source_config_sha256"] + ): + raise GateBlocked( + "canonical_owner_recovery", "source config changed during recovery" + ) + if configured.principal_id == request.source_principal_id: + replacement = configured.model_copy( + update={ + "principal_id": request.target_principal_id, + "signer_key_id": str(journal["target_signer_key_id"]), + "signer_private_key_path": target_path, + } + ) + approvers = list(config.approvers) + approvers[index] = replacement + config = config.model_copy(update={"approvers": tuple(approvers)}) + _private_write( + config_path.absolute(), + json.dumps(config.model_dump(mode="json"), indent=2, sort_keys=True).encode("utf-8") + + b"\n", + ) + elif ( + configured.principal_id != request.target_principal_id + or configured.signer_key_id != journal["target_signer_key_id"] + or configured.signer_private_key_path != target_path + ): + raise GateBlocked( + "canonical_owner_recovery", "configured target changed unexpectedly" + ) + journal["phase"] = "config_replaced" + _journal_write(journal_path, journal) + + if journal["phase"] == "config_replaced": + current = ApprovalServiceConfig.model_validate_json( + _private_read(config_path.absolute(), maximum=1_048_576) + ) + _index, configured = _configured_owner(current, request) + signer = P256KeyPair.from_private_pem( + _private_read(configured.signer_private_key_path, maximum=65_536) + ) + if ( + configured.principal_id != request.target_principal_id + or configured.signer_key_id != journal["target_signer_key_id"] + or signer.thumbprint != journal["target_signer_key_id"] + ): + raise GateBlocked("canonical_owner_recovery", "target signer state is incomplete") + _private_unlink(signer_path) + _private_unlink(backup_path) + if _interrupt_after == "retired_signers_removed": + raise RuntimeError("injected recovery interruption") + journal["phase"] = "complete" + journal["completed_at"] = now + journal = _validate_recovery_journal( + journal, + request=request, + request_digest=request_digest, + config_path=config_path, + ) + _journal_write(journal_path, journal) + _private_unlink(signer_path) + _private_unlink(backup_path) + + if journal["phase"] != "complete": + raise GateBlocked("canonical_owner_recovery", "recovery did not converge") + current = ApprovalServiceConfig.model_validate_json( + _private_read(config_path.absolute(), maximum=1_048_576) + ) + _index, configured = _configured_owner(current, request) + signer = P256KeyPair.from_private_pem( + _private_read(configured.signer_private_key_path, maximum=65_536) + ) + if ( + configured.principal_id != request.target_principal_id + or configured.signer_key_id != journal["target_signer_key_id"] + or signer.thumbprint != journal["target_signer_key_id"] + ): + raise GateBlocked("canonical_owner_recovery", "completed recovery state is invalid") + return _recovery_result( + request, + status="already_exact" if was_complete else "recovered", + journal=journal, + adoption=dict(journal["authority_adoption"]), + ) + + +__all__ = [ + "CanonicalOwnerAdoptionRequest", + "CanonicalOwnerAdoptionResult", + "CanonicalOwnerRecoveryReconstruction", + "CanonicalOwnerRecoveryJournal", + "CanonicalOwnerRecoveryResult", + "adopt_canonical_approval_owner", + "converge_canonical_approval_owner", + "validate_canonical_owner_adoption_state", + "validate_canonical_owner_recovery_journal", +] diff --git a/src/agentnet/operations/server_setup.py b/src/agentnet/operations/server_setup.py index 5bffd38..d3cf0dd 100644 --- a/src/agentnet/operations/server_setup.py +++ b/src/agentnet/operations/server_setup.py @@ -9,8 +9,10 @@ from __future__ import annotations import base64 +import copy import hashlib import ipaddress +import itertools import json import os import re @@ -18,11 +20,13 @@ import shutil import signal import stat +import sqlite3 import subprocess import tempfile import time import urllib.error import urllib.request +import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Literal, Mapping, Sequence @@ -31,8 +35,16 @@ if os.name == "posix": import grp import pwd +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + ValidationError, + model_validator, +) + -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, model_validator from agentnet import __version__ from agentnet.artifacts.clamav import ( @@ -58,6 +70,13 @@ load_audited_supersession_journal, load_supersession_journal, ) +from agentnet.operations.canonical_owner_recovery import ( + CanonicalOwnerAdoptionRequest, + CanonicalOwnerRecoveryJournal, + validate_canonical_owner_adoption_state, + validate_canonical_owner_recovery_journal, + validate_canonical_owner_source_state, +) from agentnet.operations.config import ( ExtensionConfig, ApprovalServiceClientConfig, @@ -68,7 +87,12 @@ ScannerTrustConfig, ) from agentnet.operations.config_migration import load_config_json -from agentnet.security.signatures import P256KeyPair, canonical_digest, verify_signature +from agentnet.security.signatures import ( + P256KeyPair, + canonical_digest, + canonical_json, + verify_signature, +) from agentnet.storage.migrations import MIGRATIONS from agentnet.storage.postgres import ( MIGRATION_LOCK_ID, @@ -83,6 +107,7 @@ validate_ordinary_server_postgres_dsn, ) from agentnet.storage.postgres_catalog import require_exact_postgres_catalog +from agentnet.storage.release_v7_schema import migrate_v6_communication_scopes CORE_USER = "agentnet" @@ -193,6 +218,12 @@ ("0.1.47", "0.1.50"): MANAGED_UNITS, ("0.1.48", "0.1.50"): MANAGED_UNITS, ("0.1.49", "0.1.50"): MANAGED_UNITS, + ("0.1.45", "0.1.51"): MANAGED_UNITS, + ("0.1.46", "0.1.51"): MANAGED_UNITS, + ("0.1.47", "0.1.51"): MANAGED_UNITS, + ("0.1.48", "0.1.51"): MANAGED_UNITS, + ("0.1.49", "0.1.51"): MANAGED_UNITS, + ("0.1.50", "0.1.51"): MANAGED_UNITS, } _FORWARD_ONLY_SETUP_UPGRADES = frozenset( { @@ -215,6 +246,12 @@ ("0.1.47", "0.1.50"), ("0.1.48", "0.1.50"), ("0.1.49", "0.1.50"), + ("0.1.45", "0.1.51"), + ("0.1.46", "0.1.51"), + ("0.1.47", "0.1.51"), + ("0.1.48", "0.1.51"), + ("0.1.49", "0.1.51"), + ("0.1.50", "0.1.51"), } ) # The lifecycle release is the sole rollback-capable database upgrade. Older @@ -246,6 +283,7 @@ # Blockers that mean "the response was lost", not "the operation was refused". # Only these justify one bounded idempotent retry of a product command. _RESPONSE_LOSS_BLOCKERS = frozenset({"invalid_product_evidence", "product_command_failed"}) +_APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA = "agentnet.server-setup.upgrade-journal.v3" _UPGRADE_JOURNAL_SCHEMA = "agentnet.server-setup.upgrade-journal.v2" _LEGACY_UPGRADE_JOURNAL_SCHEMA = "agentnet.server-setup.upgrade-journal.v1" _MAX_UNIT_BYTES = 65_536 @@ -263,6 +301,8 @@ } ) _JOURNALED_CONFIG_KEYS = frozenset({"core_config", "core_oidc_config"}) +_JOURNALED_APPROVAL_CONFIG_KEYS = _JOURNALED_CONFIG_KEYS | {"approval_config"} +_APPROVAL_REQUEST_TTL_UPGRADE = ("0.1.50", "0.1.51") class ServerSetupError(RuntimeError): @@ -2644,7 +2684,12 @@ def _read_upgrade_journal(path: Path, *, uid: int, gid: int) -> dict[str, Any] | and all(isinstance(value, str) for value in units.values()) ) current_unit_shape = ( - schema in {_UPGRADE_JOURNAL_SCHEMA, _LIFECYCLE_UPGRADE_JOURNAL_SCHEMA} + schema + in { + _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA, + _UPGRADE_JOURNAL_SCHEMA, + _LIFECYCLE_UPGRADE_JOURNAL_SCHEMA, + } and source_profile is not None and isinstance(units, dict) and set(units) == set(MANAGED_UNITS) @@ -2683,10 +2728,16 @@ def _read_upgrade_journal(path: Path, *, uid: int, gid: int) -> dict[str, Any] | and len(str(journal["previous_marker"])) <= 2 * _MAX_CONFIG_BYTES ) ) + expected_config_keys = ( + _JOURNALED_APPROVAL_CONFIG_KEYS + if schema == _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA + else _JOURNALED_CONFIG_KEYS + ) if ( schema not in { _LEGACY_UPGRADE_JOURNAL_SCHEMA, + _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA, _UPGRADE_JOURNAL_SCHEMA, _LIFECYCLE_UPGRADE_JOURNAL_SCHEMA, } @@ -2699,7 +2750,7 @@ def _read_upgrade_journal(path: Path, *, uid: int, gid: int) -> dict[str, Any] | or not isinstance(to_package_version, str) or not (legacy_unit_shape or current_unit_shape) or not isinstance(configs, dict) - or set(configs) != _JOURNALED_CONFIG_KEYS + or set(configs) != expected_config_keys or not lifecycle_shape ): raise ServerSetupError("setup_upgrade_conflict", "setup upgrade journal is invalid") @@ -2757,7 +2808,12 @@ def _journaled_config_payloads(journal: Mapping[str, Any]) -> dict[str, bytes]: } except (KeyError, ValueError, TypeError) as exc: raise ServerSetupError("setup_upgrade_conflict", "setup upgrade journal is invalid") from exc - if set(payloads) != _JOURNALED_CONFIG_KEYS or any( + expected_keys = ( + _JOURNALED_APPROVAL_CONFIG_KEYS + if journal.get("schema") == _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA + else _JOURNALED_CONFIG_KEYS + ) + if set(payloads) != expected_keys or any( not payload or len(payload) > _MAX_CONFIG_BYTES for payload in payloads.values() ): raise ServerSetupError("setup_upgrade_conflict", "setup upgrade journal is invalid") @@ -3436,6 +3492,47 @@ def _run_postgres_probe_as( return evidence +def _repair_committed_communication_scope_projection( + database_url: str, +) -> dict[str, Any]: + """Materialize every exact committed legacy scope under one transaction.""" + + import psycopg + from psycopg.rows import dict_row + + connection = psycopg.connect( + database_url, + autocommit=False, + row_factory=dict_row, + connect_timeout=5, + application_name="agentnet:server-setup-scope-projection", + ) + try: + with connection.transaction(): + require_exact_postgres_catalog(connection, migrations=MIGRATIONS) + migrated = migrate_v6_communication_scopes(connection, postgres=True) + return {"ready": True, "migrated": migrated} + finally: + connection.close() + + +def _repair_committed_communication_scope_projection_as( + account: pwd.struct_passwd, + database_url: str, +) -> dict[str, Any]: + try: + return _run_postgres_probe_as( + account, + lambda: _repair_committed_communication_scope_projection(database_url), + stage="communication_scope_projection", + ) + except ServerSetupError as exc: + raise ServerSetupError( + "communication_scope_projection", + "committed communication scope projection could not be proven exact", + ) from exc + + def _postgres_relation_digest(connection: Any, relation: str) -> str: """Hash one preserved relation without exporting its protected row values.""" @@ -4597,207 +4694,2076 @@ def _managed_config_digest( return canonical_digest(value) -def _require_private_directory(path: Path, account: pwd.struct_passwd, *, blocker: str) -> None: - try: - metadata = path.lstat() - resolved = path.resolve(strict=True) - except OSError as exc: - raise ServerSetupError(blocker, "managed private directory is unavailable") from exc - if ( - resolved != path - or not stat.S_ISDIR(metadata.st_mode) - or metadata.st_uid != account.pw_uid - or metadata.st_gid != account.pw_gid - or stat.S_IMODE(metadata.st_mode) != 0o700 - ): - raise ServerSetupError(blocker, "managed private directory custody conflicts with fixed profile") -def _require_private_tree( - root: Path, - account: pwd.struct_passwd, +def _canonical_owner_recovery_request_for_marker( + journal: CanonicalOwnerRecoveryJournal, *, - blocker: str, -) -> None: - _require_private_directory(root, account, blocker=blocker) - pending = [root] - records = 0 - while pending: - directory = pending.pop() - try: - entries = tuple(os.scandir(directory)) - except OSError as exc: - raise ServerSetupError(blocker, "managed private tree is unavailable") from exc - for entry in entries: - records += 1 - if records > 20_000: - raise ServerSetupError(blocker, "managed private tree exceeds fixed custody bound") - item = Path(entry.path) - try: - metadata = entry.stat(follow_symlinks=False) - except OSError as exc: - raise ServerSetupError(blocker, "managed private tree changed during validation") from exc - if stat.S_ISDIR(metadata.st_mode): - _require_private_directory(item, account, blocker=blocker) - pending.append(item) - elif stat.S_ISREG(metadata.st_mode): - _require_private_file(item, account, blocker=blocker) - else: - raise ServerSetupError(blocker, "managed private tree contains an unsupported entry") - - -def _approval_trust( - config_path: Path, - account: pwd.struct_passwd, - approval_state: Path, -) -> tuple[ApprovalServiceConfig, list[IndependentApproverConfig]]: - _require_private_file(config_path, account, blocker="approval_config") - _require_private_directory(approval_state, account, blocker="approval_custody") - try: - config = ApprovalServiceConfig.model_validate( - json.loads( - _read_private_managed_file( - config_path, - account, - blocker="approval_config", - max_bytes=1_048_576, - ).decode("utf-8") - ) + approval_config: ApprovalServiceConfig, + approval_account: pwd.struct_passwd, + request: ServerSetupRequest, + allow_source_config: bool = False, +) -> CanonicalOwnerAdoptionRequest: + if len(approval_config.approvers) != 1: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", ) - except Exception as exc: - raise ServerSetupError("approval_config", "Approval configuration is invalid") from exc - expected_database = approval_state / "approval.sqlite3" - expected_record_key = approval_state / "secrets" / "records.key" + configured = approval_config.approvers[0] + allowed_principals = {request.approval_approver_principal_id} + if allow_source_config: + allowed_principals.add(journal.source_principal_id) if ( - config.data_dir != approval_state - or config.database_path != expected_database - or config.record_key_path != expected_record_key + configured.domain_id != request.domain_id + or configured.principal_id not in allowed_principals ): - raise ServerSetupError("approval_conflict", "existing Approval custody paths conflict with fixed request") - _require_private_file(expected_record_key, account, blocker="approval_custody") - _require_private_file(expected_database, account, blocker="approval_custody") - trusted: list[IndependentApproverConfig] = [] - for index, item in enumerate(config.approvers, start=1): - expected_signer = approval_state / "signers" / f"approver-{index}.pem" - if item.signer_private_key_path != expected_signer: - raise ServerSetupError("approval_custody", "Approval signer path conflicts with fixed profile") - _require_private_file(expected_signer, account, blocker="approval_custody") - try: - signer = P256KeyPair.from_private_pem( - _read_private_managed_file( - expected_signer, - account, - blocker="approval_custody", - max_bytes=65_536, - ) - ) - except Exception as exc: - raise ServerSetupError("approval_custody", "Approval signer custody is invalid") from exc - if signer.thumbprint != item.signer_key_id: - raise ServerSetupError("approval_custody", "Approval signer key identifier mismatch") - trusted.append( - IndependentApproverConfig( - principal_id=item.principal_id, - authority_kind=item.authority_kind, - signer_key_id=item.signer_key_id, - public_key_pem=signer.public_pem, - allowed_purposes=item.allowed_purposes, + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", + ) + database_path = approval_config.database_path + _require_private_file( + database_path, + approval_account, + blocker="canonical_owner_recovery", + ) + before = database_path.stat(follow_symlinks=False) + connection: sqlite3.Connection | None = None + try: + connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=5.0, + ) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + bindings = connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + if ( + len(bindings) != 1 + or bindings[0]["approver_principal_id"] + != request.approval_approver_principal_id + or bindings[0]["oidc_issuer"] != configured.oidc_issuer + ): + raise GateBlocked( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", ) + binding = bindings[0] + recovery_request = CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"agentnet:{request.domain_id}:{journal.source_principal_id}:" + f"{request.approval_approver_principal_id}", + ) + ), + domain_id=request.domain_id, + source_principal_id=journal.source_principal_id, + target_principal_id=request.approval_approver_principal_id, + oidc_issuer=str(binding["oidc_issuer"]), + oidc_subject=str(binding["oidc_subject"]), + verified_email=str(binding["verified_email"]), + verifier_id=approval_config.verifier_id, + approved_at=int(binding["pinned_at"]), + ) + validate_canonical_owner_adoption_state( + connection, + request=recovery_request, + ) + except (OSError, sqlite3.Error, GateBlocked) as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner binding evidence is unavailable", + ) from exc + finally: + if connection is not None: + connection.close() + after = database_path.stat(follow_symlinks=False) + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", ) - return config, trusted + return recovery_request -def _require_exact_approval_policy( - config: ApprovalServiceConfig, +def _canonical_owner_source_request_for_partial( + journal: CanonicalOwnerRecoveryJournal, *, + approval_config: ApprovalServiceConfig, + approval_account: pwd.struct_passwd, request: ServerSetupRequest, - owner_oidc: ApprovalOwnerOIDCConfig, - approvers: tuple[SetupApprover, ...], - approval_state: Path, -) -> None: - actual_approvers = tuple( - SetupApprover( - principal_id=item.principal_id, - authority_kind=item.authority_kind, - domain_id=item.domain_id, - allowed_purposes=item.allowed_purposes, - oidc_issuer=item.oidc_issuer, - oidc_subject=item.oidc_subject, - verified_email_alias=item.verified_email_alias, + observed_at: int, +) -> CanonicalOwnerAdoptionRequest: + if ( + journal.partial_recovery is None + or len(approval_config.approvers) != 1 + ): + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner evidence conflicts with setup", ) - for item in config.approvers - ) - approval_host = urlsplit(request.approval_public_origin).hostname + configured = approval_config.approvers[0] if ( - approval_host is None - or config.public_origin != request.approval_public_origin - or config.rp_id != approval_host - or config.verifier_id != request.approval_verifier_id - or config.data_dir != approval_state - or config.database_path != approval_state / "approval.sqlite3" - or config.record_key_path != approval_state / "secrets" / "records.key" - or config.internal_core_credential_env != "AGENTNET_APPROVAL_CORE_TOKEN" - or config.owner_oidc != owner_oidc - or actual_approvers != approvers + configured.domain_id != request.domain_id + or configured.principal_id != journal.source_principal_id + or journal.target_principal_id + != request.approval_approver_principal_id + or configured.oidc_issuer != journal.oidc_issuer ): - raise ServerSetupError("approval_conflict", "existing Approval state conflicts with fixed request") - - -class _RejectRedirects(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] - return None - - -# A first managed start also materializes the service-private uv runtime, and a -# public route can converge after loopback health is exact. Setup gives those -# startup and public-route probes one longer bounded window; ordinary probes keep -# the shorter default so a genuinely broken deployment still fails in bounded time. -_START_HEALTH_ATTEMPTS = 90 -_HEALTH_USER_AGENT = f"AgentNet/{__version__}" + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner evidence conflicts with setup", + ) + database_path = approval_config.database_path + _require_private_file( + database_path, + approval_account, + blocker="canonical_owner_recovery", + ) + before = database_path.stat(follow_symlinks=False) + connection: sqlite3.Connection | None = None + try: + connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=5.0, + ) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + bindings = connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + if len(bindings) != 1: + raise GateBlocked( + "canonical_owner_recovery", + "partial canonical owner binding is ambiguous", + ) + binding = bindings[0] + recovery_request = CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=journal.recovery_id, + domain_id=request.domain_id, + source_principal_id=journal.source_principal_id, + target_principal_id=request.approval_approver_principal_id, + oidc_issuer=str(binding["oidc_issuer"]), + oidc_subject=str(binding["oidc_subject"]), + verified_email=str(binding["verified_email"]), + verifier_id=approval_config.verifier_id, + approved_at=int(binding["pinned_at"]), + ) + validate_canonical_owner_source_state( + connection, + request=recovery_request, + now=observed_at, + ) + except (OSError, sqlite3.Error, GateBlocked, ValueError) as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner binding evidence is unavailable", + ) from exc + finally: + if connection is not None: + connection.close() + after = database_path.stat(follow_symlinks=False) + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner binding evidence changed", + ) + return recovery_request -def _health_value_matches(actual: object, expected: object) -> bool: - if isinstance(expected, tuple): - return actual in expected - if isinstance(expected, Mapping): - return isinstance(actual, Mapping) and all( - key in actual and _health_value_matches(actual[key], item) - for key, item in expected.items() +def _canonical_owner_source_documents( + approval_document: Mapping[str, Any], + core_document: Mapping[str, Any], + *, + source_principal_id: str, + source_signer_key_id: str, + source_signer_public_key_pem: str, + source_signer_path: str, + target_principal_id: str, + target_signer_key_id: str, + target_signer_public_key_pem: str, + target_signer_path: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Invert only the exact proof-bound canonical-owner policy cutover.""" + approvers = approval_document.get("approvers") + if ( + not isinstance(approvers, list) + or len(approvers) != 1 + or not isinstance(approvers[0], dict) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner target state conflicts with recovery evidence", ) - return actual == expected - + target_approver = approvers[0] + if ( + target_approver.get("principal_id") != target_principal_id + or target_approver.get("signer_key_id") != target_signer_key_id + or target_approver.get("signer_private_key_path") != target_signer_path + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner target state conflicts with recovery evidence", + ) + source_approver = dict(target_approver) + source_approver.update( + principal_id=source_principal_id, + signer_key_id=source_signer_key_id, + signer_private_key_path=source_signer_path, + ) + source_approval = dict(approval_document) + source_approval["approvers"] = [source_approver] -def _health(url: str, *, expected: Mapping[str, object], attempts: int = 30) -> None: - opener = urllib.request.build_opener( - urllib.request.ProxyHandler({}), - _RejectRedirects(), + oidc = core_document.get("oidc_enrollment") + if not isinstance(oidc, dict): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence is unavailable", + ) + trusted = oidc.get("trusted_approvers") + approval_service = oidc.get("approval_service") + if ( + not isinstance(trusted, list) + or len(trusted) != 1 + or not isinstance(trusted[0], dict) + or not isinstance(approval_service, dict) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence is ambiguous", + ) + current_trust = trusted[0] + current_core_authority = ( + current_trust.get("principal_id"), + current_trust.get("signer_key_id"), + current_trust.get("public_key_pem"), + approval_service.get("approver_principal_id"), ) - request = urllib.request.Request( - url, - headers={"User-Agent": _HEALTH_USER_AGENT, "Accept": "application/json"}, - method="GET", + target_core_authority = ( + target_principal_id, + target_signer_key_id, + target_signer_public_key_pem, + target_principal_id, ) - for _ in range(attempts): - try: - with opener.open(request, timeout=2) as response: # noqa: S310 - fixed validated setup URL - payload = response.read(65_537) - if response.status != 200 or len(payload) > 65_536: - raise ValueError("invalid health response") - value = json.loads(payload) - if isinstance(value, dict) and _health_value_matches(value, expected): - return - except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError): - pass - time.sleep(1) - raise ServerSetupError("service_health", "AgentNet service did not return exact healthy identity evidence") + source_core_authority = ( + source_principal_id, + source_signer_key_id, + source_signer_public_key_pem, + source_principal_id, + ) + if current_core_authority == target_core_authority: + source_trust = dict(current_trust) + source_trust.update( + principal_id=source_principal_id, + signer_key_id=source_signer_key_id, + public_key_pem=source_signer_public_key_pem, + ) + source_approval_service = dict(approval_service) + source_approval_service["approver_principal_id"] = source_principal_id + source_oidc = dict(oidc) + source_oidc["trusted_approvers"] = [source_trust] + source_oidc["approval_service"] = source_approval_service + source_core = dict(core_document) + source_core["oidc_enrollment"] = source_oidc + elif current_core_authority == source_core_authority: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core cutover is incomplete", + ) + else: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence conflicts with recovery evidence", + ) + try: + _owner_recovery_compatible_approval_config( + json.dumps(source_approval, sort_keys=True).encode("utf-8") + ) + OIDCEnrollmentConfig.model_validate(source_core["oidc_enrollment"]) + except ValidationError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner source policy is invalid", + ) from exc + return source_approval, source_core -def _core_create_arguments( - request: ServerSetupRequest, +def _marker_ordered_policy_document( + document: Mapping[str, Any], *, - node_executable: Path, - executable: Path, - core_config_path: Path, + expected_digest: str, + policy_path: Sequence[str | int], +) -> dict[str, Any] | None: + """Recover only historical ordering of one fixed approval-purpose set.""" + + candidate = copy.deepcopy(dict(document)) + policy: object = candidate + for segment in policy_path: + if isinstance(segment, str) and isinstance(policy, dict): + policy = policy.get(segment) + elif ( + isinstance(segment, int) + and isinstance(policy, list) + and 0 <= segment < len(policy) + ): + policy = policy[segment] + else: + return None + if not isinstance(policy, dict): + return None + purposes = policy.get("allowed_purposes") + if ( + not isinstance(purposes, list) + or len(purposes) != len(MANDATORY_APPROVAL_PURPOSES) + or any(not isinstance(purpose, str) for purpose in purposes) + or frozenset(purposes) != MANDATORY_APPROVAL_PURPOSES + ): + return None + if canonical_digest(candidate) == expected_digest: + return candidate + for ordered in itertools.permutations(sorted(MANDATORY_APPROVAL_PURPOSES)): + if list(ordered) == purposes: + continue + policy["allowed_purposes"] = list(ordered) + if canonical_digest(candidate) == expected_digest: + return candidate + return None + + +def _reconstruct_partial_canonical_owner_recovery_for_marker( + marker: Mapping[str, Any], + approval_state: Path, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + core_config_path: Path, + core_oidc_path: Path, + core_account: pwd.struct_passwd, + *, + request: ServerSetupRequest, + approvers: Sequence[SetupApprover], + observed_at: int, + before_write: Callable[[], None], +) -> dict[str, str] | None: + """Journal only the exact source/dual-trust/target-sidecar repair shape.""" + + journal_path = approval_state / "canonical-owner-recovery.json" + if journal_path.exists() or journal_path.is_symlink(): + return None + if ( + marker.get("package_version"), + __version__, + ) != _APPROVAL_REQUEST_TTL_UPGRADE: + return None + approval_payload = _read_private_managed_file( + approval_config_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ) + approval_document = _strict_json_bytes( + approval_payload, + label="managed Approval configuration", + ) + try: + approval_config = _owner_recovery_compatible_approval_config( + approval_payload + ) + except ValidationError: + return None + if ( + len(approval_config.approvers) != 1 + or approval_config.approvers[0].principal_id + == request.approval_approver_principal_id + ): + return None + configured_source = approval_config.approvers[0] + signer_root = approval_config.data_dir / "signers" + source_signer_path = configured_source.signer_private_key_path + target_signer_path = next( + ( + path + for path in sorted(signer_root.iterdir(), key=lambda item: item.name) + if path != source_signer_path + ), + None, + ) + signer_paths = sorted(signer_root.iterdir(), key=lambda item: item.name) + if ( + target_signer_path is None + or signer_paths + != sorted([source_signer_path, target_signer_path]) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner signer evidence is ambiguous", + ) + for signer_path in signer_paths: + _require_private_file( + signer_path, + approval_account, + blocker="canonical_owner_recovery", + ) + try: + source_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + source_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + target_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + target_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + except Exception as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner signer evidence is invalid", + ) from exc + if source_signer.thumbprint != configured_source.signer_key_id: + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner source signer conflicts with Approval", + ) + + core_document = _strict_json_bytes( + _read_private_managed_file( + core_config_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core configuration", + ) + core_oidc_document = _strict_json_bytes( + _read_private_managed_file( + core_oidc_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core OIDC configuration", + ) + digest_core = dict(core_document) + digest_core.pop("enrolled_harness_id", None) + digest_core.pop("enrolled_credential_id", None) + embedded_oidc = digest_core.get("oidc_enrollment") + if not isinstance(embedded_oidc, dict): + return None + try: + embedded = OIDCEnrollmentConfig.model_validate(embedded_oidc) + target_oidc = OIDCEnrollmentConfig.model_validate(core_oidc_document) + except ValidationError: + return None + target_policies = [ + policy + for policy in approvers + if policy.principal_id == request.approval_approver_principal_id + and policy.domain_id == request.domain_id + ] + if ( + len(approvers) != 1 + or len(embedded.trusted_approvers) != 2 + or len(target_oidc.trusted_approvers) != 1 + or target_oidc.approval_service is None + or target_oidc.approval_service.approver_principal_id + != request.approval_approver_principal_id + or len(target_policies) != 1 + ): + return None + target_policy = target_policies[0] + if ( + configured_source.authority_kind != target_policy.authority_kind + or configured_source.domain_id != target_policy.domain_id + or configured_source.allowed_purposes != target_policy.allowed_purposes + or configured_source.oidc_issuer != target_policy.oidc_issuer + or configured_source.oidc_subject != target_policy.oidc_subject + or configured_source.verified_email_alias + != target_policy.verified_email_alias + ): + return None + trusts = { + trust.principal_id: trust for trust in embedded.trusted_approvers + } + if len(trusts) != 2 or set(trusts) != { + configured_source.principal_id, + request.approval_approver_principal_id, + }: + return None + source_trust = trusts[configured_source.principal_id] + target_trust = trusts[request.approval_approver_principal_id] + if ( + source_trust.signer_key_id != source_signer.thumbprint + or source_trust.public_key_pem != source_signer.public_pem + or source_trust.authority_kind != configured_source.authority_kind + or source_trust.allowed_purposes + != configured_source.allowed_purposes + or target_oidc.trusted_approvers != (target_trust,) + or target_trust.signer_key_id != target_signer.thumbprint + or target_trust.public_key_pem != target_signer.public_pem + or target_trust.authority_kind != target_policy.authority_kind + or target_trust.allowed_purposes != target_policy.allowed_purposes + ): + return None + assert target_oidc.approval_service is not None + source_oidc = target_oidc.model_copy( + update={ + "trusted_approvers": (source_trust,), + "approval_service": target_oidc.approval_service.model_copy( + update={ + "approver_principal_id": configured_source.principal_id + } + ), + } + ) + expected_dual_trusts = {"trusted_approvers": embedded.trusted_approvers} + if embedded not in ( + target_oidc.model_copy(update=expected_dual_trusts), + source_oidc.model_copy(update=expected_dual_trusts), + ): + return None + marker_approval_source = dict(approval_document) + if ( + marker_approval_source.get("request_ttl_seconds") != 3_600 + or marker_approval_source.get( + "communication_scope_request_ttl_seconds", + 3_600, + ) + != 3_600 + ): + return None + marker_approval_source["request_ttl_seconds"] = 300 + marker_approval_source.pop( + "communication_scope_request_ttl_seconds", + None, + ) + marker_approval_target = copy.deepcopy(marker_approval_source) + marker_target_approver = dict( + target_policy.model_dump(mode="json", exclude_none=True) + ) + marker_target_approver.update( + signer_key_id=target_signer.thumbprint, + signer_private_key_path=str(target_signer_path), + ) + marker_approval_target["approvers"] = [marker_target_approver] + marker_approval = None + for candidate in (marker_approval_source, marker_approval_target): + matched = _marker_ordered_policy_document( + candidate, + expected_digest=str(marker.get("approval_config_digest")), + policy_path=("approvers", 0), + ) + if matched is not None: + marker_approval = matched + break + marker_core = dict(digest_core) + marker_core["oidc_enrollment"] = source_oidc.model_dump(mode="json") + marker_core = _marker_ordered_policy_document( + marker_core, + expected_digest=str(marker.get("core_config_digest")), + policy_path=("oidc_enrollment", "trusted_approvers", 0), + ) + if marker_approval is None or marker_core is None: + return None + normalized_source_document = dict(approval_document) + normalized_source_document["request_ttl_seconds"] = 600 + normalized_source_document[ + "communication_scope_request_ttl_seconds" + ] = 3_600 + normalized_source_payload = ( + json.dumps( + normalized_source_document, + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + + provisional = CanonicalOwnerRecoveryJournal.model_validate( + { + "schema": "agentnet.canonical-owner-recovery-journal.v1", + "recovery_id": str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"agentnet:{request.domain_id}:" + f"{configured_source.principal_id}:" + f"{request.approval_approver_principal_id}", + ) + ), + "request_digest": "0" * 64, + "config_path": str(approval_config_path.absolute()), + "signer_path": str(source_signer_path), + "target_signer_path": str(target_signer_path), + "source_config_sha256": hashlib.sha256( + normalized_source_payload + ).hexdigest(), + "domain_id": request.domain_id, + "source_principal_id": configured_source.principal_id, + "target_principal_id": request.approval_approver_principal_id, + "oidc_issuer": configured_source.oidc_issuer, + "source_signer_key_id": source_signer.thumbprint, + "source_signer_public_key_pem": source_signer.public_pem, + "target_signer_key_id": target_signer.thumbprint, + "target_signer_public_key_pem": target_signer.public_pem, + "phase": "prepared", + "prepared_at": observed_at, + "partial_recovery": { + "schema": "agentnet.canonical-owner-partial-recovery.v1", + "observed_at": observed_at, + "marker_approval_config_digest": str( + marker["approval_config_digest"] + ), + "marker_core_config_digest": str( + marker["core_config_digest"] + ), + "realized_approval_config_digest": canonical_digest( + approval_document + ), + "realized_core_config_digest": canonical_digest(digest_core), + "realized_core_oidc_config_digest": canonical_digest( + core_oidc_document + ), + "source_principal_evidence": "active_owner_binding", + }, + } + ) + recovery_request = _canonical_owner_source_request_for_partial( + provisional, + approval_config=approval_config, + approval_account=approval_account, + request=request, + observed_at=observed_at, + ) + request_digest = hashlib.sha256( + canonical_json( + recovery_request.model_dump(by_alias=True, mode="json") + ) + ).hexdigest() + journal = provisional.model_copy( + update={"request_digest": request_digest} + ) + validate_canonical_owner_recovery_journal( + journal.model_dump(by_alias=True, mode="json"), + request=recovery_request, + config_path=approval_config_path.absolute(), + ) + payload = ( + json.dumps( + journal.model_dump(by_alias=True, mode="json"), + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + before_write() + _atomic_write( + journal_path, + payload, + mode=0o600, + uid=approval_account.pw_uid, + gid=approval_account.pw_gid, + ) + return { + "source_principal_id": journal.source_principal_id, + "source_signer_key_id": journal.source_signer_key_id, + "source_signer_public_key_pem": journal.source_signer_public_key_pem, + "source_signer_path": journal.signer_path, + "target_signer_key_id": journal.target_signer_key_id, + "target_signer_public_key_pem": journal.target_signer_public_key_pem, + "marker_approval_config_digest": str( + marker["approval_config_digest"] + ), + "marker_core_config_digest": str(marker["core_config_digest"]), + "partial_recovery": "true", + } + + +def _reconstruct_completed_canonical_owner_recovery_for_marker( + marker: Mapping[str, Any], + approval_state: Path, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + core_config_path: Path, + core_oidc_path: Path, + core_account: pwd.struct_passwd, + *, + request: ServerSetupRequest, + observed_at: int, + before_write: Callable[[], None], +) -> dict[str, str] | None: + """Record one exact terminal repair when its original journal was lost.""" + + journal_path = approval_state / "canonical-owner-recovery.json" + if journal_path.exists() or journal_path.is_symlink(): + return None + raw_approval = _strict_json_bytes( + _read_private_managed_file( + approval_config_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Approval configuration", + ) + try: + approval_config = _owner_recovery_compatible_approval_config( + json.dumps(raw_approval, sort_keys=True).encode("utf-8") + ) + except ValidationError: + return None + if ( + len(approval_config.approvers) != 1 + or approval_config.approvers[0].principal_id + != request.approval_approver_principal_id + ): + return None + configured = approval_config.approvers[0] + signer_root = approval_config.data_dir / "signers" + target_signer_path = configured.signer_private_key_path + if target_signer_path.parent != signer_root: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner target signer path conflicts with fixed custody", + ) + try: + signer_paths = sorted( + (path for path in signer_root.iterdir()), + key=lambda path: path.name, + ) + except OSError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer evidence is unavailable", + ) from exc + if signer_paths == [target_signer_path]: + return None + if ( + len(signer_paths) != 2 + or target_signer_path not in signer_paths + or any(path.suffix != ".pem" for path in signer_paths) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer evidence is ambiguous", + ) + source_signer_path = next( + path for path in signer_paths if path != target_signer_path + ) + for path in signer_paths: + _require_private_file( + path, + approval_account, + blocker="canonical_owner_recovery", + ) + try: + target_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + target_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + source_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + source_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + except Exception as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer evidence is invalid", + ) from exc + if ( + target_signer.thumbprint != configured.signer_key_id + or source_signer.thumbprint == target_signer.thumbprint + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer evidence conflicts with current authority", + ) + + raw_core = _strict_json_bytes( + _read_private_managed_file( + core_config_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core configuration", + ) + oidc = raw_core.get("oidc_enrollment") + if not isinstance(oidc, dict): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence is unavailable", + ) + raw_core_oidc = _strict_json_bytes( + _read_private_managed_file( + core_oidc_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core OIDC configuration", + ) + if raw_core_oidc != oidc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core OIDC evidence conflicts with Core", + ) + core_trusted = oidc.get("trusted_approvers") + approval_service = oidc.get("approval_service") + digest_core = dict(raw_core) + digest_core.pop("enrolled_harness_id", None) + digest_core.pop("enrolled_credential_id", None) + if ( + not isinstance(core_trusted, list) + or len(core_trusted) != 1 + or not isinstance(core_trusted[0], dict) + or not isinstance(approval_service, dict) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence is ambiguous", + ) + target_trust = core_trusted[0] + if ( + target_trust.get("principal_id") + != request.approval_approver_principal_id + or target_trust.get("signer_key_id") != target_signer.thumbprint + or target_trust.get("public_key_pem") != target_signer.public_pem + or approval_service.get("approver_principal_id") + != request.approval_approver_principal_id + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner Core evidence conflicts with current authority", + ) + + database_path = approval_config.database_path + _require_private_file( + database_path, + approval_account, + blocker="canonical_owner_recovery", + ) + before = database_path.stat(follow_symlinks=False) + connection: sqlite3.Connection | None = None + matches: list[ + tuple[ + CanonicalOwnerAdoptionRequest, + tuple[int, int, int], + int, + bytes, + ] + ] = [] + try: + connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=5.0, + ) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + bindings = connection.execute( + """SELECT * FROM approval_owner_bindings + WHERE domain_id=? AND status='active' + ORDER BY binding_id""", + (request.domain_id,), + ).fetchall() + if ( + len(bindings) != 1 + or bindings[0]["approver_principal_id"] + != request.approval_approver_principal_id + or bindings[0]["oidc_issuer"] != configured.oidc_issuer + ): + raise GateBlocked( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", + ) + binding = bindings[0] + candidate_rows = connection.execute( + """SELECT DISTINCT approver_principal_id + FROM approval_audit + WHERE domain_id=? AND approver_principal_id IS NOT NULL + AND approver_principal_id<>? + ORDER BY approver_principal_id + LIMIT 65""", + ( + request.domain_id, + request.approval_approver_principal_id, + ), + ).fetchall() + if not candidate_rows or len(candidate_rows) > 64: + raise GateBlocked( + "canonical_owner_recovery", + "canonical owner source evidence is ambiguous", + ) + for candidate_row in candidate_rows: + source_principal = str(candidate_row["approver_principal_id"]) + recovery_request = CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"agentnet:{request.domain_id}:{source_principal}:" + f"{request.approval_approver_principal_id}", + ) + ), + domain_id=request.domain_id, + source_principal_id=source_principal, + target_principal_id=request.approval_approver_principal_id, + oidc_issuer=str(binding["oidc_issuer"]), + oidc_subject=str(binding["oidc_subject"]), + verified_email=str(binding["verified_email"]), + verifier_id=approval_config.verifier_id, + approved_at=int(binding["pinned_at"]), + ) + try: + counts = validate_canonical_owner_adoption_state( + connection, + request=recovery_request, + ) + except GateBlocked: + continue + try: + source_approval, marker_core = _canonical_owner_source_documents( + raw_approval, + digest_core, + source_principal_id=source_principal, + source_signer_key_id=source_signer.thumbprint, + source_signer_public_key_pem=source_signer.public_pem, + source_signer_path=str(source_signer_path), + target_principal_id=request.approval_approver_principal_id, + target_signer_key_id=target_signer.thumbprint, + target_signer_public_key_pem=target_signer.public_pem, + target_signer_path=str(target_signer_path), + ) + except ServerSetupError: + continue + marker_approval = copy.deepcopy(source_approval) + marker_approval["request_ttl_seconds"] = 300 + marker_approval.pop( + "communication_scope_request_ttl_seconds", + None, + ) + approval_digest = marker.get("approval_config_digest") + core_digest = marker.get("core_config_digest") + if not isinstance(approval_digest, str) or not isinstance( + core_digest, + str, + ): + continue + marker_approval = _marker_ordered_policy_document( + marker_approval, + expected_digest=approval_digest, + policy_path=("approvers", 0), + ) + marker_core = _marker_ordered_policy_document( + marker_core, + expected_digest=core_digest, + policy_path=("oidc_enrollment", "trusted_approvers", 0), + ) + if marker_approval is None or marker_core is None: + continue + source_hotfix = copy.deepcopy(marker_approval) + source_hotfix["request_ttl_seconds"] = source_approval[ + "request_ttl_seconds" + ] + source_hotfix_payload = ( + json.dumps(source_hotfix, indent=2, sort_keys=True).encode("utf-8") + + b"\n" + ) + request_digest = hashlib.sha256( + canonical_json( + recovery_request.model_dump( + by_alias=True, + mode="json", + ) + ) + ).hexdigest() + audit_rows = connection.execute( + """SELECT occurred_at FROM approval_audit + WHERE action='owner.canonical_adoption' + AND approver_principal_id=? AND domain_id=? + AND approval_purpose='owner.canonical_adoption' + AND transaction_digest=? AND outcome='adopted'""", + ( + request.approval_approver_principal_id, + request.domain_id, + request_digest, + ), + ).fetchall() + if len(audit_rows) != 1: + continue + matches.append( + ( + recovery_request, + counts, + int(audit_rows[0]["occurred_at"]), + source_hotfix_payload, + ) + ) + except (OSError, sqlite3.Error, GateBlocked, ValueError) as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner journal-less evidence is unavailable", + ) from exc + finally: + if connection is not None: + connection.close() + after = database_path.stat(follow_symlinks=False) + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner binding evidence conflicts with setup", + ) + if len(matches) != 1: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner journal-less evidence is ambiguous", + ) + recovery_request, counts, adoption_at, source_hotfix_payload = matches[0] + adoption = { + "schema": "agentnet.canonical-owner-adoption-result.v1", + "status": "adopted", + "recovery_id": recovery_request.recovery_id, + "migrated_active_credentials": counts[0], + "revoked_browser_sessions": counts[1], + "canceled_registration_ceremonies": counts[2], + } + request_digest = hashlib.sha256( + canonical_json( + recovery_request.model_dump(by_alias=True, mode="json") + ) + ).hexdigest() + journal = CanonicalOwnerRecoveryJournal.model_validate( + { + "schema": "agentnet.canonical-owner-recovery-journal.v1", + "recovery_id": recovery_request.recovery_id, + "request_digest": request_digest, + "config_path": str(approval_config_path.absolute()), + "signer_path": str(source_signer_path), + "target_signer_path": str(target_signer_path), + "source_config_sha256": hashlib.sha256( + source_hotfix_payload + ).hexdigest(), + "domain_id": request.domain_id, + "source_principal_id": recovery_request.source_principal_id, + "target_principal_id": request.approval_approver_principal_id, + "oidc_issuer": recovery_request.oidc_issuer, + "source_signer_key_id": source_signer.thumbprint, + "source_signer_public_key_pem": source_signer.public_pem, + "target_signer_key_id": target_signer.thumbprint, + "target_signer_public_key_pem": target_signer.public_pem, + "phase": "complete", + "prepared_at": adoption_at, + "completed_at": observed_at, + "authority_adoption": adoption, + "authority_adoption_digest": hashlib.sha256( + canonical_json(adoption) + ).hexdigest(), + "reconstruction": { + "schema": ( + "agentnet.canonical-owner-recovery-reconstruction.v1" + ), + "observed_at": observed_at, + "marker_approval_config_digest": str( + marker["approval_config_digest"] + ), + "marker_core_config_digest": str( + marker["core_config_digest"] + ), + "realized_approval_config_digest": canonical_digest( + raw_approval + ), + "realized_core_config_digest": canonical_digest(digest_core), + "source_principal_evidence": ( + "approval_audit_marker_digest" + ), + }, + } + ) + payload = ( + json.dumps( + journal.model_dump(by_alias=True, mode="json"), + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + before_write() + _atomic_write( + journal_path, + payload, + mode=0o600, + uid=approval_account.pw_uid, + gid=approval_account.pw_gid, + ) + recovered = _canonical_owner_recovery_source( + approval_state, + approval_config_path, + approval_account, + request=request, + ) + if recovered is None: + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner evidence is unavailable", + ) + return recovered + + +def _completed_canonical_owner_recovery_for_marker( + approval_state: Path, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + *, + approval_config: ApprovalServiceConfig, + request: ServerSetupRequest, +) -> dict[str, Any] | None: + journal_path = approval_state / "canonical-owner-recovery.json" + if not (journal_path.exists() or journal_path.is_symlink()): + return None + try: + raw_journal = _strict_json_bytes( + _read_private_managed_file( + journal_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=262_144, + ), + label="canonical owner recovery journal", + ) + journal = CanonicalOwnerRecoveryJournal.model_validate(raw_journal) + if ( + journal.phase != "complete" + or journal.source_principal_id + == request.approval_approver_principal_id + ): + raise ValueError("completed recovery evidence conflicts with setup") + recovery_request = _canonical_owner_recovery_request_for_marker( + journal, + approval_config=approval_config, + approval_account=approval_account, + request=request, + ) + return validate_canonical_owner_recovery_journal( + raw_journal, + request=recovery_request, + config_path=approval_config_path.absolute(), + ) + except Exception as exc: + if isinstance(exc, ServerSetupError): + raise + raise ServerSetupError( + "canonical_owner_recovery", + "completed canonical owner recovery evidence is invalid", + ) from exc + + +def _require_reconstructed_recovery_signer_custody( + journal: CanonicalOwnerRecoveryJournal, + approval_document: Mapping[str, Any], + approval_account: pwd.struct_passwd, + *, + configured_role: Literal["source", "target"] = "target", +) -> None: + """Recheck the exact two-key custody that justified reconstruction.""" + + try: + approval_config = _owner_recovery_compatible_approval_config( + json.dumps(dict(approval_document), sort_keys=True).encode("utf-8") + ) + except ValidationError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence is invalid", + ) from exc + if len(approval_config.approvers) != 1: + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence is ambiguous", + ) + configured = approval_config.approvers[0] + signer_root = approval_config.data_dir / "signers" + source_signer_path = Path(journal.signer_path) + target_signer_path = Path(journal.target_signer_path) + configured_path = ( + source_signer_path + if configured_role == "source" + else target_signer_path + ) + configured_key_id = ( + journal.source_signer_key_id + if configured_role == "source" + else journal.target_signer_key_id + ) + configured_principal = ( + journal.source_principal_id + if configured_role == "source" + else journal.target_principal_id + ) + if ( + source_signer_path.parent != signer_root + or target_signer_path.parent != signer_root + or source_signer_path == target_signer_path + or configured.signer_private_key_path != configured_path + or configured.signer_key_id != configured_key_id + or configured.principal_id != configured_principal + ): + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence conflicts with custody", + ) + _require_private_directory( + signer_root, + approval_account, + blocker="canonical_owner_recovery", + ) + try: + signer_paths = sorted(signer_root.iterdir(), key=lambda path: path.name) + except OSError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence is unavailable", + ) from exc + if signer_paths != sorted( + (source_signer_path, target_signer_path), + key=lambda path: path.name, + ): + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence is ambiguous", + ) + for path in signer_paths: + _require_private_file( + path, + approval_account, + blocker="canonical_owner_recovery", + ) + try: + source_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + source_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + target_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + target_signer_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + except Exception as exc: + if isinstance(exc, ServerSetupError): + raise + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence is invalid", + ) from exc + if ( + source_signer.thumbprint != journal.source_signer_key_id + or source_signer.public_pem != journal.source_signer_public_key_pem + or target_signer.thumbprint != journal.target_signer_key_id + or target_signer.public_pem != journal.target_signer_public_key_pem + ): + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner signer evidence conflicts with custody", + ) + + +def _upgrade_marker_config_digests( + marker: Mapping[str, Any], + *, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + approval_state: Path, + core_config_path: Path, + core_oidc_path: Path, + core_account: pwd.struct_passwd, + request: ServerSetupRequest, +) -> tuple[str, str]: + """Recognize only exact marker-relative package-owned live repairs.""" + + approval_payload = _read_private_managed_file( + approval_config_path, + approval_account, + blocker="approval_config", + max_bytes=_MAX_CONFIG_BYTES, + ) + approval_document = _strict_json_bytes( + approval_payload, + label="managed Approval configuration", + ) + core_document = _strict_json_bytes( + _read_private_managed_file( + core_config_path, + core_account, + blocker="core_custody", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core configuration", + ) + core_document.pop("enrolled_harness_id", None) + core_document.pop("enrolled_credential_id", None) + realized = ( + canonical_digest(approval_document), + canonical_digest(core_document), + ) + recorded = ( + marker.get("approval_config_digest"), + marker.get("core_config_digest"), + ) + recovery_journal_path = approval_state / "canonical-owner-recovery.json" + if ( + recovery_journal_path.exists() + or recovery_journal_path.is_symlink() + ): + try: + recovery_journal = CanonicalOwnerRecoveryJournal.model_validate( + _strict_json_bytes( + _read_private_managed_file( + recovery_journal_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=262_144, + ), + label="canonical owner recovery journal", + ) + ) + except Exception as exc: + if isinstance(exc, ServerSetupError): + raise + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery journal is invalid", + ) from exc + partial = recovery_journal.partial_recovery + if partial is not None: + core_oidc_document = _strict_json_bytes( + _read_private_managed_file( + core_oidc_path, + core_account, + blocker="core_custody", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core OIDC configuration", + ) + normalized_approval_document = dict(approval_document) + normalized_approval_document["request_ttl_seconds"] = 600 + normalized_approval_document[ + "communication_scope_request_ttl_seconds" + ] = 3_600 + normalized_approval_payload = ( + json.dumps( + normalized_approval_document, + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + source_config_sha256 = recovery_journal.source_config_sha256 + approval_state_matches = ( + partial.realized_approval_config_digest == realized[0] + or source_config_sha256 + == hashlib.sha256(approval_payload).hexdigest() + ) + if ( + recovery_journal.phase != "prepared" + or ( + partial.marker_approval_config_digest, + partial.marker_core_config_digest, + ) + != recorded + or not approval_state_matches + or partial.realized_core_config_digest != realized[1] + or partial.realized_core_oidc_config_digest + != canonical_digest(core_oidc_document) + or source_config_sha256 + != hashlib.sha256(normalized_approval_payload).hexdigest() + ): + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner evidence conflicts with setup", + ) + try: + approval_config = ( + _owner_recovery_compatible_approval_config( + approval_payload + ) + ) + except ValidationError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "partial canonical owner Approval state is invalid", + ) from exc + recovery_request = _canonical_owner_source_request_for_partial( + recovery_journal, + approval_config=approval_config, + approval_account=approval_account, + request=request, + observed_at=int(time.time()), + ) + validate_canonical_owner_recovery_journal( + recovery_journal.model_dump(by_alias=True, mode="json"), + request=recovery_request, + config_path=approval_config_path.absolute(), + ) + _require_reconstructed_recovery_signer_custody( + recovery_journal, + approval_document, + approval_account, + configured_role="source", + ) + return str(recorded[0]), str(recorded[1]) + reconstruction = recovery_journal.reconstruction + if reconstruction is not None and ( + ( + reconstruction.marker_approval_config_digest, + reconstruction.marker_core_config_digest, + ) + != recorded + or ( + reconstruction.realized_approval_config_digest, + reconstruction.realized_core_config_digest, + ) + != realized + ): + raise ServerSetupError( + "canonical_owner_recovery", + "reconstructed canonical owner evidence conflicts with setup", + ) + if reconstruction is not None: + core_oidc_document = _strict_json_bytes( + _read_private_managed_file( + core_oidc_path, + core_account, + blocker="core_custody", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="managed Core OIDC configuration", + ) + if core_document.get("oidc_enrollment") != core_oidc_document: + raise ServerSetupError( + "setup_upgrade_conflict", + "managed Core OIDC configuration conflicts with Core", + ) + _require_reconstructed_recovery_signer_custody( + recovery_journal, + approval_document, + approval_account, + ) + if realized == recorded: + _canonical_owner_recovery_source( + approval_state, + approval_config_path, + approval_account, + request=request, + ) + return realized + if (marker.get("package_version"), __version__) != _APPROVAL_REQUEST_TTL_UPGRADE: + return realized + + ttl_hotfix_recognized = False + marker_approval = dict(approval_document) + if ( + marker_approval.get("request_ttl_seconds") == 3_600 + and marker_approval.get( + "communication_scope_request_ttl_seconds", + 3_600, + ) + == 3_600 + and marker_approval.get("challenge_ttl_seconds") == 180 + and marker_approval.get("receipt_ttl_seconds") == 300 + and marker_approval.get("registration_ttl_seconds") == 600 + ): + marker_approval["request_ttl_seconds"] = 300 + marker_approval.pop("communication_scope_request_ttl_seconds", None) + try: + ApprovalServiceConfig.model_validate(marker_approval) + ttl_hotfix_recognized = True + except ValidationError: + marker_approval = dict(approval_document) + marker_core = core_document + candidate = ( + canonical_digest(marker_approval), + canonical_digest(marker_core), + ) + if candidate == recorded: + _canonical_owner_recovery_source( + approval_state, + approval_config_path, + approval_account, + request=request, + ) + return candidate + if not ttl_hotfix_recognized: + return candidate + try: + current_approval_config = ApprovalServiceConfig.model_validate( + marker_approval + ) + except ValidationError: + return candidate + + journal = _completed_canonical_owner_recovery_for_marker( + approval_state, + approval_config_path, + approval_account, + approval_config=current_approval_config, + request=request, + ) + if journal is None: + return candidate + signer_root = current_approval_config.data_dir / "signers" + source_signer_path = Path(str(journal["signer_path"])) + target_signer_path = Path(str(journal["target_signer_path"])) + if ( + not source_signer_path.is_absolute() + or not target_signer_path.is_absolute() + or ".." in source_signer_path.parts + or ".." in target_signer_path.parts + or source_signer_path.parent != signer_root + or target_signer_path.parent != signer_root + or source_signer_path == target_signer_path + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer paths conflict with fixed custody", + ) + try: + target_signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + Path(str(journal["target_signer_path"])), + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + except Exception as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner target signer is invalid", + ) from exc + if target_signer.thumbprint != journal["target_signer_key_id"]: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner target signer does not match recovery evidence", + ) + + marker_approval, marker_core = _canonical_owner_source_documents( + marker_approval, + marker_core, + source_principal_id=str(journal["source_principal_id"]), + source_signer_key_id=str(journal["source_signer_key_id"]), + source_signer_public_key_pem=str( + journal["source_signer_public_key_pem"] + ), + source_signer_path=str(journal["signer_path"]), + target_principal_id=str(journal["target_principal_id"]), + target_signer_key_id=str(journal["target_signer_key_id"]), + target_signer_public_key_pem=str( + journal["target_signer_public_key_pem"] + ), + target_signer_path=str(journal["target_signer_path"]), + ) + marker_approval = _marker_ordered_policy_document( + marker_approval, + expected_digest=str(recorded[0]), + policy_path=("approvers", 0), + ) + marker_core = _marker_ordered_policy_document( + marker_core, + expected_digest=str(recorded[1]), + policy_path=("oidc_enrollment", "trusted_approvers", 0), + ) + if marker_approval is None or marker_core is None: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner source config does not match recovery evidence", + ) + source_hotfix = dict(marker_approval) + source_hotfix["request_ttl_seconds"] = 3_600 + source_hotfix.pop("communication_scope_request_ttl_seconds", None) + source_hotfix_payload = ( + json.dumps(source_hotfix, indent=2, sort_keys=True).encode("utf-8") + b"\n" + ) + if hashlib.sha256(source_hotfix_payload).hexdigest() != journal[ + "source_config_sha256" + ]: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner source config does not match recovery evidence", + ) + reconstructed = ( + canonical_digest(marker_approval), + canonical_digest(marker_core), + ) + return ( + (str(recorded[0]), str(recorded[1])) + if reconstructed == recorded + else reconstructed + ) + + + +def _require_private_directory(path: Path, account: pwd.struct_passwd, *, blocker: str) -> None: + try: + metadata = path.lstat() + resolved = path.resolve(strict=True) + except OSError as exc: + raise ServerSetupError(blocker, "managed private directory is unavailable") from exc + if ( + resolved != path + or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != account.pw_uid + or metadata.st_gid != account.pw_gid + or stat.S_IMODE(metadata.st_mode) != 0o700 + ): + raise ServerSetupError(blocker, "managed private directory custody conflicts with fixed profile") + + +def _require_private_tree( + root: Path, + account: pwd.struct_passwd, + *, + blocker: str, +) -> None: + _require_private_directory(root, account, blocker=blocker) + pending = [root] + records = 0 + while pending: + directory = pending.pop() + try: + entries = tuple(os.scandir(directory)) + except OSError as exc: + raise ServerSetupError(blocker, "managed private tree is unavailable") from exc + for entry in entries: + records += 1 + if records > 20_000: + raise ServerSetupError(blocker, "managed private tree exceeds fixed custody bound") + item = Path(entry.path) + try: + metadata = entry.stat(follow_symlinks=False) + except OSError as exc: + raise ServerSetupError(blocker, "managed private tree changed during validation") from exc + if stat.S_ISDIR(metadata.st_mode): + _require_private_directory(item, account, blocker=blocker) + pending.append(item) + elif stat.S_ISREG(metadata.st_mode): + _require_private_file(item, account, blocker=blocker) + else: + raise ServerSetupError(blocker, "managed private tree contains an unsupported entry") + + +def _approval_trust( + config_path: Path, + account: pwd.struct_passwd, + approval_state: Path, + *, + recovery_source: Mapping[str, str] | None = None, +) -> tuple[ApprovalServiceConfig, list[IndependentApproverConfig]]: + _require_private_file(config_path, account, blocker="approval_config") + _require_private_directory(approval_state, account, blocker="approval_custody") + try: + config = ApprovalServiceConfig.model_validate( + json.loads( + _read_private_managed_file( + config_path, + account, + blocker="approval_config", + max_bytes=1_048_576, + ).decode("utf-8") + ) + ) + except Exception as exc: + raise ServerSetupError("approval_config", "Approval configuration is invalid") from exc + expected_database = approval_state / "approval.sqlite3" + expected_record_key = approval_state / "secrets" / "records.key" + if ( + config.data_dir != approval_state + or config.database_path != expected_database + or config.record_key_path != expected_record_key + ): + raise ServerSetupError("approval_conflict", "existing Approval custody paths conflict with fixed request") + _require_private_file(expected_record_key, account, blocker="approval_custody") + _require_private_file(expected_database, account, blocker="approval_custody") + trusted: list[IndependentApproverConfig] = [] + for index, item in enumerate(config.approvers, start=1): + expected_signer = approval_state / "signers" / f"approver-{index}.pem" + if item.signer_private_key_path != expected_signer: + if ( + recovery_source is None + or recovery_source.get("partial_recovery") != "true" + or item.principal_id + != recovery_source.get("source_principal_id") + or item.signer_key_id + != recovery_source.get("source_signer_key_id") + or str(item.signer_private_key_path) + != recovery_source.get("source_signer_path") + ): + raise ServerSetupError( + "approval_custody", + "Approval signer path conflicts with fixed profile", + ) + expected_signer = item.signer_private_key_path + _require_private_file( + expected_signer, + account, + blocker="approval_custody", + ) + try: + signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + expected_signer, + account, + blocker="approval_custody", + max_bytes=65_536, + ) + ) + except Exception as exc: + raise ServerSetupError("approval_custody", "Approval signer custody is invalid") from exc + if ( + signer.thumbprint != item.signer_key_id + or ( + recovery_source is not None + and recovery_source.get("partial_recovery") == "true" + and signer.public_pem + != recovery_source.get("source_signer_public_key_pem") + ) + ): + raise ServerSetupError( + "approval_custody", + "Approval signer key identifier mismatch", + ) + trusted.append( + IndependentApproverConfig( + principal_id=item.principal_id, + authority_kind=item.authority_kind, + signer_key_id=item.signer_key_id, + public_key_pem=signer.public_pem, + allowed_purposes=item.allowed_purposes, + ) + ) + return config, trusted + + +def _require_exact_approval_policy( + config: ApprovalServiceConfig, + *, + request: ServerSetupRequest, + owner_oidc: ApprovalOwnerOIDCConfig, + approvers: tuple[SetupApprover, ...], + approval_state: Path, + allow_canonical_owner_adoption: bool = False, +) -> str | None: + actual_approvers = tuple( + SetupApprover( + principal_id=item.principal_id, + authority_kind=item.authority_kind, + domain_id=item.domain_id, + allowed_purposes=item.allowed_purposes, + oidc_issuer=item.oidc_issuer, + oidc_subject=item.oidc_subject, + verified_email_alias=item.verified_email_alias, + ) + for item in config.approvers + ) + approval_host = urlsplit(request.approval_public_origin).hostname + fixed_profile_matches = ( + approval_host is not None + and config.public_origin == request.approval_public_origin + and config.rp_id == approval_host + and config.verifier_id == request.approval_verifier_id + and config.data_dir == approval_state + and config.database_path == approval_state / "approval.sqlite3" + and config.record_key_path == approval_state / "secrets" / "records.key" + and config.internal_core_credential_env == "AGENTNET_APPROVAL_CORE_TOKEN" + and config.owner_oidc == owner_oidc + ) + if not fixed_profile_matches: + raise ServerSetupError( + "approval_conflict", + "existing Approval state conflicts with fixed request", + ) + if actual_approvers == approvers: + return None + if allow_canonical_owner_adoption and len(actual_approvers) == len(approvers) == 1: + source = actual_approvers[0] + target = approvers[0] + source_principal = source.principal_id + if ( + source.model_copy(update={"principal_id": target.principal_id}) == target + and source_principal != target.principal_id + and source.oidc_issuer is not None + and target.oidc_issuer is not None + ): + return source_principal + raise ServerSetupError("approval_conflict", "existing Approval state conflicts with fixed request") + + +def _owner_recovery_compatible_approval_config( + payload: bytes, +) -> ApprovalServiceConfig: + try: + return ApprovalServiceConfig.model_validate_json(payload) + except ValidationError as current_error: + document = _strict_json_bytes(payload, label="managed Approval configuration") + if ( + document.get("request_ttl_seconds") != 3_600 + or document.get("communication_scope_request_ttl_seconds", 3_600) != 3_600 + or document.get("challenge_ttl_seconds") != 180 + or document.get("receipt_ttl_seconds") != 300 + or document.get("registration_ttl_seconds") != 600 + ): + raise current_error + compatible = dict(document) + compatible["request_ttl_seconds"] = 600 + compatible["communication_scope_request_ttl_seconds"] = 3_600 + return ApprovalServiceConfig.model_validate(compatible) + + +def _canonical_owner_recovery_source( + approval_state: Path, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + *, + request: ServerSetupRequest, +) -> dict[str, str] | None: + journal_path = approval_state / "canonical-owner-recovery.json" + if not (journal_path.exists() or journal_path.is_symlink()): + return None + try: + raw_journal = _strict_json_bytes( + _read_private_managed_file( + journal_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=262_144, + ), + label="canonical owner recovery journal", + ) + journal = CanonicalOwnerRecoveryJournal.model_validate(raw_journal) + partial = journal.partial_recovery + if ( + partial is None + and journal.phase in { + "prepared", + "authority_adopted", + "signer_replaced", + } + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery is incomplete", + ) + if ( + journal.phase + not in { + "prepared", + "authority_adopted", + "signer_replaced", + "config_replacing", + "config_replaced", + "complete", + } + or journal.source_principal_id + == request.approval_approver_principal_id + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery journal conflicts with setup request", + ) + approval_payload = _read_private_managed_file( + approval_config_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ) + approval_document = _strict_json_bytes( + approval_payload, + label="managed Approval configuration", + ) + approval_config = _owner_recovery_compatible_approval_config( + approval_payload + ) + if journal.reconstruction is not None: + _require_reconstructed_recovery_signer_custody( + journal, + approval_document, + approval_account, + ) + if partial is not None and journal.phase == "prepared": + _require_reconstructed_recovery_signer_custody( + journal, + approval_document, + approval_account, + configured_role="source", + ) + recovery_request = _canonical_owner_source_request_for_partial( + journal, + approval_config=approval_config, + approval_account=approval_account, + request=request, + observed_at=int(time.time()), + ) + else: + recovery_request = _canonical_owner_recovery_request_for_marker( + journal, + approval_config=approval_config, + approval_account=approval_account, + request=request, + allow_source_config=journal.phase + in { + "authority_adopted", + "signer_replaced", + "config_replacing", + }, + ) + validated = validate_canonical_owner_recovery_journal( + raw_journal, + request=recovery_request, + config_path=approval_config_path.absolute(), + ) + configured = approval_config.approvers[0] + if configured.principal_id == journal.source_principal_id: + expected_key_id = journal.source_signer_key_id + expected_path = Path(journal.signer_path) + else: + expected_key_id = journal.target_signer_key_id + expected_path = Path(journal.target_signer_path) + if ( + configured.signer_key_id != expected_key_id + or configured.signer_private_key_path != expected_path + ): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer state conflicts with recovery evidence", + ) + signer = P256KeyPair.from_private_pem( + _read_private_managed_file( + expected_path, + approval_account, + blocker="canonical_owner_recovery", + max_bytes=65_536, + ) + ) + if signer.thumbprint != expected_key_id: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner signer state conflicts with recovery evidence", + ) + except Exception as exc: + if isinstance(exc, ServerSetupError): + raise + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery journal is invalid", + ) from exc + result = { + key: str(validated[key]) + for key in ( + "source_principal_id", + "source_signer_key_id", + "source_signer_public_key_pem", + ) + } + if partial is not None: + result["partial_recovery"] = "true" + result["source_signer_path"] = journal.signer_path + result["target_signer_key_id"] = journal.target_signer_key_id + result["target_signer_public_key_pem"] = ( + journal.target_signer_public_key_pem + ) + return result + + + + +class _RejectRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + +# A first managed start also materializes the service-private uv runtime, and a +# public route can converge after loopback health is exact. Setup gives those +# startup and public-route probes one longer bounded window; ordinary probes keep +# the shorter default so a genuinely broken deployment still fails in bounded time. +_START_HEALTH_ATTEMPTS = 90 +_HEALTH_USER_AGENT = f"AgentNet/{__version__}" + + +def _health_value_matches(actual: object, expected: object) -> bool: + if isinstance(expected, tuple): + return actual in expected + if isinstance(expected, Mapping): + return isinstance(actual, Mapping) and all( + key in actual and _health_value_matches(actual[key], item) + for key, item in expected.items() + ) + return actual == expected + + +def _health(url: str, *, expected: Mapping[str, object], attempts: int = 30) -> None: + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + _RejectRedirects(), + ) + request = urllib.request.Request( + url, + headers={"User-Agent": _HEALTH_USER_AGENT, "Accept": "application/json"}, + method="GET", + ) + for _ in range(attempts): + try: + with opener.open(request, timeout=2) as response: # noqa: S310 - fixed validated setup URL + payload = response.read(65_537) + if response.status != 200 or len(payload) > 65_536: + raise ValueError("invalid health response") + value = json.loads(payload) + if isinstance(value, dict) and _health_value_matches(value, expected): + return + except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError): + pass + time.sleep(1) + raise ServerSetupError("service_health", "AgentNet service did not return exact healthy identity evidence") + + +def _core_create_arguments( + request: ServerSetupRequest, + *, + node_executable: Path, + executable: Path, + core_config_path: Path, core_data: Path, oidc_path: Path, scanner_path: Path, @@ -4999,6 +6965,40 @@ def _legacy_remote_activation_oidc( ) +def _oidc_document_matches_exact_policy( + document: Mapping[str, Any], + expected: Mapping[str, Any], +) -> bool: + """Allow only ordering changes within exact approver purpose sets.""" + + candidate_approvers = document.get("trusted_approvers") + expected_approvers = expected.get("trusted_approvers") + if ( + not isinstance(candidate_approvers, list) + or not isinstance(expected_approvers, list) + or len(candidate_approvers) != len(expected_approvers) + ): + return False + for candidate_approver, expected_approver in zip( + candidate_approvers, expected_approvers, strict=True + ): + if not isinstance(candidate_approver, dict) or not isinstance( + expected_approver, dict + ): + return False + candidate_purposes = candidate_approver.get("allowed_purposes") + expected_purposes = expected_approver.get("allowed_purposes") + if ( + not isinstance(candidate_purposes, list) + or not isinstance(expected_purposes, list) + or len(candidate_purposes) != len(expected_purposes) + or any(not isinstance(purpose, str) for purpose in candidate_purposes) + or sorted(candidate_purposes) != sorted(expected_purposes) + ): + return False + return True + + def _load_upgrade_compatible_core_config( core_config_path: Path, core_oidc_path: Path, @@ -5012,6 +7012,8 @@ def _load_upgrade_compatible_core_config( """Validate exact current semantics, allowing only missing 0.1.30 owner pins.""" legacy_oidc = _legacy_remote_activation_oidc(oidc) + desired_document = oidc.model_dump(mode="json") + legacy_document = legacy_oidc.model_dump(mode="json") config = load_config_json( _read_private_managed_file( core_config_path, @@ -5049,10 +7051,23 @@ def _load_upgrade_compatible_core_config( ), label="Core OIDC config", ) - desired_document = oidc.model_dump(mode="json") - legacy_document = legacy_oidc.model_dump(mode="json") - standalone_legacy = standalone == legacy_document - if standalone != desired_document and not standalone_legacy: + if not ( + _oidc_document_matches_exact_policy(standalone, desired_document) + or _oidc_document_matches_exact_policy(standalone, legacy_document) + ): + raise ServerSetupError( + "setup_upgrade_conflict", + "standalone Core OIDC policy differs beyond supported owner binding migration", + ) + try: + standalone_oidc = OIDCEnrollmentConfig.model_validate(standalone) + except ValidationError as exc: + raise ServerSetupError( + "setup_upgrade_conflict", + "standalone Core OIDC policy differs beyond supported owner binding migration", + ) from exc + standalone_legacy = standalone_oidc == legacy_oidc + if standalone_oidc != oidc and not standalone_legacy: raise ServerSetupError( "setup_upgrade_conflict", "standalone Core OIDC policy differs beyond supported owner binding migration", @@ -5060,6 +7075,245 @@ def _load_upgrade_compatible_core_config( return normalized, core_legacy or standalone_legacy +def _load_partial_owner_recovery_core_config( + core_config_path: Path, + core_oidc_path: Path, + core_account: pwd.struct_passwd, + *, + request: ServerSetupRequest, + core_data: Path, + embedded_oidc: OIDCEnrollmentConfig, + standalone_oidc: OIDCEnrollmentConfig, + source_principal_id: str, + scanner_trust: ScannerTrustConfig | None, +) -> tuple[Any, OIDCEnrollmentConfig]: + """Validate the exact journaled dual-trust Core split state.""" + + core_payload = _read_private_managed_file( + core_config_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ) + config = load_config_json(core_payload.decode("utf-8")) + core_document = _strict_json_bytes( + core_payload, + label="Core configuration", + ) + try: + observed_embedded = OIDCEnrollmentConfig.model_validate( + core_document.get("oidc_enrollment") + ) + except ValidationError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "embedded Core OIDC policy conflicts with partial recovery", + ) from exc + source_service_oidc = embedded_oidc + if embedded_oidc.approval_service is not None: + source_service_oidc = embedded_oidc.model_copy( + update={ + "approval_service": embedded_oidc.approval_service.model_copy( + update={"approver_principal_id": source_principal_id} + ) + } + ) + normalized_embedded = observed_embedded.model_copy( + update={"trusted_approvers": embedded_oidc.trusted_approvers} + ) + if ( + { + trust.principal_id: trust + for trust in observed_embedded.trusted_approvers + } + != { + trust.principal_id: trust + for trust in embedded_oidc.trusted_approvers + } + or normalized_embedded not in (embedded_oidc, source_service_oidc) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "embedded Core OIDC policy conflicts with partial recovery", + ) + _require_core_config_matches( + config, + request=request, + core_data=core_data, + oidc=observed_embedded, + scanner_trust=scanner_trust, + ) + standalone = _strict_json_bytes( + _read_private_managed_file( + core_oidc_path, + core_account, + blocker="canonical_owner_recovery", + max_bytes=_MAX_CONFIG_BYTES, + ), + label="Core OIDC config", + ) + expected = standalone_oidc.model_dump(mode="json") + if not _oidc_document_matches_exact_policy(standalone, expected): + raise ServerSetupError( + "canonical_owner_recovery", + "standalone Core OIDC policy conflicts with partial recovery", + ) + try: + observed = OIDCEnrollmentConfig.model_validate(standalone) + except ValidationError as exc: + raise ServerSetupError( + "canonical_owner_recovery", + "standalone Core OIDC policy conflicts with partial recovery", + ) from exc + if observed != standalone_oidc: + raise ServerSetupError( + "canonical_owner_recovery", + "standalone Core OIDC policy conflicts with partial recovery", + ) + return config, observed_embedded + + +def _record_upgrade_config_replacements( + pending: dict[str, Any], + replacements: Mapping[str, bytes], +) -> None: + recorded = pending.setdefault("replacement_configs", {}) + if not isinstance(recorded, dict): + raise ServerSetupError( + "setup_upgrade_conflict", + "upgrade replacement config state is invalid", + ) + for key, payload in replacements.items(): + existing = recorded.get(key) + if existing is not None and existing != payload: + raise ServerSetupError( + "setup_upgrade_conflict", + "upgrade replacement config state conflicts", + ) + recorded[key] = payload + + +def _migrate_0150_approval_request_ttl_policy( + *, + approval_config_path: Path, + approval_account: pwd.struct_passwd, + pending: dict[str, Any], + before_write: Callable[[], None], +) -> str: + """Preserve published v0.1.50 TTLs or split its retained live hotfix.""" + + journal = pending.get("journal") + if ( + not isinstance(journal, Mapping) + or journal.get("schema") != _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA + or ( + journal.get("from_package_version"), + journal.get("to_package_version"), + ) + != _APPROVAL_REQUEST_TTL_UPGRADE + ): + raise ServerSetupError( + "setup_upgrade_conflict", + "Approval TTL migration requires its exact active upgrade journal", + ) + previous = _journaled_config_payloads(journal)["approval_config"] + previous_document = _strict_json_bytes( + previous, + label="journaled Approval config", + ) + request_ttl = previous_document.get("request_ttl_seconds") + other_ttls_are_published_defaults = ( + previous_document.get("challenge_ttl_seconds") == 180 + and previous_document.get("receipt_ttl_seconds") == 300 + and previous_document.get("registration_ttl_seconds") == 600 + ) + if ( + request_ttl == 300 + and "communication_scope_request_ttl_seconds" not in previous_document + and other_ttls_are_published_defaults + ): + try: + ApprovalServiceConfig.model_validate(previous_document) + except ValidationError as exc: + raise ServerSetupError( + "approval_config", + "journaled published Approval configuration is invalid", + ) from exc + current = _read_private_managed_file( + approval_config_path, + approval_account, + blocker="setup_upgrade_conflict", + max_bytes=_MAX_CONFIG_BYTES, + ) + if current != previous: + raise ServerSetupError( + "setup_upgrade_conflict", + "Approval configuration changed after upgrade journal creation", + ) + return "already_satisfied" + if ( + request_ttl != 3_600 + or previous_document.get( + "communication_scope_request_ttl_seconds", + 3_600, + ) + != 3_600 + or not other_ttls_are_published_defaults + ): + raise ServerSetupError( + "approval_config", + "journaled Approval TTL policy is not an exact supported upgrade source", + ) + normalized_document = dict(previous_document) + normalized_document["request_ttl_seconds"] = 600 + normalized_document["communication_scope_request_ttl_seconds"] = 3_600 + try: + ApprovalServiceConfig.model_validate(normalized_document) + except Exception as exc: + raise ServerSetupError( + "approval_config", + "journaled Approval configuration differs beyond the supported TTL migration", + ) from exc + normalized = ( + json.dumps(normalized_document, indent=2, sort_keys=True).encode() + b"\n" + ) + _record_upgrade_config_replacements( + pending, + {"approval_config": normalized}, + ) + current = _read_private_managed_file( + approval_config_path, + approval_account, + blocker="setup_upgrade_conflict", + max_bytes=_MAX_CONFIG_BYTES, + ) + if current == normalized: + return "already_satisfied" + if current != previous: + raise ServerSetupError( + "setup_upgrade_conflict", + "Approval configuration changed after upgrade journal creation", + ) + before_write() + return _atomic_replace_exact( + approval_config_path, + expected=previous, + payload=normalized, + mode=0o600, + uid=approval_account.pw_uid, + gid=approval_account.pw_gid, + reader=lambda target: _read_private_managed_file( + target, + approval_account, + blocker="setup_upgrade_conflict", + max_bytes=_MAX_CONFIG_BYTES, + ), + blocker="setup_upgrade_conflict", + label="Approval configuration", + result="updated_package_upgrade", + ) + + def _migrate_legacy_remote_activation_policy( *, core_config_path: Path, @@ -5075,42 +7329,118 @@ def _migrate_legacy_remote_activation_policy( "legacy owner binding migration requires an active upgrade journal", ) previous = _journaled_config_payloads(journal) - legacy_document = _legacy_remote_activation_oidc(oidc).model_dump(mode="json") - desired_document = oidc.model_dump(mode="json") - + legacy_document = _legacy_remote_activation_oidc(oidc).model_dump(mode="json") + desired_document = oidc.model_dump(mode="json") + + previous_oidc_document = _strict_json_bytes( + previous["core_oidc_config"], + label="journaled Core OIDC config", + ) + if previous_oidc_document not in (legacy_document, desired_document): + raise ServerSetupError( + "setup_upgrade_conflict", + "journaled Core OIDC policy is not an exact supported upgrade source", + ) + oidc_payload = json.dumps(desired_document, indent=2, sort_keys=True).encode() + b"\n" + + previous_core_document = _strict_json_bytes( + previous["core_config"], + label="journaled Core config", + ) + if previous_core_document.get("oidc_enrollment") not in ( + legacy_document, + desired_document, + ): + raise ServerSetupError( + "setup_upgrade_conflict", + "journaled Core config is not an exact supported owner binding source", + ) + replacement_core_document = dict(previous_core_document) + replacement_core_document["oidc_enrollment"] = desired_document + core_payload = ( + json.dumps(replacement_core_document, indent=2, sort_keys=True).encode() + b"\n" + ) + replacements = { + "core_config": core_payload, + "core_oidc_config": oidc_payload, + } + _record_upgrade_config_replacements(pending, replacements) + core_status = _write_journaled_core_config( + core_config_path, + core_payload, + account=core_account, + previous=previous["core_config"], + ) + oidc_status = _write_journaled_core_config( + core_oidc_path, + oidc_payload, + account=core_account, + previous=previous["core_oidc_config"], + ) + return ( + "updated_package_upgrade" + if "updated_package_upgrade" in {core_status, oidc_status} + else "already_satisfied" + ) + + +def _migrate_canonical_owner_core_policy( + *, + core_config_path: Path, + core_oidc_path: Path, + core_account: pwd.struct_passwd, + source_oidc: OIDCEnrollmentConfig, + target_oidc: OIDCEnrollmentConfig, + pending: dict[str, Any], +) -> str: + """Replace only the proof-bound Approval owner and signer in Core.""" + + journal = pending.get("journal") + if not isinstance(journal, Mapping): + raise ServerSetupError( + "canonical_owner_recovery", + "canonical Core policy cutover requires an active upgrade journal", + ) + previous = _journaled_config_payloads(journal) + source_document = source_oidc.model_dump(mode="json") + target_document = target_oidc.model_dump(mode="json") previous_oidc_document = _strict_json_bytes( previous["core_oidc_config"], label="journaled Core OIDC config", ) - if previous_oidc_document not in (legacy_document, desired_document): + if previous_oidc_document not in (source_document, target_document): raise ServerSetupError( - "setup_upgrade_conflict", - "journaled Core OIDC policy is not an exact supported upgrade source", + "canonical_owner_recovery", + "journaled Core OIDC policy is not the exact canonical-owner source", ) - oidc_payload = json.dumps(desired_document, indent=2, sort_keys=True).encode() + b"\n" - previous_core_document = _strict_json_bytes( previous["core_config"], label="journaled Core config", ) if previous_core_document.get("oidc_enrollment") not in ( - legacy_document, - desired_document, + source_document, + target_document, ): raise ServerSetupError( - "setup_upgrade_conflict", - "journaled Core config is not an exact supported owner binding source", + "canonical_owner_recovery", + "journaled Core config is not the exact canonical-owner source", ) + oidc_payload = ( + json.dumps(target_document, indent=2, sort_keys=True).encode() + b"\n" + ) replacement_core_document = dict(previous_core_document) - replacement_core_document["oidc_enrollment"] = desired_document + replacement_core_document["oidc_enrollment"] = target_document core_payload = ( - json.dumps(replacement_core_document, indent=2, sort_keys=True).encode() + b"\n" + json.dumps(replacement_core_document, indent=2, sort_keys=True).encode() + + b"\n" + ) + _record_upgrade_config_replacements( + pending, + { + "core_config": core_payload, + "core_oidc_config": oidc_payload, + }, ) - replacements = { - "core_config": core_payload, - "core_oidc_config": oidc_payload, - } - pending["replacement_configs"] = replacements core_status = _write_journaled_core_config( core_config_path, core_payload, @@ -5137,11 +7467,13 @@ def _prepare_supported_upgrade( approved_digest: str, approval_config_path: Path, approval_account: pwd.struct_passwd, + approval_state: Path, approval_preexisting: bool, core_config_path: Path, core_oidc_path: Path, core_account: pwd.struct_passwd, core_preexisting: bool, + request: ServerSetupRequest, database_url: str, domain_id: str, enrolled_harness_id: str | None, @@ -5274,15 +7606,24 @@ def _prepare_supported_upgrade( "an unrelated interrupted AgentNet setup upgrade is journaled on this host", ) lifecycle_upgrade = journal.get("schema") == _LIFECYCLE_UPGRADE_JOURNAL_SCHEMA + config_paths = { + "core_config": core_config_path, + "core_oidc_config": core_oidc_path, + } + config_accounts = { + "core_config": core_account, + "core_oidc_config": core_account, + } + if journal.get("schema") == _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA: + config_paths["approval_config"] = approval_config_path + config_accounts["approval_config"] = approval_account pending.update( journal=journal, journal_path=journal_path, marker_path=marker_path, unit_paths=dict(unit_paths), - config_paths={ - "core_config": core_config_path, - "core_oidc_config": core_oidc_path, - }, + config_paths=config_paths, + config_accounts=config_accounts, core_account=core_account, database_url=database_url, systemctl_executable=systemctl_executable, @@ -5310,19 +7651,20 @@ def _prepare_supported_upgrade( "setup_upgrade_conflict", "recorded setup marker has no realized Core and Approval state to upgrade", ) + approval_digest, core_digest = _upgrade_marker_config_digests( + existing_marker, + approval_config_path=approval_config_path, + approval_account=approval_account, + approval_state=approval_state, + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=core_account, + request=request, + ) _require_marker_realized_state( existing_marker, - approval_config_digest=_managed_config_digest( - approval_config_path, - approval_account, - blocker="approval_config", - ), - core_config_digest=_managed_config_digest( - core_config_path, - core_account, - blocker="core_custody", - exclude_top_level=frozenset({"enrolled_harness_id", "enrolled_credential_id"}), - ), + approval_config_digest=approval_digest, + core_config_digest=core_digest, unit_paths=source_unit_paths, uid=uid, gid=gid, @@ -5362,6 +7704,19 @@ def _prepare_supported_upgrade( ) ).decode("ascii"), } + approval_config_upgrade = ( + str(existing_marker["package_version"]), + __version__, + ) == _APPROVAL_REQUEST_TTL_UPGRADE + if approval_config_upgrade: + previous_configs["approval_config"] = base64.b64encode( + _read_private_managed_file( + approval_config_path, + approval_account, + blocker="setup_upgrade_conflict", + max_bytes=_MAX_CONFIG_BYTES, + ) + ).decode("ascii") lifecycle_upgrade = ( str(existing_marker["package_version"]), __version__, @@ -5370,7 +7725,11 @@ def _prepare_supported_upgrade( "schema": ( _LIFECYCLE_UPGRADE_JOURNAL_SCHEMA if lifecycle_upgrade - else _UPGRADE_JOURNAL_SCHEMA + else ( + _APPROVAL_CONFIG_UPGRADE_JOURNAL_SCHEMA + if approval_config_upgrade + else _UPGRADE_JOURNAL_SCHEMA + ) ), "from_marker_sha256": hashlib.sha256(existing_marker_payload).hexdigest(), "from_package_version": str(existing_marker["package_version"]), @@ -5413,15 +7772,24 @@ def _prepare_supported_upgrade( previous_systemd=previous_systemd, ) _write_upgrade_journal(journal_path, journal, uid=uid, gid=gid) + config_paths = { + "core_config": core_config_path, + "core_oidc_config": core_oidc_path, + } + config_accounts = { + "core_config": core_account, + "core_oidc_config": core_account, + } + if approval_config_upgrade: + config_paths["approval_config"] = approval_config_path + config_accounts["approval_config"] = approval_account pending.update( journal=journal, journal_path=journal_path, marker_path=marker_path, unit_paths=dict(unit_paths), - config_paths={ - "core_config": core_config_path, - "core_oidc_config": core_oidc_path, - }, + config_paths=config_paths, + config_accounts=config_accounts, core_account=core_account, database_url=database_url, systemctl_executable=systemctl_executable, @@ -5508,11 +7876,25 @@ def _rollback_pending_upgrade(pending: Mapping[str, Any]) -> None: raise ServerSetupError("setup_upgrade_conflict", "upgrade rollback state is invalid") core_account = pending["core_account"] config_paths = dict(pending["config_paths"]) + raw_config_accounts = pending.get("config_accounts") + config_accounts = ( + {key: core_account for key in config_paths} + if raw_config_accounts is None + else dict(raw_config_accounts) + ) + if set(previous_configs) != set(config_paths) or set(config_accounts) != set( + config_paths + ): + raise ServerSetupError( + "setup_upgrade_conflict", + "upgrade rollback config state is invalid", + ) current_configs: dict[str, bytes] = {} for key, path in config_paths.items(): + account = config_accounts[key] current = _read_private_managed_file( path, - core_account, + account, blocker="setup_upgrade_conflict", max_bytes=_MAX_CONFIG_BYTES, ) @@ -5522,7 +7904,7 @@ def _rollback_pending_upgrade(pending: Mapping[str, Any]) -> None: ): raise ServerSetupError( "setup_upgrade_conflict", - "managed Core config changed before upgrade rollback", + "managed config changed before upgrade rollback", ) current_configs[key] = current @@ -5605,7 +7987,7 @@ def _rollback_pending_upgrade(pending: Mapping[str, Any]) -> None: _write_journaled_core_config( path, previous_configs[key], - account=core_account, + account=config_accounts[key], previous=current, ) for unit, path in unit_paths.items(): @@ -6011,69 +8393,333 @@ def _apply_server_setup( if core_runtime_preexisting: _require_private_tree(core_runtime, core_account, blocker="core_custody") prevalidated_oidc: OIDCEnrollmentConfig | None = None + prevalidated_source_oidc: OIDCEnrollmentConfig | None = None prevalidated_config: Any | None = None legacy_owner_policy = False + canonical_owner_source: str | None = None + def prepare_upgrade( + enrolled_harness_id: str | None, + enrolled_credential_id: str | None, + ) -> str: + return _prepare_supported_upgrade( + existing_marker=existing_marker, + existing_marker_payload=existing_marker_payload, + approved_digest=approved_digest, + approval_config_path=approval_config_path, + approval_account=approval_account, + approval_state=approval_state, + approval_preexisting=approval_preexisting, + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=core_account, + core_preexisting=core_preexisting, + request=request, + database_url=request.database_url, + domain_id=request.domain_id, + enrolled_harness_id=enrolled_harness_id, + enrolled_credential_id=enrolled_credential_id, + profile_key=request.runtime_instance_id, + systemctl_executable=systemctl_executable, + unit_paths=unit_paths, + marker_path=setup_marker, + journal_path=journal_path, + uid=root_uid, + gid=root_gid, + pending=_pending_upgrade, + ) + + upgrade_status: str | None = None + approval_ttl_status: str | None = None + recorded_owner_recovery: dict[str, str] | None = None + approval_ttl_transition = ( + existing_marker is not None + and ( + existing_marker.get("package_version"), + __version__, + ) + == _APPROVAL_REQUEST_TTL_UPGRADE + and forward_only_transition + ) + if approval_ttl_transition: + recorded_owner_recovery = _canonical_owner_recovery_source( + approval_state, + approval_config_path, + approval_account, + request=request, + ) + if recorded_owner_recovery is None: + assert existing_marker is not None + + def validate_reconstructed_marker_state() -> None: + source_profile = _marker_upgrade_unit_profile(existing_marker) + if source_profile is None: + raise ServerSetupError( + "setup_upgrade_conflict", + "recorded setup marker is not an exact supported upgrade source", + ) + _require_marker_realized_state( + existing_marker, + approval_config_digest=str( + existing_marker["approval_config_digest"] + ), + core_config_digest=str( + existing_marker["core_config_digest"] + ), + unit_paths={ + unit: unit_paths[unit] + for unit in source_profile + }, + uid=root_uid, + gid=root_gid, + ) + + observed_at = int(time.time()) + recorded_owner_recovery = ( + _reconstruct_partial_canonical_owner_recovery_for_marker( + existing_marker, + approval_state, + approval_config_path, + approval_account, + core_config_path, + core_oidc_path, + core_account, + request=request, + approvers=approvers, + observed_at=observed_at, + before_write=validate_reconstructed_marker_state, + ) + or _reconstruct_completed_canonical_owner_recovery_for_marker( + existing_marker, + approval_state, + approval_config_path, + approval_account, + core_config_path, + core_oidc_path, + core_account, + request=request, + observed_at=observed_at, + before_write=validate_reconstructed_marker_state, + ) + ) + + def revalidate_ttl_write_source() -> None: + assert existing_marker is not None + source_profile = _marker_upgrade_unit_profile(existing_marker) + if source_profile is None: + raise ServerSetupError( + "setup_upgrade_conflict", + "recorded setup marker is not an exact supported upgrade source", + ) + approval_digest, core_digest = _upgrade_marker_config_digests( + existing_marker, + approval_config_path=approval_config_path, + approval_account=approval_account, + approval_state=approval_state, + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=core_account, + request=request, + ) + _require_marker_realized_state( + existing_marker, + approval_config_digest=approval_digest, + core_config_digest=core_digest, + unit_paths={ + unit: unit_paths[unit] + for unit in source_profile + }, + uid=root_uid, + gid=root_gid, + ) + + upgrade_status = prepare_upgrade(None, None) + approval_ttl_status = _migrate_0150_approval_request_ttl_policy( + approval_config_path=approval_config_path, + approval_account=approval_account, + pending=_pending_upgrade, + before_write=revalidate_ttl_write_source, + ) if approval_preexisting and core_preexisting: approval_config_before, trusted_before = _approval_trust( approval_config_path, approval_account, approval_state, + recovery_source=recorded_owner_recovery, ) - _require_exact_approval_policy( + canonical_owner_source = _require_exact_approval_policy( approval_config_before, request=request, owner_oidc=owner_oidc, approvers=approvers, approval_state=approval_state, + allow_canonical_owner_adoption=True, ) - prevalidated_oidc = _build_core_oidc_config( - request, - oidc_provider, - trusted=trusted_before, - approvers=approvers, - ) - prevalidated_config, legacy_owner_policy = _load_upgrade_compatible_core_config( - core_config_path, - core_oidc_path, - core_account, - request=request, - core_data=core_data, - oidc=prevalidated_oidc, - scanner_trust=scanner_trust, + if canonical_owner_source is None: + prevalidated_oidc = _build_core_oidc_config( + request, + oidc_provider, + trusted=trusted_before, + approvers=approvers, + ) + try: + prevalidated_config, legacy_owner_policy = ( + _load_upgrade_compatible_core_config( + core_config_path, + core_oidc_path, + core_account, + request=request, + core_data=core_data, + oidc=prevalidated_oidc, + scanner_trust=scanner_trust, + ) + ) + except ServerSetupError as exc: + completed = recorded_owner_recovery or _canonical_owner_recovery_source( + approval_state, + approval_config_path, + approval_account, + request=request, + ) + if exc.blocker != "setup_upgrade_conflict" or completed is None: + raise + canonical_owner_source = completed["source_principal_id"] + selected = approvers[0].model_copy( + update={"principal_id": canonical_owner_source} + ) + source_request = request.model_copy( + update={"approval_approver_principal_id": canonical_owner_source} + ) + source_trust = [ + IndependentApproverConfig( + principal_id=canonical_owner_source, + authority_kind=selected.authority_kind, + signer_key_id=completed["source_signer_key_id"], + public_key_pem=completed["source_signer_public_key_pem"], + allowed_purposes=selected.allowed_purposes, + ) + ] + prevalidated_source_oidc = _build_core_oidc_config( + source_request, + oidc_provider, + trusted=source_trust, + approvers=(selected,), + ) + prevalidated_config, legacy_owner_policy = ( + _load_upgrade_compatible_core_config( + core_config_path, + core_oidc_path, + core_account, + request=source_request, + core_data=core_data, + oidc=prevalidated_source_oidc, + scanner_trust=scanner_trust, + ) + ) + if ( + canonical_owner_source is None + and recorded_owner_recovery is not None + ): + canonical_owner_source = recorded_owner_recovery[ + "source_principal_id" + ] + prevalidated_source_oidc = prevalidated_oidc + else: + selected = approvers[0].model_copy( + update={"principal_id": canonical_owner_source} + ) + source_request = request.model_copy( + update={"approval_approver_principal_id": canonical_owner_source} + ) + source_oidc = _build_core_oidc_config( + source_request, + oidc_provider, + trusted=trusted_before, + approvers=(selected,), + ) + if ( + recorded_owner_recovery is not None + and recorded_owner_recovery.get("partial_recovery") + == "true" + ): + target = approvers[0] + target_trust = IndependentApproverConfig( + principal_id=target.principal_id, + authority_kind=target.authority_kind, + signer_key_id=recorded_owner_recovery[ + "target_signer_key_id" + ], + public_key_pem=recorded_owner_recovery[ + "target_signer_public_key_pem" + ], + allowed_purposes=target.allowed_purposes, + ) + prevalidated_oidc = _build_core_oidc_config( + request, + oidc_provider, + trusted=(target_trust,), + approvers=approvers, + ) + prevalidated_source_oidc = ( + prevalidated_oidc.model_copy( + update={ + "trusted_approvers": ( + *source_oidc.trusted_approvers, + *prevalidated_oidc.trusted_approvers, + ) + } + ) + ) + ( + prevalidated_config, + prevalidated_source_oidc, + ) = _load_partial_owner_recovery_core_config( + core_config_path, + core_oidc_path, + core_account, + request=request, + core_data=core_data, + embedded_oidc=prevalidated_source_oidc, + standalone_oidc=prevalidated_oidc, + source_principal_id=recorded_owner_recovery[ + "source_principal_id" + ], + scanner_trust=scanner_trust, + ) + legacy_owner_policy = False + else: + prevalidated_source_oidc = source_oidc + prevalidated_config, legacy_owner_policy = ( + _load_upgrade_compatible_core_config( + core_config_path, + core_oidc_path, + core_account, + request=source_request, + core_data=core_data, + oidc=prevalidated_source_oidc, + scanner_trust=scanner_trust, + ) + ) + if upgrade_status is None: + upgrade_status = prepare_upgrade( + ( + prevalidated_config.enrolled_harness_id + if prevalidated_config is not None + else None + ), + ( + prevalidated_config.enrolled_credential_id + if prevalidated_config is not None + else None + ), ) - upgrade_status = _prepare_supported_upgrade( - existing_marker=existing_marker, - existing_marker_payload=existing_marker_payload, - approved_digest=approved_digest, - approval_config_path=approval_config_path, - approval_account=approval_account, - approval_preexisting=approval_preexisting, - core_config_path=core_config_path, - core_oidc_path=core_oidc_path, - core_account=core_account, - core_preexisting=core_preexisting, - database_url=request.database_url, - domain_id=request.domain_id, - enrolled_harness_id=( - prevalidated_config.enrolled_harness_id - if prevalidated_config is not None - else None - ), - enrolled_credential_id=( - prevalidated_config.enrolled_credential_id - if prevalidated_config is not None - else None - ), - profile_key=request.runtime_instance_id, - systemctl_executable=systemctl_executable, - unit_paths=unit_paths, - marker_path=setup_marker, - journal_path=journal_path, - uid=root_uid, - gid=root_gid, - pending=_pending_upgrade, - ) steps.append({"id": "package_upgrade", "status": upgrade_status}) + if approval_ttl_status is not None: + steps.append( + { + "id": "approval_request_ttl_policy_upgrade", + "status": approval_ttl_status, + } + ) attempt_status, attempt_active = _prepare_setup_attempt( setup_attempt, existing_marker=existing_marker, @@ -6350,14 +8996,29 @@ def commit_setup_profile( approval_config_path, approval_account, approval_state, + recovery_source=recorded_owner_recovery, ) - _require_exact_approval_policy( + observed_owner_source = _require_exact_approval_policy( approval_config, request=request, owner_oidc=owner_oidc, approvers=approvers, approval_state=approval_state, + allow_canonical_owner_adoption=canonical_owner_source is not None, ) + if ( + observed_owner_source != canonical_owner_source + and not ( + observed_owner_source is None + and recorded_owner_recovery is not None + and canonical_owner_source + == recorded_owner_recovery["source_principal_id"] + ) + ): + raise ServerSetupError( + "canonical_owner_recovery", + "Approval owner state changed after preflight", + ) deferred_approval_status = approval_preexisting and forward_only_transition if approval_preexisting: if not deferred_approval_status: @@ -6368,19 +9029,61 @@ def commit_setup_profile( stage="approval_status", ) steps.append({"id": "approval_provision", "status": "already_satisfied"}) - oidc = _build_core_oidc_config( - request, - oidc_provider, - trusted=trusted, - approvers=approvers, - ) - if prevalidated_oidc is not None and oidc != prevalidated_oidc: - raise ServerSetupError( - "approval_conflict", - "Approval trust changed during setup", + if canonical_owner_source is not None: + if prevalidated_source_oidc is None: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical source Core policy was not prevalidated", + ) + oidc = prevalidated_source_oidc + else: + oidc = _build_core_oidc_config( + request, + oidc_provider, + trusted=trusted, + approvers=approvers, ) + if prevalidated_oidc is not None and oidc != prevalidated_oidc: + raise ServerSetupError( + "approval_conflict", + "Approval trust changed during setup", + ) oidc_path = core_oidc_path - oidc_payload = json.dumps(oidc.model_dump(mode="json"), indent=2, sort_keys=True).encode() + b"\n" + if core_preexisting: + oidc_payload = _read_private_managed_file( + oidc_path, + core_account, + blocker="core_custody", + max_bytes=_MAX_CONFIG_BYTES, + ) + try: + realized_oidc = OIDCEnrollmentConfig.model_validate_json( + oidc_payload + ) + except ValidationError as exc: + raise ServerSetupError( + "core_conflict", + "Core OIDC policy changed after preflight", + ) from exc + if ( + realized_oidc != oidc + and not ( + recorded_owner_recovery is not None + and recorded_owner_recovery.get("partial_recovery") + == "true" + and prevalidated_oidc is not None + and realized_oidc == prevalidated_oidc + ) + ): + raise ServerSetupError( + "core_conflict", + "Core OIDC policy changed after preflight", + ) + else: + oidc_payload = ( + json.dumps(oidc.model_dump(mode="json"), indent=2, sort_keys=True).encode() + + b"\n" + ) steps.append({"id": "core_oidc_config", "status": _atomic_write(oidc_path, oidc_payload, mode=0o600, uid=core_account.pw_uid, gid=core_account.pw_gid)}) scanner_path = core_data / "scanner-trust.json" if scanner_trust is not None: @@ -6455,7 +9158,13 @@ def commit_setup_profile( unit_payloads: dict[str, bytes] | None = None if rollback_capable_upgrade: unit_payloads = write_setup_units() - elif forward_only_upgrade: + elif ( + forward_only_upgrade + and not ( + recorded_owner_recovery is not None + and recorded_owner_recovery.get("partial_recovery") == "true" + ) + ): unit_payloads = commit_setup_profile() profile_committed_early = True c0_runtime_prepared = False @@ -6534,6 +9243,115 @@ def verify_upgrade_quiescence() -> None: environment=approval_environment, stage="approval_status", ) + if canonical_owner_source is not None: + recovery_identity = _validated_managed_identity_profile( + layout.host(SERVER_AGENT_IDENTITY), + layout.host(SERVER_AGENT_KEY), + core_account, + config=config, + request=request, + ) + recovery_identity_actor = recovery_identity.get("actor") + recovery_target_principal = ( + recovery_identity_actor.get("principal_id") + if isinstance(recovery_identity_actor, dict) + else None + ) + if ( + not isinstance(recovery_target_principal, str) + or recovery_target_principal + != request.approval_approver_principal_id + ): + raise ServerSetupError( + "canonical_owner_recovery", + "recovery target does not match enrolled identity", + ) + recovery_oidc_issuer = approvers[0].oidc_issuer + if recovery_oidc_issuer is None: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery requires exact OIDC issuer binding", + ) + recovery_id = str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"agentnet:{request.domain_id}:{canonical_owner_source}:" + f"{recovery_target_principal}", + ) + ) + recovery = _run_as( + approval_account, + [ + str(node_executable), + str(executable), + "approval", + "recover-canonical-owner", + "--config", + str(approval_config_path), + "--recovery-id", + recovery_id, + "--domain", + request.domain_id, + "--source-principal", + canonical_owner_source, + "--target-principal", + recovery_target_principal, + "--oidc-issuer", + recovery_oidc_issuer, + ], + environment=approval_environment, + stage="canonical_owner_recovery", + ) + recovery_pending = _pending_upgrade + if recovery["status"] not in {"recovered", "already_exact"}: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical owner recovery returned an invalid status", + ) + if prevalidated_source_oidc is None: + raise ServerSetupError( + "canonical_owner_recovery", + "canonical source Core policy was not prevalidated", + ) + approval_config_after, trusted_after = _approval_trust( + approval_config_path, + approval_account, + approval_state, + ) + _require_exact_approval_policy( + approval_config_after, + request=request, + owner_oidc=owner_oidc, + approvers=approvers, + approval_state=approval_state, + ) + prevalidated_oidc = _build_core_oidc_config( + request, + oidc_provider, + trusted=trusted_after, + approvers=approvers, + ) + if prevalidated_source_oidc == prevalidated_oidc: + core_policy_status = "already_satisfied" + else: + core_policy_status = _migrate_canonical_owner_core_policy( + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=core_account, + source_oidc=prevalidated_source_oidc, + target_oidc=prevalidated_oidc, + pending=recovery_pending, + ) + oidc = prevalidated_oidc + steps.append( + { + "id": "canonical_owner_recovery", + "status": recovery["status"], + "source_principal_id": canonical_owner_source, + "target_principal_id": recovery_target_principal, + "core_policy_status": core_policy_status, + } + ) if rollback_capable_upgrade: journal = _pending_upgrade.get("journal") if not isinstance(journal, Mapping): @@ -6597,7 +9415,32 @@ def verify_upgrade_quiescence() -> None: ) elif rollback_capable_upgrade: bootstrap_status = "schema_v7_migrated_preserved_identity" - if forward_only_transition and not rollback_capable_upgrade: + projection_evidence = _repair_committed_communication_scope_projection_as( + core_account, + request.database_url, + ) + migrated_projections = projection_evidence.get("migrated") + if ( + isinstance(migrated_projections, bool) + or not isinstance(migrated_projections, int) + or migrated_projections < 0 + ): + raise ServerSetupError( + "communication_scope_projection", + "committed communication scope projection returned invalid evidence", + ) + steps.append( + { + "id": "communication_scope_projection", + "status": "repaired" if migrated_projections else "already_exact", + "migrated": migrated_projections, + } + ) + if ( + forward_only_transition + and not rollback_capable_upgrade + and profile_committed_early + ): _clear_upgrade_journal(journal_path) if c0_responder_account is None: c0_responder_account = _ensure_account( @@ -6722,11 +9565,13 @@ def verify_upgrade_quiescence() -> None: approvers=approvers, approval_state=approval_state, ) - if trusted_after != trusted: + if canonical_owner_source is None and trusted_after != trusted: raise ServerSetupError("approval_conflict", "Approval trust changed during setup") if unit_payloads is None: unit_payloads = commit_setup_profile() + if forward_only_transition and not rollback_capable_upgrade: + _clear_upgrade_journal(journal_path) assert unit_payloads is not None if start: approval_health = { diff --git a/src/agentnet/storage/postgres.py b/src/agentnet/storage/postgres.py index f7e97bf..b89fc41 100644 --- a/src/agentnet/storage/postgres.py +++ b/src/agentnet/storage/postgres.py @@ -814,11 +814,16 @@ def _recover_for_subsequent_operation(self) -> None: "postgres_reconnect_failed", "PostgreSQL recovery connection was not accepted", ) from exc + previous_connection = self._connection self._connection = connection self._adapter = PostgreSQLConnectionAdapter(connection) self._lease = lease self._reconnect_required = False self._lease_lost_reason = None + try: + previous_connection.close() + except Exception: + pass if self._start_lease_keeper and ( self._keeper_restart_needed or self._keeper is None or not self._keeper.is_alive() ): @@ -891,12 +896,15 @@ def heartbeat_lease(self, token: LeaseToken) -> LeaseToken: def _keep_lease(self) -> None: interval = max(1.0, self._lease_ttl / 3) while not self._stop.wait(interval): - try: - self._lease = self.heartbeat_lease(self._lease) - except Exception as exc: # fail closed until a later operation completes recovery - if not self._keeper_restart_needed: - self._lease_lost_reason = type(exc).__name__ - return + with self._lock: + try: + self._lease = self.heartbeat_lease(self._lease) + except Exception as exc: # fail closed until a later operation completes recovery + if not self._keeper_restart_needed: + self._lease_lost_reason = type(exc).__name__ + self._reconnect_required = True + self._keeper_restart_needed = True + return def _require_runtime_lease(self, connection: PostgreSQLConnectionAdapter) -> None: if self._lease_lost_reason is not None: diff --git a/src/agentnet/storage/release_v7_schema.py b/src/agentnet/storage/release_v7_schema.py index e956aa1..adefd5e 100644 --- a/src/agentnet/storage/release_v7_schema.py +++ b/src/agentnet/storage/release_v7_schema.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, Literal from agentnet.errors import GateBlocked from agentnet.security.signatures import canonical_digest @@ -146,6 +146,319 @@ def _scope_digest( ) +def _execute( + connection: Any, + query: str, + parameters: tuple[object, ...] = (), + *, + postgres: bool, +) -> Any: + if postgres: + query = query.replace("?", "%s") + return connection.execute(query, parameters) + + +def _scope_projection( + connection: Any, + *, + row: Any, + postgres: bool, +) -> tuple[dict[str, object], tuple[dict[str, object], ...]]: + scope_id = str(row["scope_id"]) + domain_id = str(row["domain_id"]) + principal_id = str(row["principal_id"]) + owner_harness_id = str(row["owner_harness_id"]) + fresh_harness_id = str(row["fresh_harness_id"]) + harness_rows = _execute( + connection, + "SELECT harness_id,domain_id,principal_id FROM harnesses " + "WHERE harness_id IN (?,?) ORDER BY harness_id", + (owner_harness_id, fresh_harness_id), + postgres=postgres, + ).fetchall() + if ( + len(harness_rows) != 2 + or owner_harness_id == fresh_harness_id + or any( + str(harness["domain_id"]) != domain_id + or str(harness["principal_id"]) != principal_id + for harness in harness_rows + ) + ): + raise GateBlocked( + "schema_v7_scope_migration", + "v6 communication scope exact harness ownership is ambiguous", + ) + item_rows = _execute( + connection, + """SELECT i.harness_id,i.action,i.resource_pattern,i.expires_at, + e.action AS entitlement_action, + e.resource_pattern AS entitlement_resource_pattern, + e.expires_at AS entitlement_expires_at,e.revoked_at, + e.revision AS entitlement_revision + FROM communication_scope_items AS i + JOIN entitlements AS e ON e.entitlement_id=i.entitlement_id + WHERE i.scope_id=? ORDER BY i.harness_id,i.action""", + (scope_id,), + postgres=postgres, + ).fetchall() + expected_pairs = { + (harness_id, action) + for harness_id in (owner_harness_id, fresh_harness_id) + for action in _LEGACY_COMMUNICATION_ACTIONS + } + actual_pairs = { + (str(item["harness_id"]), str(item["action"])) for item in item_rows + } + if ( + len(item_rows) != len(expected_pairs) + or actual_pairs != expected_pairs + or any( + item["resource_pattern"] != "*" + or item["entitlement_resource_pattern"] != "*" + or item["action"] != item["entitlement_action"] + or item["expires_at"] is not None + or item["entitlement_expires_at"] is not None + or item["revoked_at"] is not None + or int(item["entitlement_revision"]) != int(row["policy_revision"]) + for item in item_rows + ) + ): + raise GateBlocked( + "schema_v7_scope_migration", + "v6 communication scope authority items are incomplete or not current", + ) + committed_at = int(row["committed_at"]) + audit_record_hash = str(row["audit_record_hash"]) + if len(audit_record_hash) != 64: + raise GateBlocked( + "schema_v7_scope_migration", + "v6 communication scope audit lineage is unavailable", + ) + proposal_digest = canonical_digest( + { + "migration": "v6-communication-scope-to-v7-collaboration-scope", + "source_communication_scope_id": scope_id, + "source_scope_digest": str(row["scope_digest"]), + "source_transaction_digest": str(row["transaction_digest"]), + } + ) + member_values = tuple( + sorted( + ( + { + "scope_id": scope_id, + "authority_kind": "principal", + "authority_id": principal_id, + "harness_id": owner_harness_id, + "role": "owner", + "state": "active", + "joined_sequence": 1, + "removed_sequence": None, + "joined_at": committed_at, + "removed_at": None, + }, + { + "scope_id": scope_id, + "authority_kind": "principal", + "authority_id": principal_id, + "harness_id": fresh_harness_id, + "role": "member", + "state": "active", + "joined_sequence": 1, + "removed_sequence": None, + "joined_at": committed_at, + "removed_at": None, + }, + ), + key=lambda value: str(value["harness_id"]), + ) + ) + members_for_digest = [ + { + "authority_kind": member["authority_kind"], + "authority_id": member["authority_id"], + "harness_id": member["harness_id"], + "role": member["role"], + "state": member["state"], + "joined_sequence": member["joined_sequence"], + "joined_at": member["joined_at"], + } + for member in member_values + ] + scope_digest = _scope_digest( + scope_id=scope_id, + domain_id=domain_id, + principal_id=principal_id, + owner_harness_id=owner_harness_id, + members=members_for_digest, + policy_revision=int(row["policy_revision"]), + domain_revocation_epoch=int(row["domain_revocation_epoch"]), + proposal_digest=proposal_digest, + created_at=committed_at, + ) + scope = { + "scope_id": scope_id, + "schema_version": 1, + "domain_id": domain_id, + "scope_kind": "direct", + "owner_principal_id": principal_id, + "owner_harness_id": owner_harness_id, + "source_communication_scope_id": scope_id, + "state": "active", + "state_reason": "migrated_v6_communication_scope", + "allowed_actions_json": _canonical_text(list(_MIGRATED_COLLABORATION_ACTIONS)), + "allowed_resource_prefixes_json": _canonical_text( + list(_MIGRATED_RESOURCE_PREFIXES) + ), + "allowed_classifications_json": _canonical_text(["C1"]), + "canonical_references_json": _canonical_text( + [f"communication-scope:{scope_id}"] + ), + "policy_floor": int(row["policy_revision"]), + "policy_revision": int(row["policy_revision"]), + "domain_revocation_epoch": int(row["domain_revocation_epoch"]), + "control_sequence": 1, + "membership_sequence": 1, + "proposal_digest": proposal_digest, + "scope_digest": scope_digest, + "audit_record_hash": audit_record_hash, + "revision": 1, + "created_at": committed_at, + "updated_at": committed_at, + "expires_at": None, + "revoked_at": None, + "archived_at": None, + "deleted_at": None, + } + members = tuple( + { + **member, + "member_digest": _member_digest( + scope_id=scope_id, + authority_id=principal_id, + harness_id=str(member["harness_id"]), + role=str(member["role"]), + joined_at=committed_at, + ), + } + for member in member_values + ) + return scope, members + + +def _require_exact_existing_projection( + connection: Any, + *, + scope: dict[str, object], + members: tuple[dict[str, object], ...], + postgres: bool, +) -> bool: + rows = _execute( + connection, + """SELECT * FROM collaboration_scopes + WHERE scope_id=? OR source_communication_scope_id=? + ORDER BY scope_id""", + (str(scope["scope_id"]), str(scope["scope_id"])), + postgres=postgres, + ).fetchall() + if not rows: + return False + observed_members = _execute( + connection, + """SELECT * FROM collaboration_scope_members + WHERE scope_id=? ORDER BY harness_id""", + (str(scope["scope_id"]),), + postgres=postgres, + ).fetchall() + if ( + len(rows) != 1 + or any(rows[0][key] != value for key, value in scope.items()) + or len(observed_members) != len(members) + or any( + any(observed[key] != value for key, value in expected.items()) + for observed, expected in zip(observed_members, members, strict=True) + ) + ): + raise GateBlocked( + "schema_v7_scope_projection", + "existing collaboration scope projection mismatches", + ) + return True + + +def materialize_v6_communication_scope( + connection: Any, + *, + scope_id: str, + postgres: bool = False, +) -> Literal["created", "already_exact"]: + """Materialize one exact committed communication scope as v7 authority.""" + + row = _execute( + connection, + "SELECT * FROM communication_scopes WHERE scope_id=? AND state='committed'", + (scope_id,), + postgres=postgres, + ).fetchone() + if row is None: + raise GateBlocked( + "schema_v7_scope_projection", + "committed communication scope is unavailable", + ) + scope, members = _scope_projection(connection, row=row, postgres=postgres) + if _require_exact_existing_projection( + connection, + scope=scope, + members=members, + postgres=postgres, + ): + return "already_exact" + _execute( + connection, + """INSERT INTO collaboration_scopes( + scope_id,schema_version,domain_id,scope_kind,owner_principal_id, + owner_harness_id,source_communication_scope_id,state,state_reason, + allowed_actions_json,allowed_resource_prefixes_json, + allowed_classifications_json,canonical_references_json,policy_floor, + policy_revision,domain_revocation_epoch,control_sequence, + membership_sequence,proposal_digest,scope_digest,audit_record_hash, + revision,created_at,updated_at,expires_at,revoked_at,archived_at,deleted_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + tuple(scope.values()), + postgres=postgres, + ) + for member in members: + _execute( + connection, + """INSERT INTO collaboration_scope_members( + scope_id,authority_kind,authority_id,harness_id,role,state, + joined_sequence,removed_sequence,member_digest,joined_at,removed_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?)""", + ( + member["scope_id"], + member["authority_kind"], + member["authority_id"], + member["harness_id"], + member["role"], + member["state"], + member["joined_sequence"], + member["removed_sequence"], + member["member_digest"], + member["joined_at"], + member["removed_at"], + ), + postgres=postgres, + ) + _require_exact_existing_projection( + connection, + scope=scope, + members=members, + postgres=postgres, + ) + return "created" + + def migrate_v6_communication_scopes( connection: Any, *, @@ -153,202 +466,37 @@ def migrate_v6_communication_scopes( ) -> int: """Map each exact current v6 communication scope into schema-v7 authority.""" - def execute(query: str, parameters: tuple[object, ...] = ()) -> Any: - if postgres: - query = query.replace("?", "%s") - return connection.execute(query, parameters) - - rows = execute( - "SELECT * FROM communication_scopes WHERE state='committed' " - "ORDER BY domain_id,principal_id,scope_id" + rows = _execute( + connection, + "SELECT scope_id,domain_id,principal_id FROM communication_scopes " + "WHERE state='committed' ORDER BY domain_id,principal_id,scope_id", + postgres=postgres, ).fetchall() seen_principals: set[tuple[str, str]] = set() migrated = 0 for row in rows: - scope_id = str(row["scope_id"]) - domain_id = str(row["domain_id"]) - principal_id = str(row["principal_id"]) - owner_harness_id = str(row["owner_harness_id"]) - fresh_harness_id = str(row["fresh_harness_id"]) - principal_key = (domain_id, principal_id) + principal_key = (str(row["domain_id"]), str(row["principal_id"])) if principal_key in seen_principals: raise GateBlocked( "schema_v7_scope_migration", "multiple committed v6 communication scopes are ambiguous", ) seen_principals.add(principal_key) - harness_rows = execute( - "SELECT harness_id,domain_id,principal_id FROM harnesses " - "WHERE harness_id IN (?,?) ORDER BY harness_id", - (owner_harness_id, fresh_harness_id), - ).fetchall() - if ( - len(harness_rows) != 2 - or owner_harness_id == fresh_harness_id - or any( - str(harness["domain_id"]) != domain_id - or str(harness["principal_id"]) != principal_id - for harness in harness_rows - ) - ): - raise GateBlocked( - "schema_v7_scope_migration", - "v6 communication scope exact harness ownership is ambiguous", - ) - item_rows = execute( - """SELECT i.harness_id,i.action,i.resource_pattern,i.expires_at, - e.action AS entitlement_action, - e.resource_pattern AS entitlement_resource_pattern, - e.expires_at AS entitlement_expires_at,e.revoked_at, - e.revision AS entitlement_revision - FROM communication_scope_items AS i - JOIN entitlements AS e ON e.entitlement_id=i.entitlement_id - WHERE i.scope_id=? ORDER BY i.harness_id,i.action""", - (scope_id,), - ).fetchall() - expected_pairs = { - (harness_id, action) - for harness_id in (owner_harness_id, fresh_harness_id) - for action in _LEGACY_COMMUNICATION_ACTIONS - } - actual_pairs = { - (str(item["harness_id"]), str(item["action"])) for item in item_rows - } if ( - len(item_rows) != len(expected_pairs) - or actual_pairs != expected_pairs - or any( - item["resource_pattern"] != "*" - or item["entitlement_resource_pattern"] != "*" - or item["action"] != item["entitlement_action"] - or item["expires_at"] is not None - or item["entitlement_expires_at"] is not None - or item["revoked_at"] is not None - or int(item["entitlement_revision"]) != int(row["policy_revision"]) - for item in item_rows + materialize_v6_communication_scope( + connection, + scope_id=str(row["scope_id"]), + postgres=postgres, ) + == "created" ): - raise GateBlocked( - "schema_v7_scope_migration", - "v6 communication scope authority items are incomplete or not current", - ) - committed_at = int(row["committed_at"]) - audit_record_hash = str(row["audit_record_hash"]) - if len(audit_record_hash) != 64: - raise GateBlocked( - "schema_v7_scope_migration", - "v6 communication scope audit lineage is unavailable", - ) - proposal_digest = canonical_digest( - { - "migration": "v6-communication-scope-to-v7-collaboration-scope", - "source_communication_scope_id": scope_id, - "source_scope_digest": str(row["scope_digest"]), - "source_transaction_digest": str(row["transaction_digest"]), - } - ) - member_values = sorted( - ( - ( - owner_harness_id, - "owner", - { - "authority_kind": "principal", - "authority_id": principal_id, - "harness_id": owner_harness_id, - "role": "owner", - "state": "active", - "joined_sequence": 1, - "joined_at": committed_at, - }, - ), - ( - fresh_harness_id, - "member", - { - "authority_kind": "principal", - "authority_id": principal_id, - "harness_id": fresh_harness_id, - "role": "member", - "state": "active", - "joined_sequence": 1, - "joined_at": committed_at, - }, - ), - ), - key=lambda value: value[0], - ) - members = [value[2] for value in member_values] - scope_digest = _scope_digest( - scope_id=scope_id, - domain_id=domain_id, - principal_id=principal_id, - owner_harness_id=owner_harness_id, - members=members, - policy_revision=int(row["policy_revision"]), - domain_revocation_epoch=int(row["domain_revocation_epoch"]), - proposal_digest=proposal_digest, - created_at=committed_at, - ) - execute( - """INSERT INTO collaboration_scopes( - scope_id,schema_version,domain_id,scope_kind,owner_principal_id, - owner_harness_id,source_communication_scope_id,state,state_reason, - allowed_actions_json,allowed_resource_prefixes_json, - allowed_classifications_json,canonical_references_json,policy_floor, - policy_revision,domain_revocation_epoch,control_sequence, - membership_sequence,proposal_digest,scope_digest,audit_record_hash, - revision,created_at,updated_at,expires_at,revoked_at,archived_at,deleted_at - ) VALUES(?,1,?,?,?,?,?,'active','migrated_v6_communication_scope', - ?,?,?,?, ?,?,?,1,1,?,?,?,1,?,?,NULL,NULL,NULL,NULL)""", - ( - scope_id, - domain_id, - "direct", - principal_id, - owner_harness_id, - scope_id, - _canonical_text(list(_MIGRATED_COLLABORATION_ACTIONS)), - _canonical_text(list(_MIGRATED_RESOURCE_PREFIXES)), - _canonical_text(["C1"]), - _canonical_text([f"communication-scope:{scope_id}"]), - int(row["policy_revision"]), - int(row["policy_revision"]), - int(row["domain_revocation_epoch"]), - proposal_digest, - scope_digest, - audit_record_hash, - committed_at, - committed_at, - ), - ) - for harness_id, role, member in member_values: - execute( - """INSERT INTO collaboration_scope_members( - scope_id,authority_kind,authority_id,harness_id,role,state, - joined_sequence,removed_sequence,member_digest,joined_at,removed_at - ) VALUES(?,'principal',?,?,?,'active',1,NULL,?,?,NULL)""", - ( - scope_id, - principal_id, - harness_id, - role, - _member_digest( - scope_id=scope_id, - authority_id=principal_id, - harness_id=harness_id, - role=role, - joined_at=int(member["joined_at"]), - ), - committed_at, - ), - ) - migrated += 1 + migrated += 1 return migrated __all__ = [ "RELEASE_V7_SCHEMA", "RELEASE_V7_SCHEMA_VERSION", + "materialize_v6_communication_scope", "migrate_v6_communication_scopes", ] diff --git a/tests/approval/test_approval_cli.py b/tests/approval/test_approval_cli.py index e3015c0..d62ed7c 100644 --- a/tests/approval/test_approval_cli.py +++ b/tests/approval/test_approval_cli.py @@ -174,6 +174,94 @@ def test_provision_wires_reference_only_owner_oidc_and_open_service( store.close() +def test_recover_canonical_owner_uses_store_bound_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config_path = tmp_path / "config.json" + config = SimpleNamespace( + verifier_id="approval.corp.example", + data_dir=tmp_path, + ) + closed = {"value": False} + + class Store: + def fetch_one( + self, + _sql: str, + params: tuple[object, ...], + ) -> dict[str, object] | None: + if params[1] != "placeholder-owner": + return None + return { + "oidc_issuer": "https://idp.example", + "oidc_subject": "owner-subject", + "verified_email": "sergey@corp.example", + "pinned_at": 1_800_000_000, + } + + def close(self) -> None: + closed["value"] = True + + seen: dict[str, object] = {} + + def converge( + store: object, + **kwargs: object, + ) -> dict[str, object]: + seen.update({"store": store, **kwargs}) + return { + "schema": "agentnet.canonical-owner-recovery-result.v1", + "status": "recovered", + } + + store = Store() + monkeypatch.setattr( + approval_cli, + "_open_service", + lambda _path: (config, store, object()), + ) + monkeypatch.setattr( + approval_cli, + "converge_canonical_approval_owner", + converge, + ) + monkeypatch.setattr(approval_cli.time, "time", lambda: 1_800_000_100) + + assert ( + cli.main( + [ + "approval", + "recover-canonical-owner", + "--config", + str(config_path), + "--recovery-id", + "93756ff6-6337-4ed1-9697-250b63fb68a2", + "--domain", + "corp.example", + "--source-principal", + "placeholder-owner", + "--target-principal", + "canonical-owner", + "--oidc-issuer", + "https://idp.example", + ] + ) + == 0 + ) + request = seen["request"] + assert getattr(request, "source_principal_id") == "placeholder-owner" + assert getattr(request, "target_principal_id") == "canonical-owner" + assert getattr(request, "oidc_subject") == "owner-subject" + assert getattr(request, "verified_email") == "sergey@corp.example" + assert seen["now"] == 1_800_000_100 + assert closed["value"] is True + assert json.loads(capsys.readouterr().out)["status"] == "recovered" + + + + def test_pending_open_and_watch_keep_approval_capability_local( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -511,6 +599,36 @@ def test_approval_config_rejects_duplicate_json_keys_before_parsing(tmp_path: Pa load_approval_service_config(config) +def test_approval_config_loads_exact_0150_one_hour_legacy_policy( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + private = _private_dir(tmp_path / "private") + config_path = private / "config.json" + assert cli.main( + [ + "approval", "provision", + "--config", str(config_path), + "--data-dir", str(tmp_path / "approval-data"), + "--public-origin", "https://approval.corp.example", + "--rp-id", "approval.corp.example", + "--verifier-id", "approval.corp.example", + "--approvers", str(_approver_spec(private / "approvers.json")), + ] + ) == 0 + capsys.readouterr() + value = json.loads(config_path.read_text(encoding="utf-8")) + value["request_ttl_seconds"] = 3_600 + value.pop("communication_scope_request_ttl_seconds", None) + config_path.write_text(json.dumps(value), encoding="utf-8") + config_path.chmod(0o600) + + loaded = load_approval_service_config(config_path) + + assert loaded.request_ttl_seconds == 600 + assert loaded.communication_scope_request_ttl_seconds == 3_600 + + def test_register_begin_outputs_only_stable_public_entrypoint( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/approval/test_owner_session_service.py b/tests/approval/test_owner_session_service.py index 27196e5..b9fa0bf 100644 --- a/tests/approval/test_owner_session_service.py +++ b/tests/approval/test_owner_session_service.py @@ -131,6 +131,34 @@ def request_options_for_owner(self, **kwargs): } +class CredentialRecoveryApprovalRequests(FakeApprovalRequests): + def actionable_requests_for_owner(self, **kwargs): + self.calls.append(("actionable", kwargs)) + return [ + { + "request_id": "request-recovery-123", + "approval_purpose": "identity.credential.recover.approve", + "state": "pending", + "created_at": NOW, + "expires_at": NOW + 300, + } + ] + + def request_options_for_owner(self, **kwargs): + self.calls.append(("options", kwargs)) + return { + "request_id": kwargs["request_id"], + "approval_purpose": "identity.credential.recover.approve", + "expires_at": NOW + 300, + "challenge_expires_at": NOW + 180, + "summary": { + "title": "Reauthorize an expired server-agent credential", + "statements": ["Authority: one bounded credential recovery"], + }, + "publicKey": {"challenge": "AA", "allowCredentials": []}, + } + + class FakeOIDCProvider: def __init__(self, identity: VerifiedOIDCIdentity) -> None: self.identity = identity @@ -343,6 +371,27 @@ def test_bound_owner_session_lists_reviews_and_approves_without_browser_capabili stack.store.close() +def test_bound_owner_can_review_credential_recovery_request(tmp_path: Path) -> None: + approvals = CredentialRecoveryApprovalRequests() + stack = _service(tmp_path, approval_service=approvals) + try: + _preauth, completed = _login(stack) + options = stack.service.begin_approval( + session_token=completed.session_token, + csrf_token=completed.csrf_token, + request_id="request-recovery-123", + ) + assert options["summary"]["title"] == ( + "Reauthorize an expired server-agent credential" + ) + assert [name for name, _call in approvals.calls[-2:]] == [ + "actionable", + "options", + ] + finally: + stack.store.close() + + def test_bound_owner_automatic_delivery_returns_no_claim_code( tmp_path: Path, ) -> None: diff --git a/tests/authorization/test_communication_scope_service.py b/tests/authorization/test_communication_scope_service.py index 69f5a0a..d4db570 100644 --- a/tests/authorization/test_communication_scope_service.py +++ b/tests/authorization/test_communication_scope_service.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest +import agentnet.authorization.communication_scope_service as communication_scope_module from agentnet.approval.service import ( IndependentApprovalVerifier, @@ -22,7 +23,7 @@ CommunicationScopeService, CommunicationScopeTerminalError, ) -from agentnet.errors import AuthenticationError, ConflictError +from agentnet.errors import AuthenticationError, ConflictError, GateBlocked from agentnet.identity.actors import ActorKind, VerifiedActor from agentnet.security.signatures import P256KeyPair @@ -269,10 +270,21 @@ def test_approved_completion_commits_exact_persistent_scope(communication_stack) assert all(row["resource_pattern"] == "*" for row in entitlements) assert all(row["expires_at"] is None for row in entitlements) row = communication_stack.store.fetch_one( - "SELECT state,authority_expires_at FROM communication_scopes" + "SELECT scope_id,state,authority_expires_at FROM communication_scopes" ) assert row["state"] == "committed" assert row["authority_expires_at"] is None + collaboration = communication_stack.store.fetch_one( + "SELECT scope_id,source_communication_scope_id,state FROM collaboration_scopes" + ) + assert collaboration is not None + assert collaboration["scope_id"] == row["scope_id"] + assert collaboration["source_communication_scope_id"] == row["scope_id"] + assert collaboration["state"] == "active" + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + (row["scope_id"],), + )["n"] == 2 def test_begin_and_complete_are_idempotent(communication_stack) -> None: @@ -283,6 +295,53 @@ def test_begin_and_complete_are_idempotent(communication_stack) -> None: assert _complete(communication_stack) == _complete(communication_stack) assert communication_stack.client.retrieve_calls == 1 assert communication_stack.store.fetch_one("SELECT COUNT(*) AS n FROM entitlements")["n"] == 38 + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes" + )["n"] == 1 + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members" + )["n"] == 2 + + +def test_projection_failure_rolls_back_terminal_scope_authority( + communication_stack, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _begin(communication_stack) + communication_stack.client.state = "issued" + _status(communication_stack) + original = communication_scope_module.materialize_v6_communication_scope + + def fail_projection(*_args, **_kwargs): + raise GateBlocked("schema_v7_scope_projection", "injected projection failure") + + monkeypatch.setattr( + communication_scope_module, + "materialize_v6_communication_scope", + fail_projection, + ) + with pytest.raises(GateBlocked, match="injected projection failure"): + _complete(communication_stack) + + assert communication_stack.store.fetch_one( + "SELECT state FROM communication_scopes" + )["state"] == "completion_reserved" + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM entitlements" + )["n"] == 0 + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM communication_scope_items" + )["n"] == 0 + assert communication_stack.store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes" + )["n"] == 0 + + monkeypatch.setattr( + communication_scope_module, + "materialize_v6_communication_scope", + original, + ) + assert _complete(communication_stack)["status"] == "communication_active" def test_committed_begin_replay_remains_bound_to_original_harness( diff --git a/tests/authorization/test_scope_harness_replacement.py b/tests/authorization/test_scope_harness_replacement.py new file mode 100644 index 0000000..c515db2 --- /dev/null +++ b/tests/authorization/test_scope_harness_replacement.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import json +import pytest +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from agentnet.approval import ( + IndependentApprovalVerifier, + TrustedApprover, + create_independent_approval_receipt, +) +from agentnet.authorization.communication_scope_service import ( + COLLABORATION_SCOPE_ISSUE_ACTION, + CollaborationScopeProposal, +) +from agentnet.authorization.evidence import IssuanceAuthority +from agentnet.authorization.policy import AuthorizationRequest, HumanEntitlement +from agentnet.authorization.scope_harness_replacement import ( + SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + ScopeHarnessReplacementService, +) +from agentnet.errors import AuthenticationError, AuthorizationError +from agentnet.core.app import CommunicationCore +from agentnet.operations.config import ExtensionConfig +from agentnet.protocol.models import Classification +from agentnet.security.signatures import P256KeyPair + + +DOMAIN = "scope-replacement.example" +NOW = 1_800_000_000 + + +def _core(tmp_path: Path, store) -> CommunicationCore: + return CommunicationCore( + ExtensionConfig( + domain_id=DOMAIN, + data_dir=tmp_path / "data", + database_url=f"sqlite:///{tmp_path / 'core.sqlite3'}", + artifact_dir=tmp_path / "artifacts", + ), + store, + ) + + +def _issue_scope(core: CommunicationCore, owner, old_member) -> object: + scope_id = "scope-expired-member-replacement-0001" + proposal = CollaborationScopeProposal( + scope_id=scope_id, + scope_kind="direct", + member_harness_ids=tuple(sorted((owner.harness_id, old_member.harness_id))), + allowed_actions=("message.acknowledge", "message.read", "message.send"), + allowed_resource_prefixes=("conversation:",), + allowed_classifications=(Classification.C1_INTERNAL,), + canonical_references=(), + policy_revision=1, + domain_revocation_epoch=1, + expires_at=NOW + 3_600, + ) + request = core.collaboration_scopes.issuance_request(actor=owner, proposal=proposal) + core.policy.bootstrap_entitlement_for_local_conformance( + HumanEntitlement( + domain_id=owner.domain_id, + principal_id=owner.principal_id, + action=COLLABORATION_SCOPE_ISSUE_ACTION, + resource_pattern=f"scope:{scope_id}", + revision=1, + expires_at=datetime.fromtimestamp(NOW, UTC) + timedelta(hours=1), + ), + when=datetime.fromtimestamp(NOW, UTC), + ) + decision = core.policy.require( + AuthorizationRequest( + actor=owner, + action=COLLABORATION_SCOPE_ISSUE_ACTION, + resource=f"scope:{scope_id}", + policy_revision=1, + context=request, + ), + when=datetime.fromtimestamp(NOW, UTC), + ) + return core.collaboration_scopes.issue( + actor=owner, + proposal=proposal, + authority=IssuanceAuthority(actor=owner, policy_decision_id=decision.decision_id), + when=datetime.fromtimestamp(NOW, UTC), + ) + + +def test_replacement_atomically_tombstones_old_member_and_activates_new_member( + store, + identity_factory, + tmp_path: Path, +) -> None: + owner, _ = identity_factory( + domain=DOMAIN, + kind="server-agent", + binding_assurance="os_bound", + ) + old_member, _ = identity_factory( + domain=DOMAIN, + principal_id=owner.principal_id, + kind="pi", + binding_assurance="os_bound", + ) + replacement, _ = identity_factory( + domain=DOMAIN, + principal_id=owner.principal_id, + kind="pi", + binding_assurance="os_bound", + ) + with store.transaction() as connection: + for actor in (owner, old_member, replacement): + connection.execute( + "UPDATE credentials SET not_before=?,expires_at=? WHERE credential_id=?", + (NOW - 60, NOW + 3_600, actor.credential_id), + ) + scope = _issue_scope(_core(tmp_path, store), owner, old_member) + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET not_before=?,expires_at=? WHERE credential_id=?", + (NOW - 3_600, NOW - 1, old_member.credential_id), + ) + + approval_key = P256KeyPair.generate() + approver = TrustedApprover( + principal_id=owner.principal_id, + domain_id=DOMAIN, + signer_key_id=approval_key.thumbprint, + public_key_pem=approval_key.public_pem, + allowed_purposes=frozenset({SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE}), + ) + verifier = IndependentApprovalVerifier( + {approval_key.thumbprint: approver}, + verifier_id="scope-replacement-approval.example", + ) + service = ScopeHarnessReplacementService(store, verifier, clock=lambda: NOW) + request = service.prepare( + actor=owner, + scope_id=scope.scope_id, + old_harness_id=old_member.harness_id, + new_harness_id=replacement.harness_id, + role="member", + request_id="scope-replacement-request-0001", + issued_at=NOW, + expires_at=NOW + 300, + ) + approval = create_independent_approval_receipt( + approval_key, + approver=approver, + verifier_id=verifier.verifier_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=request.canonical_transaction, + issued_at=NOW, + expires_at=NOW + 300, + authenticated_at=NOW, + ) + + result = service.replace(actor=owner, request=request, approval=approval) + + old_row = store.fetch_one( + "SELECT * FROM collaboration_scope_members WHERE scope_id=? AND harness_id=?", + (scope.scope_id, old_member.harness_id), + ) + new_row = store.fetch_one( + "SELECT * FROM collaboration_scope_members WHERE scope_id=? AND harness_id=?", + (scope.scope_id, replacement.harness_id), + ) + scope_row = store.fetch_one( + "SELECT * FROM collaboration_scopes WHERE scope_id=?", + (scope.scope_id,), + ) + audit = json.loads( + store.fetch_one( + "SELECT record_json FROM audit_log WHERE record_hash=?", + (result.audit_record_hash,), + )["record_json"] + ) + + assert result.idempotent_repeat is False + assert result.membership_sequence == 2 + assert result.scope_revision == 2 + assert result.scope_digest != scope.scope_digest + assert old_row["state"] == "removed" + assert old_row["removed_sequence"] == 2 + assert old_row["removed_at"] == NOW + assert new_row["authority_kind"] == "principal" + assert new_row["authority_id"] == owner.principal_id + assert new_row["role"] == "member" + assert new_row["state"] == "active" + assert new_row["joined_sequence"] == 2 + assert new_row["removed_sequence"] is None + assert new_row["joined_at"] == NOW + assert scope_row["membership_sequence"] == 2 + assert scope_row["revision"] == 2 + assert scope_row["scope_digest"] == result.scope_digest + assert scope_row["audit_record_hash"] == result.audit_record_hash + assert store.fetch_one("SELECT COUNT(*) AS count FROM replay_nonces")["count"] == 1 + assert audit["action"] == "collaboration_scope.harness_replaced" + assert audit["old_harness_id"] == old_member.harness_id + assert audit["new_harness_id"] == replacement.harness_id + + +def _replacement_case(store, identity_factory, tmp_path: Path): + owner, _ = identity_factory( + domain=DOMAIN, + kind="server-agent", + binding_assurance="os_bound", + ) + old_member, _ = identity_factory( + domain=DOMAIN, + principal_id=owner.principal_id, + kind="pi", + binding_assurance="os_bound", + ) + replacement, _ = identity_factory( + domain=DOMAIN, + principal_id=owner.principal_id, + kind="pi", + binding_assurance="os_bound", + ) + with store.transaction() as connection: + for actor in (owner, old_member, replacement): + connection.execute( + "UPDATE credentials SET not_before=?,expires_at=? WHERE credential_id=?", + (NOW - 60, NOW + 3_600, actor.credential_id), + ) + core = _core(tmp_path, store) + scope = _issue_scope(core, owner, old_member) + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET not_before=?,expires_at=? WHERE credential_id=?", + (NOW - 3_600, NOW - 1, old_member.credential_id), + ) + approval_key = P256KeyPair.generate() + approver = TrustedApprover( + principal_id=owner.principal_id, + domain_id=DOMAIN, + signer_key_id=approval_key.thumbprint, + public_key_pem=approval_key.public_pem, + allowed_purposes=frozenset({SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE}), + ) + verifier = IndependentApprovalVerifier( + {approval_key.thumbprint: approver}, + verifier_id="scope-replacement-approval.example", + ) + service = ScopeHarnessReplacementService(store, verifier, clock=lambda: NOW) + request = service.prepare( + actor=owner, + scope_id=scope.scope_id, + old_harness_id=old_member.harness_id, + new_harness_id=replacement.harness_id, + role="member", + request_id="scope-replacement-request-0002", + issued_at=NOW, + expires_at=NOW + 300, + ) + approval = create_independent_approval_receipt( + approval_key, + approver=approver, + verifier_id=verifier.verifier_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=request.canonical_transaction, + issued_at=NOW, + expires_at=NOW + 300, + authenticated_at=NOW, + ) + return core, owner, old_member, replacement, service, request, approval + + +def test_exact_retry_returns_the_committed_result_without_reconsuming_approval( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, _old, _new, service, request, approval = _replacement_case( + store, + identity_factory, + tmp_path, + ) + + first = service.replace(actor=owner, request=request, approval=approval) + service.clock = lambda: NOW + 301 + repeated = service.replace(actor=owner, request=request, approval=approval) + + assert first.idempotent_repeat is False + assert repeated.idempotent_repeat is True + assert repeated.model_dump(exclude={"idempotent_repeat"}) == first.model_dump( + exclude={"idempotent_repeat"} + ) + assert store.fetch_one("SELECT COUNT(*) AS count FROM replay_nonces")["count"] == 1 + + +def test_replacement_rejects_trusted_approver_who_is_not_scope_owner( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, _old, _new, _service, request, _approval = _replacement_case( + store, + identity_factory, + tmp_path, + ) + other_key = P256KeyPair.generate() + other_approver = TrustedApprover( + principal_id="other-principal", + domain_id=DOMAIN, + signer_key_id=other_key.thumbprint, + public_key_pem=other_key.public_pem, + allowed_purposes=frozenset({SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE}), + ) + verifier = IndependentApprovalVerifier( + {other_key.thumbprint: other_approver}, + verifier_id="scope-replacement-approval.example", + ) + service = ScopeHarnessReplacementService(store, verifier, clock=lambda: NOW) + approval = create_independent_approval_receipt( + other_key, + approver=other_approver, + verifier_id=verifier.verifier_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=request.canonical_transaction, + issued_at=NOW, + expires_at=NOW + 300, + authenticated_at=NOW, + ) + + with pytest.raises(AuthorizationError, match="exact owner approval"): + service.replace(actor=owner, request=request, approval=approval) + + +def test_replacement_rolls_back_membership_scope_and_replay_on_audit_failure( + store, + identity_factory, + tmp_path: Path, + monkeypatch, +) -> None: + _core_instance, owner, old_member, replacement, service, request, approval = ( + _replacement_case(store, identity_factory, tmp_path) + ) + + def fail_audit(*_args, **_kwargs): + raise RuntimeError("synthetic audit outage") + + monkeypatch.setattr(store, "append_audit", fail_audit) + with pytest.raises(RuntimeError, match="synthetic audit outage"): + service.replace(actor=owner, request=request, approval=approval) + + old_row = store.fetch_one( + "SELECT state,removed_sequence FROM collaboration_scope_members " + "WHERE scope_id=? AND harness_id=?", + (request.scope_id, old_member.harness_id), + ) + scope_row = store.fetch_one( + "SELECT revision,membership_sequence,scope_digest FROM collaboration_scopes " + "WHERE scope_id=?", + (request.scope_id,), + ) + assert old_row["state"] == "active" + assert old_row["removed_sequence"] is None + assert ( + store.fetch_one( + "SELECT COUNT(*) AS count FROM collaboration_scope_members " + "WHERE scope_id=? AND harness_id=?", + (request.scope_id, replacement.harness_id), + )["count"] + == 0 + ) + assert scope_row["revision"] == request.expected_scope_revision + assert scope_row["membership_sequence"] == request.expected_membership_sequence + assert scope_row["scope_digest"] == request.expected_scope_digest + assert store.fetch_one("SELECT COUNT(*) AS count FROM replay_nonces")["count"] == 0 + + +def test_replacement_rejects_a_receipt_for_a_changed_transaction_without_mutation( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, _old, _new, service, request, approval = _replacement_case( + store, + identity_factory, + tmp_path, + ) + changed = request.model_copy(update={"request_id": "scope-replacement-request-changed"}) + + with pytest.raises(AuthenticationError, match="transaction binding mismatch"): + service.replace(actor=owner, request=changed, approval=approval) + + scope_row = store.fetch_one( + "SELECT revision,membership_sequence FROM collaboration_scopes WHERE scope_id=?", + (request.scope_id,), + ) + assert scope_row["revision"] == request.expected_scope_revision + assert scope_row["membership_sequence"] == request.expected_membership_sequence + assert store.fetch_one("SELECT COUNT(*) AS count FROM replay_nonces")["count"] == 0 + + +def test_replacement_requires_the_old_current_credential_to_be_expired( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, old_member, replacement, service, _request, _approval = ( + _replacement_case(store, identity_factory, tmp_path) + ) + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET expires_at=? WHERE credential_id=?", + (NOW + 3_600, old_member.credential_id), + ) + + with pytest.raises(AuthorizationError, match="old current credential to be expired"): + service.prepare( + actor=owner, + scope_id="scope-expired-member-replacement-0001", + old_harness_id=old_member.harness_id, + new_harness_id=replacement.harness_id, + role="member", + request_id="scope-replacement-request-0003", + issued_at=NOW, + expires_at=NOW + 300, + ) + + +def test_removed_harness_stays_denied_after_reactivation_while_replacement_is_authorized( + store, + identity_factory, + tmp_path: Path, +) -> None: + core, owner, old_member, replacement, service, request, approval = _replacement_case( + store, + identity_factory, + tmp_path, + ) + service.replace(actor=owner, request=request, approval=approval) + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET expires_at=? WHERE credential_id=?", + (NOW + 3_600, old_member.credential_id), + ) + + with pytest.raises(AuthorizationError, match="collaboration scope is unavailable"): + core.collaboration_scopes.require( + actor=old_member, + scope_id=request.scope_id, + action="message.send", + resource="conversation:direct", + target_harness_ids=(owner.harness_id,), + when=datetime.fromtimestamp(NOW, UTC), + ) + authorized = core.collaboration_scopes.require( + actor=replacement, + scope_id=request.scope_id, + action="message.send", + resource="conversation:direct", + target_harness_ids=(owner.harness_id,), + when=datetime.fromtimestamp(NOW, UTC), + ) + assert authorized.member_harness_ids == tuple( + sorted((owner.harness_id, replacement.harness_id)) + ) + + +def test_replacement_rejects_a_new_harness_owned_by_another_principal( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, old_member, _replacement, service, request, _approval = ( + _replacement_case(store, identity_factory, tmp_path) + ) + foreign, _ = identity_factory( + domain=DOMAIN, + kind="pi", + binding_assurance="os_bound", + ) + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET not_before=?,expires_at=? WHERE credential_id=?", + (NOW - 60, NOW + 3_600, foreign.credential_id), + ) + + with pytest.raises(AuthorizationError, match="same-principal harnesses"): + service.prepare( + actor=owner, + scope_id=request.scope_id, + old_harness_id=old_member.harness_id, + new_harness_id=foreign.harness_id, + role="member", + request_id="scope-replacement-request-foreign", + issued_at=NOW, + expires_at=NOW + 300, + ) + + +def test_replacement_allows_an_active_same_principal_sibling_to_open_owner_ceremony( + store, + identity_factory, + tmp_path: Path, +) -> None: + _core_instance, owner, old_member, replacement, service, request, _approval = ( + _replacement_case(store, identity_factory, tmp_path) + ) + + prepared = service.prepare( + actor=replacement, + scope_id=request.scope_id, + old_harness_id=old_member.harness_id, + new_harness_id=replacement.harness_id, + role="member", + request_id="scope-replacement-request-sibling", + issued_at=NOW, + expires_at=NOW + 300, + ) + + assert prepared.owner_principal_id == owner.principal_id + assert prepared.owner_harness_id == owner.harness_id + assert prepared.new_harness_id == replacement.harness_id diff --git a/tests/cli/test_communication_scope_cli.py b/tests/cli/test_communication_scope_cli.py index e223689..23c8b96 100644 --- a/tests/cli/test_communication_scope_cli.py +++ b/tests/cli/test_communication_scope_cli.py @@ -320,7 +320,7 @@ def launch(attempted: Path, acquired: Path, release: Path) -> subprocess.Popen[s ) def wait_for(path: Path, process: subprocess.Popen[str]) -> None: - deadline = time.monotonic() + 5 + deadline = time.monotonic() + 30 while not path.exists(): if process.poll() is not None: stdout, stderr = process.communicate() diff --git a/tests/cli/test_server_agent_activation.py b/tests/cli/test_server_agent_activation.py index a261ee7..c1c621a 100644 --- a/tests/cli/test_server_agent_activation.py +++ b/tests/cli/test_server_agent_activation.py @@ -413,10 +413,30 @@ def fake_postgres(database_url, passed_cipher, **kwargs): ) assert reauthorization.func is cli.command_server_agent_reauthorize_expired_credential assert reauthorization.replace_terminal_state is False + defaults = parser.parse_args(["server-agent", "reauthorize-expired-credential"]) assert defaults.config == str(cli.CORE_CONFIG) assert defaults.identity == str(cli.SERVER_AGENT_IDENTITY) assert defaults.state == str(cli.SETUP_ROOT / "credential-reauthorization.json") + replacement = parser.parse_args( + [ + "server-agent", + "replace-expired-scope-harness", + "--scope-id", + "scope-replacement-command-0001", + "--old-harness-id", + "expired-harness", + "--new-harness-id", + "replacement-harness", + ] + ) + assert replacement.func is cli.command_server_agent_replace_expired_scope_harness + assert replacement.role == "member" + assert replacement.config == str(cli.CORE_CONFIG) + assert replacement.identity == str(cli.SERVER_AGENT_IDENTITY) + assert replacement.state == str(cli.SETUP_ROOT / "scope-harness-replacement.json") + assert replacement.replace_terminal_state is False + monkeypatch.setattr(cli.os.path, "lexists", lambda _path: True) cli._require_managed_server_reauthorization_topology(state.config) @@ -709,6 +729,246 @@ def replace_journal(path: Path, *, expected, replacement, uid, gid): assert all(opened_store.closed for opened_store in stores) + +def test_root_activation_store_connects_to_exact_peer_socket_as_core_user( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import pwd + + state = activation_fixture(tmp_path) + peer_url = "postgresql://agentnet@%2Fvar%2Frun%2Fpostgresql/agentnet" + ids = {"uid": 0, "gid": 0} + transitions: list[tuple[str, int]] = [] + fake_store = FakeStore() + monkeypatch.setattr( + cli.LocalEnvelopeCipher, + "from_key_file", + lambda _path, *, create: object(), + ) + monkeypatch.setattr(cli.os, "geteuid", lambda: ids["uid"]) + monkeypatch.setattr(cli.os, "getegid", lambda: ids["gid"]) + monkeypatch.setattr( + cli.os, + "seteuid", + lambda value: transitions.append(("uid", value)) or ids.update(uid=value), + ) + monkeypatch.setattr( + cli.os, + "setegid", + lambda value: transitions.append(("gid", value)) or ids.update(gid=value), + ) + monkeypatch.setattr( + pwd, + "getpwnam", + lambda name: SimpleNamespace(pw_uid=123, pw_gid=456) + if name == cli.CORE_USER + else pytest.fail("unexpected account"), + ) + + def fake_postgres(database_url, _cipher, **_kwargs): + assert database_url == peer_url + assert ids == {"uid": 123, "gid": 456} + return fake_store + + monkeypatch.setattr(cli, "PostgreSQLStore", fake_postgres) + + assert cli._open_server_agent_activation_store( + state.config, + database_url_override=peer_url, + ) is fake_store + assert ids == {"uid": 0, "gid": 0} + assert transitions == [ + ("gid", 456), + ("uid", 123), + ("uid", 0), + ("gid", 0), + ] + + +def test_managed_scope_replacement_is_resumable_and_waits_before_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + import pwd + + state = activation_fixture(tmp_path) + setup_root = tmp_path / "setup" + setup_root.mkdir(mode=0o700) + config_path = tmp_path / "core.json" + identity_path = tmp_path / "server-agent-identity.json" + key_path = tmp_path / "server-agent.key.pem" + pending_path = setup_root / "scope-harness-replacement.json" + config = state.config.model_copy( + update={ + "database_url": None, + "database_url_env": "AGENTNET_DATABASE_URL", + "enrolled_harness_id": state.actor.harness_id, + } + ) + identity = dict(state.identity) + identity["private_key_path"] = str(key_path) + config_raw = config.model_dump_json().encode() + identity_raw = json.dumps(identity, sort_keys=True).encode() + files = { + config_path: config_raw, + identity_path: identity_raw, + key_path: state.key.private_pem, + } + monkeypatch.setattr(cli, "CORE_CONFIG", config_path) + monkeypatch.setattr(cli, "SERVER_AGENT_IDENTITY", identity_path) + monkeypatch.setattr(cli, "SERVER_AGENT_KEY", key_path) + monkeypatch.setattr(cli, "SETUP_ROOT", setup_root) + monkeypatch.setattr(cli, "load_config_json", lambda _raw: config) + monkeypatch.setattr( + pwd, + "getpwnam", + lambda _name: SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid()), + ) + monkeypatch.setattr( + cli, + "_managed_private_file", + lambda path, **_kwargs: ( + files[Path(path)], + SimpleNamespace(st_gid=os.getgid()), + ), + ) + monkeypatch.setattr( + cli, + "_parse_environment_file", + lambda _path, **_kwargs: { + "AGENTNET_DATABASE_URL": "postgresql://runtime@postgres/agentnet", + "AGENTNET_APPROVAL_CORE_TOKEN": "x" * 43, + }, + ) + approval_config = SimpleNamespace( + approver_principal_id=state.actor.principal_id, + service_credential_env="AGENTNET_APPROVAL_CORE_TOKEN", + public_origin="https://approval.corp.example", + ) + verifier = object() + monkeypatch.setattr( + cli, + "_managed_server_reauthorization_verifier", + lambda _config: (verifier, approval_config), + ) + monkeypatch.setattr( + cli, + "_require_managed_server_reauthorization_topology", + lambda _config: None, + ) + monkeypatch.setattr( + cli, + "_require_server_agent_activation_binding", + lambda *_args, **_kwargs: None, + ) + stores: list[FakeStore] = [] + + def open_store(*_args, **_kwargs): + opened = FakeStore() + stores.append(opened) + return opened + + monkeypatch.setattr(cli, "_open_server_agent_activation_store", open_store) + request = cli.ScopeHarnessReplacementRequest( + request_id="scope-replacement-command-request-0001", + domain_id=state.actor.domain_id, + owner_principal_id=state.actor.principal_id, + owner_harness_id="expired-harness", + scope_id="scope-replacement-command-0001", + expected_scope_revision=1, + expected_scope_digest="a" * 64, + expected_membership_sequence=1, + expected_policy_revision=1, + expected_domain_revocation_epoch=1, + old_harness_id="expired-harness", + old_credential_id="expired-credential", + old_credential_epoch=1, + new_harness_id="replacement-harness", + new_credential_id="replacement-credential", + new_credential_epoch=1, + role="member", + issued_at=1_800_000_000, + expires_at=1_800_000_600, + ) + service_calls: list[str] = [] + + class FakeReplacementService: + def __init__(self, _store, _verifier) -> None: + assert _verifier is verifier + + def prepare(self, **_kwargs): + service_calls.append("prepare") + return request + + def replace(self, **kwargs): + assert kwargs["request"] == request + assert kwargs["approval"] == {"receipt": "issued"} + service_calls.append("replace") + return SimpleNamespace( + scope_id=request.scope_id, + old_harness_id=request.old_harness_id, + new_harness_id=request.new_harness_id, + scope_revision=2, + membership_sequence=2, + idempotent_repeat=False, + ) + + monkeypatch.setattr(cli, "ScopeHarnessReplacementService", FakeReplacementService) + + class FakeApprovalClient: + state = "pending" + creates = 0 + + def create_request(self, **kwargs): + assert pending_path.exists() + assert kwargs["transaction_digest"] == request.digest + self.creates += 1 + return {"request_id": "approval-request-1"} + + def request_status(self, **_kwargs): + return {"state": self.state} + + def retrieve_receipt(self, **_kwargs): + return {"receipt": "issued"} + + def close(self) -> None: + pass + + broker = FakeApprovalClient() + monkeypatch.setattr( + cli, + "_managed_server_reauthorization_client", + lambda *_args, **_kwargs: broker, + ) + monkeypatch.setattr(cli.time, "time", lambda: 1_800_000_000) + args = argparse.Namespace( + config=str(config_path), + identity=str(identity_path), + state=str(pending_path), + scope_id=request.scope_id, + old_harness_id=request.old_harness_id, + new_harness_id=request.new_harness_id, + role="member", + replace_terminal_state=False, + ) + + assert cli._command_server_agent_replace_expired_scope_harness_locked(args) == 2 + waiting = json.loads(capsys.readouterr().out) + assert waiting["status"] == "waiting_owner_approval" + assert waiting["membership_changed"] is False + assert service_calls == ["prepare"] + assert broker.creates == 1 + broker.state = "issued" + assert cli._command_server_agent_replace_expired_scope_harness_locked(args) == 0 + completed = json.loads(capsys.readouterr().out) + assert completed["status"] == "completed" + assert completed["service_restart"] == "not_performed" + assert service_calls == ["prepare", "replace"] + assert broker.creates == 1 + assert not pending_path.exists() + assert all(opened.closed for opened in stores) def test_managed_server_reauthorization_lock_rejects_concurrent_setup( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/conformance/test_release_manifest.py b/tests/conformance/test_release_manifest.py index 87c3051..5bc9291 100644 --- a/tests/conformance/test_release_manifest.py +++ b/tests/conformance/test_release_manifest.py @@ -67,7 +67,7 @@ def _copy_release_inputs(tmp_path: Path) -> Path: "evidence/local/2026-07-15-v0.1.7/manifest.json", "evidence/local/2026-07-20-v0.1.18/manifest.json", "evidence/local/2026-07-22-v0.1.19/manifest.json", - "evidence/local/2026-08-07-v0.1.50/manifest.json", + "evidence/local/2026-08-09-v0.1.51/manifest.json", "scripts/ci/exact_endpoint_routing_e2e.py", "scripts/ci/packaged_local_communication_e2e.py", "scripts/ci/packaged_v0145_user_journey.py", @@ -83,14 +83,14 @@ def _copy_release_inputs(tmp_path: Path) -> Path: root / "evidence/local/2026-07-13-final/artifacts", ) shutil.copytree( - ROOT / "evidence/local/2026-08-07-v0.1.50/artifacts", - root / "evidence/local/2026-08-07-v0.1.50/artifacts", + ROOT / "evidence/local/2026-08-09-v0.1.51/artifacts", + root / "evidence/local/2026-08-09-v0.1.51/artifacts", ) return root def _refresh_artifact_hash(root: Path, artifact_path: Path) -> None: - evidence_path = root / "evidence/local/2026-08-07-v0.1.50/manifest.json" + evidence_path = root / "evidence/local/2026-08-09-v0.1.51/manifest.json" evidence = json.loads(evidence_path.read_text(encoding="utf-8")) relative = artifact_path.relative_to(root).as_posix() record = next(item for item in evidence["artifacts"] if item["path"] == relative) @@ -150,7 +150,7 @@ def test_release_manifest_matches_current_reproducible_inputs() -> None: def test_candidate_package_evidence_cannot_remain_pending(tmp_path: Path) -> None: root = _copy_release_inputs(tmp_path) - evidence_path = root / "evidence/local/2026-08-07-v0.1.50/manifest.json" + evidence_path = root / "evidence/local/2026-08-09-v0.1.51/manifest.json" evidence = json.loads(evidence_path.read_text(encoding="utf-8")) evidence["verification_status"] = "PENDING" next( @@ -161,28 +161,28 @@ def test_candidate_package_evidence_cannot_remain_pending(tmp_path: Path) -> Non failures = verify(root=root) - assert "0.1.50 package evidence must record completed PASS verification" in failures - assert "0.1.50 package evidence cannot retain pending command results" in failures + assert "0.1.51 package evidence must record completed PASS verification" in failures + assert "0.1.51 package evidence cannot retain pending command results" in failures -def test_candidate_evidence_must_cover_v0145_release_blocker_surface(tmp_path: Path) -> None: +def test_candidate_evidence_must_cover_canonical_owner_recovery_surface(tmp_path: Path) -> None: root = _copy_release_inputs(tmp_path) - evidence_path = root / "evidence/local/2026-08-07-v0.1.50/manifest.json" + evidence_path = root / "evidence/local/2026-08-09-v0.1.51/manifest.json" evidence = json.loads(evidence_path.read_text(encoding="utf-8")) focused = next( record for record in evidence["commands"] - if "tests/integration/test_invitation_browser_journey.py" in record["command"] + if "tests/operations/test_canonical_owner_recovery.py" in record["command"] ) focused["command"] = focused["command"].replace( - " tests/integration/test_invitation_browser_journey.py", + " tests/operations/test_canonical_owner_recovery.py", "", ) evidence_path.write_text(json.dumps(evidence), encoding="utf-8") failures = verify(root=root) - assert "0.1.50 focused release-blocker evidence is incomplete" in failures + assert "0.1.51 focused release-blocker evidence is incomplete" in failures def test_attacker_consistent_stale_public_readme_is_rejected(tmp_path: Path) -> None: @@ -190,7 +190,7 @@ def test_attacker_consistent_stale_public_readme_is_rejected(tmp_path: Path) -> readme_path = root / "README.md" readme_path.write_text( readme_path.read_text(encoding="utf-8").replace( - "latest published package is\n`0.1.45`", + "latest published package is\n`0.1.50`", "latest published package is\n`0.1.38`", 1, ), @@ -219,7 +219,7 @@ def test_sdist_contract_does_not_require_installed_root_ignore_files(tmp_path: P def test_release_verifier_requires_candidate_artifact_ignore_policy(tmp_path: Path) -> None: root = _copy_release_inputs(tmp_path) (root / ".git").mkdir() - (root / "evidence/local/2026-08-07-v0.1.50/artifacts/.gitignore").unlink( + (root / "evidence/local/2026-08-09-v0.1.51/artifacts/.gitignore").unlink( missing_ok=True ) @@ -232,7 +232,7 @@ def test_release_verifier_accepts_npm_install_without_git_ignore_metadata( tmp_path: Path, ) -> None: root = _copy_release_inputs(tmp_path) - (root / "evidence/local/2026-08-07-v0.1.50/artifacts/.gitignore").unlink( + (root / "evidence/local/2026-08-09-v0.1.51/artifacts/.gitignore").unlink( missing_ok=True ) @@ -323,7 +323,7 @@ def test_deployment_ledger_and_human_manifest_drift_fail_closed(tmp_path: Path) "| G04 | `PARTIAL` | `REVIEWED_PARTIAL` |", ) .replace( - "SHA-256 `afffa3558290939ca4f3efae1b04559db3299084f4a62c0d93ccdd4ab66e69c8`", + "SHA-256 `c971fdea8d3782a9d7349b5723f4e036740d314f19f7c3553dc6ba1780c9f84d`", "SHA-256 `" + "0" * 64 + "`", 1, ), @@ -343,7 +343,7 @@ def test_deployment_ledger_and_human_manifest_drift_fail_closed(tmp_path: Path) requirements_path = summary_root / "REQUIREMENTS_STATUS.md" requirements_path.write_text( requirements_path.read_text(encoding="utf-8") - .replace("Snapshot: 2026-08-07.", "Snapshot: 2026-07-25.", 1) + .replace("Snapshot: 2026-08-09.", "Snapshot: 2026-07-25.", 1) .replace( "Requirement totals: **33 local-tested, 42 partial-external, 10 owner-blocked,\n" "0 implementation-gap = 85 unique requirements**.", @@ -356,7 +356,7 @@ def test_deployment_ledger_and_human_manifest_drift_fail_closed(tmp_path: Path) gate_path = summary_root / "docs/GATE_EVIDENCE.md" gate_path.write_text( gate_path.read_text(encoding="utf-8").replace( - "Current ledger update: 2026-08-07.", + "Current ledger update: 2026-08-09.", "Current ledger update: 2026-07-25.", 1, ), @@ -395,9 +395,9 @@ def test_build_backend_must_be_in_exact_locked_build_group(tmp_path: Path) -> No def test_artifact_self_hash_cannot_replace_archive_content_validation(tmp_path: Path) -> None: root = _copy_release_inputs(tmp_path) - wheel = root / "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50-py3-none-any.whl" + wheel = root / "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl" wheel.write_bytes(b"not a wheel") - evidence_path = root / "evidence/local/2026-08-07-v0.1.50/manifest.json" + evidence_path = root / "evidence/local/2026-08-09-v0.1.51/manifest.json" evidence = json.loads(evidence_path.read_text(encoding="utf-8")) for artifact in evidence["artifacts"]: if artifact["path"].endswith(".whl"): @@ -473,7 +473,7 @@ def test_attacker_consistent_wheel_mutation_is_rejected( mutation: str, ) -> None: root = _copy_release_inputs(tmp_path) - wheel = root / "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50-py3-none-any.whl" + wheel = root / "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl" def mutate(payloads: dict[str, bytes]) -> None: if mutation == "extra": @@ -493,7 +493,7 @@ def mutate(payloads: dict[str, bytes]) -> None: def test_duplicate_wheel_member_is_rejected(tmp_path: Path) -> None: root = _copy_release_inputs(tmp_path) - wheel = root / "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50-py3-none-any.whl" + wheel = root / "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51-py3-none-any.whl" with pytest.warns(UserWarning, match="Duplicate name"): with zipfile.ZipFile(wheel, mode="a", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("agentnet/__init__.py", b"duplicate\n") @@ -507,7 +507,7 @@ def test_duplicate_wheel_member_is_rejected(tmp_path: Path) -> None: @pytest.mark.parametrize("mutation", ["state", "nested_archive", "traversal", "symlink"]) def test_sdist_unsafe_or_extra_member_is_rejected(tmp_path: Path, mutation: str) -> None: root = _copy_release_inputs(tmp_path) - sdist = root / "evidence/local/2026-08-07-v0.1.50/artifacts/agentnet-0.1.50.tar.gz" + sdist = root / "evidence/local/2026-08-09-v0.1.51/artifacts/agentnet-0.1.51.tar.gz" def mutate(entries: list[tuple[tarfile.TarInfo, bytes]]) -> None: if mutation == "symlink": @@ -529,7 +529,7 @@ def mutate(entries: list[tuple[tarfile.TarInfo, bytes]]) -> None: "nested_archive": "dist/nested.tar.gz", "traversal": "../outside", }[mutation] - member = tarfile.TarInfo(f"agentnet-0.1.50/{suffix}") + member = tarfile.TarInfo(f"agentnet-0.1.51/{suffix}") member.size = 2 member.mode = 0o644 member.uid = member.gid = 0 diff --git a/tests/operations/test_c0_credential_supersession.py b/tests/operations/test_c0_credential_supersession.py index bb45e92..457d78b 100644 --- a/tests/operations/test_c0_credential_supersession.py +++ b/tests/operations/test_c0_credential_supersession.py @@ -12,6 +12,7 @@ from agentnet.security.envelope import LocalEnvelopeCipher from agentnet.storage.sqlite import SQLiteStore from agentnet.errors import GateBlocked +from agentnet.operations.config import RuntimeProfile from agentnet.operations.c0_credential_supersession import ( C0CredentialSupersessionJournal, append_supersession, @@ -532,6 +533,40 @@ def test_core_accepts_current_audited_supersession_journal(tmp_path: Path) -> No assert core._verified_supersession_binding == ("credential-2", 2, KEY_ID) +def test_core_reads_always_on_journal_from_managed_root(tmp_path: Path) -> None: + journal = _append() + journal_path = tmp_path / "credential-supersessions.json" + journal_path.write_bytes(canonical_supersession_journal(journal)) + journal_path.chmod(0o600) + core = _core(_TerminalAuditStore([_audit_row(journal)]), tmp_path / "core") + core.config.profile = RuntimeProfile.ALWAYS_ON_SERVER_AGENT + + core._require_managed_credential_supersession( + principal_id=PRINCIPAL_ID, + credential_id="credential-2", + credential_epoch=2, + key_id=KEY_ID, + ) + assert core._verified_supersession_binding == ("credential-2", 2, KEY_ID) + + +def test_core_reads_always_on_journal_from_root_layout(tmp_path: Path) -> None: + journal = _append() + journal_path = tmp_path / "credential-supersessions.json" + journal_path.write_bytes(canonical_supersession_journal(journal)) + journal_path.chmod(0o600) + core = _core(_TerminalAuditStore([_audit_row(journal)]), tmp_path) + core.config.profile = RuntimeProfile.ALWAYS_ON_SERVER_AGENT + + core._require_managed_credential_supersession( + principal_id=PRINCIPAL_ID, + credential_id="credential-2", + credential_epoch=2, + key_id=KEY_ID, + ) + assert core._verified_supersession_binding == ("credential-2", 2, KEY_ID) + + def test_core_rejects_tampered_supersession_journal(tmp_path: Path) -> None: journal = _append() value = journal.model_dump(mode="json", by_alias=True) diff --git a/tests/operations/test_canonical_owner_recovery.py b/tests/operations/test_canonical_owner_recovery.py new file mode 100644 index 0000000..8580131 --- /dev/null +++ b/tests/operations/test_canonical_owner_recovery.py @@ -0,0 +1,834 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import agentnet.operations.canonical_owner_recovery as recovery + +from agentnet.approval.config import ( + MANDATORY_APPROVAL_PURPOSES, + ApprovalOwnerOIDCConfig, + ApprovalServiceApproverConfig, + ApprovalServiceConfig, +) +from agentnet.approval.store import ApprovalStore, approval_user_handle +from agentnet.errors import GateBlocked +from agentnet.operations.canonical_owner_recovery import ( + CanonicalOwnerAdoptionRequest, + converge_canonical_approval_owner, + adopt_canonical_approval_owner, +) +from agentnet.security.envelope import LocalEnvelopeCipher +from agentnet.security.signatures import P256KeyPair, b64url_encode + + +NOW = 1_800_000_000 +DOMAIN = "corp.example" +SOURCE = "sergey-owner" +VERIFIER_ID = "approval.corp.example" +TARGET = "6fac7b4c-de08-4192-9f6a-ef29b5ae6b0" +ISSUER = "https://accounts.example/oidc" +SUBJECT = "oidc-sergey-subject" +EMAIL = "sergey@corp.example" +RP_ID = "approval.corp.example" + + +def _store(tmp_path: Path) -> ApprovalStore: + root = tmp_path / "approval" + root.mkdir(mode=0o700) + root.chmod(0o700) + path = root / "approval.sqlite3" + path.touch(mode=0o600) + path.chmod(0o600) + store = ApprovalStore(path, LocalEnvelopeCipher(b"r" * 32), initialize=True) + with store.transaction() as connection: + connection.execute( + """INSERT INTO approval_owner_bindings( + binding_id,domain_id,approver_principal_id,oidc_issuer,oidc_subject, + verified_email,pin_source,status,pinned_at + ) VALUES(?,?,?,?,?,?,'exact_subject','active',?)""", + ("binding-owner", DOMAIN, SOURCE, ISSUER, SUBJECT, EMAIL, NOW - 10_000), + ) + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id,user_handle_b64, + credential_public_key_b64,sign_count,device_type,backed_up,status,created_at + ) VALUES(?,?,?,?,?,?,?,?,?,?)""", + ( + "active-credential", + SOURCE, + DOMAIN, + b64url_encode( + approval_user_handle( + verifier_id=VERIFIER_ID, + principal_id=SOURCE, + domain_id=DOMAIN, + ) + ), + "active-public-key", + 17, + "single_device", + 1, + "active", + NOW - 9_000, + ), + ) + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id,user_handle_b64, + credential_public_key_b64,sign_count,device_type,backed_up,status, + created_at,revoked_at,revocation_reason + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "historical-credential", + SOURCE, + DOMAIN, + b64url_encode(b"historical-user-handle"), + "historical-public-key", + 3, + "single_device", + 0, + "revoked", + NOW - 20_000, + NOW - 15_000, + "rotated", + ), + ) + connection.execute( + """INSERT INTO approval_requests( + request_id,approver_principal_id,domain_id,approval_purpose,capability_hash, + canonical_transaction_encrypted,transaction_digest,state,active_fingerprint, + created_at,expires_at,delivery_mode + ) VALUES(?,?,?,?,?,?,?,'issued',NULL,?,?,'direct_receipt')""", + ( + "historical-request", + SOURCE, + DOMAIN, + "core.enrollment", + "1" * 64, + "encrypted-transaction", + "2" * 64, + NOW - 8_000, + NOW - 7_000, + ), + ) + connection.execute( + """INSERT INTO approval_issued_receipts( + request_id,credential_id_b64,authenticated_at,issued_at, + receipt_expires_at,receipt_encrypted,receipt_digest + ) VALUES(?,?,?,?,?,?,?)""", + ( + "historical-request", + "historical-credential", + NOW - 7_500, + NOW - 7_499, + NOW - 7_000, + "encrypted-receipt", + "3" * 64, + ), + ) + connection.execute( + """INSERT INTO approval_audit( + action,request_id,approver_principal_id,domain_id,approval_purpose, + transaction_digest,occurred_at,outcome,detail_code + ) VALUES(?,?,?,?,?,?,?,?,?)""", + ( + "approval.issued", + "historical-request", + SOURCE, + DOMAIN, + "core.enrollment", + "2" * 64, + NOW - 7_499, + "issued", + "webauthn_verified", + ), + ) + return store + + +def _request(**updates: object) -> CanonicalOwnerAdoptionRequest: + values: dict[str, object] = { + "schema": "agentnet.canonical-owner-adoption.v1", + "recovery_id": "93756ff6-6337-4ed1-9697-250b63fb68a2", + "domain_id": DOMAIN, + "source_principal_id": SOURCE, + "target_principal_id": TARGET, + "oidc_issuer": ISSUER, + "oidc_subject": SUBJECT, + "verified_email": EMAIL, + "verifier_id": VERIFIER_ID, + "approved_at": NOW - 100, + } + values.update(updates) + return CanonicalOwnerAdoptionRequest.model_validate(values) + +def _profile(tmp_path: Path) -> tuple[ApprovalStore, ApprovalServiceConfig, Path, Path]: + store = _store(tmp_path) + data_dir = tmp_path / "approval" + signer_dir = data_dir / "signers" + signer_dir.mkdir(mode=0o700) + signer = P256KeyPair.generate() + signer_path = signer_dir / "approver-1.pem" + signer_path.write_bytes(signer.private_pem) + signer_path.chmod(0o600) + secrets_dir = data_dir / "secrets" + secrets_dir.mkdir(mode=0o700) + record_key_path = secrets_dir / "records.key" + record_key_path.write_bytes(b"r" * 32) + record_key_path.chmod(0o600) + config = ApprovalServiceConfig( + public_origin="https://approval.corp.example", + rp_id=RP_ID, + verifier_id=VERIFIER_ID, + data_dir=data_dir, + database_path=data_dir / "approval.sqlite3", + record_key_path=record_key_path, + owner_oidc=ApprovalOwnerOIDCConfig( + issuer=ISSUER, + client_id="approval-client", + redirect_uri="https://approval.corp.example/v1/approval/owner/oidc/callback", + ), + approvers=( + ApprovalServiceApproverConfig( + principal_id=SOURCE, + domain_id=DOMAIN, + signer_key_id=signer.thumbprint, + signer_private_key_path=signer_path, + allowed_purposes=MANDATORY_APPROVAL_PURPOSES, + oidc_issuer=ISSUER, + oidc_subject=SUBJECT, + ), + ), + ) + config_path = data_dir / "config.json" + config_path.write_text( + config.model_dump_json(indent=2) + "\n", + encoding="utf-8", + ) + config_path.chmod(0o600) + return store, config, config_path, data_dir / "canonical-owner-recovery.json" + + +def test_profile_recovery_rotates_signer_and_converges(tmp_path: Path) -> None: + store, config, config_path, journal_path = _profile(tmp_path) + source_signer = config.approvers[0].signer_private_key_path.read_bytes() + try: + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + ) + replay = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + recovered = ApprovalServiceConfig.model_validate_json(config_path.read_text()) + assert result["status"] == "recovered" + assert replay == {**result, "status": "already_exact"} + assert recovered.approvers[0].principal_id == TARGET + assert recovered.approvers[0].signer_key_id != config.approvers[0].signer_key_id + assert recovered.approvers[0].signer_private_key_path.read_bytes() != source_signer + assert not config.approvers[0].signer_private_key_path.exists() + assert recovered.approvers[0].signer_private_key_path.exists() + assert journal_path.exists() + assert not (config.data_dir / "canonical-owner-recovery.backup.pem").exists() + finally: + store.close() + + +def test_profile_recovery_rejects_email_alias_without_exact_subject( + tmp_path: Path, +) -> None: + store, config, config_path, journal_path = _profile(tmp_path) + alias_only = config.approvers[0].model_copy( + update={ + "oidc_subject": None, + "verified_email_alias": EMAIL, + } + ) + config_path.write_text( + config.model_copy(update={"approvers": (alias_only,)}).model_dump_json(indent=2) + + "\n", + encoding="utf-8", + ) + try: + with pytest.raises(GateBlocked, match="configured owner state is ambiguous"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + ) + assert not journal_path.exists() + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_owner_bindings " + "WHERE binding_id='binding-owner'" + )[0] == SOURCE + finally: + store.close() + + +def test_profile_recovery_resumes_after_prepared_journal_crash( + tmp_path: Path, +) -> None: + store, config, config_path, journal_path = _profile(tmp_path) + target_path = config.data_dir / "signers" / "canonical-owner-recovery.pem" + try: + with pytest.raises(RuntimeError, match="injected recovery interruption"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + _interrupt_after="prepared_journal", + ) + assert journal_path.exists() + assert not target_path.exists() + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + assert result["status"] == "recovered" + assert ApprovalServiceConfig.model_validate_json( + config_path.read_text(encoding="utf-8") + ).approvers[0].principal_id == TARGET + finally: + store.close() + + +def test_profile_recovery_preserves_counts_after_authority_commit_crash( + tmp_path: Path, +) -> None: + store, _config, config_path, journal_path = _profile(tmp_path) + try: + with pytest.raises(RuntimeError, match="injected recovery interruption"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + _interrupt_after="authority_committed", + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + assert journal["phase"] == "prepared" + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_owner_bindings " + "WHERE binding_id='binding-owner'" + )[0] == TARGET + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + assert result["authority_adoption"] == { + "schema": "agentnet.canonical-owner-adoption-result.v1", + "status": "already_exact", + "recovery_id": "93756ff6-6337-4ed1-9697-250b63fb68a2", + "migrated_active_credentials": 1, + "revoked_browser_sessions": 0, + "canceled_registration_ceremonies": 0, + } + finally: + store.close() + + +def test_profile_recovery_resumes_after_authority_commit(tmp_path: Path) -> None: + store, _config, config_path, journal_path = _profile(tmp_path) + try: + with pytest.raises(RuntimeError, match="injected recovery interruption"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + _interrupt_after="authority_adopted", + ) + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_owner_bindings WHERE binding_id='binding-owner'" + )[0] == TARGET + assert ApprovalServiceConfig.model_validate_json( + config_path.read_text() + ).approvers[0].principal_id == SOURCE + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + assert result["status"] == "recovered" + assert ApprovalServiceConfig.model_validate_json( + config_path.read_text() + ).approvers[0].principal_id == TARGET + finally: + store.close() + + +def test_profile_recovery_resumes_after_signer_staging(tmp_path: Path) -> None: + store, source_config, config_path, journal_path = _profile(tmp_path) + try: + with pytest.raises(RuntimeError, match="injected recovery interruption"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + _interrupt_after="signer_replaced", + ) + interrupted = ApprovalServiceConfig.model_validate_json( + config_path.read_text(encoding="utf-8") + ) + assert interrupted.approvers[0].principal_id == SOURCE + assert ( + P256KeyPair.from_private_pem( + interrupted.approvers[0].signer_private_key_path.read_bytes() + ).thumbprint + == interrupted.approvers[0].signer_key_id + ) + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + recovered = ApprovalServiceConfig.model_validate_json( + config_path.read_text(encoding="utf-8") + ) + assert result["status"] == "recovered" + assert recovered.approvers[0].principal_id == TARGET + assert recovered.approvers[0].signer_private_key_path != ( + source_config.approvers[0].signer_private_key_path + ) + finally: + store.close() + + +def test_profile_recovery_removes_retired_signers_before_completion( + tmp_path: Path, +) -> None: + store, config, config_path, journal_path = _profile(tmp_path) + source_path = config.approvers[0].signer_private_key_path + backup_path = config.data_dir / "canonical-owner-recovery.backup.pem" + try: + with pytest.raises(RuntimeError, match="injected recovery interruption"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + _interrupt_after="retired_signers_removed", + ) + assert not source_path.exists() + assert not backup_path.exists() + assert json.loads(journal_path.read_text(encoding="utf-8"))["phase"] == ( + "config_replaced" + ) + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + assert result["status"] == "recovered" + assert not source_path.exists() + assert not backup_path.exists() + finally: + store.close() + + +def test_profile_recovery_resumes_after_config_replace_response_loss( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, _source_config, config_path, journal_path = _profile(tmp_path) + original_write = recovery._journal_write + failed = {"value": False} + + def fail_after_config_replace(path: Path, value: dict[str, object]) -> None: + if value.get("phase") == "config_replaced" and not failed["value"]: + failed["value"] = True + raise RuntimeError("injected journal response loss") + original_write(path, value) + + monkeypatch.setattr(recovery, "_journal_write", fail_after_config_replace) + try: + with pytest.raises(RuntimeError, match="injected journal response loss"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + ) + interrupted = ApprovalServiceConfig.model_validate_json( + config_path.read_text(encoding="utf-8") + ) + assert interrupted.approvers[0].principal_id == TARGET + assert json.loads(journal_path.read_text(encoding="utf-8"))["phase"] == ( + "config_replacing" + ) + + result = converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + assert result["status"] == "recovered" + finally: + store.close() + + +def test_profile_recovery_rejects_tampered_completed_journal(tmp_path: Path) -> None: + store, _config, config_path, journal_path = _profile(tmp_path) + try: + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + journal["target_signer_key_id"] = "tampered-signer-key" + journal_path.write_text( + json.dumps(journal, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with pytest.raises(GateBlocked, match="signer evidence is invalid"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + finally: + store.close() + + +def test_private_read_rejects_file_swapped_to_symlink( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = tmp_path / "state.json" + alternate = tmp_path / "alternate.json" + state.write_bytes(b"safe") + state.chmod(0o600) + alternate.write_bytes(b"unsafe") + alternate.chmod(0o600) + original_open = recovery.os.open + swapped = False + + def swap_before_open(path: object, *args: object, **kwargs: object): + nonlocal swapped + if path == state.name and kwargs.get("dir_fd") is not None and not swapped: + swapped = True + state.unlink() + state.symlink_to(alternate) + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(recovery.os, "open", swap_before_open) + with pytest.raises(GateBlocked, match="recovery state is unavailable"): + recovery._private_read(state, maximum=32) + + +def test_private_write_does_not_follow_replaced_parent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + parent = tmp_path / "state" + parent.mkdir(mode=0o700) + moved_parent = tmp_path / "original-state" + alternate = tmp_path / "alternate" + alternate.mkdir(mode=0o700) + target = parent / "journal.json" + original_replace = recovery.os.replace + swapped = False + + def swap_parent( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + nonlocal swapped + if not swapped: + swapped = True + parent.rename(moved_parent) + parent.symlink_to(alternate, target_is_directory=True) + original_replace(source, destination, *args, **kwargs) + + monkeypatch.setattr(recovery.os, "replace", swap_parent) + with pytest.raises(GateBlocked, match="recovery path changed"): + recovery._private_write(target, b"protected") + assert not (alternate / target.name).exists() + + +def test_profile_recovery_rejects_adoption_evidence_from_another_recovery( + tmp_path: Path, +) -> None: + store, _config, config_path, journal_path = _profile(tmp_path) + try: + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW, + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + journal["authority_adoption"]["recovery_id"] = ( + "d47b5211-385a-4581-9284-5af35d4fe196" + ) + journal["authority_adoption_digest"] = recovery.hashlib.sha256( + recovery.canonical_json(journal["authority_adoption"]) + ).hexdigest() + journal_path.write_text( + json.dumps(journal, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with pytest.raises(GateBlocked, match="authority adoption evidence conflicts"): + converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=journal_path, + request=_request(), + now=NOW + 1, + ) + finally: + store.close() + + + +def test_adoption_moves_only_current_owner_authority_and_is_idempotent(tmp_path: Path) -> None: + store = _store(tmp_path) + try: + before_active = dict( + store.fetch_one( + "SELECT * FROM approval_webauthn_credentials WHERE credential_id_b64=?", + ("active-credential",), + ) + ) + historical_request = dict( + store.fetch_one("SELECT * FROM approval_requests WHERE request_id='historical-request'") + ) + historical_audit = dict( + store.fetch_one("SELECT * FROM approval_audit WHERE action='approval.issued'") + ) + + result = adopt_canonical_approval_owner(store, request=_request(), now=NOW) + replay = adopt_canonical_approval_owner(store, request=_request(), now=NOW + 1) + + assert result == { + "schema": "agentnet.canonical-owner-adoption-result.v1", + "status": "adopted", + "recovery_id": "93756ff6-6337-4ed1-9697-250b63fb68a2", + "migrated_active_credentials": 1, + "revoked_browser_sessions": 0, + "canceled_registration_ceremonies": 0, + } + assert replay == {**result, "status": "already_exact"} + binding = store.fetch_one("SELECT * FROM approval_owner_bindings WHERE binding_id='binding-owner'") + assert binding is not None + assert binding["approver_principal_id"] == TARGET + assert binding["oidc_issuer"] == ISSUER + assert binding["oidc_subject"] == SUBJECT + assert binding["verified_email"] == EMAIL + + active = dict( + store.fetch_one( + "SELECT * FROM approval_webauthn_credentials WHERE credential_id_b64=?", + ("active-credential",), + ) + ) + assert active == { + **before_active, + "approver_principal_id": TARGET, + "user_handle_b64": b64url_encode( + approval_user_handle( + verifier_id=VERIFIER_ID, + principal_id=TARGET, + domain_id=DOMAIN, + ) + ), + } + historical_credential = store.fetch_one( + "SELECT * FROM approval_webauthn_credentials WHERE credential_id_b64=?", + ("historical-credential",), + ) + assert historical_credential is not None + assert historical_credential["approver_principal_id"] == SOURCE + assert dict( + store.fetch_one("SELECT * FROM approval_requests WHERE request_id='historical-request'") + ) == historical_request + assert dict( + store.fetch_one("SELECT * FROM approval_audit WHERE action='approval.issued'") + ) == historical_audit + adoption_audits = store.fetch_all( + "SELECT * FROM approval_audit WHERE action='owner.canonical_adoption'" + ) + assert len(adoption_audits) == 1 + assert adoption_audits[0]["approver_principal_id"] == TARGET + assert ( + adoption_audits[0]["detail_code"] + == "canonical_owner_adopted:v1:1:0:0" + ) + finally: + store.close() + + +def test_adoption_rejects_oidc_mismatch_without_mutation(tmp_path: Path) -> None: + store = _store(tmp_path) + try: + with pytest.raises(GateBlocked, match="source state does not match"): + adopt_canonical_approval_owner( + store, + request=_request(oidc_subject="different-subject"), + now=NOW, + ) + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_owner_bindings WHERE binding_id='binding-owner'" + )[0] == SOURCE + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_webauthn_credentials WHERE credential_id_b64='active-credential'" + )[0] == SOURCE + finally: + store.close() + + +def test_adoption_rejects_nonterminal_approval_request(tmp_path: Path) -> None: + store = _store(tmp_path) + try: + with store.transaction() as connection: + connection.execute( + """INSERT INTO approval_requests( + request_id,approver_principal_id,domain_id,approval_purpose,capability_hash, + canonical_transaction_encrypted,transaction_digest,state,active_fingerprint, + created_at,expires_at,delivery_mode + ) VALUES(?,?,?,?,?,?,?,'pending',?,?,?,'direct_receipt')""", + ( + "pending-request", + SOURCE, + DOMAIN, + "communication.scope", + "4" * 64, + "encrypted-pending", + "5" * 64, + "6" * 64, + NOW - 10, + NOW + 300, + ), + ) + with pytest.raises(GateBlocked, match="nonterminal approval state"): + adopt_canonical_approval_owner(store, request=_request(), now=NOW) + assert store.fetch_one( + "SELECT approver_principal_id FROM approval_owner_bindings WHERE binding_id='binding-owner'" + )[0] == SOURCE + finally: + store.close() + + +def test_adoption_revokes_owner_sessions_and_cancels_pending_registration(tmp_path: Path) -> None: + store = _store(tmp_path) + try: + with store.transaction() as connection: + connection.execute( + """INSERT INTO approval_browser_sessions( + session_hash,owner_binding_id,csrf_secret_encrypted,rp_id,public_origin, + verifier_id,created_at,authenticated_at,expires_at + ) VALUES(?,?,?,?,?,?,?,?,?)""", + ( + "7" * 64, + "binding-owner", + "encrypted-csrf", + RP_ID, + "https://approval.corp.example", + "approval.corp.example", + NOW - 50, + NOW - 50, + NOW + 300, + ), + ) + connection.execute( + """INSERT INTO approval_registration_ceremonies( + ceremony_id,owner_binding_id,session_hash,challenge_encrypted, + challenge_hash,state,created_at,expires_at + ) VALUES(?,?,?,?,?,'pending',?,?)""", + ( + "pending-ceremony", + "binding-owner", + "7" * 64, + "encrypted-challenge", + "8" * 64, + NOW - 20, + NOW + 100, + ), + ) + result = adopt_canonical_approval_owner(store, request=_request(), now=NOW) + assert result["revoked_browser_sessions"] == 1 + assert result["canceled_registration_ceremonies"] == 1 + session = store.fetch_one( + "SELECT revoked_at,revocation_reason FROM approval_browser_sessions WHERE session_hash=?", + ("7" * 64,), + ) + assert tuple(session) == (NOW, "canonical_owner_adoption") + assert store.fetch_one( + "SELECT state FROM approval_registration_ceremonies WHERE ceremony_id='pending-ceremony'" + )[0] == "canceled" + finally: + store.close() + + +def test_adoption_rejects_preexisting_target_authority(tmp_path: Path) -> None: + store = _store(tmp_path) + try: + with store.transaction() as connection: + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id,user_handle_b64, + credential_public_key_b64,sign_count,device_type,backed_up,status,created_at + ) VALUES(?,?,?,?,?,?,?,?,?,?)""", + ( + "target-credential", + TARGET, + DOMAIN, + b64url_encode(b"target-user-handle"), + "target-public-key", + 0, + "single_device", + 0, + "active", + NOW - 1, + ), + ) + with pytest.raises(GateBlocked, match="target authority already exists"): + adopt_canonical_approval_owner(store, request=_request(), now=NOW) + finally: + store.close() diff --git a/tests/operations/test_server_setup.py b/tests/operations/test_server_setup.py index 2a67af2..9809c55 100644 --- a/tests/operations/test_server_setup.py +++ b/tests/operations/test_server_setup.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import base64 import json import os import shutil @@ -1965,6 +1966,34 @@ def configured(principal: str, key_name: str) -> ApprovalServiceApproverConfig: approvers=(requested,), approval_state=state, ) + placeholder = config.model_copy( + update={ + "approvers": ( + config.approvers[0].model_copy( + update={"principal_id": "setup-placeholder-owner"} + ), + ) + } + ) + with pytest.raises(ServerSetupError, match="existing Approval state conflicts"): + setup._require_exact_approval_policy( + placeholder, + request=request, + owner_oidc=owner_oidc, + approvers=(requested,), + approval_state=state, + ) + assert ( + setup._require_exact_approval_policy( + placeholder, + request=request, + owner_oidc=owner_oidc, + approvers=(requested,), + approval_state=state, + allow_canonical_owner_adoption=True, + ) + == "setup-placeholder-owner" + ) drifted = config.model_copy( update={"approvers": config.approvers + (configured("extra-principal", "extra.pem"),)} ) @@ -1977,6 +2006,84 @@ def configured(principal: str, key_name: str) -> ApprovalServiceApproverConfig: approval_state=state, ) +def test_canonical_owner_core_policy_cutover_is_exact_and_resumable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import agentnet.operations.server_setup as setup + + source_document = { + "issuer": "https://accounts.example", + "approval_service": { + "approver_principal_id": "setup-placeholder-owner", + "signer_key_id": "source-key", + }, + } + target_document = { + "issuer": "https://accounts.example", + "approval_service": { + "approver_principal_id": "owner-principal", + "signer_key_id": "target-key", + }, + } + source_oidc = SimpleNamespace(model_dump=lambda **_kwargs: source_document) + target_oidc = SimpleNamespace(model_dump=lambda **_kwargs: target_document) + previous_core = ( + json.dumps( + {"oidc_enrollment": source_document, "unrelated": {"preserved": True}}, + indent=2, + sort_keys=True, + ).encode() + + b"\n" + ) + previous_oidc = json.dumps(source_document, indent=2, sort_keys=True).encode() + b"\n" + core_path = tmp_path / "agentnet.json" + oidc_path = tmp_path / "oidc.json" + current = {core_path: previous_core, oidc_path: previous_oidc} + pending: dict[str, object] = { + "journal": { + "previous_configs": { + "core_config": base64.b64encode(previous_core).decode(), + "core_oidc_config": base64.b64encode(previous_oidc).decode(), + } + } + } + + def write_config( + path: Path, + payload: bytes, + *, + account: object, + previous: bytes, + ) -> str: + del account + if current[path] == payload: + return "already_satisfied" + assert current[path] == previous + current[path] = payload + return "updated_package_upgrade" + + monkeypatch.setattr(setup, "_write_journaled_core_config", write_config) + arguments = { + "core_config_path": core_path, + "core_oidc_path": oidc_path, + "core_account": object(), + "source_oidc": source_oidc, + "target_oidc": target_oidc, + "pending": pending, + } + + assert setup._migrate_canonical_owner_core_policy(**arguments) == ( + "updated_package_upgrade" + ) + replacement_core = json.loads(current[core_path]) + assert replacement_core == { + "oidc_enrollment": target_document, + "unrelated": {"preserved": True}, + } + assert json.loads(current[oidc_path]) == target_document + assert setup._migrate_canonical_owner_core_policy(**arguments) == "already_satisfied" + def test_managed_identity_profile_accepts_only_canonical_strict_actor( tmp_path: Path, @@ -3510,6 +3617,11 @@ def test_apply_resumes_after_interruption_and_restarts_only_managed_core( "_postgres_peer_gate", lambda _account, _database_url: {"status": "validated_exact_local_peer"}, ) + monkeypatch.setattr( + setup, + "_repair_committed_communication_scope_projection_as", + lambda _account, _database_url: {"ready": True, "migrated": 0}, + ) signer = P256KeyPair.generate() trusted = IndependentApproverConfig( @@ -3530,7 +3642,14 @@ def test_apply_resumes_after_interruption_and_restarts_only_managed_core( drift_trust_during_apply = False trust_reads = 0 - def fake_approval_trust(_path: Path, _account: object, _state: Path): + def fake_approval_trust( + _path: Path, + _account: object, + _state: Path, + *, + recovery_source: object | None = None, + ): + assert recovery_source is None nonlocal trust_reads trust_reads += 1 effective = changed_trusted if drift_trust_during_apply and trust_reads % 2 == 0 else trusted diff --git a/tests/operations/test_server_setup_recovery.py b/tests/operations/test_server_setup_recovery.py index 4f0a79b..fc9974f 100644 --- a/tests/operations/test_server_setup_recovery.py +++ b/tests/operations/test_server_setup_recovery.py @@ -13,6 +13,7 @@ import hashlib import json import os +import pwd import shutil import stat import subprocess @@ -20,6 +21,7 @@ import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass +from uuid import NAMESPACE_URL, uuid4, uuid5 from pathlib import Path from types import SimpleNamespace @@ -33,6 +35,12 @@ clamav_rules_digest, ) from agentnet.approval.config import MANDATORY_APPROVAL_PURPOSES +from agentnet.approval.store import ApprovalStore +from agentnet.operations.canonical_owner_recovery import ( + CanonicalOwnerAdoptionRequest, + converge_canonical_approval_owner, +) +from agentnet.security.envelope import LocalEnvelopeCipher from agentnet.operations.config import IndependentApproverConfig, OIDCEnrollmentConfig from agentnet.operations.server_reset import ServerSetupResetError, reset_server_setup from agentnet.operations.server_setup import ( @@ -43,7 +51,7 @@ load_server_setup_request, plan_server_setup, ) -from agentnet.security.signatures import P256KeyPair +from agentnet.security.signatures import P256KeyPair, canonical_json from agentnet.storage.postgres import ORDINARY_SERVER_POSTGRES_DSN BROKER = "synthetic-shared-test-token-0123456789abcdef0123456789" @@ -151,6 +159,34 @@ def _bootstrap_evidence(domain_id: str) -> dict[str, object]: } +def test_canonical_owner_source_rejects_tampered_recovery_phase( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness + ) + approval_state = harness.layout.host(setup.APPROVAL_STATE) + recovery_path = approval_state / "canonical-owner-recovery.json" + recovery = json.loads(recovery_path.read_text(encoding="utf-8")) + recovery["phase"] = "authority_adopted" + _private_json(recovery_path, recovery) + + with pytest.raises( + ServerSetupError, + match="canonical owner recovery journal is invalid", + ): + getattr(setup, "_canonical_owner_recovery_source")( + approval_state, + config_path, + pwd.getpwuid(os.geteuid()), + request=harness.request, + ) + + @dataclass class _Harness: request: ServerSetupRequest @@ -163,6 +199,7 @@ class _Harness: systemctl_calls: list[list[str]] operation_events: list[tuple[str, object]] database_state: dict[str, object] + approval_signer: P256KeyPair @property def marker_path(self) -> Path: @@ -529,6 +566,11 @@ def fake_database_operation( "_run_v0145_database_operation_as", fake_database_operation, ) + monkeypatch.setattr( + setup, + "_repair_committed_communication_scope_projection_as", + lambda _account, _database_url: {"ready": True, "migrated": 0}, + ) monkeypatch.setattr(setup, "_run_as", fake_run_as) monkeypatch.setattr(setup, "_run_bounded_product_process", fake_bounded_product_process) return _Harness( @@ -542,8 +584,36 @@ def fake_database_operation( systemctl_calls=systemctl_calls, operation_events=operation_events, database_state=database_state, + approval_signer=signer, + ) + + +def test_managed_setup_repairs_committed_scope_projection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + seen: list[tuple[str, str]] = [] + + def repair(account: pwd.struct_passwd, database_url: str) -> dict[str, object]: + seen.append((account.pw_name, database_url)) + return {"ready": True, "migrated": 1} + + monkeypatch.setattr( + setup, + "_repair_committed_communication_scope_projection_as", + repair, ) + result = harness.apply(harness.plan_digest()) + + assert seen == [("agentnet", harness.request.database_url)] + assert { + "id": "communication_scope_projection", + "status": "repaired", + "migrated": 1, + } in result["steps"] + def _realized_0130_deployment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[_Harness, str]: """One completed 0.1.30 apply, ready for an upgrade attempt.""" @@ -650,6 +720,534 @@ def released_render_units( assert b"OnUnitActiveSec=1h" in harness.layout.unit(setup.CREDENTIAL_RENEW_TIMER).read_bytes() return harness +def _stage_0150_one_hour_approval_hotfix(harness: _Harness) -> tuple[Path, bytes]: + """Materialize the exact retained v0.1.50 Approval TTL hotfix shape.""" + + approval_state = harness.layout.host(setup.APPROVAL_STATE) + secrets = approval_state / "secrets" + signers = approval_state / "signers" + secrets.mkdir(mode=0o700) + signers.mkdir(mode=0o700) + record_key = secrets / "records.key" + record_key.write_bytes(b"k" * 32) + record_key.chmod(0o600) + database = approval_state / "approval.sqlite3" + database.touch(mode=0o600) + store = ApprovalStore( + database, + LocalEnvelopeCipher(b"k" * 32), + initialize=True, + ) + try: + with store.transaction() as connection: + connection.execute( + """INSERT INTO approval_owner_bindings( + binding_id,domain_id,approver_principal_id,oidc_issuer, + oidc_subject,verified_email,pin_source,status,pinned_at + ) VALUES(?,?,?,?,?,?,?,'active',?)""", + ( + "owner-binding-1", + harness.request.domain_id, + harness.request.approval_approver_principal_id, + "https://accounts.example", + "owner-subject", + "owner@corp.example", + "exact_subject", + 1, + ), + ) + finally: + store.close() + database.chmod(0o600) + signer = harness.approval_signer + signer_path = signers / "approver-1.pem" + signer_path.write_bytes(signer.private_pem) + signer_path.chmod(0o600) + config_path = harness.layout.host(setup.APPROVAL_CONFIG) + _private_json( + config_path, + { + "schema_version": "1.0", + "public_origin": harness.request.approval_public_origin, + "rp_id": "approval.corp.example", + "rp_name": "AgentNet Approval", + "verifier_id": harness.request.approval_verifier_id, + "data_dir": str(approval_state), + "database_path": str(database), + "record_key_path": str(record_key), + "request_ttl_seconds": 3_600, + "challenge_ttl_seconds": 180, + "receipt_ttl_seconds": 300, + "registration_ttl_seconds": 600, + "max_transaction_bytes": 65_536, + "max_http_body_bytes": 131_072, + "internal_core_credential_env": "AGENTNET_APPROVAL_CORE_TOKEN", + "owner_oidc": { + "issuer": "https://accounts.example", + "client_id": "approval-client", + "redirect_uri": ( + "https://approval.corp.example/v1/approval/owner/oidc/callback" + ), + "allowed_endpoint_origins": ["https://accounts.example"], + "allowed_signing_algorithms": ["RS256"], + }, + "approvers": [ + { + "principal_id": harness.request.approval_approver_principal_id, + "authority_kind": "human", + "domain_id": harness.request.domain_id, + "signer_key_id": signer.thumbprint, + "signer_private_key_path": str(signer_path), + "allowed_purposes": sorted(MANDATORY_APPROVAL_PURPOSES), + "oidc_issuer": "https://accounts.example", + "oidc_subject": "owner-subject", + } + ], + }, + ) + source_payload = config_path.read_bytes() + marker = harness.marker() + marker["approval_config_digest"] = setup._managed_config_digest( + config_path, + SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + blocker="approval_config", + ) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + return config_path, source_payload + +def _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness: _Harness, + *, + include_journal: bool = True, + current_core_target: bool = True, + communication_scope_ttl_present: bool = True, +) -> tuple[Path, bytes]: + """Materialize the exact combined live repair over one retained marker.""" + + config_path, _hotfix_payload = _stage_0150_one_hour_approval_hotfix(harness) + approval = json.loads(config_path.read_text(encoding="utf-8")) + if communication_scope_ttl_present: + approval["communication_scope_request_ttl_seconds"] = 3_600 + else: + approval.pop("communication_scope_request_ttl_seconds", None) + _private_json(config_path, approval) + target_approver = approval["approvers"][0] + source_principal = "setup-placeholder-owner" + source_signer = P256KeyPair.generate() + original_signer_path = Path(target_approver["signer_private_key_path"]) + if include_journal: + source_signer_path = original_signer_path.with_name( + "placeholder-owner.pem" + ) + target_signer_path = original_signer_path + else: + source_signer_path = original_signer_path.with_name( + "placeholder-owner.pem" + ) + target_signer_path = original_signer_path + source_signer_path.write_bytes(source_signer.private_pem) + source_signer_path.chmod(0o600) + + source_approval = copy.deepcopy(approval) + source_approval["request_ttl_seconds"] = 300 + source_approval.pop("communication_scope_request_ttl_seconds", None) + source_approval["approvers"][0]["principal_id"] = source_principal + source_approval["approvers"][0]["signer_key_id"] = source_signer.thumbprint + source_approval["approvers"][0]["signer_private_key_path"] = str( + source_signer_path + ) + source_hotfix = copy.deepcopy(source_approval) + source_hotfix["request_ttl_seconds"] = 3_600 + source_hotfix_payload = ( + json.dumps(source_hotfix, indent=2, sort_keys=True).encode("utf-8") + b"\n" + ) + + oidc_provider = setup.SetupOIDCProvider.model_validate( + json.loads(harness.request.oidc_provider_file.read_text(encoding="utf-8")) + ) + target_policy = setup.SetupApprover.model_validate( + json.loads( + harness.request.approval_approvers_file.read_text(encoding="utf-8") + )["approvers"][0] + ) + target_trust = IndependentApproverConfig( + principal_id=harness.request.approval_approver_principal_id, + authority_kind=target_policy.authority_kind, + signer_key_id=harness.approval_signer.thumbprint, + public_key_pem=harness.approval_signer.public_pem, + allowed_purposes=target_policy.allowed_purposes, + ) + target_oidc = setup._build_core_oidc_config( + harness.request, + oidc_provider, + trusted=(target_trust,), + approvers=(target_policy,), + ) + source_policy = target_policy.model_copy( + update={"principal_id": source_principal} + ) + source_request = harness.request.model_copy( + update={"approval_approver_principal_id": source_principal} + ) + source_trust = IndependentApproverConfig( + principal_id=source_principal, + authority_kind=source_policy.authority_kind, + signer_key_id=source_signer.thumbprint, + public_key_pem=source_signer.public_pem, + allowed_purposes=source_policy.allowed_purposes, + ) + source_oidc = setup._build_core_oidc_config( + source_request, + oidc_provider, + trusted=(source_trust,), + approvers=(source_policy,), + ) + core_config_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + enrolled_identity = { + "enrolled_harness_id": str(uuid4()), + "enrolled_credential_id": str(uuid4()), + } + target_core = { + "oidc_enrollment": target_oidc.model_dump(mode="json"), + **enrolled_identity, + } + source_core = {"oidc_enrollment": source_oidc.model_dump(mode="json")} + realized_core = target_core if current_core_target else { + **source_core, + **enrolled_identity, + } + core_config_path.write_text( + json.dumps(realized_core, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + core_config_path.chmod(0o600) + realized_oidc = target_oidc if current_core_target else source_oidc + core_oidc_path.write_text( + json.dumps(realized_oidc.model_dump(mode="json"), indent=2, sort_keys=True) + + "\n", + encoding="utf-8", + ) + core_oidc_path.chmod(0o600) + + recovery_id = str( + uuid5( + NAMESPACE_URL, + f"agentnet:{harness.request.domain_id}:{source_principal}:" + f"{harness.request.approval_approver_principal_id}", + ) + ) + adoption = { + "schema": "agentnet.canonical-owner-adoption-result.v1", + "status": "adopted", + "recovery_id": recovery_id, + "migrated_active_credentials": 1, + "revoked_browser_sessions": 0, + "canceled_registration_ceremonies": 0, + } + journal = { + "schema": "agentnet.canonical-owner-recovery-journal.v1", + "recovery_id": recovery_id, + "request_digest": hashlib.sha256( + canonical_json( + CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=recovery_id, + domain_id=harness.request.domain_id, + source_principal_id=source_principal, + target_principal_id=harness.request.approval_approver_principal_id, + oidc_issuer=target_policy.oidc_issuer, + oidc_subject="owner-subject", + verified_email="owner@corp.example", + verifier_id=harness.request.approval_verifier_id, + approved_at=1, + ).model_dump(by_alias=True, mode="json") + ) + ).hexdigest(), + "config_path": str(config_path.absolute()), + "signer_path": str(source_signer_path), + "target_signer_path": str(target_signer_path), + "source_config_sha256": hashlib.sha256(source_hotfix_payload).hexdigest(), + "domain_id": harness.request.domain_id, + "source_principal_id": source_principal, + "target_principal_id": harness.request.approval_approver_principal_id, + "oidc_issuer": target_policy.oidc_issuer, + "source_signer_key_id": source_signer.thumbprint, + "source_signer_public_key_pem": source_signer.public_pem, + "target_signer_key_id": harness.approval_signer.thumbprint, + "target_signer_public_key_pem": harness.approval_signer.public_pem, + "phase": "complete", + "prepared_at": 1, + "completed_at": 2, + "authority_adoption": adoption, + "authority_adoption_digest": hashlib.sha256( + canonical_json(adoption) + ).hexdigest(), + } + approval_store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher(Path(approval["record_key_path"]).read_bytes()), + ) + try: + with approval_store.transaction() as connection: + target_handle = base64.urlsafe_b64encode( + hashlib.sha256( + canonical_json( + { + "schema": "agentnet.approval.webauthn-user.v1", + "verifier_id": harness.request.approval_verifier_id, + "domain_id": harness.request.domain_id, + "approver_principal_id": ( + harness.request.approval_approver_principal_id + ), + } + ) + ).digest() + ).rstrip(b"=").decode("ascii") + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id, + user_handle_b64,credential_public_key_b64,sign_count, + device_type,backed_up,status,created_at,revoked_at, + revocation_reason + ) VALUES('recovered-owner-credential',?,?,?,'synthetic-key', + 0,'single_device',0,'active',1,NULL,NULL)""", + ( + harness.request.approval_approver_principal_id, + harness.request.domain_id, + target_handle, + ), + ) + connection.execute( + """INSERT INTO approval_audit( + action,request_id,approver_principal_id,domain_id, + approval_purpose,transaction_digest,occurred_at,outcome,detail_code + ) VALUES('owner.canonical_adoption',NULL,?,?, + 'owner.canonical_adoption',?,2,'adopted', + 'canonical_owner_adopted:v1:1:0:0')""", + ( + harness.request.approval_approver_principal_id, + harness.request.domain_id, + journal["request_digest"], + ), + ) + connection.execute( + """INSERT INTO approval_audit( + action,request_id,approver_principal_id,domain_id, + approval_purpose,transaction_digest,occurred_at,outcome,detail_code + ) VALUES('approval.request',NULL,?,?, + 'legacy.owner.evidence',?,1,'approved', + 'legacy_owner_source:v1')""", + ( + source_principal, + harness.request.domain_id, + "f" * 64, + ), + ) + finally: + approval_store.close() + + approval_state = harness.layout.host(setup.APPROVAL_STATE) + if include_journal: + _private_json(approval_state / "canonical-owner-recovery.json", journal) + + marker = harness.marker() + marker["approval_config_digest"] = setup.canonical_digest(source_approval) + marker["core_config_digest"] = setup.canonical_digest(source_core) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + return config_path, config_path.read_bytes() + + +def _stage_0150_partial_owner_repair_and_one_hour_hotfix( + harness: _Harness, + *, + reverse_embedded_trust: bool = False, + marker_approval_role: Literal["source", "target"] = "source", + embedded_service_role: Literal["source", "target"] = "target", +) -> tuple[Path, CanonicalOwnerAdoptionRequest]: + """Materialize the exact retained source/dual-trust/target-sidecar shape.""" + + config_path, _ = _stage_0150_one_hour_approval_hotfix(harness) + approval = json.loads(config_path.read_text(encoding="utf-8")) + target_approver = copy.deepcopy(approval["approvers"][0]) + target_signer_path = Path(target_approver["signer_private_key_path"]) + source_principal = "setup-placeholder-owner" + source_signer = P256KeyPair.generate() + source_signer_path = target_signer_path.with_name("placeholder-owner.pem") + source_signer_path.write_bytes(source_signer.private_pem) + source_signer_path.chmod(0o600) + target_policy = setup.SetupApprover.model_validate( + json.loads( + harness.request.approval_approvers_file.read_text(encoding="utf-8") + )["approvers"][0] + ) + approval["approvers"][0].update( + principal_id=source_principal, + signer_key_id=source_signer.thumbprint, + signer_private_key_path=str(source_signer_path), + ) + _private_json(config_path, approval) + + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher(Path(approval["record_key_path"]).read_bytes()), + ) + try: + with store.transaction() as connection: + connection.execute( + """UPDATE approval_owner_bindings + SET approver_principal_id=? + WHERE domain_id=? AND status='active'""", + (source_principal, harness.request.domain_id), + ) + source_handle = base64.urlsafe_b64encode( + hashlib.sha256( + canonical_json( + { + "schema": "agentnet.approval.webauthn-user.v1", + "verifier_id": harness.request.approval_verifier_id, + "domain_id": harness.request.domain_id, + "approver_principal_id": source_principal, + } + ) + ).digest() + ).rstrip(b"=").decode("ascii") + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id, + user_handle_b64,credential_public_key_b64,sign_count, + device_type,backed_up,status,created_at,revoked_at, + revocation_reason + ) VALUES('source-owner-credential',?,?,?,'synthetic-key', + 0,'single_device',0,'active',1,NULL,NULL)""", + (source_principal, harness.request.domain_id, source_handle), + ) + finally: + store.close() + + oidc_provider = setup.SetupOIDCProvider.model_validate( + json.loads(harness.request.oidc_provider_file.read_text(encoding="utf-8")) + ) + source_policy = target_policy.model_copy( + update={"principal_id": source_principal} + ) + source_request = harness.request.model_copy( + update={"approval_approver_principal_id": source_principal} + ) + source_trust = IndependentApproverConfig( + principal_id=source_principal, + authority_kind=source_policy.authority_kind, + signer_key_id=source_signer.thumbprint, + public_key_pem=source_signer.public_pem, + allowed_purposes=source_policy.allowed_purposes, + ) + target_trust = IndependentApproverConfig( + principal_id=harness.request.approval_approver_principal_id, + authority_kind=target_policy.authority_kind, + signer_key_id=harness.approval_signer.thumbprint, + public_key_pem=harness.approval_signer.public_pem, + allowed_purposes=target_policy.allowed_purposes, + ) + source_oidc = setup._build_core_oidc_config( + source_request, + oidc_provider, + trusted=(source_trust,), + approvers=(source_policy,), + ) + target_oidc = setup._build_core_oidc_config( + harness.request, + oidc_provider, + trusted=(target_trust,), + approvers=(target_policy,), + ) + dual_oidc = ( + source_oidc if embedded_service_role == "source" else target_oidc + ).model_copy( + update={ + "trusted_approvers": ( + (target_trust, source_trust) + if reverse_embedded_trust + else (source_trust, target_trust) + ) + } + ) + core_config_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + enrolled_identity = { + "enrolled_harness_id": str(uuid4()), + "enrolled_credential_id": str(uuid4()), + } + _private_json( + core_config_path, + { + "oidc_enrollment": dual_oidc.model_dump(mode="json"), + **enrolled_identity, + }, + ) + _private_json(core_oidc_path, target_oidc.model_dump(mode="json")) + + marker_approval = copy.deepcopy(approval) + if marker_approval_role == "target": + marker_approval["approvers"] = [target_approver] + marker_approval["request_ttl_seconds"] = 300 + marker_approval.pop("communication_scope_request_ttl_seconds", None) + marker = harness.marker() + marker["approval_config_digest"] = setup.canonical_digest(marker_approval) + marker["core_config_digest"] = setup.canonical_digest( + {"oidc_enrollment": source_oidc.model_dump(mode="json")} + ) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + + recovery_id = str( + uuid5( + NAMESPACE_URL, + f"agentnet:{harness.request.domain_id}:{source_principal}:" + f"{harness.request.approval_approver_principal_id}", + ) + ) + return config_path, CanonicalOwnerAdoptionRequest( + schema="agentnet.canonical-owner-adoption.v1", + recovery_id=recovery_id, + domain_id=harness.request.domain_id, + source_principal_id=source_principal, + target_principal_id=harness.request.approval_approver_principal_id, + oidc_issuer=target_policy.oidc_issuer, + oidc_subject="owner-subject", + verified_email="owner@corp.example", + verifier_id=harness.request.approval_verifier_id, + approved_at=1, + ) + + +def _rewrite_repaired_policy_purpose_order( + harness: _Harness, + approval_config_path: Path, +) -> None: + """Simulate a separately seeded repair process reserializing frozensets.""" + + approval = json.loads(approval_config_path.read_text(encoding="utf-8")) + approval["approvers"][0]["allowed_purposes"].reverse() + _private_json(approval_config_path, approval) + + core_config_path = harness.layout.host(setup.CORE_CONFIG) + core = json.loads(core_config_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["trusted_approvers"][0]["allowed_purposes"].reverse() + _private_json(core_config_path, core) + _private_json( + harness.layout.host(setup.CORE_OIDC_CONFIG), + core["oidc_enrollment"], + ) + + @@ -792,12 +1390,15 @@ def test_marker_accepts_only_released_package_caused_digest_drift( -@pytest.mark.parametrize("source", ["0.1.45", "0.1.46", "0.1.47", "0.1.48", "0.1.49"]) -def test_0150_accepts_direct_upgrade_from_every_supported_setup_release( +@pytest.mark.parametrize( + "source", + ["0.1.45", "0.1.46", "0.1.47", "0.1.48", "0.1.49", "0.1.50"], +) +def test_0151_accepts_direct_upgrade_from_every_supported_setup_release( monkeypatch: pytest.MonkeyPatch, source: str, ) -> None: - monkeypatch.setattr(setup, "__version__", "0.1.50") + monkeypatch.setattr(setup, "__version__", "0.1.51") payload = _marker_payload( schema="agentnet.server-setup.marker.v3", package_version=source, @@ -813,79 +1414,1640 @@ def test_0150_accepts_direct_upgrade_from_every_supported_setup_release( assert marker is not None assert marker["package_version"] == source - assert setup._forward_only_setup_upgrade(source, "0.1.50") is True + assert setup._forward_only_setup_upgrade(source, "0.1.51") is True + +def test_0151_upgrade_converges_exact_0150_one_hour_approval_hotfix( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, source_payload = _stage_0150_one_hour_approval_hotfix(harness) + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + assert source_payload != config_path.read_bytes() + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert { + "id": "approval_request_ttl_policy_upgrade", + "status": "updated_package_upgrade", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() -def test_0150_rejects_direct_upgrade_from_pre_lifecycle_release( +def test_0151_upgrade_rejects_duplicate_core_oidc_purpose( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) monkeypatch.setattr(setup, "__version__", "0.1.50") - payload = _marker_payload( - schema="agentnet.server-setup.marker.v3", - package_version="0.1.44", - artifact_mode="disabled", + harness.apply(harness.plan_digest()) + _stage_0150_one_hour_approval_hotfix(harness) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core_oidc = json.loads(core_oidc_path.read_text(encoding="utf-8")) + core_oidc["trusted_approvers"][0]["allowed_purposes"].append( + core_oidc["trusted_approvers"][0]["allowed_purposes"][0] ) + _private_json(core_oidc_path, core_oidc) + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") with pytest.raises(ServerSetupError) as exc_info: - setup._validated_setup_marker( - payload, - request_digest="9" * 64, - legacy_request_digest="1" * 64, - artifact_mode="disabled", - ) + harness.apply(harness.plan_digest()) - assert exc_info.value.blocker == "setup_marker_conflict" + assert exc_info.value.blocker == "setup_upgrade_conflict" + assert not harness.journal_path.exists() -@pytest.mark.parametrize( - ("package_version", "current_version"), - [ - ("0.1.29", "0.1.31"), # never released with a runtime-bound digest - ("0.1.31", "0.1.31"), # same version: the request itself changed - ("0.1.32", "0.1.31"), # downgrade - ("garbage", "0.1.31"), - ("0.1.30", "0.1.32"), # unsupported upgrade target - ("0.1.30", "0.1.30"), - ], -) -def test_marker_rejects_every_unsupported_request_digest_drift( + +def test_0151_upgrade_converges_unrecorded_0150_one_hour_approval_hotfix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - package_version: str, - current_version: str, ) -> None: - monkeypatch.setattr(setup, "__version__", current_version) - payload = _marker_payload( - schema="agentnet.server-setup.marker.v3", - package_version=package_version, - artifact_mode="disabled", + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_one_hour_approval_hotfix(harness) + hotfix_document = json.loads(config_path.read_text(encoding="utf-8")) + published_document = dict(hotfix_document) + published_document["request_ttl_seconds"] = 300 + _private_json(config_path, published_document) + marker = harness.marker() + marker["approval_config_digest"] = setup._managed_config_digest( + config_path, + SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + blocker="approval_config", ) - with pytest.raises(ServerSetupError) as exc_info: - setup._validated_setup_marker( - payload, - request_digest="9" * 64, - legacy_request_digest="1" * 64, - artifact_mode="disabled", - ) - assert exc_info.value.blocker == "setup_marker_conflict" + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + _private_json(config_path, hotfix_document) + source_payload = config_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) -def test_marker_upgrade_still_rejects_malformed_recorded_digest( + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + assert source_payload != config_path.read_bytes() + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert { + "id": "approval_request_ttl_policy_upgrade", + "status": "updated_package_upgrade", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +def test_0151_rejects_incomplete_owner_recovery_before_ttl_migration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(setup, "__version__", "0.1.31") - payload = _marker_payload( - schema="agentnet.server-setup.marker.v3", - package_version="0.1.30", - artifact_mode="disabled", - request_digest="not-a-digest", + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, source_payload = _stage_0150_one_hour_approval_hotfix(harness) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" ) - with pytest.raises(ServerSetupError) as exc_info: - setup._validated_setup_marker( - payload, - request_digest="9" * 64, - legacy_request_digest="1" * 64, - artifact_mode="disabled", - ) - assert exc_info.value.blocker == "setup_marker_conflict" + _private_json( + recovery_path, + { + "schema": "agentnet.canonical-owner-recovery-journal.v1", + "phase": "prepared", + }, + ) + recovery_before = recovery_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + real_migrate = getattr(setup, "_migrate_0150_approval_request_ttl_policy") + migrations: list[bool] = [] + + def tracked_migrate(**kwargs: object) -> str: + migrations.append(True) + return real_migrate(**kwargs) # type: ignore[arg-type] + + monkeypatch.setattr( + setup, + "_migrate_0150_approval_request_ttl_policy", + tracked_migrate, + ) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "canonical_owner_recovery" + assert migrations == [] + assert config_path.read_bytes() == source_payload + assert recovery_path.read_bytes() == recovery_before + assert not harness.journal_path.exists() + +@pytest.mark.parametrize( + "communication_scope_ttl_present", + (False, True), + ids=("scope-ttl-absent", "scope-ttl-explicit"), +) +def test_0151_upgrade_converges_completed_owner_repair_and_one_hour_ttl_hotfix( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + communication_scope_ttl_present: bool, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, repaired_payload = ( + _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness, + communication_scope_ttl_present=communication_scope_ttl_present, + ) + ) + marker = harness.marker() + realized_approval = json.loads(config_path.read_text(encoding="utf-8")) + realized_core = json.loads( + harness.layout.host(setup.CORE_CONFIG).read_text(encoding="utf-8") + ) + realized_core.pop("enrolled_harness_id", None) + realized_core.pop("enrolled_credential_id", None) + assert setup.canonical_digest(realized_approval) != marker[ + "approval_config_digest" + ] + assert setup.canonical_digest(realized_core) != marker["core_config_digest"] + assert ( + "communication_scope_request_ttl_seconds" in realized_approval + ) is communication_scope_ttl_present + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + original_run_as = setup._run_as + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + harness.operation_events.append(("product", stage)) + return {"status": "already_exact"} + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_run_as", run_as) + monkeypatch.setattr( + setup, + + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: { + "actor": { + "principal_id": harness.request.approval_approver_principal_id + } + }, + ) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + assert config_path.read_bytes() != repaired_payload + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert { + "id": "approval_request_ttl_policy_upgrade", + "status": "updated_package_upgrade", + } in result["steps"] + assert { + "id": "canonical_owner_recovery", + "status": "already_exact", + "source_principal_id": "setup-placeholder-owner", + "target_principal_id": harness.request.approval_approver_principal_id, + "core_policy_status": "already_satisfied", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +@pytest.mark.parametrize( + "marker_approval_role", + ("source", "target"), + ids=("source-marker-approval", "target-marker-approval"), +) +@pytest.mark.parametrize( + "reverse_embedded_trust", + (False, True), + ids=("source-target-trust", "target-source-trust"), +) +@pytest.mark.parametrize( + "embedded_service_role", + ("source", "target"), + ids=("source-service", "target-service"), +) +@pytest.mark.parametrize( + "interrupt_before_recovery", + (False, True), + ids=("single-pass", "resume-after-pre-recovery-rollback"), +) +def test_0151_upgrade_converges_exact_partial_owner_repair( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + interrupt_before_recovery: bool, + embedded_service_role: Literal["source", "target"], + reverse_embedded_trust: bool, + marker_approval_role: Literal["source", "target"], +) -> None: + real_approval_trust = setup._approval_trust + real_require_exact_approval_policy = setup._require_exact_approval_policy + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, recovery_request = ( + _stage_0150_partial_owner_repair_and_one_hour_hotfix( + harness, + reverse_embedded_trust=reverse_embedded_trust, + marker_approval_role=marker_approval_role, + embedded_service_role=embedded_service_role, + ) + ) + if reverse_embedded_trust: + approval = json.loads(config_path.read_text(encoding="utf-8")) + approval["approvers"][0]["allowed_purposes"].reverse() + _private_json(config_path, approval) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + marker_before = harness.marker_path.read_bytes() + assert not recovery_path.exists() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + monkeypatch.setattr( + setup, + "_require_exact_approval_policy", + real_require_exact_approval_policy, + ) + original_run_as = setup._run_as + recovery_attempts: list[str] = [] + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + recovery_attempts.append(stage) + if interrupt_before_recovery and len(recovery_attempts) == 1: + raise RuntimeError("injected pre-recovery interruption") + approval = json.loads(config_path.read_text(encoding="utf-8")) + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher( + Path(approval["record_key_path"]).read_bytes() + ), + ) + try: + return converge_canonical_approval_owner( + store, + config_path=config_path, + journal_path=recovery_path, + request=recovery_request, + now=2, + ) + finally: + store.close() + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_run_as", run_as) + monkeypatch.setattr( + setup, + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: { + "actor": { + "principal_id": harness.request.approval_approver_principal_id + } + }, + ) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + + if interrupt_before_recovery: + with pytest.raises( + RuntimeError, + match="injected pre-recovery interruption", + ): + harness.apply(harness.plan_digest()) + interrupted = json.loads(recovery_path.read_text(encoding="utf-8")) + migrated_ttl = json.loads(config_path.read_text(encoding="utf-8")) + assert interrupted["phase"] == "prepared" + assert migrated_ttl["request_ttl_seconds"] == 3_600 + assert "communication_scope_request_ttl_seconds" not in migrated_ttl + assert harness.marker_path.read_bytes() == marker_before + assert not harness.journal_path.exists() + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + recovery = json.loads(recovery_path.read_text(encoding="utf-8")) + assert marker_before != harness.marker_path.read_bytes() + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert migrated["approvers"][0]["principal_id"] == ( + harness.request.approval_approver_principal_id + ) + assert recovery["phase"] == "complete" + assert recovery["partial_recovery"]["schema"] == ( + "agentnet.canonical-owner-partial-recovery.v1" + ) + assert { + "id": "canonical_owner_recovery", + "status": "recovered", + "source_principal_id": "setup-placeholder-owner", + "target_principal_id": harness.request.approval_approver_principal_id, + "core_policy_status": "updated_package_upgrade", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +@pytest.mark.parametrize( + "tamper", + ( + "extra-signer", + "embedded-target-only", + "reordered-source-authority", + "sidecar-source", + ), +) +def test_0151_upgrade_rejects_near_partial_owner_repair_without_writes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tamper: str, +) -> None: + real_approval_trust = setup._approval_trust + real_require_exact_approval_policy = setup._require_exact_approval_policy + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + approval_path, _ = _stage_0150_partial_owner_repair_and_one_hour_hotfix( + harness + ) + core_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + if tamper == "extra-signer": + extra = recovery_path.parent / "signers" / "untracked.pem" + extra.write_bytes(P256KeyPair.generate().private_pem) + extra.chmod(0o600) + elif tamper == "embedded-target-only": + core = json.loads(core_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["trusted_approvers"] = [ + core["oidc_enrollment"]["trusted_approvers"][1] + ] + _private_json(core_path, core) + elif tamper == "reordered-source-authority": + core = json.loads(core_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["trusted_approvers"].reverse() + core["oidc_enrollment"]["trusted_approvers"][1][ + "authority_kind" + ] = "guest" + _private_json(core_path, core) + else: + core = json.loads(core_path.read_text(encoding="utf-8")) + source_oidc = copy.deepcopy(core["oidc_enrollment"]) + source_oidc["trusted_approvers"] = [ + source_oidc["trusted_approvers"][0] + ] + source_oidc["approval_service"]["approver_principal_id"] = ( + "setup-placeholder-owner" + ) + _private_json(core_oidc_path, source_oidc) + + protected_paths = ( + approval_path, + core_path, + core_oidc_path, + harness.marker_path, + ) + before = {path: path.read_bytes() for path in protected_paths} + signer_root = recovery_path.parent / "signers" + signers_before = { + path.name: path.read_bytes() for path in sorted(signer_root.iterdir()) + } + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + monkeypatch.setattr( + setup, + "_require_exact_approval_policy", + real_require_exact_approval_policy, + ) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker in { + "canonical_owner_recovery", + "setup_upgrade_conflict", + } + assert {path: path.read_bytes() for path in protected_paths} == before + assert { + path.name: path.read_bytes() for path in sorted(signer_root.iterdir()) + } == signers_before + assert not recovery_path.exists() + assert not harness.journal_path.exists() + + +@pytest.mark.parametrize( + "tamper", + ( + "source-authority", + "source-purpose", + "source-oidc-subject", + ), +) +def test_0151_target_marker_rejects_source_policy_drift_before_journal( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tamper: str, +) -> None: + real_approval_trust = setup._approval_trust + real_require_exact_approval_policy = setup._require_exact_approval_policy + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + approval_path, _ = _stage_0150_partial_owner_repair_and_one_hour_hotfix( + harness, + marker_approval_role="target", + ) + core_path = harness.layout.host(setup.CORE_CONFIG) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + approval = json.loads(approval_path.read_text(encoding="utf-8")) + core = json.loads(core_path.read_text(encoding="utf-8")) + source_policy = approval["approvers"][0] + source_trust = next( + trust + for trust in core["oidc_enrollment"]["trusted_approvers"] + if trust["principal_id"] == source_policy["principal_id"] + ) + if tamper == "source-authority": + source_policy["authority_kind"] = "guest" + source_trust["authority_kind"] = "guest" + elif tamper == "source-purpose": + source_policy["allowed_purposes"].append("authorization.unrelated.approve") + source_trust["allowed_purposes"].append("authorization.unrelated.approve") + else: + source_policy["oidc_subject"] = "unrelated-source-subject" + _private_json(approval_path, approval) + _private_json(core_path, core) + protected_paths = ( + approval_path, + core_path, + harness.layout.host(setup.CORE_OIDC_CONFIG), + harness.marker_path, + ) + before = {path: path.read_bytes() for path in protected_paths} + signer_root = recovery_path.parent / "signers" + signers_before = { + path.name: path.read_bytes() for path in sorted(signer_root.iterdir()) + } + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + monkeypatch.setattr( + setup, + "_require_exact_approval_policy", + real_require_exact_approval_policy, + ) + real_atomic_write = setup._atomic_write + recovery_writes: list[Path] = [] + + def record_atomic_write( + path: Path, + payload: bytes, + *, + mode: int, + uid: int = 0, + gid: int = 0, + ) -> str: + if path == recovery_path: + recovery_writes.append(path) + return real_atomic_write(path, payload, mode=mode, uid=uid, gid=gid) + + monkeypatch.setattr(setup, "_atomic_write", record_atomic_write) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker in { + "approval_conflict", + "canonical_owner_recovery", + "setup_upgrade_conflict", + } + assert {path: path.read_bytes() for path in protected_paths} == before + assert { + path.name: path.read_bytes() for path in sorted(signer_root.iterdir()) + } == signers_before + assert not recovery_path.exists() + assert not harness.journal_path.exists() + assert recovery_writes == [] + + +@pytest.mark.parametrize( + "purpose_order_rewritten", + (False, True), + ids=("original-purpose-order", "reserialized-purpose-order"), +) +@pytest.mark.parametrize( + "communication_scope_ttl_present", + (False, True), + ids=("scope-ttl-absent", "scope-ttl-explicit"), +) +def test_0151_upgrade_reconstructs_journalless_completed_owner_repair( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + communication_scope_ttl_present: bool, + purpose_order_rewritten: bool, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, repaired_payload = ( + _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness, + include_journal=False, + communication_scope_ttl_present=communication_scope_ttl_present, + ) + ) + if purpose_order_rewritten: + _rewrite_repaired_policy_purpose_order(harness, config_path) + repaired_payload = config_path.read_bytes() + approval_state = harness.layout.host(setup.APPROVAL_STATE) + recovery_path = approval_state / "canonical-owner-recovery.json" + assert not recovery_path.exists() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + original_run_as = setup._run_as + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + harness.operation_events.append(("product", stage)) + return {"status": "already_exact"} + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_run_as", run_as) + monkeypatch.setattr( + setup, + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: { + "actor": { + "principal_id": harness.request.approval_approver_principal_id + } + }, + ) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + reconstructed = json.loads(recovery_path.read_text(encoding="utf-8")) + assert config_path.read_bytes() != repaired_payload + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert reconstructed["phase"] == "complete" + assert reconstructed["reconstruction"]["schema"] == ( + "agentnet.canonical-owner-recovery-reconstruction.v1" + ) + assert { + "id": "canonical_owner_recovery", + "status": "already_exact", + "source_principal_id": "setup-placeholder-owner", + "target_principal_id": harness.request.approval_approver_principal_id, + "core_policy_status": "already_satisfied", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +@pytest.mark.parametrize( + "tamper_after_upgrade_journal", + ( + None, + "core", + "core_oidc", + "binding", + "extra_signer", + "source_signer", + ), +) +def test_0151_resume_revalidates_reconstructed_journal_before_ttl_migration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tamper_after_upgrade_journal: str | None, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, repaired_payload = ( + _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness, + include_journal=False, + ) + ) + approval_state = harness.layout.host(setup.APPROVAL_STATE) + recovery_path = approval_state / "canonical-owner-recovery.json" + marker_before = harness.marker_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + original_run_as = setup._run_as + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + harness.operation_events.append(("product", stage)) + return {"status": "already_exact"} + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_run_as", run_as) + monkeypatch.setattr( + setup, + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: { + "actor": { + "principal_id": harness.request.approval_approver_principal_id + } + }, + ) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + real_atomic_write = setup._atomic_write + real_write_upgrade_journal = setup._write_upgrade_journal + interrupted = False + + def interrupt_after_reconstruction( + path: Path, + payload: bytes, + *, + mode: int, + uid: int, + gid: int, + ) -> str: + nonlocal interrupted + result = real_atomic_write( + path, + payload, + mode=mode, + uid=uid, + gid=gid, + ) + if path == recovery_path and not interrupted: + interrupted = True + raise RuntimeError("injected reconstructed-journal process loss") + return result + + monkeypatch.setattr(setup, "_atomic_write", interrupt_after_reconstruction) + with pytest.raises( + RuntimeError, + match="injected reconstructed-journal process loss", + ): + harness.apply(harness.plan_digest()) + + assert interrupted + assert recovery_path.exists() + assert harness.marker_path.read_bytes() == marker_before + assert config_path.read_bytes() == repaired_payload + + monkeypatch.setattr(setup, "_atomic_write", real_atomic_write) + if tamper_after_upgrade_journal is not None: + interrupted = False + + def interrupt_after_upgrade_journal( + path: Path, + journal: dict[str, object], + *, + uid: int, + gid: int, + ) -> None: + nonlocal interrupted + real_write_upgrade_journal(path, journal, uid=uid, gid=gid) + if path == harness.journal_path and not interrupted: + interrupted = True + raise RuntimeError("injected setup-upgrade journal process loss") + + monkeypatch.setattr( + setup, + "_write_upgrade_journal", + interrupt_after_upgrade_journal, + ) + with pytest.raises( + RuntimeError, + match="injected setup-upgrade journal process loss", + ): + harness.apply(harness.plan_digest()) + assert interrupted + assert harness.journal_path.exists() + assert config_path.read_bytes() == repaired_payload + if tamper_after_upgrade_journal == "core": + core_path = harness.layout.host(setup.CORE_CONFIG) + core = json.loads(core_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["client_id"] = "unrelated-client" + _private_json(core_path, core) + elif tamper_after_upgrade_journal == "core_oidc": + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core_oidc = json.loads(core_oidc_path.read_text(encoding="utf-8")) + core_oidc["client_id"] = "unrelated-client" + _private_json(core_oidc_path, core_oidc) + elif tamper_after_upgrade_journal == "binding": + approval = json.loads(config_path.read_text(encoding="utf-8")) + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher( + Path(approval["record_key_path"]).read_bytes() + ), + ) + try: + with store.transaction() as connection: + connection.execute( + """UPDATE approval_owner_bindings + SET pinned_at=pinned_at+1 + WHERE domain_id=? AND status='active'""", + (harness.request.domain_id,), + ) + finally: + store.close() + elif tamper_after_upgrade_journal in {"extra_signer", "source_signer"}: + approval = json.loads(config_path.read_text(encoding="utf-8")) + target_signer_path = Path( + approval["approvers"][0]["signer_private_key_path"] + ) + if tamper_after_upgrade_journal == "extra_signer": + changed_signer_path = target_signer_path.with_name( + "unrelated-owner.pem" + ) + else: + changed_signer_path = next( + path + for path in target_signer_path.parent.iterdir() + if path != target_signer_path + ) + changed_signer_path.write_bytes(P256KeyPair.generate().private_pem) + changed_signer_path.chmod(0o600) + monkeypatch.setattr( + setup, + "_write_upgrade_journal", + real_write_upgrade_journal, + ) + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + assert exc_info.value.blocker == ( + "setup_upgrade_conflict" + if tamper_after_upgrade_journal in {"core", "core_oidc"} + else "canonical_owner_recovery" + ) + assert config_path.read_bytes() == repaired_payload + assert harness.marker_path.read_bytes() == marker_before + return + + result = harness.apply(harness.plan_digest()) + + migrated = json.loads(config_path.read_text(encoding="utf-8")) + reconstructed = json.loads(recovery_path.read_text(encoding="utf-8")) + assert reconstructed["phase"] == "complete" + assert reconstructed["reconstruction"]["schema"] == ( + "agentnet.canonical-owner-recovery-reconstruction.v1" + ) + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert { + "id": "canonical_owner_recovery", + "status": "already_exact", + "source_principal_id": "setup-placeholder-owner", + "target_principal_id": harness.request.approval_approver_principal_id, + "core_policy_status": "already_satisfied", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +@pytest.mark.parametrize( + "tamper", + ( + "extra_signer", + "source_signer", + "approval_purpose", + "core_purpose", + "marker_approval_digest", + "marker_core_digest", + "core_oidc_sidecar", + "core_oidc_sidecar_duplicate_purpose", + "core_policy", + "source_core", + "source_core_with_journal", + "binding_pinned_at", + "unit", + ), +) +def test_0151_journalless_reconstruction_rejects_ambiguous_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tamper: str, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, repaired_payload = ( + _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness, + include_journal=tamper == "source_core_with_journal", + current_core_target=not tamper.startswith("source_core"), + ) + ) + approval = json.loads(config_path.read_text(encoding="utf-8")) + target_signer_path = Path( + approval["approvers"][0]["signer_private_key_path"] + ) + source_signer_path = next( + ( + path + for path in target_signer_path.parent.iterdir() + if path != target_signer_path + ), + None, + ) + expected_approval_payload = repaired_payload + if tamper == "extra_signer": + assert source_signer_path is not None + extra_signer_path = target_signer_path.with_name("unrelated-owner.pem") + extra_signer_path.write_bytes(P256KeyPair.generate().private_pem) + extra_signer_path.chmod(0o600) + elif tamper == "source_signer": + assert source_signer_path is not None + source_signer_path.write_bytes(P256KeyPair.generate().private_pem) + source_signer_path.chmod(0o600) + elif tamper == "approval_purpose": + approval["approvers"][0]["allowed_purposes"].append( + "unrelated.approve" + ) + _private_json(config_path, approval) + expected_approval_payload = config_path.read_bytes() + elif tamper == "core_purpose": + expected_approval_payload = config_path.read_bytes() + core_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core = json.loads(core_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["trusted_approvers"][0][ + "allowed_purposes" + ].append("unrelated.approve") + _private_json(core_path, core) + _private_json(core_oidc_path, core["oidc_enrollment"]) + elif tamper == "marker_approval_digest": + marker = harness.marker() + marker["approval_config_digest"] = "a" * 64 + _private_json(harness.marker_path, marker) + elif tamper == "marker_core_digest": + marker = harness.marker() + marker["core_config_digest"] = "a" * 64 + _private_json(harness.marker_path, marker) + elif tamper == "core_oidc_sidecar": + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core_oidc = json.loads(core_oidc_path.read_text(encoding="utf-8")) + core_oidc["client_id"] = "unrelated-client" + _private_json(core_oidc_path, core_oidc) + elif tamper == "core_oidc_sidecar_duplicate_purpose": + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core_oidc = json.loads(core_oidc_path.read_text(encoding="utf-8")) + core_oidc["trusted_approvers"][0]["allowed_purposes"].append( + core_oidc["trusted_approvers"][0]["allowed_purposes"][0] + ) + _private_json(core_oidc_path, core_oidc) + elif tamper == "core_policy": + core_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + core = json.loads(core_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["client_id"] = "unrelated-client" + _private_json(core_path, core) + _private_json(core_oidc_path, core["oidc_enrollment"]) + elif tamper == "binding_pinned_at": + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher(Path(approval["record_key_path"]).read_bytes()), + ) + try: + with store.transaction() as connection: + connection.execute( + """UPDATE approval_owner_bindings SET pinned_at=pinned_at+1 + WHERE domain_id=? AND status='active'""", + (harness.request.domain_id,), + ) + finally: + store.close() + elif tamper == "unit": + unit_path = harness.layout.unit(setup.CORE_UNIT) + unit_path.write_bytes(unit_path.read_bytes() + b"\n# unrelated drift\n") + elif not tamper.startswith("source_core"): + raise AssertionError(tamper) + + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == ( + "setup_upgrade_conflict" + if tamper == "unit" + else "canonical_owner_recovery" + ) + assert config_path.read_bytes() == expected_approval_payload + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + assert recovery_path.exists() is (tamper == "source_core_with_journal") + + +def test_0151_rejects_tampered_reconstructed_marker_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness, + include_journal=False, + ) + account = pwd.getpwuid(os.geteuid()) + approval_state = harness.layout.host(setup.APPROVAL_STATE) + recovery_path = approval_state / "canonical-owner-recovery.json" + reconstructed = getattr( + setup, + "_reconstruct_completed_canonical_owner_recovery_for_marker", + )( + harness.marker(), + approval_state, + config_path, + account, + harness.layout.host(setup.CORE_CONFIG), + harness.layout.host(setup.CORE_OIDC_CONFIG), + account, + request=harness.request, + observed_at=42, + before_write=lambda: None, + ) + assert reconstructed is not None + journal = json.loads(recovery_path.read_text(encoding="utf-8")) + journal["reconstruction"]["marker_core_config_digest"] = "a" * 64 + _private_json(recovery_path, journal) + monkeypatch.setattr(setup, "__version__", "0.1.51") + + with pytest.raises(ServerSetupError) as exc_info: + getattr(setup, "_upgrade_marker_config_digests")( + harness.marker(), + approval_config_path=config_path, + approval_account=account, + approval_state=approval_state, + core_config_path=harness.layout.host(setup.CORE_CONFIG), + core_account=account, + core_oidc_path=harness.layout.host(setup.CORE_OIDC_CONFIG), + request=harness.request, + ) + + assert exc_info.value.blocker == "canonical_owner_recovery" + + + + +@pytest.mark.parametrize( + ("tamper", "expected_blocker"), + ( + ("journal_target", "canonical_owner_recovery"), + ("journal_request_digest", "canonical_owner_recovery"), + ("journal_oidc_issuer", "canonical_owner_recovery"), + ("journal_signer_path", "canonical_owner_recovery"), + ("journal_target_signer_path", "canonical_owner_recovery"), + ("journal_source_config_sha256", "canonical_owner_recovery"), + ("binding_verified_email", "canonical_owner_recovery"), + ("binding_pinned_at", "canonical_owner_recovery"), + ("binding_second_active", "canonical_owner_recovery"), + ("target_credential_removed", "canonical_owner_recovery"), + ("source_credential_active", "canonical_owner_recovery"), + ("adoption_audit_removed", "canonical_owner_recovery"), + ("approval_policy", "canonical_owner_recovery"), + ("core_policy", "canonical_owner_recovery"), + ), +) +def test_0151_rejects_combined_recovery_evidence_or_state_drift( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tamper: str, + expected_blocker: str, +) -> None: + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness + ) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + core_config_path = harness.layout.host(setup.CORE_CONFIG) + if tamper == "journal_target": + recovery = json.loads(recovery_path.read_text(encoding="utf-8")) + recovery["target_principal_id"] = "unrelated-owner" + _private_json(recovery_path, recovery) + elif tamper.startswith("journal_"): + recovery = json.loads(recovery_path.read_text(encoding="utf-8")) + journal_field = tamper.removeprefix("journal_") + recovery[journal_field] = ( + "b" * 64 + if journal_field in {"request_digest", "source_config_sha256"} + else str(tmp_path / "unrelated.pem") + if journal_field in {"signer_path", "target_signer_path"} + else "https://unrelated.example" + ) + _private_json(recovery_path, recovery) + elif tamper.startswith("binding_") or tamper in { + "target_credential_removed", + "source_credential_active", + "adoption_audit_removed", + }: + approval = json.loads(config_path.read_text(encoding="utf-8")) + record_key = Path(approval["record_key_path"]).read_bytes() + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher(record_key), + ) + try: + with store.transaction() as connection: + if tamper == "binding_verified_email": + connection.execute( + "UPDATE approval_owner_bindings SET verified_email=?", + ("other-owner@corp.example",), + ) + elif tamper == "binding_pinned_at": + connection.execute( + "UPDATE approval_owner_bindings SET pinned_at=?", + (2,), + ) + elif tamper == "binding_second_active": + connection.execute( + """INSERT INTO approval_owner_bindings( + binding_id,domain_id,approver_principal_id,oidc_issuer, + oidc_subject,verified_email,pin_source,status,pinned_at, + revoked_at,revocation_reason + ) VALUES(?,?,?,'https://other.example','other-subject', + 'other-owner@corp.example','exact_subject','active', + 2,NULL,NULL)""", + ( + "ambiguous-owner-binding", + harness.request.domain_id, + "ambiguous-owner", + ), + ) + elif tamper == "target_credential_removed": + connection.execute( + """DELETE FROM approval_webauthn_credentials + WHERE approver_principal_id=? AND domain_id=? + AND status='active'""", + ( + harness.request.approval_approver_principal_id, + harness.request.domain_id, + ), + ) + elif tamper == "source_credential_active": + connection.execute( + """INSERT INTO approval_webauthn_credentials( + credential_id_b64,approver_principal_id,domain_id, + user_handle_b64,credential_public_key_b64,sign_count, + device_type,backed_up,status,created_at,revoked_at, + revocation_reason + ) + SELECT 'stale-source-credential','setup-placeholder-owner', + domain_id,user_handle_b64,credential_public_key_b64, + sign_count,device_type,backed_up,status,created_at, + revoked_at,revocation_reason + FROM approval_webauthn_credentials + WHERE approver_principal_id=? AND domain_id=? + AND status='active' LIMIT 1""", + ( + harness.request.approval_approver_principal_id, + harness.request.domain_id, + ), + ) + else: + connection.execute( + """DELETE FROM approval_audit + WHERE action='owner.canonical_adoption'""" + ) + finally: + store.close() + elif tamper == "approval_policy": + approval = json.loads(config_path.read_text(encoding="utf-8")) + approval["rp_name"] = "Unrelated Approval" + _private_json(config_path, approval) + else: + core = json.loads(core_config_path.read_text(encoding="utf-8")) + core["oidc_enrollment"]["client_id"] = "unrelated-client" + core_config_path.write_text( + json.dumps(core, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + core_config_path.chmod(0o600) + marker_before = harness.marker_path.read_bytes() + approval_before = config_path.read_bytes() + core_before = core_config_path.read_bytes() + recovery_before = recovery_path.read_bytes() + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == expected_blocker + assert harness.marker_path.read_bytes() == marker_before + assert config_path.read_bytes() == approval_before + assert core_config_path.read_bytes() == core_before + assert recovery_path.read_bytes() == recovery_before + assert not harness.journal_path.exists() + + +def test_0151_rejects_completed_owner_repair_without_retained_one_hour_hotfix( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness + ) + approval = json.loads(config_path.read_text(encoding="utf-8")) + approval["request_ttl_seconds"] = 300 + _private_json(config_path, approval) + core_config_path = harness.layout.host(setup.CORE_CONFIG) + marker_before = harness.marker_path.read_bytes() + approval_before = config_path.read_bytes() + core_before = core_config_path.read_bytes() + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "setup_upgrade_conflict" + assert harness.marker_path.read_bytes() == marker_before + assert config_path.read_bytes() == approval_before + assert core_config_path.read_bytes() == core_before + assert not harness.journal_path.exists() + + +def test_0151_rejects_unrecorded_approval_drift_beyond_one_hour_hotfix( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_one_hour_approval_hotfix(harness) + drifted_document = json.loads(config_path.read_text(encoding="utf-8")) + published_document = dict(drifted_document) + published_document["request_ttl_seconds"] = 300 + _private_json(config_path, published_document) + marker = harness.marker() + marker["approval_config_digest"] = setup._managed_config_digest( + config_path, + SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + blocker="approval_config", + ) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + drifted_document["receipt_ttl_seconds"] = 301 + _private_json(config_path, drifted_document) + source_payload = config_path.read_bytes() + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "setup_upgrade_conflict" + assert config_path.read_bytes() == source_payload + assert harness.marker()["package_version"] == "0.1.50" + assert not harness.journal_path.exists() + + + +def test_0151_rejects_one_hour_approval_ttl_from_other_source_release( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.49") + harness.apply(harness.plan_digest()) + config_path, source_payload = _stage_0150_one_hour_approval_hotfix(harness) + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "approval_config" + assert config_path.read_bytes() == source_payload + assert harness.marker()["package_version"] == "0.1.49" + assert not harness.journal_path.exists() + + +def test_0151_preserves_published_0150_approval_ttl_policy( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_one_hour_approval_hotfix(harness) + source = json.loads(config_path.read_text(encoding="utf-8")) + source["request_ttl_seconds"] = 300 + _private_json(config_path, source) + marker = harness.marker() + marker["approval_config_digest"] = setup._managed_config_digest( + config_path, + SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + blocker="approval_config", + ) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + source_payload = config_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + result = harness.apply(harness.plan_digest()) + + assert config_path.read_bytes() == source_payload + assert { + "id": "approval_request_ttl_policy_upgrade", + "status": "already_satisfied", + } in result["steps"] + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +def test_0151_rejects_already_shortened_ttl_as_upgrade_source( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_one_hour_approval_hotfix(harness) + source = json.loads(config_path.read_text(encoding="utf-8")) + source["request_ttl_seconds"] = 600 + _private_json(config_path, source) + marker = harness.marker() + marker["approval_config_digest"] = setup._managed_config_digest( + config_path, + SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + blocker="approval_config", + ) + harness.marker_path.write_bytes( + json.dumps(marker, sort_keys=True, separators=(",", ":")).encode() + b"\n" + ) + harness.marker_path.chmod(0o600) + source_payload = config_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "approval_config" + assert config_path.read_bytes() == source_payload + assert harness.marker()["package_version"] == "0.1.50" + assert not harness.journal_path.exists() + + +def test_0151_approval_ttl_migration_rolls_back_before_marker_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, source_payload = _stage_0150_one_hour_approval_hotfix(harness) + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + monkeypatch.setattr( + setup, + "_commit_setup_marker", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ServerSetupError("injected_failure", "injected post-TTL-migration failure") + ), + ) + + with pytest.raises(ServerSetupError, match="injected post-TTL-migration failure"): + harness.apply(harness.plan_digest()) + + assert config_path.read_bytes() == source_payload + assert harness.marker()["package_version"] == "0.1.50" + assert not harness.journal_path.exists() + + +def test_0151_approval_ttl_migration_resumes_after_process_loss( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, source_payload = _stage_0150_one_hour_approval_hotfix(harness) + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + original_commit = setup._commit_setup_marker + original_rollback = setup._rollback_pending_upgrade + monkeypatch.setattr(setup, "_rollback_pending_upgrade", lambda _pending: None) + monkeypatch.setattr( + setup, + "_commit_setup_marker", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("injected post-TTL-migration process loss") + ), + ) + + with pytest.raises(RuntimeError, match="injected post-TTL-migration process loss"): + harness.apply(harness.plan_digest()) + + assert config_path.read_bytes() != source_payload + journal = json.loads(harness.journal_path.read_text(encoding="utf-8")) + assert journal["schema"] == "agentnet.server-setup.upgrade-journal.v3" + assert set(journal["previous_configs"]) == { + "approval_config", + "core_config", + "core_oidc_config", + } + + monkeypatch.setattr(setup, "_rollback_pending_upgrade", original_rollback) + monkeypatch.setattr(setup, "_commit_setup_marker", original_commit) + resumed = harness.apply(harness.plan_digest()) + + assert { + "id": "package_upgrade", + "status": "resumed_journaled_upgrade", + } in resumed["steps"] + migrated = json.loads(config_path.read_text(encoding="utf-8")) + assert migrated["request_ttl_seconds"] == 600 + assert migrated["communication_scope_request_ttl_seconds"] == 3_600 + assert harness.marker()["package_version"] == "0.1.51" + assert not harness.journal_path.exists() + + +def test_0151_resume_revalidates_owner_recovery_before_ttl_migration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_approval_trust = setup._approval_trust + harness = _harness(tmp_path, monkeypatch) + monkeypatch.setattr(setup, "__version__", "0.1.50") + harness.apply(harness.plan_digest()) + config_path, _ = _stage_0150_completed_owner_repair_and_one_hour_hotfix( + harness + ) + recovery_path = ( + harness.layout.host(setup.APPROVAL_STATE) + / "canonical-owner-recovery.json" + ) + recovery_before = recovery_path.read_bytes() + monkeypatch.setattr(setup, "_approval_trust", real_approval_trust) + original_run_as = setup._run_as + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + return {"status": "already_exact"} + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_run_as", run_as) + monkeypatch.setattr( + setup, + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: { + "actor": { + "principal_id": harness.request.approval_approver_principal_id + } + }, + ) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + original_commit = setup._commit_setup_marker + original_rollback = setup._rollback_pending_upgrade + monkeypatch.setattr(setup, "_rollback_pending_upgrade", lambda _pending: None) + monkeypatch.setattr( + setup, + "_commit_setup_marker", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("injected combined-recovery process loss") + ), + ) + + with pytest.raises(RuntimeError, match="injected combined-recovery process loss"): + harness.apply(harness.plan_digest()) + + assert harness.journal_path.exists() + approval = json.loads(config_path.read_text(encoding="utf-8")) + store = ApprovalStore( + Path(approval["database_path"]), + LocalEnvelopeCipher(Path(approval["record_key_path"]).read_bytes()), + ) + try: + with store.transaction() as connection: + connection.execute( + "UPDATE approval_owner_bindings SET verified_email=?", + ("drifted-owner@corp.example",), + ) + finally: + store.close() + real_migrate = getattr(setup, "_migrate_0150_approval_request_ttl_policy") + migrations: list[bool] = [] + + def tracked_migrate(**kwargs: object) -> str: + migrations.append(True) + return real_migrate(**kwargs) # type: ignore[arg-type] + + monkeypatch.setattr( + setup, + "_migrate_0150_approval_request_ttl_policy", + tracked_migrate, + ) + monkeypatch.setattr(setup, "_rollback_pending_upgrade", original_rollback) + monkeypatch.setattr(setup, "_commit_setup_marker", original_commit) + + with pytest.raises(ServerSetupError) as exc_info: + harness.apply(harness.plan_digest()) + + assert exc_info.value.blocker == "canonical_owner_recovery" + assert migrations == [] + assert recovery_path.read_bytes() == recovery_before + assert harness.marker()["package_version"] == "0.1.50" + + +def test_0151_rejects_direct_upgrade_from_pre_lifecycle_release( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(setup, "__version__", "0.1.51") + payload = _marker_payload( + schema="agentnet.server-setup.marker.v3", + package_version="0.1.44", + artifact_mode="disabled", + ) + + with pytest.raises(ServerSetupError) as exc_info: + setup._validated_setup_marker( + payload, + request_digest="9" * 64, + legacy_request_digest="1" * 64, + artifact_mode="disabled", + ) + + assert exc_info.value.blocker == "setup_marker_conflict" + +@pytest.mark.parametrize( + ("package_version", "current_version"), + [ + ("0.1.29", "0.1.31"), # never released with a runtime-bound digest + ("0.1.31", "0.1.31"), # same version: the request itself changed + ("0.1.32", "0.1.31"), # downgrade + ("garbage", "0.1.31"), + ("0.1.30", "0.1.32"), # unsupported upgrade target + ("0.1.30", "0.1.30"), + ], +) +def test_marker_rejects_every_unsupported_request_digest_drift( + monkeypatch: pytest.MonkeyPatch, + package_version: str, + current_version: str, +) -> None: + monkeypatch.setattr(setup, "__version__", current_version) + payload = _marker_payload( + schema="agentnet.server-setup.marker.v3", + package_version=package_version, + artifact_mode="disabled", + ) + with pytest.raises(ServerSetupError) as exc_info: + setup._validated_setup_marker( + payload, + request_digest="9" * 64, + legacy_request_digest="1" * 64, + artifact_mode="disabled", + ) + assert exc_info.value.blocker == "setup_marker_conflict" + + +def test_marker_upgrade_still_rejects_malformed_recorded_digest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(setup, "__version__", "0.1.31") + payload = _marker_payload( + schema="agentnet.server-setup.marker.v3", + package_version="0.1.30", + artifact_mode="disabled", + request_digest="not-a-digest", + ) + with pytest.raises(ServerSetupError) as exc_info: + setup._validated_setup_marker( + payload, + request_digest="9" * 64, + legacy_request_digest="1" * 64, + artifact_mode="disabled", + ) + assert exc_info.value.blocker == "setup_marker_conflict" @pytest.mark.parametrize( @@ -1226,6 +3388,269 @@ def test_0149_upgrade_accepts_exact_0148_five_unit_profile( assert marker["units"] == list(setup.MANAGED_UNITS) +@pytest.mark.parametrize("target_matches_identity", [True, False]) +def test_0151_upgrade_converges_placeholder_approval_owner( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + target_matches_identity: bool, +) -> None: + harness = _harness(tmp_path, monkeypatch) + + source_principal = "setup-placeholder-owner" + setup_request_path = tmp_path / "setup.json" + approvers_path = tmp_path / "approvers.json" + target_request_document = json.loads(setup_request_path.read_text(encoding="utf-8")) + target_approvers_document = json.loads(approvers_path.read_text(encoding="utf-8")) + source_request_document = copy.deepcopy(target_request_document) + source_approvers_document = copy.deepcopy(target_approvers_document) + source_request_document["approval_approver_principal_id"] = source_principal + source_approvers_document["approvers"][0]["principal_id"] = source_principal + _private_json(setup_request_path, source_request_document) + _private_json(approvers_path, source_approvers_document) + harness.request = load_server_setup_request(setup_request_path) + source_signer = P256KeyPair.generate() + target_signer = P256KeyPair.generate() + recovered = {"value": False} + approval_config = SimpleNamespace() + + def approval_trust(*_args: object, **_kwargs: object) -> tuple[object, list[IndependentApproverConfig]]: + signer = target_signer if recovered["value"] else source_signer + principal = ( + harness.request.approval_approver_principal_id + if recovered["value"] + else source_principal + ) + return approval_config, [ + IndependentApproverConfig( + principal_id=principal, + authority_kind="human", + signer_key_id=signer.thumbprint, + public_key_pem=signer.public_pem, + allowed_purposes=MANDATORY_APPROVAL_PURPOSES, + ) + ] + monkeypatch.setattr(setup, "_approval_trust", approval_trust) + monkeypatch.setattr(setup, "__version__", "0.1.49") + harness.apply(harness.plan_digest()) + _private_json(setup_request_path, target_request_document) + _private_json(approvers_path, target_approvers_document) + harness.request = load_server_setup_request(setup_request_path) + + def require_policy( + *_args: object, + allow_canonical_owner_adoption: bool = False, + **_kwargs: object, + ) -> str | None: + if recovered["value"]: + return None + if allow_canonical_owner_adoption: + return source_principal + raise ServerSetupError("approval_conflict", "placeholder owner remains active") + + seen: dict[str, object] = {} + original_run_as = setup._run_as + + def run_as( + account: pwd.struct_passwd, + argv: list[str], + *, + environment: dict[str, str], + stage: str, + accepted_returncodes: frozenset[int] = frozenset({0}), + ) -> dict[str, object]: + if argv[2:4] == ["approval", "recover-canonical-owner"]: + seen.update( + { + "account": account, + "argv": argv, + "environment": environment, + "stage": stage, + } + ) + harness.operation_events.append(("product", stage)) + recovered["value"] = True + return {"status": "recovered"} + return original_run_as( + account, + argv, + environment=environment, + stage=stage, + accepted_returncodes=accepted_returncodes, + ) + + monkeypatch.setattr(setup, "_approval_trust", approval_trust) + monkeypatch.setattr(setup, "_require_exact_approval_policy", require_policy) + monkeypatch.setattr(setup, "_run_as", run_as) + def migrate_core_policy( + *, + core_config_path: Path, + core_oidc_path: Path, + core_account: object, + source_oidc: object, + target_oidc: object, + pending: dict[str, object], + ) -> str: + del core_config_path, core_account, source_oidc, pending + target_payload = ( + json.dumps(target_oidc.model_dump(mode="json"), indent=2, sort_keys=True).encode() + + b"\n" + ) + setup._write_journaled_core_config( + core_oidc_path, + target_payload, + account=SimpleNamespace(pw_uid=os.geteuid(), pw_gid=os.getegid()), + previous=core_oidc_path.read_bytes(), + ) + return "updated_package_upgrade" + + monkeypatch.setattr( + setup, + "_migrate_canonical_owner_core_policy", + migrate_core_policy, + ) + + enrolled_target = ( + harness.request.approval_approver_principal_id + if target_matches_identity + else "different-enrolled-owner" + ) + monkeypatch.setattr( + setup, + "_validated_managed_identity_profile", + lambda *_args, **_kwargs: {"actor": {"principal_id": enrolled_target}}, + ) + + upgrade_event_offset = len(harness.operation_events) + harness.install_new_package_runtime() + monkeypatch.setattr(setup, "__version__", "0.1.51") + if not target_matches_identity: + with pytest.raises( + ServerSetupError, + match="recovery target does not match enrolled identity", + ): + harness.apply(harness.plan_digest()) + assert seen == {} + return + result = harness.apply(harness.plan_digest()) + + argv = seen["argv"] + assert isinstance(argv, list) + assert argv[argv.index("--source-principal") + 1] == source_principal + assert ( + argv[argv.index("--target-principal") + 1] + == harness.request.approval_approver_principal_id + ) + assert argv[argv.index("--config") + 1] == str( + harness.layout.host(setup.APPROVAL_CONFIG) + ) + assert seen["stage"] == "canonical_owner_recovery" + assert { + "id": "canonical_owner_recovery", + "status": "recovered", + "source_principal_id": source_principal, + "target_principal_id": harness.request.approval_approver_principal_id, + "core_policy_status": "updated_package_upgrade", + } in result["steps"] + upgrade_events = harness.operation_events[upgrade_event_offset:] + quiesced = upgrade_events.index( + ("systemctl", ("disable", "--now", setup.APPROVAL_UNIT)) + ) + recovered_event = upgrade_events.index(("product", "canonical_owner_recovery")) + assert quiesced < recovered_event + assert harness.marker()["package_version"] == "0.1.51" + + +def test_canonical_owner_core_policy_cutover_resumes_partial_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _harness(tmp_path, monkeypatch) + harness.apply(harness.plan_digest()) + core_config_path = harness.layout.host(setup.CORE_CONFIG) + core_oidc_path = harness.layout.host(setup.CORE_OIDC_CONFIG) + target_oidc = OIDCEnrollmentConfig.model_validate_json( + core_oidc_path.read_text(encoding="utf-8") + ) + target_approver = target_oidc.trusted_approvers[0] + source_signer = P256KeyPair.generate() + source_principal = "setup-placeholder-owner" + source_approver = target_approver.model_copy( + update={ + "principal_id": source_principal, + "signer_key_id": source_signer.thumbprint, + "public_key_pem": source_signer.public_pem, + } + ) + assert target_oidc.approval_service is not None + source_oidc = target_oidc.model_copy( + update={ + "trusted_approvers": (source_approver,), + "approval_service": target_oidc.approval_service.model_copy( + update={"approver_principal_id": source_principal} + ), + } + ) + source_oidc_payload = ( + json.dumps(source_oidc.model_dump(mode="json"), indent=2, sort_keys=True).encode() + + b"\n" + ) + source_core = json.loads(core_config_path.read_text(encoding="utf-8")) + source_core["oidc_enrollment"] = source_oidc.model_dump(mode="json") + source_core_payload = ( + json.dumps(source_core, indent=2, sort_keys=True).encode() + b"\n" + ) + core_config_path.write_bytes(source_core_payload) + core_oidc_path.write_bytes(source_oidc_payload) + pending: dict[str, object] = { + "journal": { + "previous_configs": { + "core_config": base64.b64encode(source_core_payload).decode("ascii"), + "core_oidc_config": base64.b64encode(source_oidc_payload).decode("ascii"), + } + } + } + original_write = setup._write_journaled_core_config + writes = {"count": 0} + + def interrupt_second_write(*args: object, **kwargs: object) -> str: + writes["count"] += 1 + if writes["count"] == 2: + raise RuntimeError("injected Core config interruption") + return original_write(*args, **kwargs) + + migrate_core_policy = getattr(setup, "_migrate_canonical_owner_core_policy") + monkeypatch.setattr(setup, "_write_journaled_core_config", interrupt_second_write) + with pytest.raises(RuntimeError, match="injected Core config interruption"): + migrate_core_policy( + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=pwd.getpwuid(os.geteuid()), + source_oidc=source_oidc, + target_oidc=target_oidc, + pending=pending, + ) + assert json.loads(core_config_path.read_text(encoding="utf-8"))[ + "oidc_enrollment" + ] == target_oidc.model_dump(mode="json") + assert OIDCEnrollmentConfig.model_validate_json( + core_oidc_path.read_text(encoding="utf-8") + ) == source_oidc + + monkeypatch.setattr(setup, "_write_journaled_core_config", original_write) + status = migrate_core_policy( + core_config_path=core_config_path, + core_oidc_path=core_oidc_path, + core_account=pwd.getpwuid(os.geteuid()), + source_oidc=source_oidc, + target_oidc=target_oidc, + pending=pending, + ) + assert status == "updated_package_upgrade" + assert OIDCEnrollmentConfig.model_validate_json( + core_oidc_path.read_text(encoding="utf-8") + ) == target_oidc + + def test_0146_replaces_released_timer_without_resetting_server_state( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/production/test_postgres_runtime.py b/tests/production/test_postgres_runtime.py index 3375db6..98bcff3 100644 --- a/tests/production/test_postgres_runtime.py +++ b/tests/production/test_postgres_runtime.py @@ -8,16 +8,31 @@ from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta from pathlib import Path +from types import SimpleNamespace from uuid import uuid4 import pytest import httpx +import agentnet.authorization.communication_scope_service as communication_scope_module import psycopg +from agentnet.approval import ( + IndependentApprovalVerifier, + TrustedApprover, + create_independent_approval_receipt, +) +from agentnet.authorization.communication_scope import ( + COMMUNICATION_SCOPE_APPROVAL_PURPOSE, +) from agentnet.authorization.communication_scope_service import ( COLLABORATION_SCOPE_ISSUE_ACTION, CollaborationScopeProposal, CollaborationScopeService, + CommunicationScopeService, +) +from agentnet.authorization.scope_harness_replacement import ( + SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + ScopeHarnessReplacementService, ) from agentnet.authorization.evidence import IssuanceAuthority from agentnet.authorization.policy import ( @@ -80,6 +95,14 @@ ) from agentnet.storage.recovery import probe_filesystem_artifact_recovery from agentnet.storage.sqlite import SQLiteStore +from tests.authorization.test_communication_scope_service import ( + FakeApprovalClient, + MutableResolver, + NOW as COMMUNICATION_NOW, + _begin as begin_communication_scope, + _complete as complete_communication_scope, + _status as communication_scope_status, +) def server_config(tmp_path: Path, *, instance_id: str = "server-agent-test") -> ExtensionConfig: @@ -1711,6 +1734,145 @@ def test_singleton_lease_requires_expiry_before_different_owner_takeover() -> No second.close() + +class _OneHeartbeatThenStop: + def __init__(self) -> None: + self.calls = 0 + self.stopped = False + + def wait(self, _timeout: float) -> bool: + self.calls += 1 + return self.calls > 1 + + def set(self) -> None: + self.stopped = True + + + + +class _StoppedKeeper: + def is_alive(self) -> bool: + return False + + def join(self, *, timeout: float) -> None: + del timeout + + +class _RestartedKeeper: + def __init__(self, *, target, name: str, daemon: bool) -> None: + self.target = target + self.name = name + self.daemon = daemon + self.started = False + + def is_alive(self) -> bool: + return self.started + + def start(self) -> None: + self.started = True + + def join(self, *, timeout: float) -> None: + del timeout + + +def test_keeper_failure_blocks_protected_work_until_recovery_state_is_published( + monkeypatch: pytest.MonkeyPatch, +) -> None: + database = _LeaseDatabase() + store = _lease_store(database, _Clock(100), "server-agent-a") + heartbeat_entered = threading.Event() + operation_completed = threading.Event() + operation_crossed_failure_gap: list[bool] = [] + store._stop = _OneHeartbeatThenStop() + + def fail_heartbeat(_token) -> None: + heartbeat_entered.set() + operation_crossed_failure_gap.append(operation_completed.wait(timeout=1)) + raise GateBlocked("lease_lost", "simulated heartbeat failure") + + def run_operation() -> None: + assert heartbeat_entered.wait(timeout=1) + with store.transaction(): + pass + operation_completed.set() + + monkeypatch.setattr(store, "heartbeat_lease", fail_heartbeat) + keeper = threading.Thread(target=store._keep_lease) + operation = threading.Thread(target=run_operation) + try: + operation.start() + keeper.start() + keeper.join(timeout=2) + operation.join(timeout=2) + + assert keeper.is_alive() is False + assert operation.is_alive() is False + assert operation_crossed_failure_gap == [False] + assert store._reconnect_required is False + assert database.connect_count == 2 + finally: + store.close() + + +def test_expired_keeper_lease_recovers_on_next_operation_with_higher_fence() -> None: + database = _LeaseDatabase() + clock = _Clock(100) + store = _lease_store(database, clock, "server-agent-a") + initial_fence = store._lease.fence + stop = _OneHeartbeatThenStop() + store._stop = stop + try: + clock.value = 131 + store._keep_lease() + + assert store._reconnect_required is True + assert store._keeper_restart_needed is True + with store.transaction(): + pass + assert database.connect_count == 2 + assert store._lease.fence > initial_fence + assert store._reconnect_required is False + assert int(database.leases[store._lease.lease_name]["expires_at"]) > clock.value + finally: + store.close() + assert stop.stopped is True + + +def test_expired_keeper_recovery_starts_new_background_keeper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + database = _LeaseDatabase() + clock = _Clock(100) + store = _lease_store(database, clock, "server-agent-a") + stop = _OneHeartbeatThenStop() + restarted: list[_RestartedKeeper] = [] + + def restart_keeper(*, target, name: str, daemon: bool) -> _RestartedKeeper: + keeper = _RestartedKeeper(target=target, name=name, daemon=daemon) + restarted.append(keeper) + return keeper + + store._stop = stop + store._start_lease_keeper = True + store._keeper = _StoppedKeeper() + monkeypatch.setattr("agentnet.storage.postgres.threading.Thread", restart_keeper) + try: + clock.value = 131 + store._keep_lease() + with store.transaction(): + pass + + assert len(restarted) == 1 + assert restarted[0].target == store._keep_lease + assert restarted[0].name == "agentnet-lease-server-agent-a" + assert restarted[0].daemon is True + assert restarted[0].started is True + assert store._keeper_restart_needed is False + finally: + store.close() + + + def test_connection_loss_is_not_retried_and_next_operation_reconnects_with_higher_fence() -> None: database = _LeaseDatabase() store = _lease_store(database, _Clock(100), "server-agent-a") @@ -2214,6 +2376,312 @@ def test_real_postgres_fresh_schema_installs_current_migration_catalog() -> None administrator.close() +@pytest.mark.skipif( + not ( + os.environ.get("AGENTNET_TEST_POSTGRES_URL") + and os.environ.get("AGENTNET_TEST_POSTGRES_ALLOW_MUTATION") == "1" + ), + reason="requires an explicitly mutation-authorized dedicated PostgreSQL test database", +) +def test_real_postgres_scope_activation_and_projection_recovery_are_atomic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + database_url = os.environ["AGENTNET_TEST_POSTGRES_URL"] + schema = f"agentnet_scope_recovery_{uuid4().hex}" + administrator = psycopg.connect(database_url, autocommit=True) + administrator.execute( + psycopg.sql.SQL("CREATE SCHEMA {}").format(psycopg.sql.Identifier(schema)) + ) + separator = "&" if "?" in database_url else "?" + isolated_url = ( + f"{database_url}{separator}options=" + f"-csearch_path%3D{schema}%20-cclient_encoding%3DUTF8" + ) + store = None + try: + store = PostgreSQLStore( + isolated_url, + LocalEnvelopeCipher(b"c" * 32), + instance_id=f"scope-recovery-{uuid4().hex}", + start_lease_keeper=False, + ) + domain_id = "scope-recovery.example" + principal_id = "scope-recovery-owner" + actor = VerifiedActor( + kind=ActorKind.VERIFIED_HUMAN_HARNESS, + domain_id=domain_id, + principal_id=principal_id, + harness_id="scope-recovery-fresh", + credential_id="scope-recovery-fresh-credential", + credential_epoch=1, + binding_assurance="os_bound", + ) + replacement_caller = VerifiedActor( + kind=ActorKind.VERIFIED_HUMAN_HARNESS, + domain_id=domain_id, + principal_id=principal_id, + harness_id="owner-harness", + credential_id="owner-credential", + credential_epoch=1, + binding_assurance="os_bound", + ) + with store.transaction() as connection: + connection.execute( + "INSERT INTO domains(domain_id,status,created_at) VALUES(?,?,?)", + (domain_id, "active", COMMUNICATION_NOW - 200), + ) + connection.execute( + """INSERT INTO principals( + principal_id,domain_id,oidc_issuer,oidc_subject,verified_email, + status,created_at + ) VALUES(?,?,?,?,?,?,?)""", + ( + principal_id, + domain_id, + "https://idp.example", + "subject", + "owner@example.test", + "active", + COMMUNICATION_NOW - 200, + ), + ) + for harness_id, kind, display_name in ( + ("owner-harness", "pi", "Owner laptop"), + (actor.harness_id, "codex", "Fresh laptop"), + ("replacement-harness", "claude", "Replacement laptop"), + ): + connection.execute( + """INSERT INTO harnesses( + harness_id,domain_id,principal_id,guest_id,kind,display_name, + status,binding_assurance,capabilities_json,credential_epoch, + created_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?)""", + ( + harness_id, + domain_id, + principal_id, + None, + kind, + display_name, + "active", + "os_bound", + "[]", + 1, + COMMUNICATION_NOW - 200, + ), + ) + for credential_id, harness_id, key_id in ( + ("owner-credential", "owner-harness", "owner-key"), + (actor.credential_id, actor.harness_id, "fresh-key"), + ("replacement-credential", "replacement-harness", "replacement-key"), + ): + connection.execute( + """INSERT INTO credentials( + credential_id,harness_id,key_id,public_key_pem,status,epoch, + not_before,expires_at + ) VALUES(?,?,?,?,?,?,?,?)""", + ( + credential_id, + harness_id, + key_id, + "synthetic-public-key", + "active", + 1, + COMMUNICATION_NOW - 100, + COMMUNICATION_NOW + 86_400, + ), + ) + + resolver = MutableResolver(actor) + signer = P256KeyPair.generate() + approver = TrustedApprover( + principal_id=principal_id, + domain_id=domain_id, + signer_key_id=signer.thumbprint, + public_key_pem=signer.public_pem, + allowed_purposes=frozenset( + { + COMMUNICATION_SCOPE_APPROVAL_PURPOSE, + SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + } + ), + ) + verifier = IndependentApprovalVerifier( + {signer.thumbprint: approver}, + verifier_id="approval.corp.example", + ) + client = FakeApprovalClient(signer, approver, verifier) + service = CommunicationScopeService( + store, + client, + verifier, + resolver=resolver, + public_approval_url="https://approval.corp.example/approval", + clock=lambda: COMMUNICATION_NOW, + ) + stack = SimpleNamespace( + service=service, + client=client, + resolver=resolver, + store=store, + actor=actor, + ) + begin_communication_scope(stack) + client.state = "issued" + assert communication_scope_status(stack)["status"] == "approval_ready" + original_materializer = communication_scope_module.materialize_v6_communication_scope + + def fail_projection(*_args, **_kwargs): + raise GateBlocked( + "schema_v7_scope_projection", + "injected PostgreSQL projection failure", + ) + + monkeypatch.setattr( + communication_scope_module, + "materialize_v6_communication_scope", + fail_projection, + ) + with pytest.raises( + GateBlocked, + match="injected PostgreSQL projection failure", + ): + complete_communication_scope(stack) + assert store.fetch_one( + "SELECT state FROM communication_scopes" + )["state"] == "completion_reserved" + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM entitlements" + )["n"] == 0 + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes" + )["n"] == 0 + + monkeypatch.setattr( + communication_scope_module, + "materialize_v6_communication_scope", + original_materializer, + ) + assert complete_communication_scope(stack)["status"] == "communication_active" + scope_id = str( + store.fetch_one("SELECT scope_id FROM communication_scopes")["scope_id"] + ) + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes WHERE scope_id=?", + (scope_id,), + )["n"] == 1 + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + (scope_id,), + )["n"] == 2 + + with store.transaction() as connection: + connection.execute( + "DELETE FROM collaboration_scope_members WHERE scope_id=?", + (scope_id,), + ) + connection.execute( + "DELETE FROM collaboration_scopes WHERE scope_id=?", + (scope_id,), + ) + assert complete_communication_scope(stack)["status"] == "communication_active" + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + (scope_id,), + )["n"] == 2 + + with store.transaction() as connection: + connection.execute( + """DELETE FROM collaboration_scope_members + WHERE scope_id=? AND role='member'""", + (scope_id,), + ) + with pytest.raises( + GateBlocked, + match="existing collaboration scope projection mismatches", + ): + complete_communication_scope(stack) + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + (scope_id,), + )["n"] == 1 + + # Restore the exact source projection, then exercise the package-owned + # expired-member replacement through the PostgreSQL adapter. + with store.transaction() as connection: + connection.execute( + "DELETE FROM collaboration_scope_members WHERE scope_id=?", + (scope_id,), + ) + connection.execute( + "DELETE FROM collaboration_scopes WHERE scope_id=?", + (scope_id,), + ) + assert complete_communication_scope(stack)["status"] == "communication_active" + with store.transaction() as connection: + connection.execute( + "UPDATE credentials SET expires_at=? WHERE credential_id=?", + (COMMUNICATION_NOW - 1, actor.credential_id), + ) + replacement_service = ScopeHarnessReplacementService( + store, + verifier, + clock=lambda: COMMUNICATION_NOW, + ) + replacement_request = replacement_service.prepare( + actor=replacement_caller, + scope_id=scope_id, + old_harness_id=actor.harness_id, + new_harness_id="replacement-harness", + role="member", + request_id="postgres-scope-replacement-request-0001", + issued_at=COMMUNICATION_NOW, + expires_at=COMMUNICATION_NOW + 300, + ) + replacement_approval = create_independent_approval_receipt( + signer, + approver=approver, + verifier_id=verifier.verifier_id, + approval_purpose=SCOPE_HARNESS_REPLACEMENT_APPROVAL_PURPOSE, + canonical_transaction=replacement_request.canonical_transaction, + issued_at=COMMUNICATION_NOW, + expires_at=COMMUNICATION_NOW + 300, + authenticated_at=COMMUNICATION_NOW, + ) + replaced = replacement_service.replace( + actor=replacement_caller, + request=replacement_request, + approval=replacement_approval, + ) + repeated = replacement_service.replace( + actor=replacement_caller, + request=replacement_request, + approval=replacement_approval, + ) + assert replaced.idempotent_repeat is False + assert repeated.idempotent_repeat is True + assert repeated.scope_digest == replaced.scope_digest + assert store.fetch_one( + """SELECT state FROM collaboration_scope_members + WHERE scope_id=? AND harness_id=?""", + (scope_id, actor.harness_id), + )["state"] == "removed" + assert store.fetch_one( + """SELECT state FROM collaboration_scope_members + WHERE scope_id=? AND harness_id=?""", + (scope_id, "replacement-harness"), + )["state"] == "active" + finally: + if store is not None: + store.close() + administrator.execute( + psycopg.sql.SQL("DROP SCHEMA {} CASCADE").format( + psycopg.sql.Identifier(schema) + ) + ) + administrator.close() + + @pytest.mark.skipif( not (os.environ.get("AGENTNET_TEST_POSTGRES_URL") and os.environ.get("AGENTNET_TEST_POSTGRES_ALLOW_MUTATION") == "1"), reason="requires an explicitly mutation-authorized dedicated PostgreSQL test database", @@ -2723,6 +3191,68 @@ def test_postgres_cross_instance_task_conflict_race_and_owner_revision_fence() - owner = identity_rows[0][0] senders = (identity_rows[1][0], identity_rows[2][0]) + assert owner.principal_id is not None + assert owner.harness_id is not None + owner_principal_id = owner.principal_id + owner_harness_id = owner.harness_id + member_harness_ids: list[str] = [] + for actor, _key in identity_rows: + assert actor.harness_id is not None + member_harness_ids.append(actor.harness_id) + domain_state = first.fetch_one( + "SELECT policy_revision,revocation_epoch FROM domains WHERE domain_id=?", + (domain,), + ) + assert domain_state is not None + scope_id = f"scope:postgres-task-conflict:{uuid4().hex}" + scope_service = CollaborationScopeService(first, clock=lambda: now_epoch) + proposal = CollaborationScopeProposal( + scope_id=scope_id, + scope_kind="shared", + member_harness_ids=tuple(sorted(member_harness_ids)), + allowed_actions=("task.accept", "task.propose"), + allowed_resource_prefixes=("task:",), + allowed_classifications=(Classification.C1_INTERNAL,), + policy_revision=int(domain_state["policy_revision"]), + domain_revocation_epoch=int(domain_state["revocation_epoch"]), + expires_at=now_epoch + 3_600, + ) + issuance_resource = f"scope:{scope_id}" + issuance_policy = LocalConformancePolicyEngine(first) + issuance_policy.bootstrap_entitlement_for_local_conformance( + HumanEntitlement( + domain_id=domain, + principal_id=owner_principal_id, + action=COLLABORATION_SCOPE_ISSUE_ACTION, + resource_pattern=issuance_resource, + revision=int(domain_state["policy_revision"]), + expires_at=datetime.fromtimestamp(now_epoch + 3_600, UTC), + ), + when=now, + ) + issuance_request = scope_service.issuance_request( + actor=owner, + proposal=proposal, + ) + issuance_decision = issuance_policy.require( + AuthorizationRequest( + actor=owner, + action=COLLABORATION_SCOPE_ISSUE_ACTION, + resource=issuance_resource, + policy_revision=int(domain_state["policy_revision"]), + context=issuance_request, + ), + when=now, + ) + scope = scope_service.issue( + actor=owner, + proposal=proposal, + authority=IssuanceAuthority( + actor=owner, + policy_decision_id=issuance_decision.decision_id, + ), + when=now, + ) intent = TaskExecutionIntent( resources=( TaskResourceIntent( @@ -2739,10 +3269,13 @@ def test_postgres_cross_instance_task_conflict_race_and_owner_revision_fence() - actor=sender, event_type=EventType.TASK_ASSIGNMENT, classification=Classification.C1_INTERNAL, - payload={"instruction": f"exclusive rewrite {index}"}, + payload={ + "instruction": f"exclusive rewrite {index}", + "authorization_context": scope.authorization_context(), + }, idempotency_key=f"postgres-task-conflict-{index}-{uuid4()}", - recipients=(owner.harness_id,), - task_id=str(uuid4()), + recipients=(owner_harness_id,), + task_id=f"task:postgres-conflict:{uuid4()}", effect_deadline=deadline, policy_revision=1, ) @@ -2750,10 +3283,7 @@ def test_postgres_cross_instance_task_conflict_race_and_owner_revision_fence() - ) stores = (first, second) mailboxes = ( - MailboxService( - first, - collaboration_scopes=CollaborationScopeService(first), - ), + MailboxService(first, collaboration_scopes=scope_service), MailboxService( second, collaboration_scopes=CollaborationScopeService(second), diff --git a/tests/production/test_release_v7_schema.py b/tests/production/test_release_v7_schema.py index d34fb12..0431f57 100644 --- a/tests/production/test_release_v7_schema.py +++ b/tests/production/test_release_v7_schema.py @@ -16,6 +16,7 @@ RELATIONSHIP_GOVERNANCE_SQLITE_SCHEMA, ) from agentnet.storage.migrations import CURRENT_SCHEMA_VERSION, MIGRATIONS +from agentnet.storage.release_v7_schema import materialize_v6_communication_scope from agentnet.storage.sqlite import SQLiteStore @@ -259,6 +260,121 @@ def _seed_committed_v6_communication_scope(path: Path) -> None: connection.close() +def test_single_scope_materializer_is_exact_and_idempotent(tmp_path: Path) -> None: + path = tmp_path / "single-scope-v7.sqlite3" + store = SQLiteStore(path, LocalEnvelopeCipher(b"p" * 32)) + try: + _insert_identity(store) + _seed_committed_v6_communication_scope(path) + with store.transaction() as connection: + assert ( + materialize_v6_communication_scope( + connection, + scope_id="scope-release-v6", + ) + == "created" + ) + with store.transaction() as connection: + assert ( + materialize_v6_communication_scope( + connection, + scope_id="scope-release-v6", + ) + == "already_exact" + ) + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes WHERE scope_id=?", + ("scope-release-v6",), + )["n"] == 1 + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + ("scope-release-v6",), + )["n"] == 2 + finally: + store.close() + + +def test_single_scope_materializer_rejects_partial_existing_projection( + tmp_path: Path, +) -> None: + path = tmp_path / "partial-scope-v7.sqlite3" + store = SQLiteStore(path, LocalEnvelopeCipher(b"q" * 32)) + try: + _insert_identity(store) + _seed_committed_v6_communication_scope(path) + with store.transaction() as connection: + materialize_v6_communication_scope( + connection, + scope_id="scope-release-v6", + ) + with store.transaction() as connection: + connection.execute( + """DELETE FROM collaboration_scope_members + WHERE scope_id=? AND role='member'""", + ("scope-release-v6",), + ) + with pytest.raises(GateBlocked, match="existing collaboration scope projection mismatches"): + with store.transaction() as connection: + materialize_v6_communication_scope( + connection, + scope_id="scope-release-v6", + ) + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + ("scope-release-v6",), + )["n"] == 1 + finally: + store.close() + + +def test_single_scope_materializer_rejects_incomplete_source_without_target_writes( + tmp_path: Path, +) -> None: + path = tmp_path / "incomplete-source-v7.sqlite3" + store = SQLiteStore(path, LocalEnvelopeCipher(b"s" * 32)) + try: + _insert_identity(store) + _seed_committed_v6_communication_scope(path) + with store.transaction() as connection: + connection.execute( + "DELETE FROM communication_scope_items WHERE scope_id=? AND item_ordinal=1", + ("scope-release-v6",), + ) + with pytest.raises( + GateBlocked, + match="v6 communication scope authority items are incomplete or not current", + ): + with store.transaction() as connection: + materialize_v6_communication_scope( + connection, + scope_id="scope-release-v6", + ) + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scopes WHERE scope_id=?", + ("scope-release-v6",), + )["n"] == 0 + assert store.fetch_one( + "SELECT COUNT(*) AS n FROM collaboration_scope_members WHERE scope_id=?", + ("scope-release-v6",), + )["n"] == 0 + finally: + store.close() + + +def test_single_scope_materializer_rejects_unknown_scope(tmp_path: Path) -> None: + path = tmp_path / "unknown-scope-v7.sqlite3" + store = SQLiteStore(path, LocalEnvelopeCipher(b"t" * 32)) + try: + with pytest.raises(GateBlocked, match="committed communication scope is unavailable"): + with store.transaction() as connection: + materialize_v6_communication_scope( + connection, + scope_id="missing-scope", + ) + finally: + store.close() + + def test_sqlite_v6_upgrade_preserves_committed_communication_authority( tmp_path: Path, ) -> None: diff --git a/tests/test_version.py b/tests/test_version.py index f9063bc..eb83d13 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -8,10 +8,10 @@ ROOT = Path(__file__).resolve().parents[1] -EXPECTED_VERSION = "0.1.50" +EXPECTED_VERSION = "0.1.51" -def test_release_versions_are_exactly_v0150() -> None: +def test_release_versions_are_exactly_v0151() -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) package = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) lock_path = ROOT / "package-lock.json" diff --git a/uv.lock b/uv.lock index 0ef73ea..4280ea5 100644 --- a/uv.lock +++ b/uv.lock @@ -35,7 +35,7 @@ http-server = [ [[package]] name = "agentnet" -version = "0.1.50" +version = "0.1.51" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server"] },