Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/real-go-binary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
- "src/server/registerGoTools.ts"
- "scripts/verify-go-binary.mjs"
- "scripts/lib/mcp-verifier-results.mjs"
- "scripts/lib/verifier-cleanup.mjs"
- "scripts/lib/verifier-stdio-transport.mjs"

permissions:
contents: read
Expand Down
79 changes: 79 additions & 0 deletions scripts/lib/verifier-stdio-transport.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { ChildProcess } from "node:child_process";

import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

import { ProviderProcessSupervisor } from "../../dist/process/ProviderProcess.js";
import { closeVerifierResources } from "./verifier-cleanup.mjs";

const noChildHandle = () =>
new Error(
"The MCP SDK stdio transport exposes no child handle, so its completion cannot be proven",
);

/**
* SDK stdio transport that can prove its child finished.
*
* The SDK's `close()` can return right after sending its final SIGKILL, before
* the child is reaped, and its `pid` is cleared by then. `close()` itself keeps
* that contract and must not reject: the SDK calls it without awaiting after a
* failed handshake. Callers that release resources the child can use await
* `closeAndVerifyChild()` instead.
*/
export class VerifierStdioClientTransport extends StdioClientTransport {
#supervisor;
#handleMissing = false;
#outputClosed = false;

async start() {
const started = super.start();
// The SDK assigns its child synchronously and exposes only that PID.
const child = Reflect.get(this, "_process");
// A failed spawn rejects here, having acquired nothing to prove finished.
await started;
if (!(child instanceof ChildProcess)) {
this.#handleMissing = true;
throw noChildHandle();
}
// An unread stderr would hold the child's pipe open past its exit.
this.stderr?.resume();
child.once("close", () => {
this.#outputClosed = true;
});
this.#supervisor = new ProviderProcessSupervisor(
{ process: child, ownsProcessLifetime: true },
{
captureStdout: false,
maxDiagnosticBytes: { stdout: 0, stderr: 0 },
},
);
}

/**
* Close through the SDK, then stop any surviving child and prove it finished.
* A rejection keeps the child owned; calling this again retries from its state.
*/
closeAndVerifyChild() {
return closeVerifierResources([
() => this.close(),
() => this.#verifyChildFinished(),
]);
}

async #verifyChildFinished() {
if (this.#handleMissing) throw noChildHandle();
const supervisor = this.#supervisor;
if (supervisor === undefined) return;
const stopped = await supervisor.stop();
const child = `MCP stdio child ${String(supervisor.launch.process.pid)}`;
if (stopped.status === "incomplete")
throw new Error(
`${child} completion could not be proven: ${stopped.reason}`,
);
// Exit proves only this process. Its pipes stay open while anything that
// inherited them runs, including a reparented process no lineage walk finds.
if (!this.#outputClosed)
throw new Error(
`${child} exited, but its stdio pipes are still open, so a process that inherited them may still be running`,
);
}
}
104 changes: 68 additions & 36 deletions scripts/verify-go-binary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,32 @@ import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { goBinarySchema } from "../dist/domain/go/goBinary.js";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { VerifierStdioClientTransport } from "./lib/verifier-stdio-transport.mjs";

/** The retained owner remains the exact authority for retrying this release. */
class GoVerifierCleanupFailure extends AggregateError {
_tag = "GoVerifierCleanupFailure";
reason = "cleanup_incomplete";

constructor(failures, cleanupOwner, retainedRoot) {
super(
failures,
`Real Go metadata verification failed; retained fixture root ${retainedRoot} because its cleanup is incomplete.`,
);
this.name = "GoVerifierCleanupFailure";
this.cleanupOwner = cleanupOwner;
this.retainedRoot = retainedRoot;
}
}

const execute = promisify(execFile);
const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go";
Expand Down Expand Up @@ -50,13 +70,8 @@ const goEnvironment = {
// Go's own verifier seam isolates telemetry; no global configuration is changed.
TEST_TELEMETRY_DIR: join(root.path, "telemetry"),
};
const client = new Client({ name: "go-binary-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
let client;
let transport;
const targets = [
["linux", "amd64", "elf", 64, "little"],
["linux", "386", "elf", 32, "little"],
Expand All @@ -70,8 +85,34 @@ const targets = [
];
const verified = [];
const omittedCoverage = [];
let verifier;
let rootRemoved = false;
// Each owner a verification acquired must finish before its root can go. Any
// failure leaves the root and owners in place, and closing again retries them.
const fixture = {
async close() {
if (rootRemoved) return;
await closeVerifierResources([
() => client?.close(),
() => transport?.closeAndVerifyChild(),
]);
verifier = await completeVerifierRun(run);
if (verifier.process_lineage.status === "verified")
assert.deepEqual(verifier.process_lineage.descendants, []);
await root.close();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
await assert.rejects(lstat(root.path), { code: "ENOENT" });
rootRemoved = true;
},
};
const failures = [];
try {
await withVerifierCleanup(async () => {
client = new Client({ name: "go-binary-verifier", version: "1" });
transport = new VerifierStdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
await prepareFixture();
try {
compilerVersion = (
Expand Down Expand Up @@ -194,29 +235,11 @@ try {
);
await verify(path, targets[0], oracle, expected);
}
} catch (cause) {
failures.push(cause);
} finally {
for (const close of [
() => client.close(),
() => transport.close(),
() => root.close(),
]) {
try {
await close();
} catch (cause) {
failures.push(cause);
}
}
}
const verifier = await completeVerifierRun(run);
try {
await assert.rejects(lstat(root.path), { code: "ENOENT" });
if (verifier.process_lineage.status === "verified")
assert.deepEqual(verifier.process_lineage.descendants, []);
} catch (cause) {
failures.push(cause);
}
}, [() => fixture.close()]).catch((cause) => {
failures.push(...flattenFailures(cause));
});
// A release that stopped early never observed lineage; report it regardless.
verifier ??= await completeVerifierRun(run);
if (failures.length > 0) {
console.error(
JSON.stringify(
Expand All @@ -225,16 +248,19 @@ if (failures.length > 0) {
compiler: compilerVersion,
verified,
omitted_coverage: omittedCoverage,
retained_root: rootRemoved ? null : root.path,
verifier,
},
null,
2,
),
);
throw new AggregateError(
failures,
"Real Go metadata verification failed; cleanup failures are retained.",
);
if (rootRemoved)
throw new AggregateError(
failures,
"Real Go metadata verification failed; cleanup failures are retained.",
);
throw new GoVerifierCleanupFailure(failures, fixture, root.path);
}
console.log(
JSON.stringify(
Expand All @@ -254,6 +280,12 @@ console.log(
),
);

function flattenFailures(cause) {
return cause instanceof AggregateError
? cause.errors.flatMap(flattenFailures)
: [cause];
}

async function go(args, overrides = {}) {
return execute(compiler, args, {
cwd: sourceRoot,
Expand Down
Loading
Loading