From fecff9c8f7fed60bca7d4c303614cd5502aa326c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 7 Oct 2026 11:30:30 -0400 Subject: [PATCH] fix: flush capability output before subprocess exit --- scripts/verify-binary.ts | 58 ++++++++++++++++++++++++++++++++++++++++ src/capability.test.ts | 45 +++++++++++++++++++++++++++++++ src/capability.ts | 7 ++--- test/native-pipe.ts | 23 ++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 test/native-pipe.ts diff --git a/scripts/verify-binary.ts b/scripts/verify-binary.ts index 6dd2ad98..55f60e98 100644 --- a/scripts/verify-binary.ts +++ b/scripts/verify-binary.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { captureNativePipe } from "../test/native-pipe"; import { auditPersistenceContract } from "./verify-binary-audit-contract"; const repoRoot = resolve(import.meta.dir, ".."); @@ -89,6 +90,63 @@ if (status.packageVersion !== version) { ); } +async function verifyCapabilityInventoryOutput(): Promise { + const root = join(tempHome, "capability-output-proof"); + const sourceRoot = join(root, "canonical"); + await mkdir(sourceRoot, { recursive: true }); + const entries = Array.from({ length: 96 }, (_, index) => ({ + id: `native-${index}`, + kind: "plugin", + owner: "native", + source: { + origin: `https://example.org/${"x".repeat(2048)}/${index}`, + revision: `v${index}`, + }, + })); + const manifest = join(sourceRoot, "registry.json"); + await Bun.write(manifest, JSON.stringify({ schemaVersion: 1, entries })); + const captured = captureNativePipe({ + command: [ + binaryPath, + "capability", + "inventory", + "--manifest", + manifest, + "--source-root", + sourceRoot, + "--target-root", + join(root, "targets"), + "--state-root", + join(root, "state"), + ], + }); + if (captured.status !== 0) { + throw new Error( + `Capability inventory pipe capture failed: ${captured.stderr}` + ); + } + const stdout = captured.stdout; + if (Buffer.byteLength(stdout) <= 128 * 1024) { + throw new Error( + "Capability inventory output was truncated at a pipe buffer boundary" + ); + } + const inventory = JSON.parse(stdout) as { + entries: Array<{ id: string; source: { origin: string } }>; + }; + if ( + JSON.stringify(inventory.entries.map((entry) => entry.id)) !== + JSON.stringify(entries.map((entry) => entry.id)) || + inventory.entries.at(-1)?.source.origin !== entries.at(-1)?.source.origin + ) { + throw new Error( + "Capability inventory did not preserve every complete entry" + ); + } +} + +await verifyCapabilityInventoryOutput(); + async function verifyProjectRenderer(): Promise { const expectedProjectTargets = 9; const projectRenderRoot = join(tempHome, "project-render-proof"); diff --git a/src/capability.test.ts b/src/capability.test.ts index cf267581..e9c033ce 100644 --- a/src/capability.test.ts +++ b/src/capability.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { captureNativePipe } from "../test/native-pipe"; import { applyCapability } from "./capability-apply"; import { snapshotCapability } from "./capability-files"; import { @@ -400,6 +401,50 @@ describe("versioned capability ownership", () => { await f.save(); await expect(f.plan()).rejects.toThrow("must not contain credentials"); }); + it("flushes inventory JSON larger than a pipe buffer before subprocess exit", async () => { + const f = await fixture(); + const entries: CapabilityEntry[] = Array.from( + { length: 96 }, + (_, index) => ({ + ...f.entry, + id: `native-${index}`, + owner: "native", + target: undefined, + source: { + origin: `https://example.org/${"x".repeat(2048)}/${index}`, + revision: `v${index}`, + }, + }) + ); + await f.save(entries); + const result = captureNativePipe({ + command: [ + process.execPath, + resolve(import.meta.dir, "index.ts"), + "capability", + "inventory", + "--manifest", + f.options.manifest, + "--source-root", + f.options.sourceRoot, + "--target-root", + f.options.targetRoot, + "--state-root", + f.options.stateRoot, + ], + }); + expect(result.status).toBe(0); + expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(128 * 1024); + const inventory = JSON.parse(result.stdout.toString()) as { + entries: Array<{ id: string; source: { origin: string } }>; + }; + expect(inventory.entries.map((entry) => entry.id)).toEqual( + entries.map((entry) => entry.id) + ); + expect(inventory.entries.at(-1)?.source.origin).toBe( + entries.at(-1)?.source.origin + ); + }); it("exposes the CLI digest and help contract", async () => { const f = await fixture(); const result = Bun.spawnSync([ diff --git a/src/capability.ts b/src/capability.ts index 8a64ccbd..b01d3ea0 100644 --- a/src/capability.ts +++ b/src/capability.ts @@ -8,6 +8,7 @@ import { type CapabilityPlan, loadCapabilityContext, } from "./capability-plan"; +import { writeCliOutput } from "./util/cli-output"; const FLAGS = new Set([ "--manifest", @@ -56,7 +57,7 @@ function required(flags: Map, key: string): string { } export async function capabilityCommand(argv: string[]): Promise { if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") { - console.log(`fclt capability — versioned ownership and narrow copy deployment + await writeCliOutput(`fclt capability — versioned ownership and narrow copy deployment digest --path inventory --manifest --source-root --target-root --state-root @@ -92,7 +93,7 @@ and native/external ownership never mutate targets. See docs/capability-registry if (!snapshot) { throw new Error("Capability path is missing"); } - console.log( + await writeCliOutput( JSON.stringify( { schemaVersion: 1, @@ -141,7 +142,7 @@ and native/external ownership never mutate targets. See docs/capability-registry required(flags, "--expected-plan") ); } - console.log(JSON.stringify(result, null, 2)); + await writeCliOutput(JSON.stringify(result, null, 2)); } catch (error) { // Do not echo parser input or filesystem errors that could contain credential values. console.error( diff --git a/test/native-pipe.ts b/test/native-pipe.ts new file mode 100644 index 00000000..f3190076 --- /dev/null +++ b/test/native-pipe.ts @@ -0,0 +1,23 @@ +interface NativePipeResult { + status: number | null; + stdout: string; + stderr: string; +} + +/** Node uses OS pipes here; Bun's subprocess sockets do not reproduce stdout backpressure. */ +export function captureNativePipe({ + command, +}: { + command: string[]; +}): NativePipeResult { + const reader = Bun.spawnSync([ + "node", + "-e", + 'const {spawnSync} = require("node:child_process"); const result = spawnSync(process.argv[1], process.argv.slice(2), {encoding: "utf8", maxBuffer: 4 * 1024 * 1024}); process.stdout.write(JSON.stringify({status: result.status, stdout: result.stdout, stderr: result.stderr}));', + ...command, + ]); + if (reader.exitCode !== 0) { + throw new Error(`Native pipe reader failed: ${reader.stderr.toString()}`); + } + return JSON.parse(reader.stdout.toString()) as NativePipeResult; +}