mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
refactor(tools): remove remaining arbitrary workflow caps
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -48,7 +48,6 @@ const registerWebMcp = (
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(10_000)
|
||||
.default(100)
|
||||
.describe("Observation duration in milliseconds"),
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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_"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 &
|
||||
(
|
||||
| {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -435,7 +435,6 @@ export class GhidraClient {
|
||||
stream: event.stream,
|
||||
bytes: event.bytes,
|
||||
totalBytes: event.totalBytes,
|
||||
truncated: event.truncated,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export type GhidraDiagnostic =
|
||||
readonly stream: "stdout" | "stderr";
|
||||
readonly bytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
| {
|
||||
readonly type: "launcher-exit";
|
||||
|
||||
@@ -77,6 +77,4 @@ const boundedStream = (
|
||||
): JsonValue => ({
|
||||
text: redact(stream.text),
|
||||
bytes: stream.bytes,
|
||||
retained_bytes: stream.retainedBytes,
|
||||
truncated: stream.truncated,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -149,7 +149,6 @@ const createMcpServer = (
|
||||
{
|
||||
capabilities: {},
|
||||
inputRequired: {
|
||||
maxRounds: 3,
|
||||
roundTimeoutMs: PROCESS_CAPTURE_ELICITATION_POLICY.roundTimeoutMs,
|
||||
},
|
||||
requestState: { verify: processCaptureStateCodec.verify },
|
||||
|
||||
Reference in New Issue
Block a user