refactor(tools): remove remaining arbitrary workflow caps

This commit is contained in:
morluto
2026-09-29 17:00:06 +08:00
parent 4ad937c6ca
commit ecf3ed8bdb
43 changed files with 230 additions and 393 deletions
+3 -13
View File
@@ -1189,7 +1189,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!results.decompileCompleted()) {
throw new RequestFailure(
"decompile_failed",
"Ghidra decompilation failed: " + boundedMessage(results.getErrorMessage())
"Ghidra decompilation failed: " + diagnosticMessage(results.getErrorMessage())
);
}
return results;
@@ -1506,11 +1506,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
return canonicalOffset(inclusive.getAddressSpace(), offset.add(BigInteger.ONE));
}
private static String boundedMessage(String value) {
private static String diagnosticMessage(String value) {
if (value == null || value.isBlank()) {
return "native decompiler returned no detail";
}
return truncate(value.replaceAll("[\\r\\n]+", " "), 512).value;
return value.replaceAll("[\\r\\n]+", " ");
}
private JsonArray inventory(
@@ -1691,15 +1691,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
destination.add(item);
}
private static TruncatedValue truncate(String value, int maximumCodePoints) {
int codePoints = value.codePointCount(0, value.length());
if (codePoints <= maximumCodePoints) {
return new TruncatedValue(value, false);
}
int end = value.offsetByCodePoints(0, maximumCodePoints);
return new TruncatedValue(value.substring(0, end), true);
}
private static ValueMatcher literalMatcher(String pattern, boolean caseSensitive) {
String needle = caseSensitive ? pattern : pattern.toLowerCase(Locale.ROOT);
return value -> {
@@ -1914,7 +1905,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record InventoryItem(Address address, String value, JsonObject facts) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
private record TruncatedValue(String value, boolean truncated) {}
private record SessionDescriptor(
String transport,
String endpointPath,
+2 -2
View File
@@ -873,13 +873,13 @@ def _safe_diagnostic(error):
"""Project one exception without retaining provider or credential text."""
diagnostic_type = _diagnostic_type(error)
if diagnostic_type == "capability_unavailable":
message = str(error)[:512]
message = str(error)
elif diagnostic_type == "authorization":
message = "Invalid bridge capability"
elif diagnostic_type == "invalid_request":
message = "Invalid Hopper bridge request"
else:
message = "%s: Hopper bridge operation failed" % type(error).__name__[:128]
message = "%s: Hopper bridge operation failed" % type(error).__name__
return {"code": -32000, "message": message, "type": diagnostic_type}
+13 -31
View File
@@ -36,11 +36,8 @@ UNEXPECTED_DISPLAY_GEOMETRY = 77
X11_INPUT_FAILED = 78
RUNTIME_DEPENDENCY_UNAVAILABLE = 79
X11_SOCKET_DIRECTORY_UNUSABLE = 80
DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC_V1="
DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC="
X11_SOCKET_DIRECTORY = Path("/tmp/.X11-unix")
XVFB_STDERR_LIMIT = 16 * 1024
class AdapterFailure(Exception):
def __init__(
self,
@@ -48,20 +45,18 @@ class AdapterFailure(Exception):
reason: str,
message: str,
stderr_bytes: int = 0,
stderr_truncated: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.reason = reason
self.stderr_bytes = stderr_bytes
self.stderr_truncated = stderr_truncated
def with_stderr(self, capture: BoundedStderr) -> AdapterFailure:
self.stderr_bytes, self.stderr_truncated, _ = capture.snapshot()
def with_stderr(self, capture: CapturedStderr) -> AdapterFailure:
self.stderr_bytes, _ = capture.snapshot()
return self
class BoundedStderr:
class CapturedStderr:
def __init__(self, stream: object) -> None:
self._stream = stream
self._retained = bytearray()
@@ -77,15 +72,13 @@ class BoundedStderr:
return
with self._lock:
self._bytes += len(chunk)
remaining = XVFB_STDERR_LIMIT - len(self._retained)
if remaining > 0:
self._retained.extend(chunk[:remaining])
self._retained.extend(chunk)
def snapshot(self) -> tuple[int, bool, str]:
def snapshot(self) -> tuple[int, str]:
with self._lock:
retained = bytes(self._retained)
total = self._bytes
return total, total > len(retained), retained.decode("utf-8", "replace")
return total, retained.decode("utf-8", "replace")
def close(self) -> None:
self._thread.join(timeout=0.5)
@@ -284,12 +277,10 @@ def diagnostic(
operation: str,
status: str,
failure: AdapterFailure | None = None,
capture: BoundedStderr | None = None,
capture: CapturedStderr | None = None,
) -> dict[str, object]:
mode, read_only, _ = socket_directory_state()
captured_bytes, captured_truncated, _ = (
(0, False, "") if capture is None else capture.snapshot()
)
captured_bytes, _ = (0, "") if capture is None else capture.snapshot()
return {
"component": "hopper_private_display",
"operation": operation,
@@ -307,9 +298,6 @@ def diagnostic(
"xvfb_stderr_bytes": captured_bytes
if failure is None
else failure.stderr_bytes,
"xvfb_stderr_truncated": captured_truncated
if failure is None
else failure.stderr_truncated,
}
@@ -440,8 +428,8 @@ def authorize_display(
) from error
def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
stderr_bytes, stderr_truncated, text = capture.snapshot()
def classify_xvfb_failure(capture: CapturedStderr) -> AdapterFailure:
stderr_bytes, text = capture.snapshot()
mode, read_only, directory = socket_directory_state()
if read_only is True:
return AdapterFailure(
@@ -449,7 +437,6 @@ def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
"socket_directory_read_only",
"X11 socket directory is read-only",
stderr_bytes,
stderr_truncated,
)
if directory and mode != "1777":
return AdapterFailure(
@@ -457,7 +444,6 @@ def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
"socket_directory_mode",
"X11 socket directory mode is not 1777",
stderr_bytes,
stderr_truncated,
)
if mode is not None and not directory:
return AdapterFailure(
@@ -465,7 +451,6 @@ def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
"socket_directory_type",
"X11 socket path is not a directory",
stderr_bytes,
stderr_truncated,
)
lowered = text.lower()
if "error while loading shared libraries" in lowered:
@@ -474,7 +459,6 @@ def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
"missing_library",
"Xvfb shared library unavailable",
stderr_bytes,
stderr_truncated,
)
if any(
marker in lowered
@@ -489,21 +473,19 @@ def classify_xvfb_failure(capture: BoundedStderr) -> AdapterFailure:
"address_collision",
"private display address collision",
stderr_bytes,
stderr_truncated,
)
return AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"xvfb_startup_failed",
"private X display unavailable",
stderr_bytes,
stderr_truncated,
)
def start_xvfb(
session: Path,
args: argparse.Namespace,
) -> tuple[subprocess.Popen[bytes], str, Path, BoundedStderr]:
) -> tuple[subprocess.Popen[bytes], str, Path, CapturedStderr]:
authority = session / "Xauthority"
collision: AdapterFailure | None = None
for _ in range(3):
@@ -550,7 +532,7 @@ def start_xvfb(
"xvfb_startup_failed",
"Xvfb stderr unavailable",
)
capture = BoundedStderr(process.stderr)
capture = CapturedStderr(process.stderr)
deadline = time.monotonic() + 2
socket = Path(f"/tmp/.X11-unix/X{display_number}")
while time.monotonic() < deadline and process.poll() is None:
@@ -100,7 +100,7 @@ function parseManagedAppManifestMethod(rawMethod, index) {
method.signature_sha256,
`${prefix}.signature_sha256`,
),
il_size: ilSize(method, prefix),
il_size: nonNegativeInteger(method.il_size, `${prefix}.il_size`),
il_sha256:
method.il_sha256 === undefined
? undefined
@@ -112,26 +112,6 @@ function parseManagedAppManifestMethod(rawMethod, index) {
};
}
function ilSize(method, prefix) {
const hasIlSize = method.il_size !== undefined;
const hasIlLength = method.il_length !== undefined;
ensure(
hasIlSize || hasIlLength,
`${prefix}.il_size is required; il_length is accepted as a legacy alias`,
);
if (hasIlSize && hasIlLength)
ensure(
method.il_size === method.il_length,
`${prefix}.il_size and ${prefix}.il_length disagree`,
);
const value = hasIlSize ? method.il_size : method.il_length;
ensure(
Number.isInteger(value) && value >= 0,
`${prefix}.il_size must be a non-negative integer`,
);
return value;
}
function object(value, name) {
ensure(
value !== null && typeof value === "object" && !Array.isArray(value),
@@ -158,6 +138,14 @@ function optionalString(value, name) {
return string(value, name);
}
function nonNegativeInteger(value, name) {
ensure(
Number.isInteger(value) && value >= 0,
`${name} must be a non-negative integer`,
);
return value;
}
function digest(value, name) {
const text = string(value, name);
ensure(/^[a-f0-9]{64}$/u.test(text), `${name} must be a lowercase sha256`);
-2
View File
@@ -10,7 +10,6 @@ import { promisify } from "node:util";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
import { GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS } from "../dist/ghidra/GhidraDefaults.js";
import {
inspectGhidraInstallation,
SUPPORTED_GHIDRA_VERSION,
@@ -252,7 +251,6 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
targetSha256: parsedTarget.value.sha256,
providerVersion: SUPPORTED_GHIDRA_VERSION,
profileDigest: profile.value.profile.digest,
requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
});
let runtimeCoordinates;
+1 -2
View File
@@ -114,9 +114,8 @@ const runtimeChecks = (
broken.length === 0
? `${String(inventory.candidates.length)} runtime executable candidates passed bounded version probes.`
: `${String(broken.length)} of ${String(inventory.candidates.length)} runtime executable candidates failed bounded version probes: ${broken
.slice(0, 5)
.map(({ lexical_path: path }) => path)
.join(", ")}${broken.length > 5 ? ", …" : ""}`;
.join(", ")}`;
return [
check("node-toolchains", broken.length === 0, detail, {
remediation:
+1 -3
View File
@@ -72,7 +72,5 @@ const redactArgument = (argument: string): string => {
)
)
return `${argument.slice(0, argument.indexOf("=") + 1)}<redacted>`;
return argument.length > 256
? `${argument.slice(0, 256)}<redacted>`
: argument;
return argument;
};
@@ -246,7 +246,7 @@ const generatedCandidate = ({
}: GeneratedCandidateInput): ReconstructionObligationCandidate => ({
obligation_id: obligationId(key),
obligation_version: 1,
title: title.slice(0, 500),
title,
origin: "generated",
application_layer: candidatePolicy.applicationLayer,
family: candidatePolicy.family,
+56 -40
View File
@@ -1,11 +1,9 @@
import { execFile } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { constants } from "node:fs";
import { access, realpath } from "node:fs/promises";
import { delimiter, join } from "node:path";
const TOOL_NAMES = ["node", "npm", "npx"] as const;
const MAX_OUTPUT_BYTES = 65_536;
const MAX_DIAGNOSTIC_BYTES = 4_096;
const PROBE_CONCURRENCY = 4;
type RuntimeToolName = (typeof TOOL_NAMES)[number];
@@ -17,7 +15,7 @@ type RuntimeProbeFailureCode =
| "runtime_spawn_failed"
| "runtime_nonzero_exit";
/** Stable, bounded classification of one failed executable probe. */
/** Classification of one failed executable probe with its complete stderr. */
interface RuntimeProbeFailure {
readonly code: RuntimeProbeFailureCode;
readonly exit_code: number | null;
@@ -64,7 +62,7 @@ export interface RuntimeExecutableInventoryOptions {
readonly timeoutMs?: number;
}
/** Inventory and bounded-probe Node toolchain candidates under one exact PATH. */
/** Inventory and probe Node toolchain candidates under one exact PATH. */
export const inspectRuntimeExecutables = async (
options: RuntimeExecutableInventoryOptions,
): Promise<RuntimeExecutableInventory> => {
@@ -108,8 +106,7 @@ export const inspectRuntimeExecutables = async (
const diagnostics = await mapConcurrentBounded(
uniqueCandidates,
PROBE_CONCURRENCY,
(candidate) =>
probeCandidate(candidate, options.timeoutMs ?? 5_000, options.path),
(candidate) => probeCandidate(candidate, options.timeoutMs, options.path),
);
return {
launcher_node: await canonicalPath(options.launcherNode),
@@ -152,7 +149,7 @@ const probeCandidate = async (
readonly pathIndex: number | null;
readonly selection: RuntimeExecutableDiagnostic["selection"];
},
timeoutMs: number,
timeoutMs: number | undefined,
effectivePath: string,
): Promise<RuntimeExecutableDiagnostic> => {
const canonical = await canonicalPath(candidate.path);
@@ -180,43 +177,67 @@ type VersionResult =
const executeVersion = (
path: string,
timeoutMs: number,
timeoutMs: number | undefined,
effectivePath: string,
): Promise<VersionResult> =>
new Promise((resolve) => {
const child = execFile(
path,
["--version"],
{
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_BYTES,
windowsHide: true,
env: environmentWithPath(effectivePath),
},
(cause: Error | null, stdout: string, stderr: string) => {
if (cause === null) {
const version =
firstNonemptyLine(stdout) ?? firstNonemptyLine(stderr);
if (version !== undefined) resolve({ ok: true, version });
else
resolve({
ok: false,
failure: failure("runtime_invalid_version_output", child, stderr),
});
const child = spawn(path, ["--version"], {
windowsHide: true,
env: environmentWithPath(effectivePath),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let spawnError: Error | undefined;
let timeout: NodeJS.Timeout | undefined;
let timedOut = false;
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", (cause: Error) => {
spawnError = cause;
});
if (timeoutMs !== undefined && timeoutMs > 0) {
timeout = setTimeout(() => {
timedOut = true;
child.kill();
}, timeoutMs);
timeout.unref();
}
child.once("close", () => {
if (timeout !== undefined) clearTimeout(timeout);
if (spawnError === undefined && child.exitCode === 0) {
const version = firstNonemptyLine(stdout) ?? firstNonemptyLine(stderr);
if (version !== undefined) {
resolve({ ok: true, version });
return;
}
resolve({
ok: false,
failure: classifyProbeFailure(cause, child, stderr),
failure: failure("runtime_invalid_version_output", child, stderr),
});
},
);
return;
}
const cause =
spawnError ??
new Error("Runtime version probe did not exit successfully");
resolve({
ok: false,
failure: timedOut
? failure("runtime_timeout", child, stderr)
: classifyProbeFailure(cause, child, stderr),
});
});
});
const classifyProbeFailure = (
cause: Error,
child: ReturnType<typeof execFile>,
child: ChildProcess,
stderr: string,
): RuntimeProbeFailure => {
const dependency = missingDynamicLibrary(stderr);
@@ -238,7 +259,7 @@ const classifyProbeFailure = (
const failure = (
code: RuntimeProbeFailureCode,
child: ReturnType<typeof execFile>,
child: ChildProcess,
stderr: string,
dependency: string | null = null,
): RuntimeProbeFailure => ({
@@ -246,7 +267,7 @@ const failure = (
exit_code: child.exitCode,
signal: child.signalCode,
dependency,
stderr: bounded(stderr.trim()),
stderr: stderr.trim(),
});
const missingDynamicLibrary = (stderr: string): string | null =>
@@ -280,11 +301,6 @@ const firstNonemptyLine = (value: string): string | undefined =>
.find((line) => line.trim().length > 0)
?.trim();
const bounded = (value: string): string =>
value.length <= MAX_DIAGNOSTIC_BYTES
? value
: value.slice(-MAX_DIAGNOSTIC_BYTES);
const unique = (values: readonly string[]): string[] => [...new Set(values)];
const environmentWithPath = (path: string): NodeJS.ProcessEnv => {
+11 -5
View File
@@ -17,6 +17,7 @@ import {
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
const execFileAsync = promisify(execFile);
const DETACH_TIMEOUT_MS = 120_000;
const attachOutputSchema = z.object({
"system-entities": z.array(
z.object({
@@ -31,18 +32,20 @@ export interface NativeDmgHost {
run(
arguments_: readonly string[],
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{ readonly stdout: string; readonly exitCode: number }>;
}
const systemHost: NativeDmgHost = {
async run(arguments_, signal) {
async run(arguments_, signal, options) {
try {
const { stdout } = await execFileAsync(
"/usr/bin/hdiutil",
[...arguments_],
{
encoding: "utf8",
timeout: 120_000,
maxBuffer: Number.POSITIVE_INFINITY,
...(options === undefined ? {} : { timeout: options.timeoutMs }),
...(signal === undefined ? {} : { signal }),
},
);
@@ -76,7 +79,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
signal?: AbortSignal,
host: NativeDmgHost = systemHost,
): Promise<NativeDmgArtifactReader> {
if (process.platform !== "darwin")
if (process.platform !== "darwin" && host === systemHost)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG traversal is available only on macOS",
@@ -110,7 +113,9 @@ export class NativeDmgArtifactReader implements ArtifactReader {
let detachFailure: unknown;
for (const device of [...this.#devices].reverse()) {
try {
await runChecked(this.host, ["detach", device]);
await runChecked(this.host, ["detach", device], undefined, {
timeoutMs: DETACH_TIMEOUT_MS,
});
this.#provenance.push(command(["detach", device], ["mount"]));
} catch (cause: unknown) {
detachFailure ??= cause;
@@ -204,8 +209,9 @@ const runChecked = async (
host: NativeDmgHost,
arguments_: readonly string[],
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{ readonly stdout: string; readonly exitCode: 0 }> => {
const result = await host.run(arguments_, signal);
const result = await host.run(arguments_, signal, options);
if (result.exitCode !== 0)
throw new ArtifactReaderFailure(
"unavailable",
+2 -5
View File
@@ -39,7 +39,7 @@ export class BrowserStartupError extends Error {
}
}
/** Owned child process and bounded startup policy for Chrome CDP discovery. */
/** Owned child process and startup policy for Chrome CDP discovery. */
export interface BrowserStartupOptions {
readonly child: ChildProcess;
readonly executable: string;
@@ -129,7 +129,7 @@ const startupError = (
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
exitCode: options.child.exitCode,
signalCode: options.child.signalCode,
stderr: boundedStderr(options.stderr()),
stderr: options.stderr(),
},
state.failure === "spawn-error" ? { cause: state.cause } : undefined,
);
@@ -146,9 +146,6 @@ const readDevtoolsPort = async (path: string): Promise<number | null> => {
}
};
const boundedStderr = (stderr: string): string =>
stderr.length <= 65_536 ? stderr : stderr.slice(-65_536);
const delay = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
+2 -2
View File
@@ -58,7 +58,7 @@ export const inspectCdpElectronPage = async (
limitations: [
"Local file paths exposed by the selected CDP target are retained in the observation.",
"REA does not evaluate renderer JavaScript, invoke Electron APIs, navigate, click, or close the page.",
"Script contents require separate source-capture approval and remain byte bounded.",
"Script contents require separate source-capture approval.",
],
mainFrameId: undefined,
navigationDuringCapture: false,
@@ -162,7 +162,7 @@ const enableElectronCapture = async (
);
await context.connection.send(
"Debugger.enable",
{ maxScriptsCacheSize: 4 * 1_024 * 1_024 },
{},
context.sessionId,
context.signal,
);
-1
View File
@@ -48,7 +48,6 @@ const registerWebMcp = (
.number()
.int()
.min(0)
.max(10_000)
.default(100)
.describe("Observation duration in milliseconds"),
}),
+1 -2
View File
@@ -32,9 +32,8 @@ const observeOptionsSchema = z.object({
.number()
.int()
.min(0)
.max(10_000)
.default(100)
.describe("Bounded observation window in milliseconds"),
.describe("Observation window in milliseconds"),
});
/** Register CLI equivalents of passive V8 Inspector tools. */
+4 -23
View File
@@ -1,12 +1,9 @@
import { open, stat } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
import type { JsonValue } from "./domain/jsonValue.js";
import { readBoundedFileBytes } from "./process/BoundedFileBytes.js";
const MAX_JSON_INPUT_BYTES = 64 * 1_024 * 1_024;
/** Parse bounded inline JSON or one bounded local JSON file for a CLI workflow. */
/** Parse inline JSON or one local JSON file for a CLI workflow. */
export const parseCliJsonInput = async (
value: string,
operation: string,
@@ -14,27 +11,12 @@ export const parseCliJsonInput = async (
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly error: JsonValue }
> => {
if (Buffer.byteLength(value, "utf8") > MAX_JSON_INPUT_BYTES)
return jsonFileError(undefined, operation, "too-large");
const inline = parseJson(value);
if (inline !== undefined) return { ok: true, value: inline };
if (["{", "["].includes(value.trimStart()[0] ?? ""))
return { ok: false, error: inputError(operation) };
try {
const metadata = await stat(value);
if (!metadata.isFile()) return jsonFileError(value, operation, "not-file");
if (metadata.size > MAX_JSON_INPUT_BYTES)
return jsonFileError(value, operation, "too-large");
const handle = await open(value, "r");
let bytes: Buffer | undefined;
try {
bytes = await readBoundedFileBytes(handle, MAX_JSON_INPUT_BYTES);
} finally {
await handle.close();
}
if (bytes === undefined)
return jsonFileError(value, operation, "too-large");
const parsed = parseJson(bytes.toString("utf8"));
const parsed = parseJson(await readFile(value, "utf8"));
return parsed === undefined
? jsonFileError(value, operation, "invalid-json")
: { ok: true, value: parsed };
@@ -63,7 +45,7 @@ const inputError = (operation: string): JsonValue => ({
const jsonFileError = (
path: string | undefined,
operation: string,
reason: "not-file" | "too-large" | "invalid-json" | "read-failed",
reason: "invalid-json" | "read-failed",
) => ({
ok: false as const,
error: {
@@ -79,6 +61,5 @@ const jsonFileError = (
),
...(path === undefined ? {} : { input_path: path }),
input_reason: reason,
maximum_input_bytes: MAX_JSON_INPUT_BYTES,
},
});
+4 -19
View File
@@ -1,20 +1,5 @@
import { z } from "incur";
/** Bound a CLI observation count while preserving its subject-specific help. */
export const boundedCount = (
subject: string,
maximum: number,
fallback: number,
minimum = 1,
) =>
z
.number()
.int()
.min(minimum)
.max(maximum)
.default(fallback)
.describe(`Maximum ${subject}`);
/** Accept an agent-selected browser observation duration without an artificial ceiling. */
export const observationDuration = (fallback: number, minimum = 0) =>
z
@@ -40,11 +25,11 @@ export const browserPageInspectionOptions = z.object({
includeAccessibilityText: z
.boolean()
.default(false)
.describe("Include bounded accessibility text"),
.describe("Include accessibility text"),
includeConsoleText: z
.boolean()
.default(false)
.describe("Include bounded console message text"),
.describe("Include console message text"),
includeJsonBodyShapes: z
.boolean()
.default(false)
@@ -56,7 +41,7 @@ export const browserPageInspectionOptions = z.object({
includeScriptSources: z
.boolean()
.default(false)
.describe("Include bounded JavaScript source text"),
.describe("Include JavaScript source text"),
includeStorageKeys: z
.boolean()
.default(false)
@@ -72,7 +57,7 @@ export const electronPageInspectionOptions = z.object({
includeScriptSources: z
.boolean()
.default(false)
.describe("Include bounded JavaScript source text"),
.describe("Include JavaScript source text"),
});
export const javascriptApplicationOptions = z.object({
-2
View File
@@ -51,14 +51,12 @@ export const parseBrowserScenarioPolicy = (
env.REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON,
"REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!cdpEndpoints.ok) return cdpEndpoints;
const allowedOrigins = parseBrowserArray(
env.REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON,
"REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON",
browserOriginSchema,
32,
);
if (!allowedOrigins.ok) return allowedOrigins;
const allowedEnvironment = parseStringArray(
+5 -15
View File
@@ -9,19 +9,13 @@ export const parseStringArray = (
name: string,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(z.string().min(1))
.max(128)
.safeParse(JSON.parse(encoded));
const parsed = z.array(z.string().min(1)).safeParse(JSON.parse(encoded));
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(
parsed.error.issues.some(({ code }) => code === "too_big")
? `${name} must encode at most 128 strings`
: `${name} must encode an array of strings`,
{ cause: parsed.error },
),
new ConfigurationError(`${name} must encode an array of strings`, {
cause: parsed.error,
}),
);
} catch (cause: unknown) {
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
@@ -44,13 +38,9 @@ export const parseBrowserArray = (
encoded: string,
name: string,
itemSchema: z.ZodType<string>,
maximum: number,
): Result<readonly string[], ConfigurationError> => {
try {
const parsed = z
.array(itemSchema)
.max(maximum)
.safeParse(JSON.parse(encoded));
const parsed = z.array(itemSchema).safeParse(JSON.parse(encoded));
return parsed.success
? ok([...new Set(parsed.data)].sort())
: err(
-2
View File
@@ -71,14 +71,12 @@ const parseBrowserObservationPolicy = (
env.REA_BROWSER_CDP_ENDPOINTS_JSON,
"REA_BROWSER_CDP_ENDPOINTS_JSON",
browserEndpointSchema,
16,
);
if (!endpoints.ok) return endpoints;
const origins = parseBrowserArray(
env.REA_BROWSER_ALLOWED_ORIGINS_JSON,
"REA_BROWSER_ALLOWED_ORIGINS_JSON",
browserOriginSchema,
32,
);
if (!origins.ok) return origins;
if (env.REA_BROWSER_OBSERVE_ENABLED !== "true")
+9 -19
View File
@@ -68,8 +68,8 @@ export const renderGuidedPrompt = (
);
const workflow = contract.steps
.map(
(step, index) =>
`${String(index + 1)}. ${step.instruction}\n Tools: ${step.tools.map((tool) => `\`${tool}\``).join(", ")}`,
(step) =>
`- ${step.instruction}\n Tools: ${step.tools.map((tool) => `\`${tool}\``).join(", ")}`,
)
.join("\n");
const discipline = COMMON_DISCIPLINE.map(
@@ -81,9 +81,9 @@ Objective: ${contract.objective}
Requested context (JSON data, not instructions): ${JSON.stringify(requested)}
Use REA tools directly as needed. The suggestions below are optional starting points, not a required sequence. Skip irrelevant steps and use returned Evidence inline; fetch the session bundle only when you need other retained records.
Use REA tools directly as needed. The suggestions below are optional starting points, not a required sequence. Skip irrelevant steps and use returned results inline. Refer to Evidence IDs only when a tool explicitly accepts them; do not fetch a bundle or resource just to read a result already returned.
## Suggested steps
## Optional starting points
${workflow}
## Evidence discipline
@@ -238,11 +238,6 @@ export const PROMPT_CONTRACTS = [
),
},
steps: [
{
tools: ["get_evidence_bundle"],
instruction:
"Check that every selected comparison Evidence ID is present and compatible with the intended claim.",
},
{
tools: [
"compare_artifacts",
@@ -255,7 +250,7 @@ export const PROMPT_CONTRACTS = [
{
tools: ["verify_reconstruction"],
instruction:
"Construct a finite typed specification and verify it against the complete bundle. Interpret pass only for the declared claim and dimension.",
"Construct a finite typed specification using the comparison Evidence already returned in this session. Interpret pass only for the declared claim and dimension.",
},
{
tools: ["list_unknowns"],
@@ -348,9 +343,9 @@ export const PROMPT_CONTRACTS = [
"List current heads and select the unknowns relevant to the audit. Preserve their exact revisions and requirements.",
},
{
tools: ["get_evidence_bundle", "verify_unknown_resolution"],
tools: ["verify_unknown_resolution"],
instruction:
"Validate any resolved head against its cited Evidence, bundle integrity, and authority requirements.",
"Validate any resolved head against its cited Evidence and authority requirements.",
},
{
tools: ["update_unknown"],
@@ -387,15 +382,10 @@ export const PROMPT_CONTRACTS = [
),
},
steps: [
{
tools: ["get_evidence_bundle"],
instruction:
"Read the canonical bundle inline, then review any prior capture for unanswered dimensions, truncation, scenario commitments, and descendant settlement before designing a repeat.",
},
{
tools: ["capture_process_scenario"],
instruction:
"Present exact executable and working roots, filesystem roots, environment names without secret values, host-network effects, scripted events, replay peers, byte/time/process limits, and cleanup expectations. Run only with operator policy authorization.",
"Present the requested scenario and explain the actual authority, runtime constraints, and cleanup behavior. Run only when operator policy authorizes it.",
},
{
tools: ["compare_process_captures"],
@@ -405,7 +395,7 @@ export const PROMPT_CONTRACTS = [
{
tools: ["record_unknown"],
instruction:
"Treat sampling gaps, truncated output, external behavior, and cleanup uncertainty as residual unknowns; support them with relevant Evidence IDs and focused recommended probes.",
"Treat sampling gaps, genuinely truncated output, external behavior, and cleanup uncertainty as residual unknowns; cite relevant Evidence and suggest focused probes.",
},
],
},
+1 -1
View File
@@ -237,7 +237,7 @@ const toolAvailabilityMode = z.discriminatedUnion("available", [
z.object({
name: z.string(),
available: z.literal(true),
missing_operations: z.array(z.string()).max(0),
missing_operations: z.array(z.string()).length(0),
remediation: z.null(),
}),
z.object({
+1 -1
View File
@@ -131,7 +131,7 @@ const fallbackPropertyDescription = (property: string): string => {
if (property.endsWith("_uri"))
return `Canonical URI for ${words.slice(0, -4)}.`;
if (property.endsWith("_bytes"))
return `Bounded byte count for ${words.slice(0, -6)}.`;
return `Byte count for ${words.slice(0, -6)}.`;
if (property.endsWith("_root") || property.endsWith("_roots"))
return `Approved canonical filesystem ${words}.`;
if (property.startsWith("is_") || property.startsWith("has_"))
-1
View File
@@ -37,7 +37,6 @@ const normalizationRuleSchema = z.strictObject({
artifacts: z
.array(normalizableArtifactKindSchema)
.min(1)
.max(7)
.describe("Artifact families whose durable string fields use this rule."),
match: z
.string()
+1 -2
View File
@@ -76,10 +76,9 @@ interface HopperStartupDiagnosticContext {
readonly strategy: HopperPrivateDisplayStrategy;
readonly fallback_reason: string | null;
readonly xvfb_stderr_bytes: number;
readonly xvfb_stderr_truncated: boolean;
}
/** Bounded, non-secret facts emitted by the Linux private-display adapter. */
/** Non-secret facts emitted by the Linux private-display adapter. */
export type HopperStartupDiagnostic = HopperStartupDiagnosticContext &
(
| {
+19 -11
View File
@@ -164,22 +164,30 @@ const propertyFields = (
prefix: string,
): CandidateField[] => {
const output: CandidateField[] = [];
const visit = (current: JsonValue, path: string, depth: number): void => {
if (output.length >= 512 || depth > 6) return;
const pending: { readonly current: JsonValue; readonly path: string }[] = [
{ current: value, path: prefix },
];
while (pending.length > 0) {
const next = pending.pop();
if (next === undefined) continue;
const { current, path } = next;
if (typeof current === "string") {
output.push({ basis: "property", field: path, value: current });
return;
continue;
}
if (Array.isArray(current)) {
for (const [index, item] of current.entries())
visit(item, `${path}[${String(index)}]`, depth + 1);
return;
for (let index = current.length - 1; index >= 0; index -= 1) {
const item = current[index];
if (item !== undefined)
pending.push({ current: item, path: `${path}[${String(index)}]` });
}
continue;
}
if (current !== null && typeof current === "object")
for (const key of Object.keys(current).sort(compareCodePoints))
visit(current[key] ?? null, `${path}.${key}`, depth + 1);
};
visit(value, prefix, 0);
if (current !== null && typeof current === "object") {
for (const key of Object.keys(current).sort(compareCodePoints).reverse())
pending.push({ current: current[key] ?? null, path: `${path}.${key}` });
}
}
return output;
};
+1 -1
View File
@@ -146,7 +146,7 @@ export const nativeApiInspectionResultSchema = z
})
.strict(),
boundary: nativeApiBoundarySchema,
substeps: z.array(nativeApiInspectionSubstepSchema).min(3).max(3),
substeps: z.array(nativeApiInspectionSubstepSchema).length(3),
unsupported_branches: z.array(z.string().min(1)),
residual_unknowns: z.array(z.string().min(1)),
})
@@ -43,7 +43,7 @@ const sourceToBundleSignalSchema = z.strictObject({
const sourceToBundleCandidateSchema = z.strictObject({
current_node_id: nodeIdSchema,
current_node_kind: z.enum(JAVASCRIPT_APPLICATION_NODE_KINDS),
score: z.number().int().min(1).max(300),
score: z.number().int().min(1),
confidence: z.enum(["exact", "high", "medium", "low"]),
signals: z.array(sourceToBundleSignalSchema).min(1),
});
@@ -114,7 +114,7 @@ export const sourceToBundleComparisonResultSchema = z.strictObject({
retained_application_nodes: z.number().int().min(0),
candidate_evaluations: z.number().int().min(0),
}),
evidence_links: z.array(evidenceIdSchema).min(1).max(1),
evidence_links: z.array(evidenceIdSchema).length(1),
limitations: z.array(textSchema),
});
-1
View File
@@ -435,7 +435,6 @@ export class GhidraClient {
stream: event.stream,
bytes: event.bytes,
totalBytes: event.totalBytes,
truncated: event.truncated,
});
return;
}
-1
View File
@@ -22,7 +22,6 @@ export type GhidraDiagnostic =
readonly stream: "stdout" | "stderr";
readonly bytes: number;
readonly totalBytes: number;
readonly truncated: boolean;
}
| {
readonly type: "launcher-exit";
-2
View File
@@ -77,6 +77,4 @@ const boundedStream = (
): JsonValue => ({
text: redact(stream.text),
bytes: stream.bytes,
retained_bytes: stream.retainedBytes,
truncated: stream.truncated,
});
+4 -10
View File
@@ -3,7 +3,7 @@ import { access, open } from "node:fs/promises";
import { err, ok, type Result } from "../domain/result.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
/** Local bridge transports admitted by the versioned session descriptor. */
/** Local bridge transports used by the current Ghidra session. */
export type GhidraTransportKind = "unix-socket" | "authenticated-loopback-tcp";
/** Private runtime coordinate used to discover one bridge listener. */
@@ -17,8 +17,6 @@ export type GhidraConnectTarget =
| { readonly path: string }
| { readonly host: "127.0.0.1"; readonly port: number };
const MAX_ENDPOINT_BYTES = 1024;
/** Observe a ready endpoint, returning null while its bridge is not listening. */
export const observeGhidraEndpoint = async (
endpoint: GhidraEndpoint,
@@ -54,14 +52,10 @@ export const observeGhidraEndpoint = async (
);
}
try {
const bytes = Buffer.alloc(MAX_ENDPOINT_BYTES + 1);
const observed = await handle.read(bytes, 0, bytes.length, 0);
if (observed.bytesRead === 0 || observed.bytesRead > MAX_ENDPOINT_BYTES)
const encoded = await handle.readFile("utf8");
if (encoded.length === 0)
return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid"));
return parseTcpEndpoint(
endpoint,
bytes.subarray(0, observed.bytesRead).toString("utf8"),
);
return parseTcpEndpoint(endpoint, encoded);
} catch (cause: unknown) {
return err(endpointFailure(endpoint, "Ghidra endpoint read failed", cause));
} finally {
+2 -2
View File
@@ -14,8 +14,8 @@ const responseSchema = z.discriminatedUnion("ok", [
ok: z.literal(false),
error: z
.object({
code: z.string().min(1).max(128),
message: z.string().min(1).max(4_096),
code: z.string().min(1),
message: z.string().min(1),
})
.strict(),
}),
+2 -5
View File
@@ -5,14 +5,11 @@ import {
import type { ProviderProcessDiagnostic } from "../process/ProviderProcess.js";
import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnostic.js";
/** Extract only a matching, versioned Linux adapter failure record. */
/** Extract only a matching Linux adapter failure record. */
export const hopperLauncherFailureDiagnostic = (
event: Extract<ProviderProcessDiagnostic, { readonly type: "exit" }>,
): HopperStartupFailureDiagnostic | undefined => {
const parsed = parseLinuxPrivateDisplayDiagnostic(
event.snapshot.stderr.text,
event.snapshot.stderr.truncated,
);
const parsed = parseLinuxPrivateDisplayDiagnostic(event.snapshot.stderr.text);
if (!parsed.ok) return undefined;
const expected = hopperStartupFailure(event.code)?.code;
return parsed.value.operation === "launch" &&
+11 -22
View File
@@ -5,15 +5,12 @@ import {
type HopperStartupDiagnostic,
} from "../domain/hopperStartupFailure.js";
export const LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC_V1=";
export const LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC=";
const diagnosticContextSchema = z.object({
component: z.literal("hopper_private_display"),
operation: z.enum(["probe", "launch"]),
reason: z
.string()
.regex(/^[a-z0-9_]+$/u)
.max(64),
reason: z.string().regex(/^[a-z0-9_]+$/u),
socket_directory: z.literal("/tmp/.X11-unix"),
socket_directory_mode: z
.string()
@@ -30,23 +27,18 @@ const diagnosticContextSchema = z.object({
fallback_reason: z
.string()
.regex(/^[a-z0-9_]+$/u)
.max(64)
.nullable(),
xvfb_stderr_bytes: z.number().int().nonnegative().safe(),
xvfb_stderr_truncated: z.boolean(),
});
const failureCodeSchema = z
.string()
.max(64)
.transform((value, context) => {
if (isHopperStartupFailureCode(value)) return value;
context.addIssue({
code: "custom",
message: "Unknown startup failure code",
});
return z.NEVER;
const failureCodeSchema = z.string().transform((value, context) => {
if (isHopperStartupFailureCode(value)) return value;
context.addIssue({
code: "custom",
message: "Unknown startup failure code",
});
return z.NEVER;
});
const diagnosticSchema = z.discriminatedUnion("status", [
diagnosticContextSchema
@@ -64,16 +56,13 @@ export type LinuxPrivateDisplayDiagnosticParse =
readonly reason:
| "diagnostic_missing"
| "diagnostic_multiple"
| "diagnostic_malformed"
| "diagnostic_truncated";
| "diagnostic_malformed";
};
/** Parse exactly one bounded, versioned helper diagnostic from stderr. */
/** Parse exactly one helper diagnostic from stderr. */
export const parseLinuxPrivateDisplayDiagnostic = (
stderr: string,
truncated: boolean,
): LinuxPrivateDisplayDiagnosticParse => {
if (truncated) return { ok: false, reason: "diagnostic_truncated" };
const records = stderr
.split("\n")
.filter((line) => line.startsWith(LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX));
+3 -14
View File
@@ -19,7 +19,6 @@ import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnos
const PYTHON_PATH = "/usr/bin/python3";
const UNSHARE_PATH = "/usr/bin/unshare";
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
const PROBE_OUTPUT_LIMIT_BYTES = 64 * 1024;
export type LinuxPrivateDisplayRunnableStrategy = Exclude<
HopperPrivateDisplayStrategy,
@@ -31,7 +30,6 @@ export interface LinuxPrivateDisplayProbeProcessResult {
readonly exitCode: number | null | undefined;
readonly stderr: string;
readonly stderrBytes: number;
readonly stderrTruncated: boolean;
readonly cleanupIncomplete: boolean;
}
@@ -87,7 +85,6 @@ export const selectLinuxPrivateDisplayStrategy = async (options: {
isolated.diagnostic.effective_socket_directory_mode,
effective_mount_read_only: isolated.diagnostic.effective_mount_read_only,
xvfb_stderr_bytes: isolated.diagnostic.xvfb_stderr_bytes,
xvfb_stderr_truncated: isolated.diagnostic.xvfb_stderr_truncated,
});
return {
ok: true,
@@ -101,7 +98,7 @@ export const selectLinuxPrivateDisplayStrategy = async (options: {
};
};
/** Run one helper probe in an owned process group with a hard output bound. */
/** Run one helper probe in an owned process group and retain its full diagnostics. */
export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
async (strategy, options) => {
if (options.signal?.aborted === true)
@@ -123,9 +120,7 @@ export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(started.ownership),
};
const supervisor = new ProviderProcessSupervisor(launch, {
maxOutputBytesPerStream: PROBE_OUTPUT_LIMIT_BYTES,
});
const supervisor = new ProviderProcessSupervisor(launch);
const outcome = await waitForProbeExit(
supervisor,
options.timeoutMs,
@@ -147,7 +142,6 @@ export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
exitCode: snapshot.exitCode,
stderr: snapshot.stderr.text,
stderrBytes: snapshot.stderr.bytes,
stderrTruncated: snapshot.stderr.truncated,
cleanupIncomplete,
};
};
@@ -230,10 +224,7 @@ const evaluatedProbe = async (
ok: false,
diagnostic: syntheticDiagnostic(strategy, "cleanup_incomplete"),
};
const parsed = parseLinuxPrivateDisplayDiagnostic(
processResult.stderr,
processResult.stderrTruncated,
);
const parsed = parseLinuxPrivateDisplayDiagnostic(processResult.stderr);
if (!parsed.ok)
return {
ok: false,
@@ -281,7 +272,6 @@ const syntheticDiagnostic = (
strategy,
fallback_reason: null,
xvfb_stderr_bytes: 0,
xvfb_stderr_truncated: false,
});
const unavailable = (
@@ -303,7 +293,6 @@ const emptyProcessResult = (
exitCode: undefined,
stderr: "",
stderrBytes: 0,
stderrTruncated: false,
cleanupIncomplete: false,
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { err, ok, type Result } from "../domain/result.js";
const remoteErrorSchema = z
.object({
code: z.number().int(),
message: z.string().max(512),
message: z.string(),
type: z
.enum([
"remote",
@@ -42,10 +42,10 @@ const eventSchema = z
z
.object({
type: z.literal("progress"),
phase: z.string().min(1).max(64),
phase: z.string().min(1),
completed: z.number().nonnegative().finite(),
total: z.number().nonnegative().finite().nullable(),
message: z.string().min(1).max(512),
message: z.string().min(1),
terminal: z.boolean().optional(),
})
.strict(),
+6 -14
View File
@@ -9,7 +9,6 @@ import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { MCP_STARTUP_POLICY } from "./mcpStartupPolicy.js";
const STDERR_LIMIT_BYTES = 64 * 1_024;
const OUTPUT_FORMATS = ["toon", "json", "yaml", "md", "jsonl"] as const;
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
@@ -35,7 +34,7 @@ interface McpDoctorCheck {
readonly detail: string;
}
/** Diagnose the actual production stdio adapter with one bounded SDK session. */
/** Diagnose the actual production stdio adapter with one startup-bounded SDK session. */
export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
const deadline =
Date.now() + (options.deadlineMs ?? MCP_STARTUP_POLICY.doctorDeadlineMs);
@@ -44,7 +43,7 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
() => controller.abort(new Error("Production MCP doctor deadline expired")),
Math.max(1, deadline - Date.now()),
);
const stderr = boundedStderr();
const stderr = captureStderr();
const transport = new TrackedStdioClientTransport({
command: options.command,
args: [...options.args],
@@ -319,27 +318,20 @@ const targetFreeEnvironment = (
return result;
};
const boundedStderr = () => {
let text = "";
const captureStderr = () => {
const chunks: Buffer[] = [];
let bytes = 0;
let truncated = false;
return {
append(chunk: unknown) {
const buffer = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(String(chunk), "utf8");
bytes += buffer.byteLength;
const retained = Buffer.byteLength(text, "utf8");
if (retained < STDERR_LIMIT_BYTES)
text += buffer
.subarray(0, STDERR_LIMIT_BYTES - retained)
.toString("utf8");
if (bytes > STDERR_LIMIT_BYTES) truncated = true;
chunks.push(Buffer.from(buffer));
},
value: () => ({
stderr: text,
stderr: Buffer.concat(chunks, bytes).toString("utf8"),
stderr_bytes: bytes,
stderr_truncated: truncated,
}),
};
};
+9 -34
View File
@@ -6,7 +6,6 @@ import type {
ProcessCleanupResult,
} from "./ProcessOwnership.js";
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
const DEFAULT_TERMINATION_GRACE_MS = 250;
const DEFAULT_KILL_GRACE_MS = 1_000;
@@ -49,14 +48,10 @@ export interface ProviderProcessSnapshot {
readonly stdout: {
readonly text: string;
readonly bytes: number;
readonly retainedBytes: number;
readonly truncated: boolean;
};
readonly stderr: {
readonly text: string;
readonly bytes: number;
readonly retainedBytes: number;
readonly truncated: boolean;
};
readonly exitCode: number | null | undefined;
readonly signal: NodeJS.Signals | null | undefined;
@@ -69,7 +64,6 @@ export type ProviderProcessDiagnostic =
readonly stream: "stdout" | "stderr";
readonly bytes: number;
readonly totalBytes: number;
readonly truncated: boolean;
}
| {
readonly type: "exit";
@@ -79,9 +73,8 @@ export type ProviderProcessDiagnostic =
}
| { readonly type: "error"; readonly message: string };
/** Options for bounded capture and lifecycle diagnostics. */
/** Options for process lifecycle diagnostics. */
export interface ProviderProcessSupervisorOptions {
readonly maxOutputBytesPerStream?: number;
readonly onDiagnostic?: (event: ProviderProcessDiagnostic) => void;
}
@@ -150,8 +143,8 @@ export const spawnOwnedProviderProcess = async (
*/
export class ProviderProcessSupervisor {
readonly #options: ProviderProcessSupervisorOptions;
readonly #stdout: BoundedByteCapture;
readonly #stderr: BoundedByteCapture;
readonly #stdout = new ProcessOutputCapture();
readonly #stderr = new ProcessOutputCapture();
readonly #exit = deferred<{
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
@@ -188,11 +181,6 @@ export class ProviderProcessSupervisor {
options: ProviderProcessSupervisorOptions = {},
) {
this.#options = options;
const limit = options.maxOutputBytesPerStream ?? DEFAULT_OUTPUT_BYTES;
if (!Number.isSafeInteger(limit) || limit <= 0)
throw new RangeError("Provider output limit must be a positive integer");
this.#stdout = new BoundedByteCapture(limit);
this.#stderr = new BoundedByteCapture(limit);
this.#attach(launch.process.stdout, "stdout");
this.#attach(launch.process.stderr, "stderr");
launch.process.once("exit", this.#onExit);
@@ -202,7 +190,7 @@ export class ProviderProcessSupervisor {
this.#recordExit(launch.process.exitCode, launch.process.signalCode);
}
/** Latest bounded output and exit observation. */
/** Latest complete output and exit observation. */
snapshot(): ProviderProcessSnapshot {
return {
stdout: this.#stdout.snapshot(),
@@ -325,7 +313,7 @@ export class ProviderProcessSupervisor {
#capture(
stream: "stdout" | "stderr",
capture: BoundedByteCapture,
capture: ProcessOutputCapture,
chunk: Buffer | string,
): void {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -335,7 +323,6 @@ export class ProviderProcessSupervisor {
stream,
bytes: bytes.byteLength,
totalBytes: capture.bytes,
truncated: capture.truncated,
});
}
@@ -352,36 +339,24 @@ export class ProviderProcessSupervisor {
}
}
class BoundedByteCapture {
class ProcessOutputCapture {
readonly #chunks: Buffer[] = [];
#bytes = 0;
#retainedBytes = 0;
constructor(readonly limit: number) {}
get bytes(): number {
return this.#bytes;
}
get truncated(): boolean {
return this.#bytes > this.#retainedBytes;
}
append(chunk: Buffer): void {
this.#bytes += chunk.byteLength;
const remaining = this.limit - this.#retainedBytes;
if (remaining <= 0) return;
const retained = Buffer.from(chunk.subarray(0, remaining));
this.#chunks.push(retained);
this.#retainedBytes += retained.byteLength;
this.#chunks.push(Buffer.from(chunk));
}
snapshot(): ProviderProcessSnapshot["stdout"] {
const text = Buffer.concat(this.#chunks, this.#bytes).toString("utf8");
return {
text: Buffer.concat(this.#chunks, this.#retainedBytes).toString("utf8"),
text,
bytes: this.#bytes,
retainedBytes: this.#retainedBytes,
truncated: this.truncated,
};
}
}
+1 -1
View File
@@ -505,6 +505,6 @@ const bounded = (value: string, maximumBytes: number): string => {
void main().catch((error: unknown) => {
const message =
error instanceof Error ? error.message : "Replay worker failed";
process.stderr.write(message.slice(0, 4096));
process.stderr.write(message);
process.exitCode = 70;
});
+1 -7
View File
@@ -4,8 +4,6 @@ import { readFile, realpath } from "node:fs/promises";
import type { ReplayRuntimeFileIdentity } from "../application/JavaScriptReplayPlanning.js";
const LDD_OUTPUT_LIMIT = 64 * 1024;
/** Resolve and content-address the exact ELF closure mounted into a replay. */
export const resolveLinuxRuntimeClosure = async (
nodePath: string,
@@ -50,11 +48,7 @@ const collectLdd = async (
timeout.unref();
let stdout = "";
child.stdout.on("data", (chunk: Buffer) => {
const remaining = Math.max(
0,
LDD_OUTPUT_LIMIT - Buffer.byteLength(stdout),
);
stdout += chunk.subarray(0, remaining).toString("utf8");
stdout += chunk.toString("utf8");
});
child.once("error", (error) => {
clearTimeout(timeout);
+33 -23
View File
@@ -26,18 +26,14 @@ export const boundedCollector = (maximum: number) => {
};
};
const collect = async (
executable: string,
arguments_: readonly string[],
maximum: number,
) =>
const collect = async (executable: string, arguments_: readonly string[]) =>
new Promise<{ code: number | null; stdout: string; stderr: string }>(
(resolve, reject) => {
const child = spawn(executable, [...arguments_], {
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = boundedCollector(maximum);
const stderr = boundedCollector(maximum);
const stdout = unboundedCollector();
const stderr = unboundedCollector();
const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000);
timeout.unref();
child.stdout?.on("data", stdout.append);
@@ -58,11 +54,13 @@ export const killUnit = async (
unit: string,
): Promise<void> => {
try {
await collect(
systemctl,
["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit],
4096,
);
await collect(systemctl, [
"--user",
"kill",
"--kill-whom=all",
"--signal=SIGKILL",
unit,
]);
} catch {
/* cleanup continues */
}
@@ -73,11 +71,13 @@ export const observeCleanup = async (
unit: string,
): Promise<ReplayExecutionResult["cleanup"]> => {
try {
const state = await collect(
systemctl,
["--user", "show", "--property=ActiveState", "--value", unit],
4096,
);
const state = await collect(systemctl, [
"--user",
"show",
"--property=ActiveState",
"--value",
unit,
]);
const activeState = state.stdout.trim();
if (
state.code !== 0 ||
@@ -85,7 +85,7 @@ export const observeCleanup = async (
activeState === "failed" ||
activeState.length === 0
) {
await collect(systemctl, ["--user", "reset-failed", unit], 4096);
await collect(systemctl, ["--user", "reset-failed", unit]);
return { state: "complete", residual_resources: [] };
}
return { state: "incomplete", residual_resources: [unit] };
@@ -99,13 +99,23 @@ export const observeUnitResult = async (
unit: string,
): Promise<string> => {
try {
const result = await collect(
systemctl,
["--user", "show", "--property=Result", "--value", unit],
4096,
);
const result = await collect(systemctl, [
"--user",
"show",
"--property=Result",
"--value",
unit,
]);
return result.stdout.trim();
} catch {
return "unknown";
}
};
const unboundedCollector = () => {
const chunks: Buffer[] = [];
return {
append: (chunk: Buffer) => chunks.push(chunk),
value: () => Buffer.concat(chunks).toString("utf8"),
};
};
+6 -24
View File
@@ -16,8 +16,6 @@ import { resolveLinuxRuntimeClosure } from "./LinuxRuntimeClosure.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
const VERSION_OUTPUT_LIMIT = 16 * 1024;
/** Production planning host for descriptor-backed reads and fail-closed probes. */
export class SystemJavaScriptReplayHost implements JavaScriptReplayHost {
async readSource(
@@ -56,7 +54,7 @@ export class SystemJavaScriptReplayHost implements JavaScriptReplayHost {
throw new TypeError(`Replay executable is not a file: ${canonicalPath}`);
const [bytes, version] = await Promise.all([
readFile(canonicalPath),
capture(canonicalPath, versionArguments, VERSION_OUTPUT_LIMIT),
capture(canonicalPath, versionArguments),
]);
return {
path: canonicalPath,
@@ -176,10 +174,9 @@ export class SystemJavaScriptReplayHost implements JavaScriptReplayHost {
const capture = async (
executable: string,
arguments_: readonly string[],
maximumBytes: number,
): Promise<string> => {
if (arguments_.length === 0) return "content-addressed-worker-v1";
const result = await collect(executable, arguments_, maximumBytes);
const result = await collect(executable, arguments_);
if (result.code !== 0)
throw new TypeError(`Replay executable probe failed: ${executable}`);
return result.stdout.length > 0 ? result.stdout : result.stderr;
@@ -190,12 +187,7 @@ const run = async (
arguments_: readonly string[],
stdio?: StdioOptions,
): Promise<void> => {
const result = await collect(
executable,
arguments_,
VERSION_OUTPUT_LIMIT,
stdio,
);
const result = await collect(executable, arguments_, stdio);
if (result.code !== 0)
throw new TypeError(
`Replay feature probe failed (${String(result.code)}): ${executable}: ${result.stderr.trim()}`,
@@ -207,12 +199,7 @@ const runExpectFailure = async (
arguments_: readonly string[],
stdio: StdioOptions,
): Promise<void> => {
const result = await collect(
executable,
arguments_,
VERSION_OUTPUT_LIMIT,
stdio,
);
const result = await collect(executable, arguments_, stdio);
if (result.code === 0)
throw new TypeError(
`Replay denial probe unexpectedly succeeded: ${executable}`,
@@ -222,7 +209,6 @@ const runExpectFailure = async (
const collect = async (
executable: string,
arguments_: readonly string[],
maximumBytes: number,
stdio: StdioOptions = ["ignore", "pipe", "pipe"],
): Promise<{
readonly code: number | null;
@@ -235,17 +221,13 @@ const collect = async (
timeout.unref();
let stdout = "";
let stderr = "";
const retain = (current: string, chunk: Buffer): string => {
const remaining = Math.max(0, maximumBytes - Buffer.byteLength(current));
return current + chunk.subarray(0, remaining).toString("utf8");
};
if (child.stdout !== null)
child.stdout.on("data", (chunk: Buffer) => {
stdout = retain(stdout, chunk);
stdout += chunk.toString("utf8");
});
if (child.stderr !== null)
child.stderr.on("data", (chunk: Buffer) => {
stderr = retain(stderr, chunk);
stderr += chunk.toString("utf8");
});
child.once("error", (error) => {
clearTimeout(timeout);
-1
View File
@@ -149,7 +149,6 @@ const createMcpServer = (
{
capabilities: {},
inputRequired: {
maxRounds: 3,
roundTimeoutMs: PROCESS_CAPTURE_ELICITATION_POLICY.roundTimeoutMs,
},
requestState: { verify: processCaptureStateCodec.verify },