Repository navigation
fix(verifiers): retain the Go fixture root until MCP ownership completes - #8650
kaoru0822-kitauji wants to merge 5 commits into
Conversation
The Go verifier removed its private root even when the client or transport close failed, and its constructors ran before the guarded block. A successful SDK close was not proof either: StdioClientTransport can return right after its final SIGKILL, before the child is reaped. Add a shared transport that adopts the SDK's child into the process supervisor, proves its exit and output closure, and stays retryable. Release the root only after every owner and the process lineage check succeed, and report a retained root with the owner that can retry it. Fixes #8555 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…owners Look scenarios up in a Map, so a name from argv cannot reach anything but a declared handler. Take the directory from os.tmpdir(), which is how the verifier picks its root's parent, and derive the pipe holder's file from it instead of passing a second path through the environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CodeQL #684 ( Taint path: It appears because |
[Low impact] The PR appears safe to merge; no new actionable defect was established. SummaryThe Go verifier now keeps its private fixture root until its owners finish cleanup.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Verification finishes or fails] --> B[Close client and transport]
B --> C{Child exit and pipes closed?}
C -->|No| R[Retain root and report retry owner]
C -->|Yes| D{Platform}
D -->|POSIX| E{Lineage verified and empty?}
E -->|No| R
E -->|Yes| F[Remove fixture root]
D -->|Windows| G[Report missing descendant coverage]
G --> F
Reviews (4) · Last reviewed commit: "Merge origin/main into fix/8555-go-verif..." · Reviewed by Greptile |
The root was removed whenever the lineage observation returned unavailable, so a failed snapshot, an unreadable descendant environment or a foreign run token all counted as proof that no descendant remained. Require positive lineage through assertVerifierCleanupComplete on POSIX, and retry from a retained root. Windows has no lineage facility here, so the root is still released there and the report states the weaker proof. Stop everything a harness scenario starts on every exit, so a failed assertion no longer leaves a pipe holder or an unkillable child running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| }; | ||
| const setUpScenario = new Map(Object.entries(scenarios)).get(scenario); | ||
| assert.ok(setUpScenario, `Unknown scenario: ${scenario}`); | ||
| await setUpScenario(); |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yash27git
left a comment
There was a problem hiding this comment.
This is a false positive. The dynamic invocation is restricted to a statically declared scenario allowlist.
scenarios contains only locally defined handlers. The harness creates a Map from Object.entries(scenarios), looks up the requested scenario, and validates the result with assert.ok(setUpScenario) before invoking it.
Because Map does not expose inherited object properties, inputs such as __proto__ and constructor cannot resolve to prototype methods. Unknown scenario names fail before invocation.
This behavior was also tested explicitly: nope, __proto__, and constructor each exit with Unknown scenario.
There is no arbitrary method dispatch or attacker-controlled function execution here. I recommend keeping this finding classified as a false positive rather than changing correct test-harness logic solely to satisfy the static-analysis rule.
|
TL;DR: I ran the one changed test file on Node 22.23.4, 24.22.1 and 26.12.1. All 20 tests passed on each version. On head 61aea43, the
The file was CI on the head, read with
Not run
Test setupCommandsnpm ci --offline
npm run test:prepare
xvfb-run -a npm run test:focused -- tests/boundary/process/goVerifierCleanup.test.ts🤖 Written and tested with Claude Code (Sonnet 5.5, verified by Opus 5.5), posted with my OK 🦊 |
What broke:
verify-go-binary.mjsremoved its private root even when the MCP client or transport close failed, or while a child it had just sent SIGKILL was still unreaped, and a constructor failure skipped cleanup and leaked the root.The fix: A new transport adopts the SDK's child into
ProviderProcessSupervisorand proves exit and output closure. On POSIX the root goes only after every owner succeeds and lineage positively shows no live descendant; otherwise it is retained, reported and retryable. Windows has no lineage facility here, so it releases on the child's proof alone and the report says so.The risk: Limited to this verifier, plus one new helper with no other consumer yet. The helper reads the SDK's private
_processfield, with an IDA precedent, and fails closed if a later SDK removes it. A lineage failure on POSIX now fails the run and keeps the root, where it used to pass. Real Go did not run locally; thereal-go-binarylane passed on this head with lineage verified.Summary
Fixes #8555.
The verifier treated "the close call returned" as "the owner finished". A failed close, and a successful close with a live child, both led straight to root removal. It now holds the root until completion is proven and says so when it cannot.
Problem and expected behavior
Smallest triggers, all reproduced on latest main (
f95bdbce7) with the actual verifier:new Clientornew StdioClientTransportthrows: no cleanup runs and the root stays on disk with no report.close()resolves right after its final SIGKILL and the recorded order isroot-close,child-exit,child-close.The violated invariant is that a root must outlive anything that can still use it. As the issue says, waiting for a successful SDK close does not establish that.
Change and scope
scripts/lib/verifier-stdio-transport.mjsaddsVerifierStdioClientTransport, a subclass of the SDK transport.closeAndVerifyChild()runs the SDK close, then stops any survivor through the shared supervisor and requires the child's exit and itscloseevent. A rejection keeps the child owned and a later call retries it.close()deliberately keeps its contract and does not reject. After a failed handshake the SDK calls it without awaiting, so a rejecting override would become an unhandled rejection and end the process before the root is reported.verify-go-binary.mjsconstructs its owners insidewithVerifierCleanupand gains onefixtureowner with this order: client and transport, lineage check, root removal. Lineage used to be observed after the root was gone.assertVerifierCleanupCompletereplaces the old "assert only if verified" condition, so anyunavailableresult keeps the root and a later close observes again. This is what makes the root rule hold: unknown no longer counts as absent.completeVerifierRunalways returnsunavailablebecause the verifier has no Job Object lease, so requiring it would retain every root. The root is released on the child's completion alone, andomitted_coveragesays other descendants were not observed.retained_root, and the thrownGoVerifierCleanupFailurecarriesretainedRootandcleanupOwnerfor retry. Failures stay one flat list with the primary first.real-go-binary.ymlnow also triggers on the twoscripts/libfiles the verifier depends on for this.Not included: #8561, #8562 and #8564. A
verifiedlineage only walks live parent chains, so a reparented descendant holding no pipes still reads as absent rather than unknown. That limit ofcompleteVerifierRunis unchanged, and retaining onunavailabledoes not close it. The SDK child is not placed in its own process group, so there is no group kill of descendants. Moving tospawnOwnedProviderProcesswould add that, at the cost of replacing the SDK's stdio client in a verifier meant to exercise it.Contribution scope: ordinary fix within existing boundaries; maintainer scope decision not applicable.
Contract and boundary impact
fixtureowns release ordering. The unconditional cleanup loop is removed.docs/public/product-catalog.json), package, or installation impact: none.Expected effects (check known effects; use documentation-only on its own).
Unchecked items do not establish absence; leave uncertainties visible.
The launched processes are the verifier's own MCP child and compiler, as before.
Evidence and regression coverage
tests/boundary/process/goVerifierCleanup.test.ts, 20 scenarios that import the actual verifier in a subprocess with a fake compiler. Faults are authored; the SDK client and transport are real except in the three scenarios that replace an SDK module. Scenarios that reach a child use realrea mcponce, as a control, and a disposable child otherwise.close()), a kill that never lands, constructor failures, failed spawn, refused handshake, each mix of client, transport and root close failures, retained-root reporting, and retry after each retained case.process.platformflip around that one window. Everything else stays POSIX.real-server,missing-compilerandspawn-failure.cdb875257, a failed snapshot, an unreadable environment and a foreign token each reportedunavailable, removed the root and raised no cleanup failure. Those three and the Windows scenario fail there;live-descendantalso fails there, only because its expected message was tightened.pipe-holderandundelivered-kill, the earlier harness left one process running in each and this one leaves none. The base andcdb875257test runs above also left none.late-exit, because the lineagepshappened to give the child time to die, so I held the kill back to make that scenario strict. Requiring lineage on Windows too fails the Windows scenario.real-go-binarylane on Linux. Native Windows was not run and the test is skipped there, so the Windows release path has only the simulated run above. The other twounavailablereasons, a changed parent and an absent launcher, were not run separately; they take the same check. Frequency of the late-exit window in real runs stays NOT_ENOUGH_INFO.Lifecycle regressions from
docs/testing.md: mixed child outcomes and cleanup retry are covered. A refused handshake is covered; a child that never answers the handshake is not, because it would wait out the SDK's 60-second default. Cancellation during final cleanup does not apply, as this verifier has no cancellation path.For evidence-bearing changes: not applicable, no Evidence contract changed.
Validation performed
npx vitest runongoVerifierCleanup,verifierCleanup,verifierRun,goBinaryMcpandciChangeScope— 397 passed in 5 files.goVerifierCleanupalone, 3 consecutive runs — 20 of 20 each, about 27 seconds each.real-go-binaryon this head (CI, Linux, Go 1.26.1) — passed,status: passed, 15 binaries, 30 public cases, lineageverifiedwith no descendants.npm run check:fast— passed.npm run format:check— passed.node scripts/verify-go-binary.mjswith no Go installed — exit 1, primary "requires Go" error kept,retained_root: null, no root left on disk.rea mcpand a refused client close — exit 1, primary and cleanup failures both printed, root retained and named.node scripts/ci/plan.mjsfor this diff — selectscode,runtimeandworkflows, so the new test runs in the builtprocess-boundaryproject.Docs and catalog are unchanged, so
docs:checkandmetadata:checkwere not run. Analysis cost is unaffected. The new test file adds about 27 seconds toprocess-boundary, mostly the SDK's two fixed 2-second close grace periods.Compatibility, safety, and release
real-go-binaryCI lane, on Linux. This change could have broken its success path, sinceunavailablenow fails the run; that lane reported lineageverified, so it did not.Author readiness
Human owner: @kaoru0822-kitauji
Declarations entered by Claude Code on the owner's standing written instruction.
Before merge
type(scope): outcome.🤖 Generated with Claude Code