fix(verifiers): own setup terminal and snapshot lifetimes

Join identity-verified PTY family cleanup and native exit before workspace removal, retaining failed cleanup capabilities for retry. Replace duplicate kill-and-reject loops with shared process ownership and deadlines.

Capture configuration before setup using owned file handles and compare raw bytes. Preserve interactive cancellation, terminal restoration, redirected output, and selected inputs; remove the test-only forwarding export. Verified 122 focused cases, 9 README cases, build, static checks, docs, discovery, and duplication at 3.58%.
This commit is contained in:
morluto
2026-10-24 17:54:18 +08:00
parent ddde7276c4
commit d01ee7b19a
14 changed files with 1782 additions and 196 deletions
@@ -0,0 +1,8 @@
export { runOwnedVerifierTerminal } from "./verifier-terminal-owner.mjs";
export type {
VerifierTerminalOptions,
VerifierTerminalDependencies,
VerifierTerminalResult,
VerifierTerminalFailure,
VerifierTerminalCleanupFailure,
} from "./verifier-terminal-owner.mjs";
+13
View File
@@ -0,0 +1,13 @@
let implementation;
/** Load the development terminal owner and canonical process helpers together. */
export async function runOwnedVerifierTerminal(options, dependencies) {
implementation ??= import("tsx/esm/api").then(({ tsImport }) =>
tsImport(
new URL("./verifier-terminal-owner.mts", import.meta.url).href,
import.meta.url,
),
);
const module = await implementation;
return module.runOwnedVerifierTerminal(options, dependencies);
}
@@ -0,0 +1,81 @@
import { lstat, open } from "node:fs/promises";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
import type { PackageVerifierCommands } from "./package-command-owner.mjs";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
export type VerifierFileSnapshotEntry =
| {
readonly path: string;
readonly exists: true;
readonly content: Buffer;
}
| {
readonly path: string;
readonly exists: false;
readonly content: null;
};
/** Capture source values while the command owner retains every in-flight file. */
export async function captureVerifierFileSnapshot(
commands: Pick<PackageVerifierCommands, "adopt">,
paths: readonly string[],
): Promise<readonly VerifierFileSnapshotEntry[]> {
const resources = new ArtifactResourceScope();
commands.adopt(resources, "verifier file snapshots");
return withVerifierCleanup(
() =>
Promise.all(
paths.map((path) =>
resources.run(async (): Promise<VerifierFileSnapshotEntry> => {
try {
await lstat(path);
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
)
return { path, exists: false, content: null };
throw cause;
}
const handle = await open(path, "r");
const owner = new OwnedFileHandle(handle);
resources.retain({
kind: "file-handle",
resource: path,
handle: owner,
});
return {
path,
exists: true,
content: await handle.readFile(),
};
}),
),
),
[() => resources.close()],
);
}
/** Compare the same selected paths against their completed baseline facts. */
export async function assertVerifierFilesUnchanged(
commands: Pick<PackageVerifierCommands, "adopt">,
snapshot: readonly VerifierFileSnapshotEntry[],
label: string,
): Promise<void> {
const current = await captureVerifierFileSnapshot(
commands,
snapshot.map(({ path }) => path),
);
for (const [index, entry] of snapshot.entries()) {
const observed = current[index]!;
if (
entry.exists
? !observed.exists || !entry.content.equals(observed.content)
: observed.exists
)
throw new Error(`${label} changed ${entry.path}`);
}
}
@@ -0,0 +1,5 @@
export {
captureVerifierFileSnapshot,
assertVerifierFilesUnchanged,
} from "./verifier-file-snapshot-owner.mjs";
export type { VerifierFileSnapshotEntry } from "./verifier-file-snapshot-owner.mjs";
+22
View File
@@ -0,0 +1,22 @@
let implementation;
/** Load the source file owner only when a verifier needs snapshot facts. */
const loadImplementation = () => {
implementation ??= import("tsx/esm/api").then(({ tsImport }) =>
tsImport(
new URL("./verifier-file-snapshot-owner.mts", import.meta.url).href,
import.meta.url,
),
);
return implementation;
};
export async function captureVerifierFileSnapshot(commands, paths) {
const module = await loadImplementation();
return module.captureVerifierFileSnapshot(commands, paths);
}
export async function assertVerifierFilesUnchanged(commands, snapshot, label) {
const module = await loadImplementation();
return module.assertVerifierFilesUnchanged(commands, snapshot, label);
}
+425
View File
@@ -0,0 +1,425 @@
import { randomUUID } from "node:crypto";
import type { IDisposable, IPty } from "@lydell/node-pty";
import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js";
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { err, ok, type Result } from "../../src/domain/result.js";
import {
cleanupOwnedProcessGroup,
type PersistentOwnedProcessGroup,
} from "../../src/process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../../src/process/ProcessOwnershipEvidence.js";
import {
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../../src/process/ProcessOwnershipObservation.js";
import { PROCESS_CLEANUP_VERIFICATION_GRACE_MS } from "../../src/process/ProcessLifecycle.js";
import { ProviderStartupDeadline } from "../../src/process/ProviderDeadline.js";
import { processCaptureOwnershipUnavailableReason } from "../../src/process/capture/ProcessCaptureCapability.js";
import { retainOwnedCommandCleanup } from "../../src/providers/OwnedCommandCleanup.js";
import type {
PackageCleanupOwner,
PackageVerifierCommands,
} from "./package-command-owner.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./verifier-cleanup.mjs";
export interface VerifierTerminalOptions {
readonly commands: Pick<PackageVerifierCommands, "adopt">;
readonly command: string;
readonly args: readonly string[];
readonly cwd: string;
readonly environment: NodeJS.ProcessEnv;
readonly timeoutMs: number;
/** Capacity of the decoded text's UTF-8 representation, not raw PTY bytes. */
readonly maxOutputBytes?: number;
readonly onData?: (output: string, write: (data: string) => void) => void;
readonly signal?: AbortSignal;
}
export interface VerifierTerminalDependencies {
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
readonly cleanupProcessGroup?: typeof cleanupOwnedProcessGroup;
}
export interface VerifierTerminalResult {
readonly output: string;
readonly exitCode: number;
readonly signal?: number;
}
type TerminalExit = Omit<VerifierTerminalResult, "output">;
type TerminalFailureReason =
| "timeout"
| "cancelled"
| "output_limit"
| "unsupported_platform";
/** Expected terminal failures retain the decoded output observed at failure. */
export class VerifierTerminalFailure extends Error {
readonly _tag = "VerifierTerminalFailure";
constructor(
readonly reason: TerminalFailureReason,
readonly output: string,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "VerifierTerminalFailure";
}
}
/** The adopted owner remains the exact authority for retrying this release. */
export class VerifierTerminalCleanupFailure extends Error {
readonly _tag = "VerifierTerminalCleanupFailure";
readonly reason = "cleanup_incomplete";
constructor(
readonly cleanupOwner: PackageCleanupOwner,
readonly output: string,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "VerifierTerminalCleanupFailure";
}
}
/** Select a code-point prefix before allocating its retained representation. */
const decodedPrefix = (text: string, capacity: number): string => {
let end = 0;
let bytes = 0;
for (const character of text) {
const size = Buffer.byteLength(character, "utf8");
if (bytes + size > capacity) break;
bytes += size;
end += character.length;
}
return text.slice(0, end);
};
class VerifierTerminalOwner implements PackageCleanupOwner {
readonly #controller = new AbortController();
readonly #runId = randomUUID();
readonly #evidence = new ProcessOwnershipEvidenceOwner();
readonly #preparationResources = new ArtifactResourceScope();
readonly #resourceOwner: RetainedResourceScope = {
run: (work) => this.#preparationResources.run(work),
retain: (owner, resource) =>
this.#preparationResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
};
readonly #subscriptions = new Set<IDisposable>();
readonly #maxOutputBytes: number;
#output = "";
#outputBytes = 0;
#outputLimited = false;
#terminal: IPty | undefined;
#ownership: PersistentOwnedProcessGroup | undefined;
#startup: Promise<Result<void, unknown>> | undefined;
#releasePromise: Promise<void> | undefined;
#groupReleased = false;
#released = false;
#settled = false;
#cancelled: VerifierTerminalFailure | undefined;
#deadline: ProviderStartupDeadline | undefined;
#resolveCompletion!: (result: Result<TerminalExit, unknown>) => void;
readonly #completion = new Promise<Result<TerminalExit, unknown>>(
(resolve) => {
this.#resolveCompletion = resolve;
},
);
#resolveExit!: () => void;
readonly #nativeExit = new Promise<void>((resolve) => {
this.#resolveExit = resolve;
});
readonly #onAbort = (): void => {
this.#cancel(this.options.signal?.reason);
};
readonly #onDeadlineAbort = (): void => {
const deadline = this.#deadline;
if (deadline === undefined) return;
if (deadline.interruption === "timeout") {
this.#finish(
err(
new VerifierTerminalFailure(
"timeout",
this.#output,
`Verifier terminal timed out after ${String(this.options.timeoutMs)} ms`,
),
),
);
return;
}
this.#cancel(deadline.signal.reason);
};
constructor(
private readonly options: VerifierTerminalOptions,
private readonly dependencies: VerifierTerminalDependencies,
) {
this.#maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
}
start(): void {
// Adoption precedes even the first asynchronous preparation operation.
this.#startup = Promise.resolve()
.then(() => this.#acquire())
.then(ok, (cause: unknown) => {
// Retain behind the already adopted terminal owner, even while its
// parent is closing. Startup cannot settle before transfer completes.
retainOwnedCommandCleanup(
cause,
this.#resourceOwner,
"package-verifier",
this.options.command,
);
return err(cause);
});
this.options.signal?.addEventListener("abort", this.#onAbort, {
once: true,
});
if (this.options.signal?.aborted === true) this.#onAbort();
}
async result(): Promise<Result<VerifierTerminalResult, unknown>> {
const startup = await this.#startup;
if (startup !== undefined && !startup.ok) return startup;
const exit = await this.#completion;
return exit.ok ? ok({ output: this.#output, ...exit.value }) : exit;
}
throwIfCancelled(): void {
if (this.#cancelled !== undefined) throw this.#cancelled;
}
close(): Promise<void> {
if (!this.#released)
this.#cancel(new Error("Verifier terminal owner closed"));
return this.release();
}
/** Normal finalization releases resources without cancelling its own run. */
release(): Promise<void> {
if (this.#released) return Promise.resolve();
if (this.#releasePromise !== undefined) return this.#releasePromise;
const attempt = Promise.resolve()
.then(() => this.#release())
.finally(() => {
if (this.#releasePromise === attempt) this.#releasePromise = undefined;
});
this.#releasePromise = attempt;
return attempt;
}
async #acquire(): Promise<void> {
const { signal } = this.#controller;
signal.throwIfAborted();
await prepareProcessOwnershipInspection(signal);
signal.throwIfAborted();
const captureBaseline = systemProcessOwnershipHost.captureBaseline;
if (captureBaseline === undefined)
throw new Error("Process ownership baseline is unavailable on this host");
const baseline = await captureBaseline(signal);
signal.throwIfAborted();
const { spawn } = await (
this.dependencies.loadPty ?? (() => import("@lydell/node-pty"))
)();
signal.throwIfAborted();
const terminal = spawn(this.options.command, [...this.options.args], {
cwd: this.options.cwd,
env: { ...this.options.environment, REA_PROCESS_RUN_ID: this.#runId },
name: "xterm-256color",
cols: 120,
rows: 40,
});
this.#terminal = terminal;
this.#ownership = {
runId: this.#runId,
leaderPid: terminal.pid,
processGroupId: terminal.pid,
expectedParentPid: process.pid,
captureBaseline: baseline,
sweepTokenOwnedProcesses: true,
evidenceOwner: this.#evidence,
};
this.#subscriptions.add(
terminal.onExit((exit) => {
this.#resolveExit();
this.#finish(ok(exit));
}),
);
this.#subscriptions.add(terminal.onData((data) => this.#onData(data)));
if (!this.#settled) {
this.#deadline = new ProviderStartupDeadline(
this.options.timeoutMs,
this.#controller.signal,
);
this.#deadline.signal.addEventListener("abort", this.#onDeadlineAbort, {
once: true,
});
if (this.#deadline.signal.aborted) this.#onDeadlineAbort();
}
}
#onData(data: string): void {
if (this.#outputLimited) return;
const bytes = Buffer.byteLength(data, "utf8");
const remaining = this.#maxOutputBytes - this.#outputBytes;
if (bytes > remaining) {
const prefix = decodedPrefix(data, remaining);
this.#output += prefix;
this.#outputBytes += Buffer.byteLength(prefix, "utf8");
this.#outputLimited = true;
this.#finish(
err(
new VerifierTerminalFailure(
"output_limit",
this.#output,
`Verifier terminal decoded output exceeded ${String(this.#maxOutputBytes)} UTF-8 bytes`,
),
),
);
return;
}
this.#output += data;
this.#outputBytes += bytes;
if (this.#settled) return;
try {
this.options.onData?.(this.#output, (input) =>
this.#terminal?.write(input),
);
} catch (cause: unknown) {
this.#finish(err(cause));
}
}
#finish(result: Result<TerminalExit, unknown>): void {
if (this.#settled) return;
this.#settled = true;
this.#disposeDeadline();
this.#resolveCompletion(result);
}
#disposeDeadline(): void {
this.#deadline?.signal.removeEventListener("abort", this.#onDeadlineAbort);
this.#deadline?.dispose();
this.#deadline = undefined;
}
#cancel(cause: unknown): void {
if (this.#released || this.#cancelled !== undefined) return;
this.#cancelled = new VerifierTerminalFailure(
"cancelled",
this.#output,
"Verifier terminal was cancelled",
{ cause },
);
this.#controller.abort(this.#cancelled);
this.#finish(err(this.#cancelled));
}
async #release(): Promise<void> {
// Join acquisition, not result(): close itself may be what ends execution.
await this.#startup;
this.#disposeDeadline();
const terminal = await this.#releaseTerminal().then(ok, err);
const preparation = await this.#preparationResources.close().then(ok, err);
if (!terminal.ok && !preparation.ok)
throw new AggregateError(
[terminal.error, preparation.error],
"Verifier terminal and ownership preparation cleanup failed",
);
if (!terminal.ok) throw terminal.error;
if (!preparation.ok) throw preparation.error;
this.options.signal?.removeEventListener("abort", this.#onAbort);
this.#released = true;
}
async #releaseTerminal(): Promise<void> {
if (this.#ownership !== undefined && !this.#groupReleased) {
const result = await (
this.dependencies.cleanupProcessGroup ?? cleanupOwnedProcessGroup
)(this.#ownership);
if (!result.cleaned)
throw new VerifierTerminalCleanupFailure(
this,
this.#output,
result.reason,
{ cause: result },
);
this.#groupReleased = true;
}
if (this.#terminal !== undefined && !(await this.#waitForNativeExit()))
throw new VerifierTerminalCleanupFailure(
this,
this.#output,
"Verified process cleanup did not produce the native terminal exit event",
);
await closeVerifierResources(
[...this.#subscriptions].map((subscription) => () => {
subscription.dispose();
this.#subscriptions.delete(subscription);
}),
);
}
async #waitForNativeExit(): Promise<boolean> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
this.#nativeExit.then(() => true),
new Promise<boolean>((resolve) => {
timeout = setTimeout(
() => resolve(false),
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
);
}),
]);
} finally {
clearTimeout(timeout);
}
}
}
/** Run one native POSIX PTY through observed exit and retained family cleanup. */
export async function runOwnedVerifierTerminal(
options: VerifierTerminalOptions,
dependencies: VerifierTerminalDependencies = {},
): Promise<VerifierTerminalResult> {
const unavailable = processCaptureOwnershipUnavailableReason(
process.platform,
);
if (unavailable !== undefined)
throw new VerifierTerminalFailure("unsupported_platform", "", unavailable);
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)
throw new RangeError(
"Verifier terminal timeout must be positive and finite",
);
const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0)
throw new RangeError(
"Verifier terminal output capacity must be a non-negative safe integer",
);
const owner = new VerifierTerminalOwner(options, dependencies);
options.commands.adopt(owner);
owner.start();
let release: Promise<void> | undefined;
const finish = (): Promise<void> => (release ??= owner.release());
return withVerifierCleanup(async () => {
const result = await owner.result();
// Select late cancellation after finalization; composition reuses this exact
// attempt, including its rejection, instead of invoking cleanup twice.
await finish().then(ok, err);
if (!result.ok) throw result.error;
owner.throwIfCancelled();
return result.value;
}, [finish]);
}
+19
View File
@@ -0,0 +1,19 @@
import type { PackageVerifierCommands } from "./verify-package-commands.mjs";
export interface SetupTerminalOptions {
readonly commands: Pick<PackageVerifierCommands, "adopt">;
readonly command: string;
readonly environment: NodeJS.ProcessEnv;
readonly root: string;
readonly unchangedPaths: readonly string[];
}
/** Require positive canonical-input and echo observations. */
export function terminalModesRestored(termios: unknown): boolean;
export function verifyInteractiveSetup(
options: SetupTerminalOptions,
): Promise<void>;
export function verifyRedirectedStdoutSetup(
options: SetupTerminalOptions,
): Promise<void>;
+142
View File
@@ -0,0 +1,142 @@
import { runOwnedVerifierTerminal } from "./owned-verifier-terminal.mjs";
import {
captureVerifierFileSnapshot,
assertVerifierFilesUnchanged,
} from "./verifier-file-snapshots.mjs";
export const terminalModesRestored = (termios) => {
if (typeof termios !== "string") return false;
const modes = new Set(termios.trim().split(/\s+/u));
return modes.has("icanon") && modes.has("echo");
};
export async function verifyInteractiveSetup({
commands,
command,
environment,
root,
unchangedPaths,
}) {
const unchanged = await captureVerifierFileSnapshot(commands, unchangedPaths);
let cancelled = false;
const wrapper = `
const { spawnSync } = require("node:child_process");
const child = spawnSync(process.argv[1], ["setup"], { stdio: "inherit", env: process.env, shell: process.platform === "win32" });
const terminal = process.platform === "win32"
? spawnSync("cmd.exe", ["/c", "mode", "CON"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] })
: spawnSync("stty", ["-a"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
process.stdout.write("\\n__REA_SETUP_EXIT__" + JSON.stringify({ status: child.status, termios: terminal.stdout, error: child.error?.message }));
process.exit(child.status ?? 1);
`;
let result;
try {
result = await runOwnedVerifierTerminal({
commands,
command: process.execPath,
args: ["-e", wrapper, command],
cwd: root,
environment: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
timeoutMs: 20_000,
onData(output, write) {
if (!cancelled && output.includes("Which agents should use REA?")) {
cancelled = true;
write("\u0003");
}
},
});
} catch (cause) {
if (cause?._tag === "VerifierTerminalFailure" && cause.reason === "timeout")
throw new Error(
`packaged setup wizard timed out after output: ${cause.output}`,
{ cause },
);
throw cause;
}
const { output, exitCode } = result;
const marker = output.lastIndexOf("__REA_SETUP_EXIT__");
let childResult;
try {
childResult =
marker < 0 ? undefined : JSON.parse(output.slice(marker + 18).trim());
} catch {
childResult = undefined;
}
if (
!cancelled ||
exitCode !== 0 ||
childResult?.status !== 0 ||
!output.includes("\u001b[?25h") ||
!terminalModesRestored(childResult?.termios) ||
!output.includes("No changes were made.")
)
throw new Error(
`packaged rea-agents setup did not cancel with a restored terminal and exit 0: ${output}`,
);
await assertVerifierFilesUnchanged(
commands,
unchanged,
"Interactive cancellation",
);
}
export async function verifyRedirectedStdoutSetup({
commands,
command,
environment,
root,
unchangedPaths,
}) {
const unchanged = await captureVerifierFileSnapshot(commands, unchangedPaths);
const wrapper = `
const { spawnSync } = require("node:child_process");
const child = spawnSync(process.argv[1], ["setup"], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"], shell: process.platform === "win32" });
process.stdout.write("\\n__REA_REDIRECTED_SETUP__" + JSON.stringify({ status: child.status, stdout: child.stdout, error: child.error?.message, stdinTTY: process.stdin.isTTY, stderrTTY: process.stderr.isTTY, stdoutRedirected: true }));
process.exit(child.status ?? 1);
`;
let result;
try {
result = await runOwnedVerifierTerminal({
commands,
command: process.execPath,
args: ["-e", wrapper, command],
cwd: root,
environment: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
timeoutMs: 15_000,
});
} catch (cause) {
if (cause?._tag === "VerifierTerminalFailure" && cause.reason === "timeout")
throw new Error(
`packaged setup started an interactive prompt with redirected stdout: ${cause.output}`,
{ cause },
);
throw cause;
}
const { output, exitCode } = result;
const marker = output.lastIndexOf("__REA_REDIRECTED_SETUP__");
let childResult;
try {
childResult =
marker < 0 ? undefined : JSON.parse(output.slice(marker + 24).trim());
} catch {
childResult = undefined;
}
const allOutput = `${output}\n${childResult?.stdout ?? ""}`;
if (
(exitCode !== 0 && exitCode !== 1) ||
childResult?.status !== exitCode ||
childResult?.stdinTTY !== true ||
childResult?.stderrTTY !== true ||
childResult?.stdoutRedirected !== true ||
childResult?.stdout?.includes("Which agents should use REA?") ||
allOutput.includes("Which agents should use REA?") ||
/\u001b\[[0-9;?]*[ -/]*[@-~]/u.test(allOutput)
)
throw new Error(
`packaged setup did not remain plain and non-interactive with redirected stdout: ${output}`,
);
await assertVerifierFilesUnchanged(
commands,
unchanged,
"Redirected-stdout setup",
);
}
+4 -4
View File
@@ -7,12 +7,12 @@ export function assertClientConfig(
original: Uint8Array,
): Promise<void>;
/** Return true only when stty output positively reports canonical input and echo. */
export function terminalModesRestored(termios: unknown): boolean;
/** Verify setup using the parent's explicit command owner. */
export function verifyPackageSetup(input: {
readonly commands: Pick<PackageVerifierCommands, "run" | "runWithStatus">;
readonly commands: Pick<
PackageVerifierCommands,
"run" | "runWithStatus" | "adopt"
>;
readonly cli: string;
readonly packageRunnerCli: string;
readonly environment: NodeJS.ProcessEnv;
+6 -188
View File
@@ -9,21 +9,17 @@ import {
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { spawn } from "@lydell/node-pty";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { json, pathExists } from "./lib/verify-package-core.mjs";
import { parse as parseJsonc } from "jsonc-parser";
import { skillReferenceIssues } from "./lib/docs-facts.mjs";
import {
verifyInteractiveSetup,
verifyRedirectedStdoutSetup,
} from "./lib/verify-setup-terminal.mjs";
import { setupPlanFailureMessage } from "./verify-package-setup-plan.mjs";
export const terminalModesRestored = (termios) => {
if (typeof termios !== "string") return false;
const modes = new Set(termios.trim().split(/\s+/u));
return modes.has("icanon") && modes.has("echo");
};
const OPENCODE_ORIGINAL = `{
// OpenCode preferences must survive setup.
"model": "provider/model",
@@ -49,186 +45,6 @@ const verifyMcpAdd = async ({ commands, cli, environment, npxLog }) => {
throw new Error("Incur mcp add did not register the pinned npx command");
};
const snapshotFiles = async (paths) =>
Promise.all(
paths.map(async (path) => {
const exists = await pathExists(path);
return {
path,
exists,
content: exists ? await readFile(path, "utf8") : null,
};
}),
);
const assertFilesUnchanged = async (snapshot, label) => {
for (const entry of snapshot) {
const exists = await pathExists(entry.path);
const content = exists ? await readFile(entry.path, "utf8") : null;
if (exists !== entry.exists || content !== entry.content)
throw new Error(`${label} changed ${entry.path}`);
}
};
const verifyInteractiveSetup = async ({
command,
environment,
root,
unchangedPaths,
}) =>
new Promise((resolvePromise, reject) => {
let output = "";
let cancelled = false;
let settled = false;
const unchanged = snapshotFiles(unchangedPaths);
const wrapper = `
const { spawnSync } = require("node:child_process");
const child = spawnSync(process.argv[1], ["setup"], { stdio: "inherit", env: process.env, shell: process.platform === "win32" });
const terminal = process.platform === "win32"
? spawnSync("cmd.exe", ["/c", "mode", "CON"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] })
: spawnSync("stty", ["-a"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
process.stdout.write("\\n__REA_SETUP_EXIT__" + JSON.stringify({ status: child.status, termios: terminal.stdout, error: child.error?.message }));
process.exit(child.status ?? 1);
`;
const terminal = spawn(process.execPath, ["-e", wrapper, command], {
cwd: root,
env: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
name: "xterm-256color",
cols: 120,
rows: 40,
});
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error === undefined) resolvePromise();
else reject(error);
};
const timeout = setTimeout(() => {
terminal.kill();
finish(
new Error(`packaged setup wizard timed out after output: ${output}`),
);
}, 20_000);
terminal.onData((data) => {
output += data;
if (!cancelled && output.includes("Which agents should use REA?")) {
cancelled = true;
terminal.write("\u0003");
}
});
terminal.onExit(async ({ exitCode }) => {
const marker = output.lastIndexOf("__REA_SETUP_EXIT__");
const result = marker < 0 ? undefined : output.slice(marker + 18).trim();
let childResult;
try {
childResult = result === undefined ? undefined : JSON.parse(result);
} catch {
childResult = undefined;
}
if (
!cancelled ||
exitCode !== 0 ||
childResult?.status !== 0 ||
!output.includes("\u001b[?25h") ||
(process.platform !== "win32" &&
!terminalModesRestored(childResult.termios)) ||
!output.includes("No changes were made.")
) {
finish(
new Error(
`packaged rea-agents setup did not cancel with a restored terminal and exit 0: ${output}`,
),
);
return;
}
try {
await assertFilesUnchanged(await unchanged, "Interactive cancellation");
finish();
} catch (error) {
finish(error);
}
});
});
const verifyRedirectedStdoutSetup = async ({
command,
environment,
root,
unchangedPaths,
}) =>
new Promise((resolvePromise, reject) => {
let output = "";
let settled = false;
const unchanged = snapshotFiles(unchangedPaths);
const wrapper = `
const { spawnSync } = require("node:child_process");
const child = spawnSync(process.argv[1], ["setup"], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"], shell: process.platform === "win32" });
process.stdout.write("\\n__REA_REDIRECTED_SETUP__" + JSON.stringify({ status: child.status, stdout: child.stdout, error: child.error?.message, stdinTTY: process.stdin.isTTY, stderrTTY: process.stderr.isTTY, stdoutRedirected: true }));
process.exit(child.status ?? 1);
`;
const terminal = spawn(process.execPath, ["-e", wrapper, command], {
cwd: root,
env: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
name: "xterm-256color",
cols: 120,
rows: 40,
});
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error === undefined) resolvePromise();
else reject(error);
};
const timeout = setTimeout(() => {
terminal.kill();
finish(
new Error(
`packaged setup started an interactive prompt with redirected stdout: ${output}`,
),
);
}, 15_000);
terminal.onData((data) => {
output += data;
});
terminal.onExit(async ({ exitCode }) => {
const marker = output.lastIndexOf("__REA_REDIRECTED_SETUP__");
let childResult;
try {
childResult =
marker < 0 ? undefined : JSON.parse(output.slice(marker + 24).trim());
} catch {
childResult = undefined;
}
const allOutput = `${output}\n${childResult?.stdout ?? ""}`;
if (
(exitCode !== 0 && exitCode !== 1) ||
childResult?.status !== exitCode ||
childResult.stdinTTY !== true ||
childResult.stderrTTY !== true ||
childResult.stdoutRedirected !== true ||
childResult.stdout?.includes("Which agents should use REA?") ||
allOutput.includes("Which agents should use REA?") ||
/\u001b\[[0-9;?]*[ -/]*[@-~]/u.test(allOutput)
) {
finish(
new Error(
`packaged setup did not remain plain and non-interactive with redirected stdout: ${output}`,
),
);
return;
}
try {
await assertFilesUnchanged(await unchanged, "Redirected-stdout setup");
finish();
} catch (error) {
finish(error);
}
});
});
const verifySetupPlan = async ({
commands,
cli,
@@ -771,12 +587,14 @@ export async function verifyPackageSetup({
? await Promise.all([readFile(claudeConfig), readFile(cursorConfig)])
: [];
await verifyInteractiveSetup({
commands,
command: packageRunnerCli,
environment,
root,
unchangedPaths,
});
await verifyRedirectedStdoutSetup({
commands,
command: packageRunnerCli,
environment,
root,