From 2cdbd7aba997fbdfc01e56445f72d9688a29864f Mon Sep 17 00:00:00 2001 From: AhravDutta Date: Wed, 26 Aug 2026 20:03:03 +0000 Subject: [PATCH 1/5] test(release): authenticate qualification evidence --- .../verify-mc-host-release-evidence.test.ts | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/scripts/verify-mc-host-release-evidence.test.ts b/scripts/verify-mc-host-release-evidence.test.ts index 12e604c18..37c33c7d6 100644 --- a/scripts/verify-mc-host-release-evidence.test.ts +++ b/scripts/verify-mc-host-release-evidence.test.ts @@ -523,36 +523,50 @@ describe("installed release evidence", () => { ).toThrow(/must cite a test report under tmp\/mc-host-test-reports\//); }); - test("a test report must attest the target that cites it", () => { - const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); - const evidence = qualifiedEvidence(); - installReleaseArtifacts(root, evidence); - installProofArtifacts(root, evidence); - const targetProof = (evidence.proof_artifacts as { kind: string; path: string }[]).find( - (proof) => proof.kind === "target", - ); - if (targetProof === undefined) throw new Error("missing target proof"); - const report = JSON.parse( - readFileSync(join(root, targetProof.path), "utf8"), - ) as Record; - const reportPath = (report.observations as Record) - .test_report_path as string; - // Same path and a matching digest, but the report names another target. - const forged = `${canonicalJson({ - schema: "magic-context.mc-host-test-report/v1", - target: "some-other-target", - passed: true, - })}\n`; - writeFileSync(join(root, reportPath), forged); - rewriteProof(root, evidence, targetProof.path, (current) => { - const observations = current.observations as Record; - observations.test_report_sha256 = sha256Hex(forged); - }); + // Schema, target, and verdict share a single reject condition, so one case + // per clause keeps a regression in any one of them from riding on its + // neighbours still being enforced. + for (const mutation of ["failed", "wrong-schema", "wrong-target"] as const) { + test(`a test report must attest a passing run for its target (${mutation})`, () => { + const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); + const evidence = qualifiedEvidence(); + installReleaseArtifacts(root, evidence); + installProofArtifacts(root, evidence); + const targetProof = ( + evidence.proof_artifacts as { kind: string; path: string; subject: string }[] + ).find((proof) => proof.kind === "target"); + if (targetProof === undefined) throw new Error("missing target proof"); + const report = JSON.parse( + readFileSync(join(root, targetProof.path), "utf8"), + ) as Record; + const reportPath = (report.observations as Record) + .test_report_path as string; + // The citation path and digest stay consistent, so only the report's + // own content can carry the rejection. + const forged = `${canonicalJson({ + schema: + mutation === "wrong-schema" + ? "magic-context.mc-host-test-report/v0" + : "magic-context.mc-host-test-report/v1", + target: mutation === "wrong-target" ? "some-other-target" : targetProof.subject, + passed: mutation !== "failed", + })}\n`; + writeFileSync(join(root, reportPath), forged); + rewriteProof(root, evidence, targetProof.path, (current) => { + const observations = current.observations as Record; + observations.test_report_sha256 = sha256Hex(forged); + }); - expect(() => - validateInstalledReleaseEvidenceAgainstArtifacts(root, evidence, true, fullStubs()), - ).toThrow(/test report does not attest a passing/); - }); + expect(() => + validateInstalledReleaseEvidenceAgainstArtifacts( + root, + evidence, + true, + fullStubs(), + ), + ).toThrow(/test report does not attest a passing/); + }); + } test("one test report cannot satisfy two targets", () => { const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); From 8d7c12d4132802c76e40ea4720f298dae56c29e3 Mon Sep 17 00:00:00 2001 From: AhravDutta Date: Sat, 29 Aug 2026 16:18:35 +0000 Subject: [PATCH 2/5] fix(release): close review findings on the evidence and daemon fence - Fail closed and re-validate in the Synapse provider when a module restart invalidates the compatible daemon identity, so later pages never ride an incarnation that skipped compatibility validation. - Drop the discarded evaluateCompatibility call in the managed probe; applyCompatibility is the single enforcement point. - Stop fabricating storage/synapse readiness on probe short-circuit paths; only observed components are reported. - Reuse the compatibility probe's host.status storage observation so demand-start no longer opens a second connection in the common case. - Derive the compatibility stage set, order, and check ids from one exported descriptor list. - Require the qualification workflow to exist in the checkout for GA-qualified verification, and verify the shared workflow run once instead of per proof. - Share the U9 evidence identity gate between the build context loader and requireQualificationEvidence. - Restore macOS coverage for the native lifecycle binary and CLI contract in the relocated mc-host-lifecycle job. - Exercise the schema-level validator directly in the GA proof table test instead of installing unreachable artifacts. --- .github/workflows/ci.yml | 15 ++++- .../magic-context/memory/embedding-synapse.ts | 19 ++++++ .../shared/mc-host-lifecycle/compatibility.ts | 58 +++++++++++++++---- .../mc-host-lifecycle/managed-policy.ts | 41 ++++++++++--- .../src/shared/mc-host-lifecycle/policy.ts | 56 ++++++------------ scripts/build-mc-host-payload.ts | 21 +++---- scripts/qualify-mc-host-production-inputs.ts | 53 ++++++++++------- .../verify-mc-host-release-evidence.test.ts | 21 ++++++- scripts/verify-mc-host-release-evidence.ts | 13 ++++- 9 files changed, 205 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc8d2511..6e1832950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Host lifecycle and closure proofs + - name: Host lifecycle and closure proofs (Linux) + if: runner.os == 'Linux' run: | cargo test -p mc-host \ --test broca_protocol \ @@ -222,13 +223,23 @@ jobs: --test harness_closure \ --test protocol_vectors - - name: Native module adapter and CLI lifecycle + - name: Native module adapter and CLI lifecycle (Linux) + if: runner.os == 'Linux' run: | cargo test -p mc-module --bin ck-mc-host cargo test -p mc-module \ --test host_adapter \ --test lifecycle_cli + # The release ships darwin payloads and the GA evidence gate requires + # darwin target proofs, so the native lifecycle binary and its CLI + # contract need macOS proof alongside the Linux integration set. + - name: Native lifecycle binary and CLI contract (macOS) + if: runner.os == 'macOS' + run: | + cargo build -p mc-module --bin ck-mc-host + cargo test -p mc-module --test lifecycle_cli + check-plugin: name: Check (plugin) runs-on: ubuntu-latest diff --git a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts index 03e2fe182..2d26c1aa8 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts @@ -895,6 +895,11 @@ export class SynapseEmbeddingProvider implements EmbeddingProvider { } for (let start = 0; start < items.length; ) { if (signal?.aborted || this.permanentFailure) break; + // A `module_restarted` failure on an earlier page invalidated the + // compatible daemon identity. Re-run the full initialization so + // the remaining pages only proceed against an incarnation that + // re-passed lifecycle compatibility validation. + if (!this.initialized && !(await this.initialize(signal))) break; const page = this.nextPage(items, start); start += page.length; try { @@ -1024,6 +1029,20 @@ export class SynapseEmbeddingProvider implements EmbeddingProvider { }); continue; } + // A `module_restarted` failure on an earlier page invalidated + // the compatible daemon identity; re-validate before this page + // so it never rides an unverified incarnation. + if (!this.initialized && !(await this.initialize(signal))) { + result.failures.push({ + applicationGroup, + items: manifest, + rowId: null, + code: this.permanentFailure ? "artifact_invalid" : "transport", + message: "Synapse lane is unavailable", + disposition: this.permanentFailure ? "permanent" : "retryable", + }); + continue; + } try { result.receipts.push( await this.runDetailedPage(page, applicationGroup, context, signal), diff --git a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts index e10d16cbb..69e196192 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts @@ -182,19 +182,57 @@ export function evaluateEpochCompatibility(observed: ObservedEpochs): Compatibil return { ok: true }; } +export interface CompatibilityInput { + authenticatedDaemonVer: string; + catalog: CatalogEntry[]; + epochs: ObservedEpochs; +} + +/** + * The single ordered source of truth for the compatibility gate: stage id, + * the CLI check id it reports under, and its evaluator. `evaluateCompatibility`, + * the managed probe's `evaluatedThrough` labels, and the policy's emitted + * `compatibility.*` checks all derive from this list, so a stage added or + * reordered in one place cannot leave the probe sequence and the reported + * checks disagreeing. + */ +export const COMPATIBILITY_STAGES = [ + { + stage: "daemon", + checkId: "compatibility.daemon", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateDaemonCompatibility(input.authenticatedDaemonVer), + }, + { + stage: "modules", + checkId: "compatibility.modules", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateModuleCompatibility(input.catalog), + }, + { + stage: "epochs", + checkId: "compatibility.epochs", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateEpochCompatibility(input.epochs), + }, +] as const; + +export type CompatibilityStage = (typeof COMPATIBILITY_STAGES)[number]["stage"]; + +/** Position of `stage` in the ordered gate; the order is the array order. */ +export function compatibilityStageIndex(stage: CompatibilityStage): number { + return COMPATIBILITY_STAGES.findIndex((entry) => entry.stage === stage); +} + /** * The composed demand/status/doctor gate order: daemon range, then modules, * then epochs. First failure wins and is reported without any stop, replace, * or restart side effect (R17). */ -export function evaluateCompatibility(input: { - authenticatedDaemonVer: string; - catalog: CatalogEntry[]; - epochs: ObservedEpochs; -}): CompatibilityVerdict { - const daemon = evaluateDaemonCompatibility(input.authenticatedDaemonVer); - if (!daemon.ok) return daemon; - const modules = evaluateModuleCompatibility(input.catalog); - if (!modules.ok) return modules; - return evaluateEpochCompatibility(input.epochs); +export function evaluateCompatibility(input: CompatibilityInput): CompatibilityVerdict { + for (const stage of COMPATIBILITY_STAGES) { + const verdict = stage.evaluate(input); + if (!verdict.ok) return verdict; + } + return { ok: true }; } diff --git a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts index 9041ac41c..283e1551a 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts @@ -217,6 +217,8 @@ async function readCompatibilityProbe( if (signal?.aborted) throw signal.reason ?? new Error("compatibility probe aborted"); const components = asRecord(status.metrics.components); const magicContextMetrics = asRecord(asRecord(components?.["magic-context"])?.metrics); + // The probe only reports what it observed; the compatibility verdict is + // owned by exactly one place, `McHostLifecyclePolicy.applyCompatibility`. const snapshot = { authenticatedDaemonVersion: authenticated.daemonVer, authenticatedDaemonId: Uint8Array.from(authenticated.daemonId), @@ -239,7 +241,7 @@ async function probeManagedCompatibility( root: string, budgetMs: number, signal?: AbortSignal, -): Promise { +): Promise { const deadline = Date.now() + budgetMs; const client = await McHostClient.connect({ connectionFile: connectionFilePath(root), @@ -247,7 +249,7 @@ async function probeManagedCompatibility( requestTimeoutMs: Math.max(1, budgetMs), }); try { - return await readCompatibilitySnapshot(client, deadline, signal); + return await readCompatibilityProbe(client, deadline, signal); } finally { await client.closeAsync().catch(() => {}); } @@ -383,18 +385,39 @@ export function createManagedLifecyclePolicy( ? {} : { explicitExternalRoot: options.explicitExternalRoot }), }); + // The default compatibility probe's `host.status` reply already + // carries the storage state, so the demand path's storage probe can + // consume that observation instead of opening a second connection and + // re-issuing `host.status`. The observation is single-use and only a + // terminal state short-circuits; a `starting` observation still runs + // the polling probe so it can wait out startup within its own budget. + let observedStorage: "ready" | "starting" | "unavailable" | null = null; + const defaultCompatibilityProbe = async ( + budgetMs: number, + signal?: AbortSignal, + ): Promise => { + const probe = await probeManagedCompatibility(root.root, budgetMs, signal); + observedStorage = probe.status === null ? null : storageState(probe.status.metrics); + return probe.snapshot; + }; + const defaultStorageProbe = ( + budgetMs: number, + expectedDaemonId?: Uint8Array, + ): Promise<"ready" | "starting" | "unavailable"> => { + const observed = observedStorage; + observedStorage = null; + if (observed === "ready" || observed === "unavailable") { + return Promise.resolve(observed); + } + return probeManagedStorage(root.root, budgetMs, expectedDaemonId); + }; return new McHostLifecyclePolicy({ ...options, env, launchTarget: prepared, defaultStartupEnvelope: buildManagedCredentialEnvelope(env), - storageProbe: - options.storageProbe ?? - ((budgetMs, expectedDaemonId) => - probeManagedStorage(root.root, budgetMs, expectedDaemonId)), - compatibilityProbe: - options.compatibilityProbe ?? - ((budgetMs, signal) => probeManagedCompatibility(root.root, budgetMs, signal)), + storageProbe: options.storageProbe ?? defaultStorageProbe, + compatibilityProbe: options.compatibilityProbe ?? defaultCompatibilityProbe, readinessProbe: options.readinessProbe ?? ((budgetMs) => probeManagedReadiness(root.root, budgetMs)), diff --git a/packages/plugin/src/shared/mc-host-lifecycle/policy.ts b/packages/plugin/src/shared/mc-host-lifecycle/policy.ts index 80a071dc9..202294c85 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/policy.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/policy.ts @@ -18,11 +18,11 @@ import type { CatalogEntry } from "../mc-host-client"; import { checkPlatform, type LifecycleFailureReason, type PlatformReaders } from "./bootstrap"; import { + COMPATIBILITY_STAGES, + type CompatibilityStage, type CompatibilityVerdict, + compatibilityStageIndex, evaluateCompatibility, - evaluateDaemonCompatibility, - evaluateEpochCompatibility, - evaluateModuleCompatibility, type ObservedEpochs, } from "./compatibility"; import { @@ -55,7 +55,7 @@ export type LifecycleCommand = "start" | "stop" | "restart" | "status" | "doctor export type StorageReadiness = "ready" | "starting" | "unavailable"; -export type CompatibilityStage = "daemon" | "modules" | "epochs"; +export type { CompatibilityStage } from "./compatibility"; export interface CompatibilitySnapshot { authenticatedDaemonVersion: string; @@ -609,41 +609,21 @@ export class McHostLifecyclePolicy { result: DaemonResultV1, snapshot: CompatibilitySnapshot, ): { result: DaemonResultV1; verdict: CompatibilityVerdict } { - const verdict = evaluateCompatibility(compatibilityInput(snapshot)); - const stages: ReadonlyArray<{ - stage: CompatibilityStage; - id: "compatibility.daemon" | "compatibility.modules" | "compatibility.epochs"; - verdict: CompatibilityVerdict; - }> = [ - { - stage: "daemon", - id: "compatibility.daemon", - verdict: evaluateDaemonCompatibility(snapshot.authenticatedDaemonVersion), - }, - { - stage: "modules", - id: "compatibility.modules", - verdict: evaluateModuleCompatibility(snapshot.catalog), - }, - { - stage: "epochs", - id: "compatibility.epochs", - verdict: evaluateEpochCompatibility(snapshot.epochs), - }, - ]; - const stageOrder: Record = { - daemon: 0, - modules: 1, - epochs: 2, - }; - const evaluatedThrough = snapshot.evaluatedThrough ?? "epochs"; + const input = compatibilityInput(snapshot); + const verdict = evaluateCompatibility(input); + const evaluatedThroughIndex = compatibilityStageIndex( + snapshot.evaluatedThrough ?? "epochs", + ); const checksById = new Map(result.checks.map((check) => [check.id, check] as const)); - for (const stage of stages) { - if (stageOrder[stage.stage] > stageOrder[evaluatedThrough]) continue; - const reason = stage.verdict.ok ? "healthy" : stage.verdict.reason; - checksById.set(stage.id, { - id: stage.id, - status: stage.verdict.ok ? "pass" : "fail", + for (const [index, stage] of COMPATIBILITY_STAGES.entries()) { + // Only stages the probe actually reached are reported; a check for + // an unevaluated stage would assert an observation never made. + if (index > evaluatedThroughIndex) continue; + const stageVerdict = stage.evaluate(input); + const reason = stageVerdict.ok ? "healthy" : stageVerdict.reason; + checksById.set(stage.checkId, { + id: stage.checkId, + status: stageVerdict.ok ? "pass" : "fail", reason, remediation: remediationForReason(reason), }); diff --git a/scripts/build-mc-host-payload.ts b/scripts/build-mc-host-payload.ts index a4696dd24..c50b8101a 100644 --- a/scripts/build-mc-host-payload.ts +++ b/scripts/build-mc-host-payload.ts @@ -64,6 +64,7 @@ import { INPUT_KEYS, isPlaceholderSha256, OUTPUT_PATHS as U9_OUTPUT_PATHS, + qualificationEvidenceIdentityMismatch, requireQualificationEvidence, SOURCE_MANIFEST_PATH, } from "./qualify-mc-host-production-inputs"; @@ -259,17 +260,17 @@ export function loadReleaseContext(rootDir: string): ReleaseContext { let artifacts: Record | undefined; if (existsSync(evidencePath)) { const evidence = readJson(rootDir, U9_OUTPUT_PATHS.evidence); - const release = evidence.release as - | { id?: unknown; version?: unknown } - | undefined; - if ( - evidence.schema !== "magic-context.mc-host-release-qualification/v1" || - evidence.release_contract_sha256 !== u8Digest || - release?.id !== contract.release.id || - release?.version !== contract.release.version - ) { + // The identity rules are shared with `requireQualificationEvidence` + // (the U2/U6 consumption gate) so the two validators of this document + // cannot drift apart. + const identityMismatch = qualificationEvidenceIdentityMismatch( + evidence, + contract, + u8Digest, + ); + if (identityMismatch !== null) { fail( - `stale or unknown U9 qualification evidence at ${U9_OUTPUT_PATHS.evidence}`, + `stale or unknown U9 qualification evidence at ${U9_OUTPUT_PATHS.evidence}: ${identityMismatch}`, ); } if (lock.production_qualified !== evidence.production_qualified) { diff --git a/scripts/qualify-mc-host-production-inputs.ts b/scripts/qualify-mc-host-production-inputs.ts index 577cd0fec..703151e9e 100644 --- a/scripts/qualify-mc-host-production-inputs.ts +++ b/scripts/qualify-mc-host-production-inputs.ts @@ -2640,6 +2640,36 @@ export function generate( * mismatch), test-only, or non-production evidence. Returns the verified * evidence and artifact digests for embedding into build inputs. */ +/** + * Shared identity gate for one U9 qualification-evidence document: shape, + * schema, U8 release-contract digest, and release identity. Both the build's + * release-context loader and the production consumption gate call this, so + * what counts as "this release's evidence" cannot drift between the two + * validators. Returns the mismatch reason, or null when the document is this + * release's evidence. + */ +export function qualificationEvidenceIdentityMismatch( + evidence: unknown, + contract: { release: { id: string; version: string } }, + u8Digest: string, +): string | null { + if (evidence === null || typeof evidence !== "object" || Array.isArray(evidence)) { + return "malformed evidence document"; + } + const document = evidence as Record; + if (document.schema !== "magic-context.mc-host-release-qualification/v1") { + return "malformed or unknown schema"; + } + if (document.release_contract_sha256 !== u8Digest) { + return "stale U8 release-contract digest"; + } + const release = document.release as { id?: unknown; version?: unknown } | undefined; + if (release?.id !== contract.release.id || release?.version !== contract.release.version) { + return "release identity mismatch"; + } + return null; +} + export function requireQualificationEvidence(rootDir: string): { evidence: Record; u8Digest: string; @@ -2658,28 +2688,11 @@ export function requireQualificationEvidence(rootDir: string): { } catch { reject("malformed JSON"); } - if ( - evidence === null || - typeof evidence !== "object" || - Array.isArray(evidence) || - (evidence as { schema?: unknown }).schema !== - "magic-context.mc-host-release-qualification/v1" - ) { - reject("malformed or unknown schema"); - } const contract = buildContract(); const u8Digest = sha256Hex(canonicalJson(contract)); - if (evidence.release_contract_sha256 !== u8Digest) { - reject("stale U8 release-contract digest"); - } - const release = evidence.release as - | { id?: unknown; version?: unknown } - | undefined; - if ( - release?.id !== contract.release.id || - release?.version !== contract.release.version - ) { - reject("release identity mismatch"); + const identityMismatch = qualificationEvidenceIdentityMismatch(evidence, contract, u8Digest); + if (identityMismatch !== null) { + reject(identityMismatch); } if (evidence.test_only !== false) { reject("test-only evidence can never qualify a production build"); diff --git a/scripts/verify-mc-host-release-evidence.test.ts b/scripts/verify-mc-host-release-evidence.test.ts index 37c33c7d6..5bcca3223 100644 --- a/scripts/verify-mc-host-release-evidence.test.ts +++ b/scripts/verify-mc-host-release-evidence.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildContract, canonicalJson, sha256Hex } from "./generate-mc-host-release-manifest"; import { attestationMatchesWorkflowSource, buildInstalledReleaseEvidence, + QUALIFICATION_WORKFLOW_PATH, validateInstalledReleaseEvidence, validateInstalledReleaseEvidenceAgainstArtifacts, workflowRunApiPath, @@ -257,6 +258,11 @@ function installReleaseArtifacts( writeFileSync(join(root, relative), bytes); evidence[field] = sha256Hex(bytes); } + // Qualified verification requires the signer workflow the proofs cite to + // exist in the checkout under validation. + const workflowPath = join(root, QUALIFICATION_WORKFLOW_PATH); + mkdirSync(dirname(workflowPath), { recursive: true }); + writeFileSync(workflowPath, "name: qualification stub\n"); } describe("installed release evidence", () => { @@ -874,6 +880,19 @@ describe("installed release evidence", () => { ); }); + test("a checkout without the qualification workflow cannot pass the GA gate", () => { + const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); + const evidence = qualifiedEvidence(); + installReleaseArtifacts(root, evidence); + installProofArtifacts(root, evidence); + // Every artifact and stub still verifies; only the workflow the evidence + // claims to have run under is absent from this checkout. + rmSync(join(root, QUALIFICATION_WORKFLOW_PATH)); + expect(() => + validateInstalledReleaseEvidenceAgainstArtifacts(root, evidence, true, fullStubs()), + ).toThrow(/qualification workflow .* does not exist/); + }); + test("qualification evidence cannot substitute for installed release evidence", () => { expect(() => validateInstalledReleaseEvidence( diff --git a/scripts/verify-mc-host-release-evidence.ts b/scripts/verify-mc-host-release-evidence.ts index 51870c87c..30ca827a0 100644 --- a/scripts/verify-mc-host-release-evidence.ts +++ b/scripts/verify-mc-host-release-evidence.ts @@ -6,7 +6,7 @@ */ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -22,7 +22,7 @@ const INPUT_LOCK_PATH = "release/mc-host-production-inputs.lock.json"; const PAYLOAD_INDEX_PATH = "release/mc-host-payload-index.json"; const STOP_PROVENANCE_PATH = "release/mc-host-n-minus-one-stop.json"; const SHA256_RE = /^[0-9a-f]{64}$/; -const QUALIFICATION_WORKFLOW_PATH = ".github/workflows/mc-host-release-qualification.yml"; +export const QUALIFICATION_WORKFLOW_PATH = ".github/workflows/mc-host-release-qualification.yml"; const EXPECTED_REPOSITORY = "ahrav/magic-context"; const TEST_REPORT_DIR = "tmp/mc-host-test-reports/"; const TEST_REPORT_SCHEMA = "magic-context.mc-host-test-report/v1"; @@ -824,6 +824,15 @@ export function validateInstalledReleaseEvidenceAgainstArtifacts( if (requireQualified && !/^[0-9a-f]{40}$/.test(expectedHeadSha)) { fail("cannot bind qualified evidence to the current release commit"); } + // Every qualified proof must cite this signer workflow, so a checkout + // that does not carry it can never have produced (or reproduce) the + // attested evidence. Failing here names the gap directly instead of + // surfacing it later as an opaque per-proof attestation mismatch. + if (requireQualified && !existsSync(join(rootDir, QUALIFICATION_WORKFLOW_PATH))) { + fail( + `qualification workflow ${QUALIFICATION_WORKFLOW_PATH} does not exist in this checkout`, + ); + } const expected = { production_inputs_sha256: sha256File(rootDir, INPUT_LOCK_PATH), qualification_sha256: sha256File(rootDir, QUALIFICATION_PATH), From fe8ee1db3ec7e14bef4c8dbc84722a12c67e5932 Mon Sep 17 00:00:00 2001 From: AhravDutta Date: Sat, 29 Aug 2026 17:23:55 +0000 Subject: [PATCH 3/5] fix(mc-host): bind the cached storage observation to its daemon The managed lifecycle policy caches the storage state carried by the compatibility probe's `host.status` reply so the demand path can reuse it instead of opening a second connection. That cache was a single untagged slot, and more than one probe can write it: `sharedCompatibility` dedupes per data root, so a real-root and a no-root key can be in flight together, and a demand for a capability other than `magic-context` writes an observation it returns without ever consuming. A waiter could therefore consume a state observed on a different request or daemon incarnation than the one it certified, and a stale `ready` would let module traffic publish even though that waiter's own authenticated status reported storage as starting. The reuse path also bypassed the `expectedDaemonId` fence, which only guarded the fallback probe. Tag the observation with the incarnation whose `host.status` produced it and reuse it only for a demand that certified the same incarnation. Anything else falls through to the polling probe, which already carries the fence. --- .../mc-host-lifecycle/managed-policy.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts index 283e1551a..954f293de 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts @@ -391,13 +391,31 @@ export function createManagedLifecyclePolicy( // re-issuing `host.status`. The observation is single-use and only a // terminal state short-circuits; a `starting` observation still runs // the polling probe so it can wait out startup within its own budget. - let observedStorage: "ready" | "starting" | "unavailable" | null = null; + // + // The observation is tagged with the daemon incarnation whose + // `host.status` produced it and is only consumed by a demand that + // certified that same incarnation. Concurrent probes share this slot: + // `sharedCompatibility` dedupes per data root, so a real-root and a + // no-root key can be in flight together, and a non-`magic-context` + // demand writes an observation it never consumes. Untagged reuse would + // let a waiter read a state observed on a different request or daemon + // generation and publish module traffic against it. + let observedStorage: { + daemonId: Uint8Array; + state: "ready" | "starting" | "unavailable"; + } | null = null; const defaultCompatibilityProbe = async ( budgetMs: number, signal?: AbortSignal, ): Promise => { const probe = await probeManagedCompatibility(root.root, budgetMs, signal); - observedStorage = probe.status === null ? null : storageState(probe.status.metrics); + observedStorage = + probe.status === null + ? null + : { + daemonId: Uint8Array.from(probe.snapshot.authenticatedDaemonId), + state: storageState(probe.status.metrics), + }; return probe.snapshot; }; const defaultStorageProbe = ( @@ -406,8 +424,13 @@ export function createManagedLifecyclePolicy( ): Promise<"ready" | "starting" | "unavailable"> => { const observed = observedStorage; observedStorage = null; - if (observed === "ready" || observed === "unavailable") { - return Promise.resolve(observed); + if ( + expectedDaemonId !== undefined && + observed !== null && + sameDaemonId(observed.daemonId, expectedDaemonId) && + (observed.state === "ready" || observed.state === "unavailable") + ) { + return Promise.resolve(observed.state); } return probeManagedStorage(root.root, budgetMs, expectedDaemonId); }; From a4344f6ed26520d94629340b0fdd8ca1b7ee0d0e Mon Sep 17 00:00:00 2001 From: AhravDutta Date: Sat, 29 Aug 2026 17:50:22 +0000 Subject: [PATCH 4/5] fix(mc-host): create the envelope pipe portably on Darwin `pipe2` is a Linux extension that the Darwin libc does not provide, so the `libc::pipe2` call failed to resolve and broke the macOS build of the `ck-mc-host` binary outright: error[E0425]: cannot find function `pipe2` in crate `libc` Keep the atomic close-on-exec creation on Linux and fall back to `pipe` followed by an explicit `FD_CLOEXEC` on Darwin. Both descriptors are wrapped in `OwnedFd` before the flag is set so a failing `fcntl` closes them instead of leaking a pair. The fallback is not atomic with creation the way `pipe2` is, which matters only if another thread execs between the two calls. The child spawned below is the only exec this binary performs, it runs after both calls, and it keeps the read end deliberately by dup2-ing it onto stdin while closing every descriptor above 3. --- crates/mc-module/src/bin/ck_mc_host/spawn.rs | 26 +++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/mc-module/src/bin/ck_mc_host/spawn.rs b/crates/mc-module/src/bin/ck_mc_host/spawn.rs index f1499382c..b00846bdd 100644 --- a/crates/mc-module/src/bin/ck_mc_host/spawn.rs +++ b/crates/mc-module/src/bin/ck_mc_host/spawn.rs @@ -94,18 +94,42 @@ pub fn spawn_detached( }; let mut pipe_fds = [0 as libc::c_int; 2]; + // Linux creates the pipe already close-on-exec. Darwin has no `pipe2`, so + // there the flag is applied in a second step below. + #[cfg(target_os = "linux")] // SAFETY: pipe2 writes exactly two descriptors into the array. cvt( unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }, "envelope pipe creation failed", )?; - // SAFETY: the descriptors were just returned by pipe2 and are owned here. + #[cfg(target_os = "macos")] + // SAFETY: pipe writes exactly two descriptors into the array. + cvt( + unsafe { libc::pipe(pipe_fds.as_mut_ptr()) }, + "envelope pipe creation failed", + )?; + // SAFETY: the descriptors were just returned by pipe2/pipe and are owned here. let (pipe_r, pipe_w) = unsafe { ( OwnedFd::from_raw_fd(pipe_fds[0]), OwnedFd::from_raw_fd(pipe_fds[1]), ) }; + // Both ends are owned before the flag is set, so a failure here closes them + // instead of leaking a descriptor pair. Unlike `pipe2` this is not atomic + // with creation: a concurrent exec in another thread could inherit the ends + // in that window. The child below is the only exec this binary performs, it + // happens after this point, and it keeps the read end deliberately by + // dup2-ing it onto stdin (which clears close-on-exec) while closing every + // descriptor above 3. + #[cfg(target_os = "macos")] + for fd in [pipe_r.as_raw_fd(), pipe_w.as_raw_fd()] { + // SAFETY: fd is owned by pipe_r/pipe_w and open for this call. + cvt( + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }, + "envelope pipe cloexec failed", + )?; + } // Everything the child touches is prepared before fork: with tokio // worker threads alive, the child may only use async-signal-safe calls From 348a1f846c33c6d97b7afbb26890752edf87b289 Mon Sep 17 00:00:00 2001 From: AhravDutta Date: Sat, 29 Aug 2026 18:23:09 +0000 Subject: [PATCH 5/5] fix(plugin): reject non-canonical versions and preserve page cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent gate defects found in review, each with a test that fails without its fix. `parseSemverTriple` matched each part with `\d+`, so a version carrying leading zeroes parsed. `Number.parseInt` then normalized it, and `evaluateDaemonCompatibility("mc-host/00.01.000")` returned `{ ok: true }` because `[0, 1, 0]` falls inside the supported half-open range. The verdict this function emits promises a canonical `X.Y.Z` value, so the check was admitting a shape it reports as canonical. Each part is now a semver numeric identifier: a single `0`, or a non-zero digit followed by any digits. The only other callers are the contract's own range bounds and a catalog entry's module version; the generated contract's bounds are canonical, so the narrowing rejects a non-canonical peer and nothing else. `embedItemsDetailed` classified a failed per-page re-validation as a retryable `transport` failure. `initialize` folds an abort raised during its own await into a plain `false` rather than a rejection, so a request the caller cancelled mid-re-validation recorded that page as an infrastructure failure — inviting a retry of work the caller had withdrawn — while every later page reported `cancelled` from the check at the top of the loop. The signal is re-read after initialization so one page can no longer disagree with its siblings about why the request stopped. --- .../memory/embedding-synapse.test.ts | 96 +++++++++++++++++++ .../magic-context/memory/embedding-synapse.ts | 19 +++- .../mc-host-lifecycle/compatibility.test.ts | 20 ++++ .../shared/mc-host-lifecycle/compatibility.ts | 11 ++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts index 7c674861b..8ded1ec2a 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts @@ -1721,6 +1721,102 @@ describe("embedItemsDetailed", () => { } }); + it("reports a page cancelled when the abort lands inside its re-validation", async () => { + const db = ledgerDb(); + try { + const host = new DetailedHost(); + const controller = new AbortController(); + let demands = 0; + const provider = new SynapseEmbeddingProvider({ + connectionFile: "fixture", + projectRoot: "/repo", + session: "ses-1", + model: MODEL, + fingerprint: FP, + tableEpoch: 0, + dims: 3, + recommendedBatch: 2, + batchTimeoutMs: 5_000, + clientFactory: async () => host, + demandStart: async () => { + demands += 1; + // The abort lands while the managed demand is in flight, so + // `initialize` observes it on its own await and reports the + // plain `false` a rejected `raceSignal` is folded into. + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { + ok: true, + reason: "started", + storage: "ready", + authenticatedDaemonId: new Uint8Array([7, 7]), + }; + }, + }); + // Certify the lane before installing the managed origin: `initialize` + // is the only writer of the identity, and the pre-loop initialize + // must return from the already-certified state so the first page + // dispatches instead of demanding. + expect(await provider.initialize()).toBe(true); + const internals = provider as unknown as { + connectionOrigin: string; + compatibleDaemonId: Uint8Array | null; + initialized: boolean; + }; + internals.connectionOrigin = "managed-default"; + internals.compatibleDaemonId = new Uint8Array([7, 7]); + + // Reproduce the state a rotation on an earlier page installs: the + // lane is managed and no longer certified, which is precisely the + // precondition the per-page re-validation exists to answer. Doing it + // from the first page's own response keeps the second page's + // `signal.aborted` check ahead of the abort, so the abort can only + // be observed inside the re-validation itself. + host.resultPages = (_jobId, items) => { + if (items.some((item) => item.id === "memory:1")) internals.initialized = false; + return { + result: { + ...ENVELOPE, + done: true, + vectors: items.map((item) => ({ + id: item.id, + content_sha256: item.content_sha256, + vector: [1, 2, 3], + })), + }, + }; + }; + + const result = await provider.embedItemsDetailed( + detailedItems([ + { id: "memory:1", group: "g1" }, + { id: "memory:2", group: "g2" }, + ]), + detailedContext(db), + controller.signal, + ); + + // The first page completed before the identity was invalidated. + expect(result.receipts).toHaveLength(1); + expect(result.receipts[0].applicationGroup).toBe("g1"); + // Exactly one demand: the second page's re-validation. + expect(demands).toBe(1); + expect(result.failures).toHaveLength(1); + const g2 = result.failures[0]; + expect(g2.applicationGroup).toBe("g2"); + // This read `transport`/`retryable` before the signal was re-checked + // after initialization, which invites a retry of a request the caller + // withdrew and disagrees with the `cancelled` every later page reports. + expect(g2.code).toBe("cancelled"); + expect(g2.message).toBe("Synapse request aborted"); + expect(g2.disposition).toBe("retryable"); + // The cancelled page must never have reached the wire. + expect(host.batchCalls()).toHaveLength(1); + } finally { + closeQuietly(db); + } + }); + it("scopes an exhausted restart budget to its page and leaves sibling pages runnable", async () => { const db = ledgerDb(); try { diff --git a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts index 2d26c1aa8..be444663d 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts @@ -1033,12 +1033,27 @@ export class SynapseEmbeddingProvider implements EmbeddingProvider { // the compatible daemon identity; re-validate before this page // so it never rides an unverified incarnation. if (!this.initialized && !(await this.initialize(signal))) { + // `initialize` reports an abort raised during its own await + // as a plain `false`, so the signal is re-read here. Without + // it a caller-cancelled request records this one page as a + // retryable `transport` failure — inviting a retry of work + // the caller withdrew — while every later page correctly + // reports `cancelled` from the check above. + const aborted = signal?.aborted === true; result.failures.push({ applicationGroup, items: manifest, rowId: null, - code: this.permanentFailure ? "artifact_invalid" : "transport", - message: "Synapse lane is unavailable", + code: this.permanentFailure + ? "artifact_invalid" + : aborted + ? "cancelled" + : "transport", + message: this.permanentFailure + ? "Synapse lane disabled after a permanent failure" + : aborted + ? "Synapse request aborted" + : "Synapse lane is unavailable", disposition: this.permanentFailure ? "permanent" : "retryable", }); continue; diff --git a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts index d3de29c77..b42ee8e8f 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts @@ -137,4 +137,24 @@ describe("semver parsing", () => { expect(parseSemverTriple(bad)).toBeNull(); } }); + + test("leading zeroes are rejected rather than normalized", () => { + expect(parseSemverTriple("0.1.0")).toEqual([0, 1, 0]); + // Each of these would parse to an in-range triple under `\d+`, so the + // range gate would accept a non-canonical version. + for (const bad of ["00.1.0", "0.01.0", "0.1.00", "00.01.000", "01.2.3"]) { + expect(parseSemverTriple(bad)).toBeNull(); + } + }); + + test("a non-canonical daemon version fails the compatibility gate", () => { + // `00.01.000` normalizes to `[0, 1, 0]`, which is inside the supported + // half-open range, so only canonical-form rejection keeps this closed. + const verdict = evaluateDaemonCompatibility("mc-host/00.01.000"); + expect(verdict.ok).toBe(false); + if (!verdict.ok) { + expect(verdict.reason).toBe("incompatible_daemon"); + expect(verdict.detail).toBe("daemon version is not a canonical mc-host/X.Y.Z value"); + } + }); }); diff --git a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts index 69e196192..f0b8f690d 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts @@ -24,8 +24,17 @@ export type CompatibilityVerdict = type SemverTriple = [number, number, number]; +/** + * Each part is a semver numeric identifier: a single `0`, or a non-zero digit + * followed by any digits. `\d+` would also admit leading zeroes, which + * `Number.parseInt` then silently normalizes — `00.01.000` would parse to + * `[0, 1, 0]` and pass the range gate, so a non-canonical peer version would + * satisfy a check whose verdict promises a canonical `X.Y.Z` value. + */ +const CANONICAL_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + export function parseSemverTriple(value: string): SemverTriple | null { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); + const match = CANONICAL_SEMVER.exec(value); if (!match) return null; const triple: SemverTriple = [ Number.parseInt(match[1] as string, 10),