Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 3 additions & 1 deletion proofkit/witness-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -2160,6 +2160,7 @@
"proofkit/cli-contract.v2.json",
"proofkit/requirement-bindings.json",
"proofkit/witness-plan.json",
"scripts",
"scripts/admission-records-structure.test.mjs",
"scripts/adoption-envelope-structure.test.mjs",
"scripts/agent-workflow-structure.test.mjs",
Expand Down Expand Up @@ -2196,7 +2197,8 @@
"scripts/selective-proof-structure.test.mjs",
"scripts/source-conversion-structure.test.mjs",
"scripts/stable-json.mjs",
"scripts/workspace-structure.test.mjs"
"scripts/workspace-structure.test.mjs",
"tests"
],
"outputSelectors": [],
"resourceReads": [
Expand Down
6 changes: 3 additions & 3 deletions scripts/admission-records-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import {execFileSync, spawnSync} from 'node:child_process';
import {execFileSync} from 'node:child_process';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import test, {after, before} from 'node:test';
import Ajv2020 from 'ajv/dist/2020.js';
import {assertCLITransport} from './cli-process.mjs';
import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs';
import {expandSharedDefinitions} from './schema-definitions.mjs';

const root = new URL('../', import.meta.url);
Expand Down Expand Up @@ -94,7 +94,7 @@ function branchIndex(command, value) {
return matches[0];
}
function run(command, input, flags = []) {
const result = spawnSync(binary, [command, '--input', '-', ...flags], {
const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], {
input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 30000, maxBuffer: 32 << 20,
});
assertCLITransport(result);
Expand Down
6 changes: 3 additions & 3 deletions scripts/adoption-envelope-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import {execFileSync, spawnSync} from 'node:child_process';
import {execFileSync} from 'node:child_process';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import test, {after, before} from 'node:test';
import Ajv2020 from 'ajv/dist/2020.js';
import {assertCLITransport} from './cli-process.mjs';
import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs';
import {impactInput} from './impact-fixtures.mjs';

const root = new URL('../', import.meta.url);
Expand Down Expand Up @@ -234,7 +234,7 @@ const stateOf = value => Array.isArray(value) ? (value.every(row => row.state ==
: value.state ?? value.report?.state ?? value.sourceReport?.state;

function run(command, input, flags = [], env = {}) {
const result = spawnSync(binary, [command, '--input', '-', ...flags], {
const result = spawnWithInputFile(binary, [command, '--input', '-', ...flags], {
input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20,
env: {...process.env, AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: '', ...env},
});
Expand Down
4 changes: 2 additions & 2 deletions scripts/agent-workflow-structure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {tmpdir} from 'node:os';
import {join} from 'node:path';
import test, {after, before} from 'node:test';
import Ajv2020 from 'ajv/dist/2020.js';
import {assertCLITransport} from './cli-process.mjs';
import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs';
import {stableJSONStringify} from './stable-json.mjs';

// Independent oracle for agent-route, change-workflow-plan and
Expand Down Expand Up @@ -64,7 +64,7 @@ after(() => {if (directory) rmSync(directory, {recursive: true, force: true});})

const pathLauncher = {AGENTIC_PROOFKIT_LAUNCHER_PROFILE: 'path', AGENTIC_PROOFKIT_PYTHON_EXECUTABLE: ''};
function run(args, input, {env = pathLauncher, cwd} = {}) {
const result = spawnSync(binary, args, {
const result = spawnWithInputFile(binary, args, {
input: input === undefined ? undefined : typeof input === 'string' ? input : JSON.stringify(input),
encoding: 'utf8', timeout: 20000, maxBuffer: 16 << 20, env: {...process.env, ...env}, cwd,
});
Expand Down
6 changes: 3 additions & 3 deletions scripts/authority-guard-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import {createHash} from "node:crypto";
import {execFileSync, spawnSync} from "node:child_process";
import {execFileSync} from "node:child_process";
import {mkdtempSync, readFileSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import test, {before, after} from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import {assertCLITransport} from "./cli-process.mjs";
import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs";

const root = new URL("../", import.meta.url), producer = "producer-policy-self-proof", migration = "migration-parity-admission";
const baseline = JSON.parse(readFileSync(new URL("internal/app/testdata/authority-guard-native-observations.json", root)));
Expand Down Expand Up @@ -41,7 +41,7 @@ before(() => {
after(() => {if (directory) rmSync(directory, {recursive: true, force: true});});
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
function invoke(command, input, argv = [command, "--input", "-"]) {
const result = spawnSync(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20});
const result = spawnWithInputFile(binary, argv, {input, encoding: "utf8", timeout: 10000, maxBuffer: 2 << 20});
return assertCLITransport(result);
}
const utf8Order = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
Expand Down
6 changes: 3 additions & 3 deletions scripts/bootstrap-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import {createHash} from "node:crypto";
import {execFileSync, spawnSync} from "node:child_process";
import {execFileSync} from "node:child_process";
import {mkdtempSync, readFileSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import test, {before, after} from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import {assertCLITransport} from "./cli-process.mjs";
import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs";

const root = new URL("../", import.meta.url);
const contract = JSON.parse(readFileSync(process.env.PROOFKIT_BOOTSTRAP_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8"));
Expand All @@ -27,7 +27,7 @@ before(() => {
after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); });
const hash = text => createHash("sha256").update(text).digest("hex");
function invoke(inputJSON, args = []) {
const r = spawnSync(binary, ["self-check", "--input", "-", ...args], {input: inputJSON, encoding: "utf8", timeout: 10000, maxBuffer: 1 << 20});
const r = spawnWithInputFile(binary, ["self-check", "--input", "-", ...args], {input: inputJSON, encoding: "utf8", timeout: 10000, maxBuffer: 1 << 20});
return assertCLITransport(r);
}
function expected(kind) {
Expand Down
6 changes: 3 additions & 3 deletions scripts/boundary-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import assert from "node:assert/strict";
import {execFileSync, spawnSync} from "node:child_process";
import {execFileSync} from "node:child_process";
import {mkdtempSync, readFileSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import test, {before, after} from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import {assertCLITransport} from "./cli-process.mjs";
import {assertCLITransport, spawnWithInputFile} from "./cli-process.mjs";

const root = new URL("../", import.meta.url);
const contract = JSON.parse(readFileSync(process.env.PROOFKIT_BOUNDARY_CONTRACT || new URL("proofkit/cli-contract.v2.json", root), "utf8"));
Expand Down Expand Up @@ -46,7 +46,7 @@ before(() => {
after(() => { if (directory) rmSync(directory, {recursive: true, force: true}); });

function native(family, input, expected, label) {
const result = spawnSync(binary, [family.command, "--input", "-"], {
const result = spawnWithInputFile(binary, [family.command, "--input", "-"], {
input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", timeout: 10_000, maxBuffer: 2 << 20,
});
assertCLITransport(result, label);
Expand Down
12 changes: 6 additions & 6 deletions scripts/browser-server-structure.test.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import assert from 'node:assert/strict';
import {execFileSync, spawn, spawnSync} from 'node:child_process';
import {execFileSync, spawn} from 'node:child_process';
import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {get, request as httpRequest} from 'node:http';
import {tmpdir} from 'node:os';
import {delimiter, join} from 'node:path';
import test, {after, before} from 'node:test';
import {setTimeout as sleep} from 'node:timers/promises';
import Ajv2020 from 'ajv/dist/2020.js';
import {assertCLITransport} from './cli-process.mjs';
import {assertCLITransport, spawnWithInputFile} from './cli-process.mjs';
import {bindingInput, resolverInput} from './proof-routing-fixtures.mjs';
import {directComposeInput, inventoryInput, normalizedComposeInput, requirementSource} from './inventory-coverage-fixtures.mjs';

Expand Down Expand Up @@ -116,7 +116,7 @@ const flipped = value => typeof value === 'string' ? 7 : typeof value === 'numbe
let directory, binary;
const fixtures = {};
function spawnCLI(args, input, options = {}) {
return spawnSync(binary, args, {input: input === undefined ? '' : JSON.stringify(input),
return spawnWithInputFile(binary, args, {input: input === undefined ? '' : JSON.stringify(input),
encoding: 'utf8', timeout: 30000, maxBuffer: 32 << 20, ...options});
}
function run(args, input, options = {}) {
Expand Down Expand Up @@ -437,11 +437,11 @@ test('serving browse mode emits one non-JSON URL record and terminates on SIGTER
});

test('a CLI that exits before reading its stdin is accepted only as a refusal', () => {
// 8 MiB exceeds every pipe buffer; the flag refusal exits before reading stdin, so writing the payload must report EPIPE.
// The flag refusal exits before reading stdin, so the child leaves every byte of the payload unread.
const payload = {...clone(fixtures.workspace), padding: 'x'.repeat(8 << 20)};
const args = browserArgs('workspace', ['--serve', '--session-mode', 'one-shot-question']);
assert.equal(refused(args, payload, 'oversized one-shot without --open').error?.code, 'EPIPE');
// The closed pipe passes the transport check, so a caller that expects success fails on the refusal status.
assert.equal(refused(args, payload, 'oversized one-shot without --open').stdinUnreadBytes, Buffer.byteLength(JSON.stringify(payload)));
// Unread input passes the transport check only with a refusal status, so a caller that expects success fails on that status.
assert.throws(() => json(args, payload), error => error.code === 'ERR_ASSERTION' && error.actual === 1 && error.expected === 0);
});

Expand Down
132 changes: 122 additions & 10 deletions scripts/cli-process.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,132 @@
// Transport check for oracles that run the Proofkit CLI through spawnSync.
// Transport for oracles that run the Proofkit CLI, or another child process,
// synchronously.
//
// A CLI that refuses its flags exits before it reads stdin, so spawnSync
// can report EPIPE for the input it could not write. As in
// internal/tools/workflowsmoke/process_streams.go, that is an ordinary early
// exit and not a transport failure. EPIPE is accepted only when the child
// exited normally with a non-zero status and no signal. Any other spawn error,
// a timeout, a signal, or EPIPE with status 0 (a success that did not consume
// its input) fails. Callers keep their own status, stdout and stderr checks.
// spawnWithInputFile(file, args, options) is spawnSync(file, args, options),
// except that options.input reaches the child as a standard input opened on
// a temporary regular file instead of through a pipe. For the `input` option,
// Node's spawnSync gives the child one end of a Unix-domain socket pair: it
// writes the input, calls shutdown(SHUT_WR) on its end and keeps that end open
// until the child exits or the timeout kills it. On macOS, a child blocked in
// read(2) on the socket is sometimes not woken by the shutdown although the
// socket already holds end of file, so the call ends only at its timeout. The
// same syscall sequence stalls without Node; a pipe, a close or a regular file
// does not. A regular file ends at its size, so the child always reads end of
// file.
//
// args must be an array. spawnSync(file, options) would take the options from
// the args position and drop the third argument, so the two-argument form
// would feed the input through the socket again; it is refused.
//
// The input is written to a file of mode 0600 in a fresh directory of mode
// 0700 and opened read-only. The directory is removed as soon as the file is
// open, before the child starts: the open descriptor keeps the file, so while
// the child runs no input remains on disk, and a signal that ends this process
// then leaves none behind. A throw while the file is written or opened removes
// the directory too. The removal uses path calls only; if it fails, the input
// descriptor is closed and the call throws.
//
// The result is spawnSync's result (status, signal, error, stdout, stderr,
// output, pid) plus stdinUnreadBytes: the input bytes past the file offset
// that the child shares with this process, read when the child has exited.
// read(2) and lseek(2) on the inherited descriptor move that offset; pread(2),
// mmap(2) and, on Linux, reopening /dev/stdin do not. A child that reads its
// input only those ways reports unread bytes although it read them, and a
// child that seeks past bytes it did not read reports them as read. No current
// caller depends on those readings: its children are Proofkit CLI builds and
// the Node probes of cli-process.test.mjs, which read their standard input
// with read(2) or not at all. An empty or absent input is an empty file. With
// stdio ["ignore", "pipe", "pipe"] the child's standard input is the null
// device, no input is allowed and stdinUnreadBytes is absent.
//
// assertCLITransport checks the transport of a result. A CLI that refuses its
// flags exits before it reads stdin. Through a pipe (an asynchronous spawn
// that writes stdin), the parent can then report EPIPE for the input it could
// not write; through spawnWithInputFile, stdinUnreadBytes is not zero. As in
// internal/tools/workflowsmoke/process_streams.go, either is an ordinary early
// exit and not a transport failure only when the child exited normally with a
// non-zero status and no signal. Any other spawn error, a timeout, a signal,
// and EPIPE or a non-zero stdinUnreadBytes with status 0 (a success that did
// not consume its input) fail. Callers keep their own status, stdout and
// stderr checks.
import assert from "node:assert/strict";
import {spawnSync} from "node:child_process";
import {closeSync, mkdtempSync, openSync, readSync, rmdirSync, rmSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";

const passedOptions = new Set(["cwd", "encoding", "env", "killSignal", "maxBuffer", "timeout"]);
const ignoredStdin = ["ignore", "pipe", "pipe"];

export function spawnWithInputFile(file, args, options = {}) {
if (!Array.isArray(args)) {
throw new TypeError("spawnWithInputFile: args must be an array; spawnWithInputFile(file, options) is refused");
}
const {input, stdio, ...passed} = options;
for (const key of Object.keys(passed)) {
if (!passedOptions.has(key)) throw new TypeError(`spawnWithInputFile: unsupported option ${key}`);
}
if (stdio !== undefined) {
if (!Array.isArray(stdio) || stdio.length !== ignoredStdin.length || stdio.some((value, index) => value !== ignoredStdin[index])) {
throw new TypeError('spawnWithInputFile: stdio must be absent or ["ignore", "pipe", "pipe"]');
}
if (input !== undefined) throw new TypeError("spawnWithInputFile: input with an ignored standard input");
return spawnSync(file, args, {...passed, stdio});
}
const bytes = inputBytes(input, passed.encoding);
const directory = mkdtempSync(join(tmpdir(), "proofkit-cli-stdin-"));
const path = join(directory, "stdin");
let descriptor;
try {
writeFileSync(path, bytes, {flag: "wx", mode: 0o600});
descriptor = openSync(path, "r");
} finally {
// Path calls only: the removal needs no descriptor of its own. If it
// fails, the input descriptor is closed before the throw.
try {
rmSync(path, {force: true});
rmdirSync(directory);
} catch (error) {
if (descriptor !== undefined) closeSync(descriptor);
throw error;
}
}
try {
// Only the caller's options pass: an explicit undefined maxBuffer would lift spawnSync's default bound.
const result = spawnSync(file, args, {...passed, stdio: [descriptor, "pipe", "pipe"]});
result.stdinUnreadBytes = unreadBytes(descriptor);
return result;
} finally {
closeSync(descriptor);
}
}

// As spawnSync does, a falsy input is no input and a string is encoded with
// options.encoding.
function inputBytes(input, encoding) {
if (!input) return Buffer.alloc(0);
if (typeof input === "string") return Buffer.from(input, encoding);
if (ArrayBuffer.isView(input)) return input;
throw new TypeError("spawnWithInputFile: input must be a string, Buffer, TypedArray or DataView");
}

// The child inherited this open file description, so its read(2) calls
// advanced the shared offset; reading from that offset counts the bytes past
// it.
function unreadBytes(descriptor) {
const buffer = Buffer.allocUnsafe(1 << 16);
let total = 0;
for (let count; (count = readSync(descriptor, buffer, 0, buffer.length, null)) > 0;) total += count;
return total;
}

export function assertCLITransport(result, label = "CLI") {
const observed = `${label}: transport error ${result.error}, status ${result.status}, signal ${result.signal}`;
const observed = `${label}: transport error ${result.error}, status ${result.status}, signal ${result.signal}, unread input bytes ${result.stdinUnreadBytes}`;
const refusal = Number.isInteger(result.status) && result.status !== 0;
assert.equal(result.signal, null, observed);
if (result.error !== undefined) {
assert.equal(result.error.code, "EPIPE", observed);
assert.ok(Number.isInteger(result.status) && result.status !== 0, observed);
assert.ok(refusal, observed);
}
if (result.stdinUnreadBytes !== undefined && result.stdinUnreadBytes !== 0) assert.ok(refusal, observed);
return result;
}
Loading
Loading