diff --git a/docs/live-acceptance.md b/docs/live-acceptance.md index 55e4235..8cc73e6 100644 --- a/docs/live-acceptance.md +++ b/docs/live-acceptance.md @@ -15,9 +15,9 @@ This harness is repository-only. It lives under `scripts/`, is excluded from the Every child is a canonical mode-`0700` directory owned by the invoking user. The harness records the device and inode of each directory before it starts either worker. It refuses any run directory that overlaps the production HRA state root or the invoking home in either direction. -Each worker receives one strict descriptor through inherited nonterminal file descriptor 3. The descriptor is bounded to 8 KiB and contains the run ID, device name, state root, project directory, exact expected `HOME`, and optional cloud deployment URL. These values never appear in worker arguments or environment variables. The worker arguments contain only the fixed source-worker path. +Each worker receives one strict installation document as the first bounded JSONL frame on standard input. The document is limited to 8 KiB and contains the run ID, device name, state root, project directory, exact expected `HOME`, and optional cloud deployment URL. These values never appear in worker arguments or environment variables. The worker arguments contain only the fixed source-worker path. -File descriptor 4 carries bounded CLI invocations, protected input documents, and internal cleanup commands. It also owns the worker lifetime. Every scenario operation enters the exported HRA `main()` function, passes through the production parser and renderer, and reaches the daemon through the ordinary local transport. A protected command must select `--input-fd 4`; the worker proves that descriptor is nonterminal, consumes exactly one paired document, and rejects stdin, another descriptor, an unused document, `--follow`, and daemon lifecycle commands. Parent death aborts an in-flight local request immediately, closes the queue, and requests bounded daemon shutdown. File descriptor 5 carries bounded typed CLI results and lifecycle acknowledgements. Worker stdout and stderr are closed, so provider output, credentials, paths, and diagnostics cannot escape through process output. +The same standard-input stream then carries bounded CLI invocations, paired protected input documents, and internal cleanup commands. Closing it owns the worker lifetime. Every scenario operation enters the exported HRA `main()` function, passes through the production parser and renderer, and reaches the daemon through the ordinary local transport. A protected command must select `--input-fd 0`; the worker proves that standard input is nonterminal, consumes exactly one paired document from the control frame, and rejects `--input-stdin`, another descriptor, an unused document, `--follow`, and daemon lifecycle commands. Parent death aborts an in-flight local request immediately, closes the queue, and requests bounded daemon shutdown. Standard output carries only bounded typed results and lifecycle acknowledgements. Worker stderr is discarded, so provider output, credentials, paths, and diagnostics cannot escape through process output. Each worker supervises sequential full daemon generations. A successful auth completion or account-deletion response that declares `daemonRestartRequired` is delivered first, then the worker waits for complete authority release and starts a new generation before accepting another operation. An unexpected daemon completion after readiness is terminal. Device suspend and resume stop and start a full generation while preserving the worker control process, which allows the scenario to cross the hosted presence boundary without a second state authority. @@ -50,14 +50,28 @@ Terminal mode hides every invite, OTP, auth document, interaction answer, and pe The gate also requires a clean Git worktree and resolves the exact `HEAD` commit before starting workers. Passing evidence binds the SHA-256 digest of the configured cloud origin, the package version, and the 40-character source revision. This makes a result from a local fake, a different deployment, a dirty checkout, or a different source revision distinguishable from the intended release candidate. -Agent runners can select `{"operator":{"kind":"jsonl"}}`. In that mode fixed inherited streaming IPC descriptor 5 emits bounded requests and fixed inherited streaming IPC descriptor 4 accepts one matching response at a time. Requests carry a UUID and one of `protected_input_required`, `device_login_required`, or `progress`. Responses must echo the UUID and be exactly one of: +Agent runners start the gate with standard-stream mode: + +```sh +bun run acceptance:live --scenario-stdin +``` + +The first standard-input line must be this bounded candidate configuration: + +```json +{"cloudDeploymentUrl":"https://qualified-hummingbird-537.convex.cloud","operator":{"kind":"jsonl"},"version":1} +``` + +Later lines supply one matching response at a time. Standard output emits bounded JSONL requests, progress, and the final result. Response-bearing requests carry a UUID and have type `protected_input_required` or `device_login_required`; progress frames have type `progress` and no UUID. Responses must echo the request UUID and be exactly one of: ```json {"document":{"email":"person@example.com"},"requestId":"00000000-0000-4000-8000-000000000000","type":"protected_input","version":1} {"acknowledged":true,"requestId":"00000000-0000-4000-8000-000000000000","type":"device_login","version":1} ``` -The candidate configuration descriptor cannot reuse descriptor 4 or 5. A closed operator input, an unexpected response type or UUID, a terminal descriptor, an oversized frame, or extra fields fails closed and retains recovery state. `SIGINT` and `SIGTERM` abort protected reads, device calls, polls, presence sleeps, and cleanup waits. Preservation joins any in-flight cleanup before it writes a recovery state, so cleanup and interruption cannot race receipt deletion or overwrite each other's checkpoint. +An agent distinguishes the terminal frame by its `ok` field; request and progress frames use `type`. Terminal status is `passed`, `startup_failed`, `recovery_required`, or `evidence_unavailable_after_cleanup`. Exit `0` reports passing evidence. Exit `1` reports a startup or run failure, and the terminal status says whether recovery state remains. Exit `75` reports interruption; the terminal frame still says whether recovery state remains. Exit `2` is an argument-usage error and emits no stdout frame. Agents must retain and surface `recoveryReceiptPath` whenever `recoveryReceiptRetained` is true. + +`--scenario-fd` accepts only terminal mode, and `--scenario-stdin` accepts only JSONL mode. A closed operator input, an unexpected response type or UUID, a terminal standard stream, an oversized frame, or extra fields fails closed and retains recovery state. `SIGINT` and `SIGTERM` abort protected reads, device calls, polls, presence sleeps, and cleanup waits. Preservation joins any in-flight cleanup before it writes a recovery state, so cleanup and interruption cannot race receipt deletion or overwrite each other's checkpoint. The source API remains available for deterministic tests and recovery tooling. `run.device("a")` exposes only the verified project directory, bounded CLI `execute()`, and daemon-generation `suspend()` and `resume()`. It does not expose arbitrary state paths, sockets, capabilities, cloud controls, or a public `LocalCommand` transport. @@ -110,7 +124,7 @@ If cleanup stopped before daemon shutdown, recovery starts two new attached work ## Deterministic evidence -The suite proves strict descriptor parsing, exact candidate selection, canonical private roots, unchanged `HOME`, distinct state and temporary paths, file-only HRA and Codex custody, disabled desktop switching, absence of state authority in worker argv and environment, real CLI and protected-input routing, full-generation suspend/resume, symlink refusal, lost-pair derivation, exact-peer revocation, ambiguous-logout reconciliation, gated quarantine deletion, authoritative on-disk recovery, checkpoint resumption, serialized cleanup interruption, and a real two-subprocess smoke in which both full daemons become ready and stop with released authority and absent socket and capability endpoints. The scenario test drives every release step through a deterministic two-device world, rejects prompt-only result markers, empty usage, local event gaps, wrong remote projection authority, incomplete remote turns, mismatched interaction receipts, mismatched B identity, and terminal-unsafe login handoffs. A real subprocess regression keeps the JSONL input descriptor open, aborts the read, and proves the child exits. Final evidence omits provider identity values, provider-derived hashes, and device-code values. +The suite proves strict framing and descriptor parsing, exact candidate selection, canonical private roots, unchanged `HOME`, distinct state and temporary paths, file-only HRA and Codex custody, disabled desktop switching, absence of state authority in worker argv and environment, real CLI and protected-input routing, full-generation suspend/resume, symlink refusal, lost-pair derivation, exact-peer revocation, ambiguous-logout reconciliation, gated quarantine deletion, authoritative on-disk recovery, checkpoint resumption, serialized cleanup interruption, and a real two-subprocess smoke in which both full daemons become ready and stop with released authority and absent socket and capability endpoints. The scenario test drives every release step through a deterministic two-device world, rejects prompt-only result markers, empty usage, local event gaps, wrong remote projection authority, incomplete remote turns, mismatched interaction receipts, mismatched B identity, and terminal-unsafe login handoffs. A real subprocess regression keeps standard input open, aborts a pending JSONL read, and proves the child exits without inherited extra pipes. Final evidence omits provider identity values, provider-derived hashes, and device-code values. ```sh bun test scripts/live-acceptance.test.ts scripts/live-acceptance-scenario.test.ts diff --git a/kb/plans/hra-v1.md b/kb/plans/hra-v1.md index c3f0d3e..5f484d4 100644 --- a/kb/plans/hra-v1.md +++ b/kb/plans/hra-v1.md @@ -434,8 +434,8 @@ The beta requires all of these scenarios: | Phase 2 | Implemented and gated locally | `hra` provides the persistent line shell, protected input, one-shot JSON, reconnecting JSONL follow, atomic status/events, interaction commands, and read-only plugin discovery. The closeout revision gives daemon and renderer one strict public interaction DTO, refuses terminal protected input before any JSON-mode prompt or effect, and rejects MCP URL elicitation before storage. Package smoke proves the installed binary. | | Phase 3 | Implemented and gated locally | Staggered polling, bounded historical success and failure ledgers, exact velocity windows, durable sliding daily upload anchors, record-and-byte quota capacity proof, identity-scoped A to B to A custody, automatic registration, graceful disconnect, fenced server-time device presence, and observation-only encrypted interaction state have deterministic coverage. Live hosted multi-device proof remains pending. | | Phase 4 | Implemented locally; hosted bootstrap pending | Account deletion, abandoned cleanup, hard aggregate/resource quota, exact Convex Auth accounting, fair maintenance, status-first revocation, invitation-gated admission, atomic request-bound hosted bootstrap, exact lost-response recovery, and durable post-acceptance friend gating pass hostile deterministic suites. The fresh default deployment remains pristine and must receive exact merged source before its first bootstrap write. | -| Phase 5 | Closeout and executable acceptance harness adversarially gated; live run pending | Profiles, sessions, interactions, usage, presence, deletion, desktop switching, encrypted sync, compact recovery, shell live updates, generated public contracts, site generation, and package installation passed the earlier repository-wide gate. The independently reviewed two-installation harness drives the production parser and protected-file-descriptor path, supervises real daemon generations, proves continuous exact-turn authority and HITL response settlement, requires an explicit candidate URL, and emits bounded non-linkable scenario evidence. The complete local gate, real Codex two-account run, and live hosted two-device run remain pending. | -| Phase 6 | Provider namespace staging in progress | Old GitHub repository ID `1334876494`, Vercel project ID `prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr`, and Convex project ID `2680173` now read back as HRA v0 while preserving their numeric identities. New GitHub repository ID `1343008607` and Vercel project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS` now read back as HRA. Fresh Convex deployment `qualified-hummingbird-537` is the default for project ID `2854545` and was proved to contain no functions, tables, storage, or environment values before promotion. Final source PR and CI, exact source deployment, protected configuration, live acceptance, domain rehearsal and cutover, immutable tag, and release remain pending. | +| Phase 5 | Closeout and executable acceptance harness adversarially gated; live run pending | Profiles, sessions, interactions, usage, presence, deletion, desktop switching, encrypted sync, compact recovery, shell live updates, generated public contracts, site generation, and package installation pass the revised repository-wide gate. The independently reviewed two-installation harness drives the production parser and protected-input path, supervises real daemon generations, proves continuous exact-turn authority and HITL response settlement, requires an explicit candidate URL, and emits bounded non-linkable scenario evidence. Human runs use a protected terminal configuration descriptor. Agent runs use descriptor-first stdin and typed JSONL stdout without inherited extra pipes. Real Codex two-account and live hosted two-device execution remain pending. | +| Phase 6 | Provider namespace staging and final source hardening in progress | Old GitHub repository ID `1334876494`, Vercel project ID `prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr`, and Convex project ID `2680173` now read back as HRA v0 while preserving their numeric identities. New GitHub repository ID `1343008607` and Vercel project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS` now read back as HRA. Fresh Convex deployment `qualified-hummingbird-537` is the default for project ID `2854545` and was proved to contain no functions, tables, storage, or environment values before promotion. Source PR 5 merged to `main`; the Bun 1.3.14 acceptance-transport hardening PR and CI remain in progress. Exact source deployment, protected configuration, live acceptance, domain rehearsal and cutover, immutable tag, and release remain pending. | ## Execution evidence @@ -471,10 +471,11 @@ The beta requires all of these scenarios: - 2026-08-23: The pre-closeout release candidate passed the complete local release gate: ESLint, TypeScript, 650 tests with 12,980 assertions across 65 files, canonical site parity, the production CLI and site builds, public-tree and complete-history sensitive-text scanning, and isolated local and global installation of `hra-0.1.0.tgz`. - 2026-08-23: The final adversarial closeout added absolute receipt-time interaction deadlines, a bounded timeout pump, exact prepared terminal intent, neutral timeout errors, uncertain-write provider quarantine, closed provider diagnostics, argv/runtime diagnostic scrubbing, drain-aware JSONL, abortable local transport, signed-in bounded usage refresh-all, gap-safe remote interaction rendering, terminal-tail cloud append authority, and crash-journaled interaction baselines for compact recovery. Focused domain, storage, Codex, daemon, CLI, cloud, and Convex suites pass; the complete release gate remains pending in this plan entry. - 2026-08-23: Hosted bootstrap now binds one locally generated 256-bit capability digest to quota, service control, and the first invitation in one Convex transaction. Exact replay is neutral, different concurrent requests admit one winner, raw operator readback recomputes the invitation's Convex byte charge, and a dedicated protected-file recovery path reconciles crashes without calling ordinary invitation issuance. First-invite consumption records durable bootstrap acceptance, and a regression proves later friend issuance still works after terminal receipt cleanup. -- 2026-08-23: The live acceptance gate owns two complete isolated installations without changing `HOME`, enters through the production CLI parser and protected descriptor transport, supervises full daemon stop/restart generations, and drives the two-account and two-device scenario. Cleanup now reconciles lost registration and logout responses, requires the exact complete peer set including revoked history, serializes cancellation with final deletion, and preserves recovery evidence on uncertainty. Continuous local cursor pages, exact remote terminal projection settlement, exact HITL discovery and response events, and JSONL descriptor cancellation have focused regressions. Evidence compares provider identities only in memory and exports one boolean rather than email-derived commitments. An independent frozen-snapshot review found no P0, P1, or P2 issues; 155 broader owned tests, 29 focused harness and package-policy tests with 204 assertions, ESLint, TypeScript, isolated local and global package acceptance, and diff checks pass. Real provider execution remains pending. +- 2026-08-23: The live acceptance gate owns two complete isolated installations without changing `HOME`, enters through the production CLI parser and protected input transport, supervises full daemon stop/restart generations, and drives the two-account and two-device scenario. Cleanup now reconciles lost registration and logout responses, requires the exact complete peer set including revoked history, serializes cancellation with final deletion, and preserves recovery evidence on uncertainty. Continuous local cursor pages, exact remote terminal projection settlement, exact HITL discovery and response events, and JSONL standard-input cancellation have focused regressions. Evidence compares provider identities only in memory and exports one boolean rather than email-derived commitments. An independent frozen-snapshot review found no P0, P1, or P2 issues; the original broader gate, focused harness and package-policy tests, package acceptance, and diff checks passed. Real provider execution remains pending. - 2026-08-23: Provider namespace staging preserved old HRA numeric identities under HRA v0, established new HRA GitHub and Vercel names, created fresh Convex deployment `qualified-hummingbird-537`, proved it empty by provider readback, and promoted it to the exact default without deploying code or creating data. Final merged-source deployment and every public commit-point action remain pending. - 2026-08-23: Release distribution now excludes repository-only harness, operator, Convex, site, and test sources from the install tarball; isolated local and global consumers pass help, version, offline doctor, and production-tree checks. The tag workflow builds and accepts the exact tarball, creates separate tarball-identity and Ubuntu 24.04 x64 runtime SPDX records, stages one exact draft, and compares every draft asset with the accepted bytes. It has no publication step. A separate local operator binds the exact repository, workflow run, attempt, tag, commit, notes, checksums, SPDX contracts, draft metadata, artifact bytes, canonical marker, and admin-keyring immutable-release setting before the one-way undraft. The slow marker proof completes before a fresh draft byte comparison, followed by final tag, main, and immutable-setting reads. Publication PATCHes only the verified numeric release ID, and lost-response recovery requires that same ID. The operator then requires exact immutable readback, an anonymous byte-verified download, and isolated installation from the public release URL; ambiguous responses and post-publication failures have distinct recovery states. Independent review found no P0, P1, or P2 release findings; 66 focused release, package, site, and domain tests, ESLint, TypeScript, generated-site parity, isolated package acceptance, and diff checks pass. GitHub provider readback reports immutable releases enabled for repository ID `1343008607`; the actual tag and release remain pending. - 2026-08-23: Two shared-global full-suite attempts reached the same high-volume SQLite boundary with no failed assertion before the OS killed the Bun process. The exact 55-test storage file passes alone. The public root test script now gives every file a fresh Bun global object and bounds test concurrency to one; the coordinated exact suite passes 859 tests across 80 files. This changes test-process lifetime, not selection or product behavior. The complete exclusive root gate then passes ESLint, TypeScript, all tests, generated-site parity, the CLI and site builds, public-tree checks, and isolated local and global package acceptance. +- 2026-08-23: Linux CI exposed a Bun 1.3.14 cross-owner close in `node:child_process` extra stdio, with failures moving among unrelated newly opened descriptors. Upstream runtime evidence and isolated reproduction established the cause. HRA retains its pinned runtime and removes all acceptance child pipes above standard descriptors instead. Each worker receives its bounded installation document as the first stdin frame, consumes later controls from the same iterator, emits only typed status on stdout, and discards stderr. Agent acceptance likewise receives its bounded configuration and later HITL responses on stdin while serializing progress, requests, and the final result on stdout. Human mode retains an explicit protected configuration descriptor. Mode mismatches and usage fail deterministically. The revised focused gate passes 33 tests with 477 assertions, including 100 repeated worker protocol runs and 100 repeated JSONL abort and continuity runs. The coordinated exclusive release gate passes ESLint, TypeScript, 865 tests with 14,684 assertions across 80 files, generated-site parity, production CLI and site builds, and isolated local and global package acceptance. Exact-head Linux CI remains pending. ## Review findings diff --git a/scripts/live-acceptance-installation.ts b/scripts/live-acceptance-installation.ts index 7866766..b62cdab 100644 --- a/scripts/live-acceptance-installation.ts +++ b/scripts/live-acceptance-installation.ts @@ -1,5 +1,13 @@ -import { constants } from "node:fs"; -import { open } from "node:fs/promises"; +import { + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + openSync, + readSync, + writeFileSync, +} from "node:fs"; import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { z } from "zod"; @@ -70,15 +78,15 @@ async function acceptanceCodexEnvironment( return environment; } -async function assertAcceptanceCodexConfig(configPath: string): Promise { +function assertAcceptanceCodexConfig(configPath: string): void { const currentUid = process.getuid?.(); if (currentUid === undefined) { throw new Error("Live acceptance requires an operating system user ID."); } - const handle = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW); + const descriptor = openSync(configPath, constants.O_RDONLY | constants.O_NOFOLLOW); try { - const before = await handle.stat(); + const before = fstatSync(descriptor); if ( !before.isFile() || before.nlink !== 1 @@ -92,12 +100,18 @@ async function assertAcceptanceCodexConfig(configPath: string): Promise { const contents = Buffer.alloc(maximumAcceptanceCodexConfigBytes + 1); let length = 0; while (length < contents.length) { - const read = await handle.read(contents, length, contents.length - length, length); - if (read.bytesRead === 0) break; - length += read.bytesRead; + const bytesRead = readSync( + descriptor, + contents, + length, + contents.length - length, + length, + ); + if (bytesRead === 0) break; + length += bytesRead; } - const after = await handle.stat(); + const after = fstatSync(descriptor); if ( !after.isFile() || after.nlink !== 1 @@ -113,7 +127,7 @@ async function assertAcceptanceCodexConfig(configPath: string): Promise { throw new Error("Acceptance CODEX_HOME has an unexpected credential-store configuration."); } } finally { - await handle.close(); + closeSync(descriptor); } } @@ -124,28 +138,31 @@ async function prepareAcceptanceCodexHome(codexHome: string): Promise { } const configPath = join(canonicalHome, "config.toml"); try { - const handle = await open( + const descriptor = openSync( configPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600, ); try { - await handle.writeFile(acceptanceCodexConfig, "utf8"); - await handle.chmod(0o600); - await handle.sync(); + writeFileSync(descriptor, acceptanceCodexConfig, "utf8"); + fchmodSync(descriptor, 0o600); + fsyncSync(descriptor); } finally { - await handle.close(); + closeSync(descriptor); } - const directory = await open(canonicalHome, constants.O_RDONLY | constants.O_NOFOLLOW); + const directoryDescriptor = openSync( + canonicalHome, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); try { - await directory.sync(); + fsyncSync(directoryDescriptor); } finally { - await directory.close(); + closeSync(directoryDescriptor); } } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } - await assertAcceptanceCodexConfig(configPath); + assertAcceptanceCodexConfig(configPath); await ensurePrivateDirectory(join(canonicalHome, "tmp")); } diff --git a/scripts/live-acceptance-scenario.test.ts b/scripts/live-acceptance-scenario.test.ts index 329f9c2..00bb81a 100644 --- a/scripts/live-acceptance-scenario.test.ts +++ b/scripts/live-acceptance-scenario.test.ts @@ -1,18 +1,20 @@ import { describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline/promises"; -import type { Readable, Writable } from "node:stream"; import { pathToFileURL } from "node:url"; import type { PublicInteraction } from "../src/domain/interactions"; import type { SessionEvent } from "../src/domain/session-events"; import { DEFAULT_CLOUD_DEPLOYMENT_URL } from "../src/cloud/identity-custody"; -import type { - LiveAcceptanceCliResult, - LiveAcceptanceDevice, - LiveAcceptanceDeviceName, +import { + LIVE_ACCEPTANCE_CONTROL_FD, + type LiveAcceptanceCliResult, + type LiveAcceptanceDevice, + type LiveAcceptanceDeviceName, } from "./live-acceptance"; import { liveAcceptanceScenarioConfigurationSchema, @@ -537,7 +539,7 @@ class FakeOperator implements LiveAcceptanceScenarioOperator { this.deviceLogins += 1; } - progress(): void {} + async progress(): Promise {} async protectedDocument( request: LiveAcceptanceOperatorRequest, @@ -667,7 +669,8 @@ describe("live acceptance release scenario", () => { expect(Object.hasOwn(evidence, "providerIdentityDigests")).toBe(false); expect(serialized).not.toContain("ABCD-EFGH"); expect(devices.a.calls.some((argv) => - argv.includes("--input-fd") && argv.includes("4"))).toBe(true); + argv.includes("--input-fd") && argv.includes(String(LIVE_ACCEPTANCE_CONTROL_FD)))) + .toBe(true); for (const action of ["auth", "disable", "enable", "install"]) { expect(devices.a.calls.some((argv) => argv[0] === "plugin" && argv[1] === action)) .toBe(true); @@ -723,7 +726,7 @@ describe("live acceptance release scenario", () => { await expect(startFakeScenario(noOpResolution, new FakeOperator()).promise).rejects.toThrow(); }); - test("JSONL operator abort closes the inherited input read and lets the process exit", async () => { + test("JSONL operator abort closes the standard-input read and lets the process exit", async () => { const moduleUrl = pathToFileURL(join(import.meta.dir, "live-acceptance-scenario.ts")).href; const child = spawn(process.execPath, [ "-e", @@ -751,25 +754,19 @@ describe("live acceptance release scenario", () => { "} catch {", " if (!controller.signal.aborted) process.exitCode = 3;", "}", + "process.stdout.write(JSON.stringify({ status: 'after_abort', version: 1 }) + '\\n');", ].join("\n"), ], { cwd: join(import.meta.dir, ".."), - stdio: ["ignore", "ignore", "ignore", "ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "ignore"], }); - const operatorOutput = (child.stdio as Array)[5] as - | Readable - | null - | undefined; - if (operatorOutput === undefined || operatorOutput === null) { - throw new Error("Missing JSONL operator output pipe."); - } - const outputLines = createInterface({ input: operatorOutput }); - const outputIterator = outputLines[Symbol.asyncIterator](); - const exitPromise = new Promise>((resolvePromise) => { - child.once("exit", (code, signal) => { + child.once("close", (code, signal) => { resolvePromise({ code, signal }); }); }); @@ -786,7 +783,10 @@ describe("live acceptance release scenario", () => { if (timeout !== undefined) clearTimeout(timeout); } }; + let outputLines: ReturnType | undefined; try { + outputLines = createInterface({ input: operatorOutput }); + const outputIterator = outputLines[Symbol.asyncIterator](); const frame = await bounded(outputIterator.next()); if (frame === null || frame.done) throw new Error("Missing JSONL operator request."); expect(JSON.parse(frame.value) as unknown).toMatchObject({ @@ -796,14 +796,164 @@ describe("live acceptance release scenario", () => { type: "protected_input_required", version: 1, }); - expect(await bounded(exitPromise)).toEqual({ code: 0, signal: null }); + const finalFrame = await bounded(outputIterator.next()); + if (finalFrame === null || finalFrame.done) throw new Error("Missing post-abort JSONL output."); + expect(JSON.parse(finalFrame.value) as unknown).toEqual({ + status: "after_abort", + version: 1, + }); + expect(await bounded(closePromise)).toEqual({ code: 0, signal: null }); } finally { - outputLines.close(); - operatorOutput.resume(); + const inputClosed = operatorInput.closed + ? Promise.resolve() + : new Promise((resolvePromise) => operatorInput.once("close", resolvePromise)); + const outputClosed = operatorOutput.closed + ? Promise.resolve() + : new Promise((resolvePromise) => operatorOutput.once("close", resolvePromise)); + outputLines?.close(); + operatorInput.destroy(); + operatorOutput.destroy(); if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); - await exitPromise; } + await Promise.all([closePromise, inputClosed, outputClosed]); + } + }, 10_000); + + test("JSONL operator reads configuration and matching responses from one stdin stream", async () => { + const moduleUrl = pathToFileURL(join(import.meta.dir, "live-acceptance-scenario.ts")).href; + const child = spawn(process.execPath, [ + "-e", + [ + `import { createStandardJsonlLiveAcceptanceScenario } from ${JSON.stringify(moduleUrl)};`, + "const controller = new AbortController();", + "const { configuration, operator } = await createStandardJsonlLiveAcceptanceScenario(controller.signal);", + `if (configuration.cloudDeploymentUrl !== ${JSON.stringify(DEFAULT_CLOUD_DEPLOYMENT_URL)}) throw new Error('wrong_config');`, + "const document = await operator.protectedDocument({ kind: 'device_a_auth_invite', prompt: 'probe' }, controller.signal);", + "if (document?.answer !== 42) throw new Error('wrong_response');", + "await operator.progress('complete');", + "await operator.flush();", + "operator.close();", + ].join("\n"), + ], { + cwd: join(import.meta.dir, ".."), + stdio: ["pipe", "pipe", "pipe"], + }); + let childStderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + childStderr += chunk; + }); + const outputLines = createInterface({ input: child.stdout }); + const outputIterator = outputLines[Symbol.asyncIterator](); + const closePromise = new Promise>((resolvePromise) => { + child.once("close", (code, signal) => resolvePromise({ code, signal })); + }); + const writeLine = async (line: string): Promise => { + await new Promise((resolvePromise, rejectPromise) => { + child.stdin.write(`${line}\n`, (error) => { + if (error === undefined || error === null) resolvePromise(); + else rejectPromise(error); + }); + }); + }; + const write = async (value: unknown): Promise => { + await writeLine(JSON.stringify(value)); + }; + try { + await writeLine(` ${JSON.stringify({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "jsonl" }, + version: 1, + })} `); + const requestLine = await outputIterator.next(); + if (requestLine.done) throw new Error("Missing JSONL operator request."); + const request = JSON.parse(requestLine.value) as { requestId?: unknown }; + const requestId = request.requestId; + expect(typeof requestId).toBe("string"); + expect(request).toMatchObject({ + kind: "device_a_auth_invite", + prompt: "probe", + requestId, + type: "protected_input_required", + version: 1, + }); + await write({ + document: { answer: 42 }, + requestId, + type: "protected_input", + version: 1, + }); + const progressLine = await outputIterator.next(); + if (progressLine.done) throw new Error(`Missing JSONL progress frame. ${childStderr}`); + expect(JSON.parse(progressLine.value) as unknown).toEqual({ + step: "complete", + type: "progress", + version: 1, + }); + expect(await closePromise).toEqual({ code: 0, signal: null }); + expect(await outputIterator.next()).toMatchObject({ done: true }); + expect(childStderr).toBe(""); + } finally { + outputLines.close(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closePromise; + } + }, 10_000); + + test("JSONL operator rejects an otherwise-valid first frame above 8 KiB", async () => { + const moduleUrl = pathToFileURL(join(import.meta.dir, "live-acceptance-scenario.ts")).href; + const child = spawn(process.execPath, [ + "-e", + [ + `import { createStandardJsonlLiveAcceptanceScenario } from ${JSON.stringify(moduleUrl)};`, + "const controller = new AbortController();", + "try {", + " await createStandardJsonlLiveAcceptanceScenario(controller.signal);", + " process.stdout.write('accepted\\n');", + " process.exitCode = 2;", + "} catch {", + " process.stdout.write('rejected\\n');", + "}", + ].join("\n"), + ], { + cwd: join(import.meta.dir, ".."), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + const closePromise = new Promise>((resolvePromise) => { + child.once("close", (code, signal) => resolvePromise({ code, signal })); + }); + try { + const validConfiguration = JSON.stringify({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "jsonl" }, + version: 1, + }); + child.stdin.end(`${" ".repeat(8 * 1024)}${validConfiguration}\n`); + expect(await closePromise).toEqual({ code: 0, signal: null }); + expect(stdout).toBe("rejected\n"); + expect(stderr).toBe(""); + } finally { + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closePromise; } }, 10_000); @@ -911,7 +1061,7 @@ describe("live acceptance release scenario", () => { }); }); - test("the executable refuses to start workers without an explicit scenario descriptor", async () => { + test("the executable requires one explicit terminal or agent scenario mode", async () => { const child = Bun.spawn([ process.execPath, join(import.meta.dir, "live-acceptance.ts"), @@ -927,6 +1077,78 @@ describe("live acceptance release scenario", () => { ]); expect(exitCode).toBe(2); expect(stdout).toBe(""); - expect(stderr).toContain("explicit candidate configuration"); + expect(stderr).toContain("--scenario-fd"); + expect(stderr).toContain("--scenario-stdin"); + }); + + test("the executable rejects terminal configuration on agent stdin with a final frame", async () => { + const child = Bun.spawn([ + process.execPath, + join(import.meta.dir, "live-acceptance.ts"), + "--scenario-stdin", + ], { + cwd: join(import.meta.dir, ".."), + stdin: "pipe", + stderr: "pipe", + stdout: "pipe", + }); + await child.stdin.write(`${JSON.stringify({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "terminal" }, + version: 1, + })}\n`); + await child.stdin.end(); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode).toBe(1); + expect(stdout).toBe(`${JSON.stringify({ + ok: false, + status: "startup_failed", + version: 1, + })}\n`); + expect(stderr).toContain("startup failed safely"); + }); + + test("the executable rejects JSONL configuration from terminal descriptor mode", async () => { + const directory = await mkdtemp(join(tmpdir(), "hra-scenario-mode-")); + try { + const configurationPath = join(directory, "configuration.json"); + await writeFile(configurationPath, JSON.stringify({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "jsonl" }, + version: 1, + }), { mode: 0o600 }); + const child = Bun.spawn([ + "/bin/sh", + "-c", + 'exec 3< "$1"; exec "$2" "$3" --scenario-fd 3', + "hra-live-acceptance", + configurationPath, + process.execPath, + join(import.meta.dir, "live-acceptance.ts"), + ], { + cwd: join(import.meta.dir, ".."), + stdin: "ignore", + stderr: "pipe", + stdout: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode).toBe(1); + expect(stdout).toBe(`${JSON.stringify({ + ok: false, + status: "startup_failed", + version: 1, + })}\n`); + expect(stderr).toContain("startup failed safely"); + } finally { + await rm(directory, { force: false, recursive: true }); + } }); }); diff --git a/scripts/live-acceptance-scenario.ts b/scripts/live-acceptance-scenario.ts index a1c829a..84bfd31 100644 --- a/scripts/live-acceptance-scenario.ts +++ b/scripts/live-acceptance-scenario.ts @@ -1,7 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { createWriteStream } from "node:fs"; import { readSync } from "node:fs"; -import { Socket } from "node:net"; import type { Readable, Writable } from "node:stream"; import { Writable as WritableStream } from "node:stream"; import { createInterface } from "node:readline/promises"; @@ -15,15 +13,16 @@ import { } from "../src/cloud/identity-custody"; import { publicInteractionSchema, type PublicInteraction } from "../src/domain/interactions"; import { sessionEventPageSchema, type SessionEvent } from "../src/domain/session-events"; -import type { - LiveAcceptanceCliResult, - LiveAcceptanceDevice, - LiveAcceptanceDeviceName, - LiveAcceptanceRun, +import { + LIVE_ACCEPTANCE_CONTROL_FD, + type LiveAcceptanceCliResult, + type LiveAcceptanceDevice, + type LiveAcceptanceDeviceName, + type LiveAcceptanceRun, } from "./live-acceptance"; -const protectedOperatorInputFd = 4; -const operatorOutputFd = 5; +const operatorInputFd = 0; +const operatorOutputFd = 1; const scenarioConfigurationMaximumBytes = 8 * 1024; const operatorFrameMaximumBytes = 64 * 1024; const accountLoginDeadlineMs = 10 * 60 * 1_000; @@ -72,11 +71,13 @@ export interface LiveAcceptanceScenarioOperator { userCode: string; verificationUrl: string; }>, signal: AbortSignal): Promise; - progress(step: string): void; + progress(step: string): Promise; protectedDocument( request: LiveAcceptanceOperatorRequest, signal: AbortSignal, ): Promise; + close?(): void; + flush?(): Promise; } type ScenarioRun = Pick< @@ -374,7 +375,7 @@ const protectedAuth = async ( } const data = await executeJson( device, - ["auth", "login", "--input-fd", String(protectedOperatorInputFd), "--json"], + ["auth", "login", "--input-fd", String(LIVE_ACCEPTANCE_CONTROL_FD), "--json"], { protectedDocument: document }, ); return { data, emailDigest }; @@ -790,7 +791,7 @@ const resolveUserInput = async ( "--revision", String(interaction.revision), "--input-fd", - String(protectedOperatorInputFd), + String(LIVE_ACCEPTANCE_CONTROL_FD), "--json", ], { protectedDocument: await operator.protectedDocument({ @@ -822,7 +823,7 @@ const resolvePermission = async ( "--scope", "turn", "--input-fd", - String(protectedOperatorInputFd), + String(LIVE_ACCEPTANCE_CONTROL_FD), "--json", ], { protectedDocument: await operator.protectedDocument({ @@ -1092,10 +1093,10 @@ export async function runLiveAcceptanceScenario( const deviceA = cancellableDevice(run.device("a"), signal); const deviceB = cancellableDevice(run.device("b"), signal); - operator.progress("projects"); + await operator.progress("projects"); const [projectA] = await Promise.all([addProject(deviceA), addProject(deviceB)]); - operator.progress("device_a_auth"); + await operator.progress("device_a_auth"); const identityA = await protectedAuth( deviceA, operator, @@ -1116,7 +1117,7 @@ export async function runLiveAcceptanceScenario( throw new ScenarioFailure("device_a_pairing_failed"); } - operator.progress("codex_accounts"); + await operator.progress("codex_accounts"); const accountA = await addAccount(deviceA, "Acceptance Primary"); const signedInA = await loginAccount({ accountId: accountA, @@ -1153,7 +1154,7 @@ export async function runLiveAcceptanceScenario( assertObservedUsage(usageA, accountA); assertObservedUsage(usageB, accountB); - operator.progress("device_b_pending"); + await operator.progress("device_b_pending"); await protectedAuth( deviceB, operator, @@ -1202,7 +1203,7 @@ export async function runLiveAcceptanceScenario( "--json", ], { code: "UNAVAILABLE" }); - operator.progress("device_b_approval"); + await operator.progress("device_b_approval"); const approved = record(await executeJson( deviceA, ["device", "approve", deviceBPublicId, "--json"], @@ -1216,7 +1217,7 @@ export async function runLiveAcceptanceScenario( throw new ScenarioFailure("device_b_pairing_failed"); } - operator.progress("sessions_and_interactions"); + await operator.progress("sessions_and_interactions"); const sessionA = await startSession(deviceA, accountA, projectA); const sessionB = await startSession(deviceA, accountB, projectA); const markerA = `hra-live-user-input-${randomUUID()}`; @@ -1323,7 +1324,7 @@ export async function runLiveAcceptanceScenario( ); } - operator.progress("sync_and_remote"); + await operator.progress("sync_and_remote"); await executeJson(deviceA, ["sync", "now", "--json"]); await executeJson(deviceB, ["sync", "now", "--json"]); const remoteHeads = record(await executeJson( @@ -1413,7 +1414,7 @@ export async function runLiveAcceptanceScenario( sleep, }); - operator.progress("presence_and_revocation"); + await operator.progress("presence_and_revocation"); const onlineBefore = deviceListScenarioSchema.parse(await executeJson( deviceA, ["device", "list", "--json"], @@ -1519,7 +1520,7 @@ export async function runLiveAcceptanceScenario( status: "passed", version: 1, }); - operator.progress("cleanup"); + await operator.progress("cleanup"); await run.cleanup({ signal }); return evidence; } @@ -1634,7 +1635,7 @@ export class TerminalLiveAcceptanceOperator implements LiveAcceptanceScenarioOpe } } - progress(step: string): void { + async progress(step: string): Promise { process.stderr.write(`hra live acceptance: ${step}\n`); } @@ -1670,10 +1671,9 @@ class JsonlFrameReader { #closed = false; #iterator: AsyncIterator; - constructor(fd: number) { - if (isatty(fd)) throw new ScenarioFailure("operator_descriptor_invalid"); - // IPC ownership makes a pending pipe read cancellable on Linux; fs.ReadStream does not. - this.#stream = new Socket({ fd, readable: true, writable: false }); + constructor() { + if (isatty(operatorInputFd)) throw new ScenarioFailure("operator_descriptor_invalid"); + this.#stream = process.stdin; this.#iterator = this.#stream[Symbol.asyncIterator](); } @@ -1687,15 +1687,20 @@ class JsonlFrameReader { if (returned !== undefined) void returned.catch(() => undefined); } - async read(signal: AbortSignal): Promise { + async read( + signal: AbortSignal, + maximumBytes: number = operatorFrameMaximumBytes, + ): Promise { try { for (;;) { if (this.#closed) throw new ScenarioFailure("operator_closed"); const newline = this.#buffer.indexOf(0x0a); if (newline >= 0) { + if (newline === 0 || newline + 1 > maximumBytes) { + throw new ScenarioFailure("operator_response_invalid"); + } const line = this.#buffer.subarray(0, newline); this.#buffer = this.#buffer.subarray(newline + 1); - if (line.byteLength === 0) throw new ScenarioFailure("operator_response_invalid"); try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown; } catch { @@ -1704,15 +1709,24 @@ class JsonlFrameReader { line.fill(0); } } + if (this.#buffer.byteLength >= maximumBytes) { + throw new ScenarioFailure("operator_response_invalid"); + } const next = await abortable(async () => await this.#iterator.next(), signal); if (next.done) throw new ScenarioFailure("operator_closed"); const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value as Uint8Array); - this.#buffer = Buffer.concat([this.#buffer, chunk]); - if (this.#buffer.byteLength > operatorFrameMaximumBytes) { + const prior = this.#buffer; + if (chunk.byteLength > operatorFrameMaximumBytes - prior.byteLength) { + prior.fill(0); + chunk.fill(0); + this.#buffer = Buffer.alloc(0); throw new ScenarioFailure("operator_response_invalid"); } + this.#buffer = Buffer.concat([prior, chunk]); + prior.fill(0); + chunk.fill(0); } } catch (error: unknown) { if (signal.aborted) this.close(); @@ -1745,22 +1759,50 @@ const writeFrame = async ( export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperator { readonly #input: JsonlFrameReader; readonly #output: Writable; + #outputTail = Promise.resolve(); constructor() { - if (isatty(protectedOperatorInputFd) || isatty(operatorOutputFd)) { + if (isatty(operatorInputFd) || isatty(operatorOutputFd)) { throw new ScenarioFailure("operator_descriptor_invalid"); } - this.#input = new JsonlFrameReader(protectedOperatorInputFd); - this.#output = createWriteStream("/dev/null", { - autoClose: true, - fd: operatorOutputFd, - }); + this.#input = new JsonlFrameReader(); + this.#output = process.stdout; } - #closeAfterAbort(signal: AbortSignal): void { - if (!signal.aborted) return; + close(): void { this.#input.close(); - this.#output.destroy(); + } + + async flush(): Promise { + await this.#outputTail; + } + + #write(value: unknown, signal?: AbortSignal): Promise { + const operation = this.#outputTail.then( + async () => await writeFrame(this.#output, value, signal), + ); + const guarded = operation.catch((error: unknown) => { + this.close(); + throw error; + }); + this.#outputTail = guarded; + void guarded.catch(() => undefined); + return guarded; + } + + async readConfiguration(signal: AbortSignal): Promise { + try { + const configuration = liveAcceptanceScenarioConfigurationSchema.parse( + await this.#input.read(signal, scenarioConfigurationMaximumBytes), + ); + if (configuration.operator.kind !== "jsonl") { + throw new ScenarioFailure("operator_descriptor_invalid"); + } + return configuration; + } catch (error: unknown) { + this.close(); + throw error; + } } async acknowledgeDeviceLogin(input: Readonly<{ @@ -1770,7 +1812,7 @@ export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperat }>, signal: AbortSignal): Promise { const requestId = randomUUID(); try { - await writeFrame(this.#output, { + await this.#write({ ...input, requestId, type: "device_login_required", @@ -1782,17 +1824,17 @@ export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperat || response.requestId !== requestId ) throw new ScenarioFailure("operator_response_invalid"); } catch (error: unknown) { - this.#closeAfterAbort(signal); + this.close(); throw error; } } - progress(step: string): void { - void writeFrame(this.#output, { + async progress(step: string): Promise { + await this.#write({ step, type: "progress", version: 1, - }).catch(() => undefined); + }); } async protectedDocument( @@ -1801,7 +1843,7 @@ export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperat ): Promise { const requestId = randomUUID(); try { - await writeFrame(this.#output, { + await this.#write({ ...(request.context === undefined ? {} : { context: request.context }), kind: request.kind, prompt: request.prompt, @@ -1815,7 +1857,7 @@ export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperat } return response.document; } catch (error: unknown) { - this.#closeAfterAbort(signal); + this.close(); throw error; } } @@ -1829,10 +1871,16 @@ export function createLiveAcceptanceScenarioOperator( : new JsonlLiveAcceptanceOperator(); } -export const liveAcceptanceScenarioFixedOperatorFds = { - input: protectedOperatorInputFd, - output: operatorOutputFd, -} as const; +export async function createStandardJsonlLiveAcceptanceScenario( + signal: AbortSignal, +): Promise> { + const operator = new JsonlLiveAcceptanceOperator(); + const configuration = await operator.readConfiguration(signal); + return { configuration, operator }; +} export const liveAcceptanceScenarioPresenceOfflineBoundaryMs = presenceOfflineBoundaryMs; diff --git a/scripts/live-acceptance-worker.ts b/scripts/live-acceptance-worker.ts index 9e12df5..d886fc3 100644 --- a/scripts/live-acceptance-worker.ts +++ b/scripts/live-acceptance-worker.ts @@ -1,9 +1,6 @@ #!/usr/bin/env bun -import { createReadStream } from "node:fs"; -import { readSync } from "node:fs"; import type { Readable, Writable } from "node:stream"; -import { createWriteStream } from "node:fs"; import { isatty } from "node:tty"; import { callLocalDaemon } from "../src/daemon/local-transport"; @@ -19,7 +16,6 @@ import { assertAcceptanceDescriptorLayout, LIVE_ACCEPTANCE_CONTROL_FD, LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES, - LIVE_ACCEPTANCE_DESCRIPTOR_FD, LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES, LIVE_ACCEPTANCE_STATUS_FD, LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES, @@ -35,46 +31,91 @@ class WorkerFailure extends Error { } } -function readDescriptor(): unknown { - if (isatty(LIVE_ACCEPTANCE_DESCRIPTOR_FD)) { - throw new WorkerFailure("descriptor_invalid"); +type InputFailureCode = "control_invalid" | "descriptor_invalid"; + +class WorkerInput { + readonly #stream: Readable; + readonly #iterator: AsyncIterator; + #buffer = Buffer.alloc(0); + #ended = false; + + constructor() { + if (isatty(LIVE_ACCEPTANCE_CONTROL_FD)) { + throw new WorkerFailure("descriptor_invalid"); + } + this.#stream = process.stdin; + this.#iterator = this.#stream[Symbol.asyncIterator](); } - const chunks: Buffer[] = []; - let total = 0; - try { + + async readFrame( + maximumBytes: number, + failureCode: InputFailureCode, + ): Promise { for (;;) { - const remaining = LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES + 1 - total; - if (remaining <= 0) throw new WorkerFailure("descriptor_invalid"); - const chunk = Buffer.allocUnsafe(Math.min(4 * 1024, remaining)); - const count = readSync( - LIVE_ACCEPTANCE_DESCRIPTOR_FD, - chunk, - 0, - chunk.byteLength, - null, - ); - if (count === 0) { - chunk.fill(0); - break; + const newline = this.#buffer.indexOf(0x0a); + if (newline >= 0) { + if (newline === 0 || newline + 1 > maximumBytes) { + throw new WorkerFailure(failureCode); + } + const line = Buffer.from(this.#buffer.subarray(0, newline)); + const remainder = Buffer.from(this.#buffer.subarray(newline + 1)); + this.#buffer.fill(0); + this.#buffer = remainder; + return line; } - chunks.push(chunk.subarray(0, count)); - total += count; - if (total > LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES) { - throw new WorkerFailure("descriptor_invalid"); + if (this.#buffer.byteLength >= maximumBytes) { + throw new WorkerFailure(failureCode); } + if (this.#ended) { + if (this.#buffer.byteLength !== 0) throw new WorkerFailure(failureCode); + return null; + } + let next: IteratorResult; + try { + next = await this.#iterator.next(); + } catch { + throw new WorkerFailure(failureCode); + } + if (next.done) { + this.#ended = true; + continue; + } + const chunk = Buffer.isBuffer(next.value) + ? next.value + : Buffer.from(next.value as Uint8Array); + if (chunk.byteLength === 0) continue; + const prior = this.#buffer; + if (chunk.byteLength > LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES - prior.byteLength) { + prior.fill(0); + chunk.fill(0); + this.#buffer = Buffer.alloc(0); + throw new WorkerFailure(failureCode); + } + this.#buffer = Buffer.concat([prior, chunk]); + prior.fill(0); + chunk.fill(0); } - if (total === 0) throw new WorkerFailure("descriptor_invalid"); - const bytes = Buffer.concat(chunks, total); - try { - return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; - } finally { - bytes.fill(0); - } - } catch (error: unknown) { - if (error instanceof WorkerFailure) throw error; + } + + destroy(): void { + this.#buffer.fill(0); + this.#buffer = Buffer.alloc(0); + this.#stream.destroy(); + } +} + +async function readDescriptor(input: WorkerInput): Promise { + const frame = await input.readFrame( + LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES, + "descriptor_invalid", + ); + if (frame === null) throw new WorkerFailure("descriptor_invalid"); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)) as unknown; + } catch { throw new WorkerFailure("descriptor_invalid"); } finally { - for (const chunk of chunks) chunk.fill(0); + frame.fill(0); } } @@ -86,10 +127,7 @@ class StatusWriter { if (isatty(LIVE_ACCEPTANCE_STATUS_FD)) { throw new WorkerFailure("status_unavailable"); } - this.#stream = createWriteStream("/dev/null", { - autoClose: false, - fd: LIVE_ACCEPTANCE_STATUS_FD, - }); + this.#stream = process.stdout; } write(statusInput: LiveAcceptanceWorkerStatus): Promise { @@ -464,69 +502,54 @@ async function handleControl( } async function consumeControl( + input: WorkerInput, descriptor: AcceptanceInstallationDescriptor, status: StatusWriter, supervisor: DaemonSupervisor, ): Promise { - if (isatty(LIVE_ACCEPTANCE_CONTROL_FD)) throw new WorkerFailure("control_invalid"); - const stream: Readable = createReadStream("/dev/null", { - autoClose: false, - fd: LIVE_ACCEPTANCE_CONTROL_FD, - }); const parentLifetime = new AbortController(); const completed = deferred(); - let buffered = Buffer.alloc(0); let pendingFrames = 0; let tail = Promise.resolve(null); try { const readLoop = (async () => { try { - for await (const unknownChunk of stream) { - const chunk = Buffer.isBuffer(unknownChunk) - ? unknownChunk - : Buffer.from(unknownChunk as Uint8Array); - buffered = Buffer.concat([buffered, chunk]); - if (buffered.byteLength > LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES) { + for (;;) { + const line = await input.readFrame( + LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES, + "control_invalid", + ); + if (line === null) break; + let control: ReturnType; + try { + control = liveAcceptanceWorkerControlSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown, + ); + } catch { throw new WorkerFailure("control_invalid"); + } finally { + line.fill(0); } - for (;;) { - const newline = buffered.indexOf(0x0a); - if (newline < 0) break; - const line = buffered.subarray(0, newline); - buffered = buffered.subarray(newline + 1); - if (line.byteLength === 0) throw new WorkerFailure("control_invalid"); - let control: ReturnType; + pendingFrames += 1; + if (pendingFrames > 128) throw new WorkerFailure("control_invalid"); + tail = tail.then(async (prior) => { + if (prior !== null) return prior; try { - control = liveAcceptanceWorkerControlSchema.parse( - JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown, + const outcome = await handleControl( + control, + descriptor, + status, + supervisor, + parentLifetime.signal, ); - } catch { - throw new WorkerFailure("control_invalid"); + if (outcome !== null) completed.resolve(outcome); + return outcome; } finally { - line.fill(0); + pendingFrames -= 1; } - pendingFrames += 1; - if (pendingFrames > 128) throw new WorkerFailure("control_invalid"); - tail = tail.then(async (prior) => { - if (prior !== null) return prior; - try { - const outcome = await handleControl( - control, - descriptor, - status, - supervisor, - parentLifetime.signal, - ); - if (outcome !== null) completed.resolve(outcome); - return outcome; - } finally { - pendingFrames -= 1; - } - }); - void tail.catch((error: unknown) => completed.reject(error)); - } + }); + void tail.catch((error: unknown) => completed.reject(error)); } - if (buffered.byteLength !== 0) throw new WorkerFailure("control_invalid"); parentLifetime.abort(new Error("The acceptance parent control pipe closed.")); supervisor.beginParentShutdown(); completed.resolve("parent_closed"); @@ -539,7 +562,7 @@ async function consumeControl( parentLifetime.abort(new Error("The acceptance parent control pipe closed.")); supervisor.beginParentShutdown(); } - stream.destroy(); + input.destroy(); await readLoop.catch(() => undefined); await beforeDeadline(tail, 5_000).catch((error: unknown) => { if (outcome !== "parent_closed") throw error; @@ -547,8 +570,7 @@ async function consumeControl( return outcome; } finally { parentLifetime.abort(new Error("The acceptance control lifetime ended.")); - buffered.fill(0); - stream.destroy(); + input.destroy(); } } @@ -562,10 +584,12 @@ const signalDaemon = (): void => { async function workerMain(): Promise { let status: StatusWriter | undefined; + let input: WorkerInput | undefined; let descriptor: AcceptanceInstallationDescriptor | undefined; try { status = new StatusWriter(); - descriptor = await assertAcceptanceDescriptorLayout(readDescriptor()); + input = new WorkerInput(); + descriptor = await assertAcceptanceDescriptorLayout(await readDescriptor(input)); if (process.env.HOME !== descriptor.expectedHomeDirectory) { throw new WorkerFailure("home_changed"); } @@ -588,7 +612,7 @@ async function workerMain(): Promise { type: "ready", version: 1, }); - const outcome = await consumeControl(descriptor, status, supervisor); + const outcome = await consumeControl(input, descriptor, status, supervisor); if (outcome === "parent_closed") { const shutdown = new AbortController(); await supervisor.stop("parent_closed", shutdown.signal); @@ -618,6 +642,8 @@ async function workerMain(): Promise { }).catch(() => undefined); await status?.close().catch(() => undefined); return 1; + } finally { + input?.destroy(); } } diff --git a/scripts/live-acceptance.test.ts b/scripts/live-acceptance.test.ts index 26b7738..2a457d5 100644 --- a/scripts/live-acceptance.test.ts +++ b/scripts/live-acceptance.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, @@ -14,6 +15,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; import type { CommandResponse, LocalCommand } from "../src/domain/contracts"; import { readDaemonAuthorityReceipt } from "../src/daemon/daemon-lock"; @@ -26,7 +28,10 @@ import { import { assertAcceptanceDescriptorLayout, createLiveAcceptanceLayout, + LIVE_ACCEPTANCE_CONTROL_FD, + LIVE_ACCEPTANCE_WORKER_STDIO, liveAcceptanceRecoveryReceiptSchema, + liveAcceptanceWorkerStatusSchema, liveAcceptanceWorkerLaunch, LiveAcceptanceStartError, resumeLiveAcceptanceCleanup, @@ -326,6 +331,45 @@ describe("source-only live acceptance isolation", () => { } }); + test("prepares many private credential homes concurrently", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const installation = createAcceptanceInstallation(layout.descriptors.a); + const codexHomes = Array.from({ length: 64 }, (_, index) => join( + layout.descriptors.a.rootDirectory, + "profiles", + `concurrent-${index}`, + "codex-home", + )); + + await Promise.all(codexHomes.map(async (codexHome) => { + await installation.prepareCodexHome(codexHome); + })); + + await Promise.all(codexHomes.map(async (codexHome) => { + const configPath = join(codexHome, "config.toml"); + const [metadata, contents] = await Promise.all([ + lstat(configPath), + readFile(configPath, "utf8"), + ]); + expect(metadata.isFile()).toBe(true); + expect(metadata.isSymbolicLink()).toBe(false); + expect(metadata.mode & 0o777).toBe(0o600); + expect(contents).toBe([ + 'cli_auth_credentials_store = "file"', + 'mcp_oauth_credentials_store = "file"', + "", + ].join("\n")); + })); + } finally { + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }); + test("keeps state, sockets, and capabilities out of worker argv and environment", async () => { const base = await privateTestBase(); let runRoot: string | undefined; @@ -335,6 +379,7 @@ describe("source-only live acceptance isolation", () => { const launch = liveAcceptanceWorkerLaunch(layout.descriptors.a); expect(launch.arguments).toHaveLength(1); expect(launch.arguments[0].endsWith("/scripts/live-acceptance-worker.ts")).toBe(true); + expect(LIVE_ACCEPTANCE_WORKER_STDIO).toEqual(["pipe", "pipe", "ignore"]); const serializedLaunch = JSON.stringify({ arguments: launch.arguments, environment: launch.environment, @@ -352,6 +397,74 @@ describe("source-only live acceptance isolation", () => { } }); + test("carries descriptor and fatal controls on stdin with status only on stdout", async () => { + const base = await privateTestBase(); + let child: ReturnType | undefined; + let childClosed: Promise> | undefined; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + const launch = liveAcceptanceWorkerLaunch(descriptor); + child = spawn(launch.executable, [...launch.arguments], { + cwd: launch.cwd, + env: launch.environment, + stdio: [...LIVE_ACCEPTANCE_WORKER_STDIO], + }); + childClosed = new Promise>( + (resolvePromise) => child!.once("close", (code, signal) => { + resolvePromise({ code, signal }); + }), + ); + expect(child.stdio).toHaveLength(3); + expect(child.stdin).not.toBeNull(); + expect(child.stdout).not.toBeNull(); + expect(child.stderr).toBeNull(); + const lines = createInterface({ input: child.stdout! })[Symbol.asyncIterator](); + + child.stdin!.write(`${JSON.stringify(descriptor)}\n`); + const readyLine = await lines.next(); + expect(readyLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(readyLine.value!) as unknown, + )).toMatchObject({ + device: descriptor.device, + runId: descriptor.runId, + type: "ready", + }); + + child.stdin!.write("{}\n"); + const failedLine = await lines.next(); + expect(failedLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(failedLine.value!) as unknown, + )).toEqual({ + code: "control_invalid", + device: descriptor.device, + runId: descriptor.runId, + type: "failed", + version: 1, + }); + expect(await lines.next()).toMatchObject({ done: true }); + expect(await childClosed).toEqual({ code: 1, signal: null }); + child = undefined; + childClosed = undefined; + } finally { + if (child !== undefined) { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.kill("SIGTERM"); + await childClosed?.catch(() => undefined); + } + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + test("starts and cleanly joins two full daemon subprocesses with HOME unchanged", async () => { const base = await privateTestBase(); const originalHomeDirectory = process.env.HOME; @@ -403,7 +516,7 @@ describe("source-only live acceptance isolation", () => { "auth", "login", "--input-fd", - "4", + String(LIVE_ACCEPTANCE_CONTROL_FD), "--json", ], { protectedDocument: { email: "not-an-email" } }); expect(protectedRefusal).toMatchObject({ exitCode: 2, stderr: "" }); diff --git a/scripts/live-acceptance.ts b/scripts/live-acceptance.ts index dfe322d..7d2a06a 100644 --- a/scripts/live-acceptance.ts +++ b/scripts/live-acceptance.ts @@ -38,12 +38,12 @@ import { DEFAULT_CLOUD_DEPLOYMENT_URL } from "../src/cloud/identity-custody"; import { resolveStatePaths } from "../src/storage/paths"; import { HRA_VERSION } from "../src/version"; -export const LIVE_ACCEPTANCE_DESCRIPTOR_FD = 3; -export const LIVE_ACCEPTANCE_CONTROL_FD = 4; -export const LIVE_ACCEPTANCE_STATUS_FD = 5; +export const LIVE_ACCEPTANCE_CONTROL_FD = 0; +export const LIVE_ACCEPTANCE_STATUS_FD = 1; export const LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES = 8 * 1024; export const LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES = 256 * 1024; export const LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES = 2 * 1024 * 1024; +export const LIVE_ACCEPTANCE_WORKER_STDIO = ["pipe", "pipe", "ignore"] as const; const workerStartupDeadlineMs = 30_000; const workerCommandDeadlineMs = 90_000; @@ -108,7 +108,7 @@ export const liveAcceptanceWorkerControlSchema = z.discriminatedUnion("type", [ context.addIssue({ code: "custom", message: "Unbounded CLI following is unavailable." }); } if (value.argv.includes("--input-stdin")) { - context.addIssue({ code: "custom", message: "Protected input must use inherited descriptor 4." }); + context.addIssue({ code: "custom", message: "Protected input must use worker standard input descriptor 0." }); } const inputFd = value.argv.indexOf("--input-fd"); const requestsProtectedInput = inputFd >= 0 @@ -117,7 +117,7 @@ export const liveAcceptanceWorkerControlSchema = z.discriminatedUnion("type", [ context.addIssue({ code: "custom", message: "Protected input and descriptor selection must agree." }); } if (inputFd >= 0 && !requestsProtectedInput) { - context.addIssue({ code: "custom", message: "Only inherited descriptor 4 carries protected input." }); + context.addIssue({ code: "custom", message: "Only worker standard input carries protected input." }); } }); @@ -906,14 +906,13 @@ const asReadable = (value: unknown): Readable => { return value as Readable; }; -const writeStreamDocument = async (stream: Writable, document: string, close: boolean): Promise => { +const writeStreamDocument = async (stream: Writable, document: string): Promise => { await new Promise((resolvePromise, rejectPromise) => { const settle = (error?: Error | null): void => { if (error === undefined || error === null) resolvePromise(); else rejectPromise(error); }; - if (close) stream.end(document, settle); - else stream.write(document, settle); + stream.write(document, settle); }); }; @@ -938,6 +937,7 @@ class ProcessWorker implements LiveAcceptanceWorker { readonly projectDirectory: string; readonly #child: ChildProcess; readonly #control: Writable; + readonly #closed = deferredSignal(); readonly #descriptor: AcceptanceInstallationDescriptor; readonly #lifetime = deferredSignal(); readonly #ready = deferredSignal(); @@ -963,6 +963,7 @@ class ProcessWorker implements LiveAcceptanceWorker { this.#descriptor = descriptor; this.#child = child; this.#control = control; + void this.#closed.promise.catch(() => undefined); void this.#lifetime.promise.catch(() => undefined); void this.#ready.promise.catch(() => undefined); void this.#stopped.promise.catch(() => undefined); @@ -985,6 +986,7 @@ class ProcessWorker implements LiveAcceptanceWorker { if (code !== 0 || signal !== null) this.#fail(new LiveAcceptanceError("worker_failed")); }); child.once("close", (code, signal) => { + this.#closed.resolve(); if (code !== 0 || signal !== null || !this.#receivedStopped || !this.#statusEnded) { this.#fail(new LiveAcceptanceError("worker_failed")); return; @@ -999,25 +1001,38 @@ class ProcessWorker implements LiveAcceptanceWorker { const child = spawn(launch.executable, [...launch.arguments], { cwd: launch.cwd, env: launch.environment, - stdio: ["ignore", "ignore", "ignore", "pipe", "pipe", "pipe"], + stdio: [...LIVE_ACCEPTANCE_WORKER_STDIO], + }); + const childClosed = new Promise((resolvePromise) => { + child.once("close", () => resolvePromise()); }); try { - const extendedStdio = child.stdio as Array; - const descriptorPipe = asWritable(extendedStdio[LIVE_ACCEPTANCE_DESCRIPTOR_FD]); - const control = asWritable(extendedStdio[LIVE_ACCEPTANCE_CONTROL_FD]); - const status = asReadable(extendedStdio[LIVE_ACCEPTANCE_STATUS_FD]); + const control = asWritable(child.stdin); + const status = asReadable(child.stdout); const worker = new ProcessWorker(descriptor, child, control, status); const serialized = `${JSON.stringify(descriptor)}\n`; if (Buffer.byteLength(serialized, "utf8") > LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES) { throw new LiveAcceptanceError("input_invalid"); } - await writeStreamDocument(descriptorPipe, serialized, true); + await writeStreamDocument(control, serialized); return worker; } catch (error: unknown) { - for (const pipe of (child.stdio as Array).slice(3)) { - pipe?.destroy(); - } + child.stdin.destroy(); + child.stdout.destroy(); child.kill("SIGTERM"); + const closedAfterTermination = await boundedDeadline( + childClosed, + workerShutdownDeadlineMs, + "worker_failed", + ).then(() => true, () => false); + if (!closedAfterTermination) { + child.kill("SIGKILL"); + await boundedDeadline( + childClosed, + workerShutdownDeadlineMs, + "worker_failed", + ); + } throw error; } } @@ -1116,10 +1131,11 @@ class ProcessWorker implements LiveAcceptanceWorker { } async preserve(): Promise { - if (this.#child.exitCode !== null || this.#child.signalCode !== null) return; - this.#control.end(); + if (this.#child.exitCode === null && this.#child.signalCode === null) { + this.#control.end(); + } await boundedDeadline( - this.#lifetime.promise.catch(() => undefined), + this.#closed.promise, workerShutdownDeadlineMs, "daemon_shutdown_unproven", ).catch(() => undefined); @@ -1147,7 +1163,7 @@ class ProcessWorker implements LiveAcceptanceWorker { throw new LiveAcceptanceError("input_invalid"); } try { - await writeStreamDocument(this.#control, frame, false); + await writeStreamDocument(this.#control, frame); } catch { const error = new LiveAcceptanceError("worker_failed"); this.#fail(error); @@ -2331,6 +2347,16 @@ export const liveAcceptanceSourceAttestation = async ( }; }; +const writeStandardOutputFrame = async (value: unknown): Promise => { + const frame = `${JSON.stringify(value)}\n`; + await new Promise((resolvePromise, rejectPromise) => { + process.stdout.write(frame, (error) => { + if (error === undefined || error === null) resolvePromise(); + else rejectPromise(error); + }); + }); +}; + export const liveAcceptanceMain = async ( arguments_: readonly string[] = Bun.argv.slice(2), ): Promise => { @@ -2349,7 +2375,7 @@ export const liveAcceptanceMain = async ( readLiveAcceptanceRecoveryReceiptFromFd(Number(rawFd)), { signal: resumeAbort.signal }, ); - process.stdout.write(`${JSON.stringify({ ok: true, status: "cleanup_complete", version: 1 })}\n`); + await writeStandardOutputFrame({ ok: true, status: "cleanup_complete", version: 1 }); return 0; } catch { process.stderr.write("hra live acceptance: cleanup remains recovery-required\n"); @@ -2359,19 +2385,24 @@ export const liveAcceptanceMain = async ( process.off("SIGTERM", stopResume); } } - if ( - arguments_.length !== 2 - || arguments_[0] !== "--scenario-fd" - || arguments_[1] === undefined - || !/^[0-9]+$/u.test(arguments_[1]) - ) { + const standardStreamScenario = arguments_.length === 1 + && arguments_[0] === "--scenario-stdin"; + const descriptorScenario = arguments_.length === 2 + && arguments_[0] === "--scenario-fd" + && arguments_[1] !== undefined + && /^[0-9]+$/u.test(arguments_[1]); + if (!standardStreamScenario && !descriptorScenario) { process.stderr.write( - "hra live acceptance: pass one explicit candidate configuration through --scenario-fd \n", + "hra live acceptance: use --scenario-fd for a terminal run or --scenario-stdin for a JSONL agent run\n", ); return 2; } - const scenarioFd = Number(arguments_[1]); + const scenarioFd = descriptorScenario ? Number(arguments_[1]) : undefined; let run: LiveAcceptanceRun | undefined; + let scenarioOperator: Readonly<{ + close?: () => void; + flush?: () => Promise; + }> | undefined; const interruption = deferredSignal(); const scenarioAbort = new AbortController(); const stop = () => { @@ -2385,19 +2416,26 @@ export const liveAcceptanceMain = async ( process.once("SIGTERM", stop); try { const scenarioModule = await import("./live-acceptance-scenario"); - const configuration = scenarioModule.readLiveAcceptanceScenarioConfigurationFromFd( - scenarioFd, - ); - if ( - configuration.operator.kind === "jsonl" - && (scenarioFd === scenarioModule.liveAcceptanceScenarioFixedOperatorFds.input - || scenarioFd === scenarioModule.liveAcceptanceScenarioFixedOperatorFds.output) - ) throw new LiveAcceptanceError("input_invalid"); + const standardScenario = standardStreamScenario + ? await scenarioModule.createStandardJsonlLiveAcceptanceScenario(scenarioAbort.signal) + : undefined; + let configuration; + if (standardScenario !== undefined) { + configuration = standardScenario.configuration; + } else { + if (scenarioFd === undefined) throw new LiveAcceptanceError("input_invalid"); + configuration = scenarioModule.readLiveAcceptanceScenarioConfigurationFromFd(scenarioFd); + } + if (!standardStreamScenario && configuration.operator.kind !== "terminal") { + throw new LiveAcceptanceError("input_invalid"); + } if ( configuration.operator.kind === "terminal" && (!process.stdin.isTTY || !process.stderr.isTTY) ) throw new LiveAcceptanceError("input_invalid"); - const operator = scenarioModule.createLiveAcceptanceScenarioOperator(configuration); + const operator = standardScenario?.operator + ?? scenarioModule.createLiveAcceptanceScenarioOperator(configuration); + scenarioOperator = operator; const attestation = await liveAcceptanceSourceAttestation(configuration.cloudDeploymentUrl); if (scenarioAbort.signal.aborted) throw new LiveAcceptanceError("operator_interrupted"); run = await startLiveAcceptanceRun({ @@ -2429,40 +2467,44 @@ export const liveAcceptanceMain = async ( "worker_failed", ).catch(() => ({ type: "failed" as const })); if (preservation === "cleanup_complete" && settlement.type === "complete") { - process.stdout.write(`${JSON.stringify({ + await scenarioOperator.flush?.(); + await writeStandardOutputFrame({ evidence: settlement.evidence, ok: true, status: "passed", version: 1, - })}\n`); + }); return 0; } if (preservation === "cleanup_complete") { - process.stdout.write(`${JSON.stringify({ + await scenarioOperator.flush?.(); + await writeStandardOutputFrame({ ok: false, recoveryReceiptRetained: false, runId: activeRun.runId, status: "evidence_unavailable_after_cleanup", version: 1, - })}\n`); + }); return 1; } - process.stdout.write(`${JSON.stringify({ + await scenarioOperator.flush?.(); + await writeStandardOutputFrame({ ok: false, recoveryReceiptPath: activeRun.recoveryReceiptPath, recoveryReceiptRetained: true, runId: activeRun.runId, status: "recovery_required", version: 1, - })}\n`); + }); return 75; } - process.stdout.write(`${JSON.stringify({ + await scenarioOperator.flush?.(); + await writeStandardOutputFrame({ evidence: outcome.evidence, ok: true, status: "passed", version: 1, - })}\n`); + }); return 0; } catch (error: unknown) { const failedRun = run; @@ -2479,31 +2521,39 @@ export const liveAcceptanceMain = async ( operatorInterrupted ? "operator_interrupted" : "worker_failed", ).catch(() => undefined); if (failedRun !== undefined && preservation === "cleanup_complete") { - process.stdout.write(`${JSON.stringify({ + await scenarioOperator?.flush?.().catch(() => undefined); + await writeStandardOutputFrame({ ok: false, recoveryReceiptRetained: false, runId: failedRun.runId, status: "evidence_unavailable_after_cleanup", version: 1, - })}\n`); + }).catch(() => undefined); return 1; } const recovery = failedRun ?? (error instanceof LiveAcceptanceStartError ? error : undefined); + await scenarioOperator?.flush?.().catch(() => undefined); if (recovery !== undefined) { - process.stdout.write(`${JSON.stringify({ + await writeStandardOutputFrame({ ok: false, recoveryReceiptPath: recovery.recoveryReceiptPath, recoveryReceiptRetained: true, runId: recovery.runId, status: "recovery_required", version: 1, - })}\n`); + }).catch(() => undefined); } else { + await writeStandardOutputFrame({ + ok: false, + status: "startup_failed", + version: 1, + }).catch(() => undefined); process.stderr.write("hra live acceptance: startup failed safely\n"); } return operatorInterrupted ? 75 : 1; } finally { + scenarioOperator?.close?.(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }