Skip to content

test(release): authenticate qualification evidence - #64

Merged
ahrav merged 5 commits into
feat/mc-host-daemon-lifecyclefrom
stack/mc-host-16-release-verifier
Aug 29, 2026
Merged

test(release): authenticate qualification evidence#64
ahrav merged 5 commits into
feat/mc-host-daemon-lifecyclefrom
stack/mc-host-16-release-verifier

Conversation

@ahrav

@ahrav ahrav commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Installed-release qualification now binds proof bytes, target reports, the checkout commit, workflow run, signer workflow, and the top-level evidence record into one attested chain. Target reports must name the expected target and carry an exact passing schema; injected verification failures remain hermetic and never fall through to ambient GitHub state.

Verification

  • bun test scripts/verify-mc-host-release-evidence.test.ts: 23 passed
  • bun run typecheck
  • bun run lint

Remaining Qualification

The protected qualification workflow and real registry/platform evidence remain external. npm publication is intentionally skipped.

Stack

Layer 6 of 7 above #46. Parent: #62. Next: #65.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c70e10ee-228e-4ee9-abee-154634396ea9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review

Reviewed scripts/verify-mc-host-release-evidence.ts and its test file. This is a solid security-hardening change with two real, well-targeted fixes:

  1. Attestation fallback bypass (the main bug fix) — previously options.verifyAttestation?.(...) ?? (fallback)() meant an injected verifier that legitimately returns null (a rejection) would fall through to the ambient gh attestation verify call instead of being treated as a rejection, since ?? doesn't distinguish "not provided" from "returned null." resolveAttestationVerification fixes this by keying off whether the injected function itself is undefined, not its return value. The new test (an injected attestation rejection never invokes the ambient fallback) directly pins this behavior. Good catch — this is exactly the kind of bug that could let a compromised/misbehaving verifier silently escalate to a passing ambient check in test/mocked contexts.

  2. Target proof content wasn't actually checked (validateTargetTestReport) — previously the target proof's expectedObservations.test_report_path/test_report_sha256 were computed by echoing back whatever the actual observations claimed (once format/hash-consistency checks passed), so the drift check was tautological — it never verified the referenced report actually asserted passed: true for the right target/schema. validateTargetTestReport now parses the report and enforces schema, target, and passed === true explicitly. The new parametrized test (failed / wrong-schema / wrong-target) exercises exactly the gap this closes. This looks like the more important of the two fixes from a security-gating standpoint — without it, GA qualification could previously be gated on a test report that never actually passed, as long as the hash chain was internally consistent.

Both fixes are well covered by tests, and I traced the existing test fixtures (installProofArtifacts) to confirm they already produce schema-conformant, passed: true reports, so no other tests are silently broken by the stricter check.

Minor observations (non-blocking)

  • The options.verifyAttestation === undefined ? undefined : () => options.verifyAttestation?.(...) pattern is repeated twice with an arguably-redundant ?. inside the closure (once the outer ternary already established the property is defined). This is likely there because TS narrowing of options.<prop> doesn't reliably persist across the closure boundary — worth a one-line comment if the redundancy is intentional, otherwise harmless.
  • verifyAttestationWithGitHub nicely deduplicates what were two near-identical inline spawnSync IIFEs — good simplification alongside the behavioral fix.

No security or correctness issues found beyond what's already fixed by this PR; no additional bugs identified.

@ahrav
ahrav force-pushed the stack/mc-host-16-release-verifier branch from 33c34e1 to 2b72aef Compare August 28, 2026 19:03
@ahrav
ahrav marked this pull request as ready for review August 29, 2026 03:33
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@kilo-code-bot

kilo-code-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • crates/mc-module/src/bin/ck_mc_host/spawn.rs
Previous Review Summaries (4 snapshots, latest commit a4344f6)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit a4344f6)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • crates/mc-module/src/bin/ck_mc_host/spawn.rs

Previous review (commit fe8ee1d)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (9 files)
  • .github/workflows/ci.yml
  • packages/plugin/src/features/magic-context/memory/embedding-synapse.ts
  • packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts
  • packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts
  • packages/plugin/src/shared/mc-host-lifecycle/policy.ts
  • scripts/build-mc-host-payload.ts
  • scripts/qualify-mc-host-production-inputs.ts
  • scripts/verify-mc-host-release-evidence.test.ts
  • scripts/verify-mc-host-release-evidence.ts

Previous review (commit bc17fa3)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (10 files)
  • .github/workflows/ci.yml
  • packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts
  • packages/plugin/src/features/magic-context/memory/embedding-synapse.ts
  • packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts
  • packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts
  • packages/plugin/src/shared/mc-host-lifecycle/policy.ts
  • scripts/build-mc-host-payload.ts
  • scripts/qualify-mc-host-production-inputs.ts
  • scripts/verify-mc-host-release-evidence.test.ts
  • scripts/verify-mc-host-release-evidence.ts

Previous review (commit 2b72aef)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • scripts/verify-mc-host-release-evidence.test.ts
  • scripts/verify-mc-host-release-evidence.ts

Reviewed by gemini-3.7-flash · Input: 105.7K · Output: 4.1K · Cached: 371.4K

Base automatically changed from stack/mc-host-15-ci-portability to feat/mc-host-daemon-lifecycle August 29, 2026 15:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);

P2 Badge Reject leading-zero semantic versions

When an authenticated daemon or catalog entry reports a version such as 00.01.000, this regex accepts it and parseInt normalizes it to [0, 1, 0], so evaluateDaemonCompatibility("mc-host/00.01.000") currently returns { ok: true }. Numeric semantic-version identifiers with leading zeroes are not canonical, despite this function and its compatibility errors promising canonical X.Y.Z values, so a malformed peer version can pass the fail-closed compatibility gate; reject leading zeroes except for the single digit 0.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// 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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the workflow required by the GA gate

When release:evidence:check validates qualified evidence, this condition always rejects the current repository because .github/workflows/mc-host-release-qualification.yml is absent. A repo-wide search of .github/workflows and workflow references found only the four existing workflow files and no generator or renamed equivalent, so no qualified release can pass this newly added gate until the referenced signer workflow is included or the constant names an existing workflow.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the factual core: the file is genuinely absent, and this gate is added by this PR, not inherited from the base. ls .github/workflows shows only ci.yml, claude-code-review.yml, retrieval-benchmark.yml, and shm-hardening-optin.yml, and the only references to .github/workflows/mc-host-release-qualification.yml are this script and its test. The other mc-host-release-qualification hits in the tree are tmp/mc-host-release-qualification.json, a different artifact that happens to share the substring.

Where I disagree is the consequence. This gate is not what makes a qualified release unpassable, because two pre-existing checks already require that workflow to have run:

// line 900, already on the base branch
workflow !== QUALIFICATION_WORKFLOW_PATH

Every qualified proof's cited source.workflow must equal that same constant, and each proof's attestation is then verified with --signer-workflow ${source.repository}/${source.workflow}. GitHub can only produce such an attestation if that workflow actually ran in this repository, which requires the file to exist. So with the workflow absent, qualified evidence fails at the per-proof attestation step whether or not this existsSync check is present. Removing it would not let a GA release through; it would only move the failure later and make it read as an opaque attestation mismatch, which is what the comment above it is there to avoid.

For what it's worth, release:evidence:check currently fails earlier than either check, at readJson with ENOENT, because the installed evidence file does not exist in the checkout yet. That is true on the base branch too, so this PR does not change the outcome of that command.

So I am keeping the gate as a fail-closed assertion and treating the missing workflow as separate release work: it has to land for GA regardless, since it is the workflow that mints the attestations. Happy to drop the check if you would rather the branch carry no reference to a workflow that does not exist yet, but that trades a clear error for a vaguer one without unblocking anything.

// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind cached storage state to its compatibility probe

When managed demands overlap, this closure-level slot is shared by every compatibilityProbe invocation, including both magic-context and synapse demands, while each Magic Context waiter later consumes whichever value was written last. A slower probe can therefore overwrite another waiter's observation, causing that waiter to report ready or unavailable from a different request or daemon generation; in the ready case, module traffic can proceed even though its own authenticated status reported storage as starting. Carry the storage observation alongside the corresponding snapshot or key it to the request and daemon identity instead of using one shared slot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in fe8ee1db. You identified a hazard this branch created while merging two independent hardenings, and the write-side reasoning is right on every point.

What I verified before changing anything:

  • sharedCompatibility dedupes per data root, and its key is rootResolution.ok ? rootResolution.root : "\u0000no-root" while the closure always probes root.root. So a real-root and a no-root key can be in flight at once, giving two concurrent writers to one slot.
  • A demand whose capability is not magic-context returns before the storage probe, so it writes an observation it never consumes.
  • The reuse path never saw expectedDaemonId; the fence I had threaded only guarded the probeManagedStorage fallback. So untagged reuse was exactly the "different request or daemon generation" case you describe.

The slot now carries the incarnation whose host.status produced it, and is consumed only when the demanding waiter certified that same incarnation:

let observedStorage: {
    daemonId: Uint8Array;
    state: "ready" | "starting" | "unavailable";
} | null = null;

Reuse requires expectedDaemonId !== undefined && sameDaemonId(observed.daemonId, expectedDaemonId); anything else falls through to the polling probe, which already carries the fence. I reused the existing sameDaemonId from mc-host-client/types rather than adding a comparison helper.

I took the tagging option rather than carrying the observation on the snapshot because it keeps the change inside the two closures; the snapshot is a shared single-flight value, so attaching per-waiter state to it would have widened the surface.

One limitation worth stating plainly: this is not covered by a new unit test. defaultStorageProbe and defaultCompatibilityProbe are closures internal to createManagedLifecyclePolicy that call the module-level probes directly, and passing options.storageProbe bypasses the defaults entirely, so observing them would mean restructuring production code to inject a seam. The change is a strict narrowing of when reuse is allowed, so it cannot produce a ready the old code would not also have produced. It is covered indirectly by the 124 lifecycle tests and the full plugin suite.

Comment on lines +322 to +324
command === "restart" &&
record.ok &&
(effects?.start_committed !== true || state !== "running" || reason !== "started")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep rejecting impossible committed-start failures

When a malformed native restart result combines a pre-start failure with start_committed: true, this narrowed condition now accepts it as long as ok is false. For example, {state:"unavailable", reason:"no_data_dir", effects:{stop_committed:false,start_committed:true}} parses successfully, even though the native start_phase sets start_committed:false for every failure before spawn and authenticated publication. This lets the CLI report a start commit that provably never occurred; retain the newly supported post-commit failure outcomes while rejecting reasons and states that can only occur before the start commit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is now outdated rather than declined. It was raised against bc17fa3d, the pre-rebase head, and contract.ts is no longer part of this PR.

The branch has since been rebased onto the current base, which had already absorbed #59 through #62. Six of the eight commits here were duplicates of work that had landed, and the parseDaemonResult change was one of them, so the base's own version now applies:

git diff --quiet origin/feat/mc-host-daemon-lifecycle HEAD -- \
  packages/plugin/src/shared/mc-host-lifecycle/contract.ts   # identical

contract.ts does not appear in this PR's diff at all now, which is why GitHub left this comment pinned to the old commit while re-anchoring the other two.

On the substance, though, your reading looks correct against the code that is on the base branch. The condition there is

command === "restart" &&
record.ok &&
(effects?.start_committed !== true || state !== "running" || reason !== "started")

so gating on record.ok does mean a result with ok: false and start_committed: true no longer trips it, where the previous effects?.start_committed === true && (!record.ok || ...) form would have. Your example of {state:"unavailable", reason:"no_data_dir", effects:{start_committed:true}} parses under that.

I have not changed it here, because doing so would pull base code into a PR whose diff no longer touches that file, and the fix wants its own test for the pre-commit reasons and states. It is worth raising against the base branch as its own change; flagging it here so it does not get lost when this comment is marked resolved.

ahrav added 2 commits August 29, 2026 17:02
- 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.
@ahrav
ahrav force-pushed the stack/mc-host-16-release-verifier branch from bc17fa3 to 8d7c12d Compare August 29, 2026 17:15
@ahrav

ahrav commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto the current feat/mc-host-daemon-lifecycle to clear the merge conflict. Worth reading before re-reviewing, because the diff changed shape.

Why the history was rewritten. The base had absorbed #59/#60/#61/#62 since this branch forked, and 6 of the 8 commits here were already in it: 2 were byte-identical (git cherry -), and 4 were older variants of commits base had since carried further review fixes on top of. Replaying them would have reverted base's fixes (base → pre-branch-tip was 478 insertions / 2122 deletions). The branch is now rebase --onto base with only its own two commits, and the conflict went from 44 files to 6.

On the two observations above; both are resolved by deletion, not by a comment.

resolveAttestationVerification and verifyAttestationWithGitHub are gone. Base already fixed the ?? fallback bypass inline:

const attestationResult =
    options.verifyAttestation !== undefined
        ? options.verifyAttestation(proofPath, proof, workflowSource)
        : ghAttestationJson(rootDir, proofPath, workflowSource);

That keys off the property being undefined rather than its return value, so it is the same fix, and because it calls the function directly instead of wrapping it in a closure, the arguably-redundant ?. you flagged no longer exists. verifyAttestationWithGitHub was also byte-for-byte ghAttestationJson, so keeping it would have been a duplicate helper.

Base additionally narrows attestations to a shared workflow-run attempt (matchedAttestationAttempts) and picks the attested attempt by verdict rather than array order, which this branch's version did not do. The security property your point 1 was protecting is still covered end-to-end by base's a declined attestation stub is rejected without consulting gh, which asserts both the rejection and that the injected verifier ran exactly once.

What this branch still contributes. Base's verifyTargetTestReport supersedes the old validateTargetTestReport (it adds a TEST_REPORT_DIR check and a seen map rejecting one report satisfying two targets), so production code here is base's. But base gated schema, target, and passed in a single condition while testing only the wrong-target case. I parametrized that test over failed / wrong-schema / wrong-target and confirmed it is not vacuous: deleting report.passed !== true fails the (failed) case, while the pre-existing wrong-target-only test still passes. I also kept a test for the missing-qualification-workflow check, which base enforced in code but never exercised.

I dropped this branch's re-validates the daemon identity for pages after a module restart test: base covers that property more thoroughly, in particular scopes an exhausted restart budget to its page and leaves sibling pages runnable.

One resolution was a genuine merge of both sides rather than a pick: base threaded expectedDaemonId into the storage probe, while this branch had added reuse of the host.status storage observation. Neither was a superset, so defaultStorageProbe now does both.

Gates on 8d7c12d4: typecheck and lint clean, 3232 plugin tests and 150 release tests passing.

ahrav added 2 commits August 29, 2026 17:23
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.
`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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4344f6ed2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +105 to +109
#[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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve retained descriptors before remapping standard fds

When ck-mc-host starts on macOS with any standard descriptor closed, resolve_generation_launcher or open_log can reuse fd 0, 1, or 2 before this newly enabled pipe path runs. The child remaps stdin/stdout/stderr before copying the retained executable to fd 3, so that remapping can overwrite the launcher descriptor; a same-fd dup2 also leaves the newly applied FD_CLOEXEC flag intact. Service managers that launch the CLI with closed stdio can therefore make daemon startup time out or lose its log, so duplicate all retained descriptors above the standard-fd range before performing the child remaps.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented this one before I could defend it, then reverted it. The reasoning about the child is correct, but the premise it rests on cannot hold in this binary, so the fix was guarding a state that does not occur.

Everything you say about the child is accurate, and I want to be clear that I checked it rather than waving it off:

  • Nothing in this repository sanitizes stdio. There is no /dev/null guard and no F_GETFD sweep anywhere under crates/mc-module/src/bin/ck_mc_host/.
  • The ordering hazard is real if a retained descriptor is low. dup2(log_fd, 1) would overwrite a launcher at fd 1, and the later dup2(exe_fd, 3) would then pin the log, leaving fexecve(3) pointed at the log file.
  • Your close-on-exec point is the sharper half and it is also correct. A same-fd dup2 succeeds as a no-op and does not clear FD_CLOEXEC, so a read end at fd 0 or a log at fd 1 would be closed at exec rather than inherited. Worth noting this half is not Darwin-specific: Linux sets O_CLOEXEC at creation via pipe2, and open_log passes O_CLOEXEC on both platforms, so a same-fd dup2 would strand the descriptor there too.

Where it breaks down is the first step: ck-mc-host cannot reach resolve_generation_launcher or open_log with fd 0, 1, or 2 free, because Rust's standard library reopens closed standard descriptors before main runs. std::sys::pal::unix::init calls sanitize_standard_fds(), which points any closed standard descriptor at /dev/null, and aborts the process if it cannot.

Measured on this toolchain (1.98.0), launching a Rust binary from a parent that closed fd 0 with exec 0<&-:

open fds at main(): ["0->/dev/null", "1->socket:[...]", "2->socket:[...]", "3->/proc/.../fd"]
first open() got fd 3

The same parent running ls instead shows 0 -> /proc/<pid>/fd, so fd 0 was genuinely free — the difference is the Rust runtime, not the launcher.

Since you scoped this to macOS, that is the case I checked most carefully in the std source rather than assuming it generalizes. The poll fast path is in fact excluded there:

// The poll on Darwin doesn't set POLLNVAL for closed fds.
target_vendor = "apple",

But the fcntl fallback immediately below it is not excluded for Darwin — its list is emscripten, fuchsia, vxworks, l4re, horizon, vita — and it does the same repair:

for fd in 0..3 {
    if libc::fcntl(fd, libc::F_GETFD) == -1 && errno() == libc::EBADF {
        open_devnull();
    }
}

So on darwin-arm64 and darwin-x64, the two targets this PR adds to CI, a closed standard descriptor is reopened before any of this code runs.

I also checked the one route that would bypass startup repair, a descriptor freed later at runtime. The only stdin use in the binary is std::io::stdin().lock() in read_envelope and read_launcher_envelope, which borrows the shared handle and never closes fd 0. Nothing else touches fds 0-2.

For completeness on what I discarded: I had move_above_stdio hoisting the log, launcher, and pipe read end above the standard range with F_DUPFD_CLOEXEC, plus a lifecycle_cli test that stages a real launcher and runs start from a child with fd 0 closed. That test is what settled it — it passes with the hoist removed, because the launcher lands at fd 3 either way. I would rather carry no test than one that cannot fail, and adding unsafe and a new SpawnError path to guard an unreachable state is the kind of defensive code this repository asks me not to accrete.

Happy to reconsider if you can point at a path where a standard descriptor is free after main — that would make this reachable and I would take the hoist back.

// 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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cancellation while revalidating a page

When the caller's AbortSignal fires while this newly added revalidation is awaiting initialize(signal), initialize catches the aborted raceSignal and returns false. This branch then records the current page as a retryable transport failure even though the immediately preceding check classified the same signal as cancelled; later pages are also reported as cancelled. Check the signal again after initialization so an aborted multi-page request does not misreport one page as an infrastructure failure and invite an unintended retry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 348a1f84. Your read of the mechanism is exactly right, including the detail that makes it easy to miss: the abort arrives as a return value, not as an exception.

What I verified before changing anything:

  • initialize swallows the abort. The tail is try { return await raceSignal(initialization, signal); } catch { return false; }, so a raceSignal rejection from the abort listener is folded into a plain false with no way for the caller to tell it apart from a real initialization failure.
  • The managed path folds it the same way one level up: const outcome = signal ? await raceSignal(demand, signal) : await demand; sits inside a try whose catch logs and returns false.
  • The disagreement you describe is real. The branch hard-coded code: "transport" and disposition: "retryable", while the check at the top of the same loop classifies the identical signal as cancelled. So one page of a cancelled multi-page request was recorded as an infrastructure failure and the rest as cancelled.

The signal is now re-read after initialization, with permanentFailure keeping precedence so the branch matches the ordering of the check above it:

const aborted = signal?.aborted === true;

The consequence you flagged is the one I care about most: retryable on a withdrawn request invites a retry of work the caller already abandoned.

On coverage, the test is reports a page cancelled when the abort lands inside its re-validation, and I confirmed it is not vacuous — with the aborted read forced to false it fails with Expected: "cancelled" / Received: "transport", which is the exact symptom you reported.

Getting it to land in that branch took some care, and the reason is worth recording. In the injected-client shape the branch is reachable but not abortable: getSharedClient memoizes per factory, and the constructor pre-seeds metadata from fingerprint/tableEpoch/dims, so a second initialize has no suspension point and returns true before any signal can fire. The managed path does have a real await, but recertifyForRestart re-initializes eagerly, so a page that restarts leaves initialized true again for the next page. The test therefore installs the branch's documented precondition directly — managed origin, identity cleared from the first page's own response — the same way the existing tests in this file install post-rotation state through internals. That keeps the second page's signal.aborted check ahead of the abort, so the abort can only be observed inside the re-validation itself. It asserts one demand, one receipt for the first group, and that the cancelled page never reached the wire.

I left embedItems alone. It has the same !this.initialized && !(await this.initialize(signal)) shape, but it breaks without recording a failure, so there is no classification to get wrong.

…tion

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.
@ahrav

ahrav commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Leading-zero semantic versions — fixed in 348a1f84

Answering the compatibility.ts#L28 finding from the Codex review at 16:30. It arrived in the review body rather than as an inline thread, so there was nothing to reply to in place and it went unanswered while I worked through the three inline comments — flagging it here so it is not lost.

The report is correct as written, and I reproduced the exact claim before changing anything. evaluateDaemonCompatibility("mc-host/00.01.000") returned { ok: true }:

  • /^(\d+)\.(\d+)\.(\d+)$/ matches 00.01.000.
  • Number.parseInt(..., 10) normalizes the parts to [0, 1, 0]. Base 10 is explicit, so there is no octal reading — the zeroes are simply dropped.
  • supported_daemon_range in the generated contract is min_inclusive: "0.1.0", max_exclusive: "0.2.0", so [0, 1, 0] is inside the range and the gate passes.

The part that makes this worth fixing rather than noting is the promise on the other side of the check. The failure detail is "daemon version is not a canonical mc-host/X.Y.Z value", and the existing test is named only canonical X.Y.Z parses, so both the verdict and the test asserted a canonical form the parser did not enforce.

Each part is now a semver numeric identifier — a single 0, or a non-zero digit followed by any digits:

const CANONICAL_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;

The Number.isSafeInteger guard stays, since a long run of digits still matches and still needs rejecting.

On blast radius, I checked every caller rather than assuming this only affects peer versions. parseSemverTriple is called in three places, all inside compatibility.ts: the two range bounds in inHalfOpenRange, the authenticated daemon version, and entry.module_version from the catalog. The bounds come from the generated contract, and every version and range string in it is canonical — 0.1.0, 0.2.0. So the narrowing cannot fail a legitimate comparison; it can only reject a non-canonical value arriving from a peer, which is the intent.

Two tests, both confirmed to fail without the fix:

  • leading zeroes are rejected rather than normalized covers 00.1.0, 0.01.0, 0.1.00, 00.01.000, and 01.2.3.
  • a non-canonical daemon version fails the compatibility gate pins the end-to-end verdict, since that is the behavior the report actually named. Without the fix it fails with Expected: false / Received: true.

Worth saying plainly: this is base code, not something this branch introduced — parseSemverTriple is untouched by this PR's diff, which only refactors evaluateCompatibility into the COMPATIBILITY_STAGES table further down the file. I fixed it here rather than deferring it, unlike the contract.ts finding I declined earlier, and the distinction is whether the file is already in this PR's diff. compatibility.ts is, so a one-line narrowing plus tests does not widen the review surface; contract.ts is not, so touching it would have added a file to a PR whose diff no longer includes it.

@ahrav
ahrav merged commit cae23cf into feat/mc-host-daemon-lifecycle Aug 29, 2026
17 of 21 checks passed
@ahrav
ahrav deleted the stack/mc-host-16-release-verifier branch August 29, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant