mirror of
https://github.com/morluto/rea
synced 2026-10-28 23:11:11 -04:00
4aa51bfe19
* feat(process): add owned debugger shutdown lifecycle * feat(reverse): add local inspection commands * feat(gdb): add persistent debugger integration * feat(cutter): add Cutter bridge integration * test(reverse): enforce complete integration coverage * fix(process): preserve debugger shutdown ownership * fix(reverse): retain evidence across interrupted commands * fix(gdb): honor captured provider environment * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * fix(server): snapshot provider environment at creation * fix(reverse): close review gaps with complete coverage * fix(reverse): address inspection review findings * fix(gdb): bound sessions and retain cleanup ownership * fix(ci): require reverse-tools quality lane * fix(process): unref inherited debugger output pipes * fix(reverse): address remaining PR 1960 findings * fix(gdb): address remaining PR 1961 findings * fix(reverse): account for unrestricted provider execution * fix(process): retain debugger ownership until safe shutdown * fix(process): preserve replacement debugger sessions * fix(reverse): preserve admission and output provenance * fix(process): retain exited debugger until output settles * fix(mcp): bound provider admission and debugger cleanup * fix(reverse): preserve failure provenance and classify E2BIG * fix(gdb): preserve startup cleanup and detach safety * fix(process): retain debugger ownership after detach failure * fix(reverse): preserve mutation and failure provenance * fix(gdb): address debugger review findings * fix(reverse): rebind sessions after external mutation * fix(process): cap debugger output after exit * fix(gdb): protect one-shot detach verification * feat(debugger): add reverse tool coverage gate * test(debugger): cover integrated failure paths * fix(gdb): bound and preserve session observations * fix(gdb): address review regressions * style: format merged integration files * fix(reverse): preserve session and output evidence * refactor(gdb): share repeated test and partial result setup * fix(gdb): invalidate TTY proof for Python aliases * fix(reverse): retain failure output byte counts * test(process): synchronize late debugger output * fix(gdb): invalidate TTY proof for wrapped commands * fix(gdb): recognize wit command abbreviation * test(reverse): wait for cancelled descendant readiness * test(reverse): bound descendant cancellation cleanup * style(reverse): format integration tests and adapters * style: format reverse engineering integration files * test(reverse): remove duplicate environment harness * test(gdb): share session startup assertions * fix(gdb): invalidate output proof for command abbreviations * test(reverse): share composition edge case scenarios * fix(ci): recognize verification and mutation tool entries * test(rizin): cover successful fake inferior detach * test(cli): align full output failure envelope * test(cli): isolate reverse command lifecycle service * test(process): share debugger output fixture setup * fix(binary): preserve high-bit Windows signature bytes * style(binary): format Windows signature parser * test(mcp): preserve call coverage in unchanged comparison * fix(ci): include reverse tools Vitest configs in lint cache * fix(mcp): preserve target content on delivery errors * ci: align duplication ceiling with expanded suite * test(reverse): allow signal admission mutants * fix(reverse): bound retained cleanup failures to process owners * fix(test): replace drifting mutation ranges with owned selectors * fix(reverse): admit persistent commands and retain cleanup ownership * fix(cutter): recover timed-out commands from exact receipts * fix(cutter): reject unsupported bridge request kinds * fix(ci): repair client format and shared verification fixtures * fix(native): preserve debugger cleanup ownership and bridge authentication * test(native): retain diagnostics without perfect coverage requirements * chore(licenses): refresh locked dependency inventory and notices * fix(test): exclude transient command locks from mutation sandboxes * fix(gdb): admit only platforms with safe detach verification * fix(cutter): retain bounded completion receipts until acknowledgement * fix(cutter): acknowledge commands rejected before UI dispatch * test(reverse): fail cancellation checks before fixture teardown * test(gdb): clean up the retained broken-stdin fixture owner * docs(test): clarify focused provider verification scope * fix(rizin): retain close cleanup failures * fix(gdb): wait for inferior stop and bound MI buffering * fix(cutter): retain failed receipt acknowledgements * fix(rizin): return retained session identity on rebind failure * fix(gdb): preserve startup cancellation and lossy status * fix(rizin): retain launch provenance and assemble frames once * fix(cutter): recover receipts across callers and bound discovery * docs: describe debugger evidence and bridge recovery * fix(process): keep cleanup incomplete while writers remain Stopping a debugger no longer reports success when inherited output streams stay open, so BinarySession cannot reopen a binary a child is still changing. Concurrent Cutter cleanup retries share one receipt acknowledgement instead of treating a consumed receipt as failure. * fix(debuggers): recover cleanup with captured process identities * fix(analysis): retire sessions after unrestricted commands * fix(cli): preserve completed evidence after cleanup recovery * fix(cutter): bound frame collection and coalesce cleanup * fix(windows): resolve selected provider paths absolutely * fix(gdb): report decoding loss in failed startup evidence * fix(rizin): scope decoding loss to collected command frames * test(mcp): cover snapshot retirement in reverse tools CI --------- Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com> Co-authored-by: REA Maintainers <noreply@github.com> Co-authored-by: OpenAI Codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
892 lines
29 KiB
TypeScript
892 lines
29 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { createConnection } from "node:net";
|
|
import { lstat, open, opendir } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisOutputError,
|
|
AnalysisResourceConstraintError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
|
|
|
const descriptorSchema = z.object({
|
|
session_id: z.string().uuid(),
|
|
pid: z.number().int().positive(),
|
|
host: z.literal("127.0.0.1"),
|
|
port: z.number().int().min(1).max(65535),
|
|
token: z.string().min(20),
|
|
document_generation: z.number().int().nonnegative(),
|
|
current_file: z.string().nullable(),
|
|
cutter_version: z.string().nullable(),
|
|
identity_status: z.literal("partial"),
|
|
});
|
|
|
|
export const CUTTER_PROVIDER_IDENTITY = {
|
|
id: "cutter.python-plugin",
|
|
name: "Cutter Python plugin",
|
|
version: null,
|
|
} as const;
|
|
|
|
const responseSchema = z.object({
|
|
ok: z.boolean(),
|
|
session_id: z.string().uuid().optional(),
|
|
output: z.unknown().optional(),
|
|
current_file: z.string().nullable().optional(),
|
|
cutter_version: z.string().nullable().optional(),
|
|
document_generation: z.number().int().nonnegative().optional(),
|
|
identity_status: z.literal("partial").optional(),
|
|
error: z.string().optional(),
|
|
message: z.string().optional(),
|
|
execution_state: z
|
|
.enum(["not_started", "running", "complete", "failed", "unknown"])
|
|
.optional(),
|
|
output_truncated: z.boolean().optional(),
|
|
completed_commands: z
|
|
.array(
|
|
z.object({
|
|
request_id: z.string().uuid(),
|
|
execution_state: z.enum(["not_started", "complete", "failed"]),
|
|
}),
|
|
)
|
|
.max(64)
|
|
.optional(),
|
|
completed_connection: z
|
|
.string()
|
|
.regex(/^[0-9a-f]{16}$/)
|
|
.optional(),
|
|
});
|
|
const acknowledgementSchema = z.object({
|
|
ok: z.literal(true),
|
|
acknowledged: z.boolean(),
|
|
});
|
|
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
|
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
const FRAME_PAGE_BYTES = 64 * 1024;
|
|
// Bound admitted descriptor input bytes and concurrent file/socket handles.
|
|
const MAX_DESCRIPTOR_CANDIDATES = MAX_FRAME_BYTES / MAX_DESCRIPTOR_BYTES;
|
|
const DISCOVERY_CONCURRENCY = 8;
|
|
type AuthenticatedRequestBytes = string & {
|
|
readonly __authenticatedRequestBytes: unique symbol;
|
|
};
|
|
|
|
const serializeRequest = (
|
|
descriptor: z.infer<typeof descriptorSchema>,
|
|
request: Readonly<Record<string, unknown>>,
|
|
): AuthenticatedRequestBytes => {
|
|
const requestBytes = JSON.stringify({
|
|
...request,
|
|
session_id: descriptor.session_id,
|
|
});
|
|
let frameBytes =
|
|
Buffer.byteLength(JSON.stringify({ request: "", proof: "0".repeat(64) })) +
|
|
Buffer.byteLength(requestBytes);
|
|
for (const character of requestBytes)
|
|
if (character === '"' || character === "\\") frameBytes += 1;
|
|
if (frameBytes > MAX_FRAME_BYTES)
|
|
throw new AnalysisResourceConstraintError(
|
|
"cutter_command",
|
|
"transport",
|
|
"Cutter request exceeds its authenticated frame limit; no command was sent.",
|
|
{ frame_bytes: frameBytes, max_frame_bytes: MAX_FRAME_BYTES },
|
|
);
|
|
return requestBytes as AuthenticatedRequestBytes;
|
|
};
|
|
|
|
class CutterBridgeResponseFrames {
|
|
#parts: Buffer[] = [];
|
|
#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
|
#pageLength = 0;
|
|
#contentBytes = 0;
|
|
#framesRead = 0;
|
|
|
|
push(chunk: Buffer, onFrame: (line: Buffer) => boolean): void {
|
|
let offset = 0;
|
|
while (offset < chunk.length) {
|
|
const delimiter = chunk.indexOf(0x0a, offset);
|
|
const end = delimiter < 0 ? chunk.length : delimiter;
|
|
const segment = chunk.subarray(offset, end);
|
|
if (
|
|
this.#contentBytes + segment.length + (delimiter < 0 ? 1 : 0) >
|
|
MAX_FRAME_BYTES
|
|
)
|
|
throw new Error(
|
|
"Cutter bridge response exceeded its framed transport limit",
|
|
);
|
|
this.#append(segment);
|
|
if (delimiter < 0) return;
|
|
if (++this.#contentBytes > MAX_FRAME_BYTES)
|
|
throw new Error(
|
|
"Cutter bridge response exceeded its framed transport limit",
|
|
);
|
|
if (this.#pageLength > 0)
|
|
this.#parts.push(this.#page.subarray(0, this.#pageLength));
|
|
const line = Buffer.concat(this.#parts, this.#contentBytes - 1);
|
|
this.#reset();
|
|
if (++this.#framesRead > 2)
|
|
throw new Error("Cutter bridge sent more than two protocol frames");
|
|
offset = delimiter + 1;
|
|
if (!onFrame(line)) return;
|
|
}
|
|
}
|
|
|
|
#append(segment: Buffer): void {
|
|
let copied = 0;
|
|
while (copied < segment.length) {
|
|
const length = Math.min(
|
|
segment.length - copied,
|
|
FRAME_PAGE_BYTES - this.#pageLength,
|
|
);
|
|
segment.copy(this.#page, this.#pageLength, copied, copied + length);
|
|
this.#pageLength += length;
|
|
this.#contentBytes += length;
|
|
copied += length;
|
|
if (this.#pageLength === FRAME_PAGE_BYTES) {
|
|
this.#parts.push(this.#page);
|
|
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
|
this.#pageLength = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
#reset(): void {
|
|
this.#parts = [];
|
|
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
|
this.#pageLength = 0;
|
|
this.#contentBytes = 0;
|
|
}
|
|
}
|
|
|
|
const mapDiscovery = async <Input, Output>(
|
|
inputs: readonly Input[],
|
|
operation: (input: Input) => Promise<Output>,
|
|
): Promise<Output[]> => {
|
|
const results = new Array<Output>(inputs.length);
|
|
let next = 0;
|
|
await Promise.all(
|
|
Array.from(
|
|
{ length: Math.min(inputs.length, DISCOVERY_CONCURRENCY) },
|
|
async () => {
|
|
while (next < inputs.length) {
|
|
const index = next++;
|
|
results[index] = await operation(inputs[index]!);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
return results;
|
|
};
|
|
export type CutterBridgeExecutionState =
|
|
| "not_started"
|
|
| "complete"
|
|
| "failed"
|
|
| "unknown";
|
|
export interface CutterBridgeExecution {
|
|
readonly requestId: string;
|
|
readonly output: unknown;
|
|
readonly currentFile: string | null;
|
|
readonly documentGeneration: number;
|
|
readonly identityStatus: "partial";
|
|
readonly cutterVersion: string | null;
|
|
readonly executionState: CutterBridgeExecutionState;
|
|
readonly error: string | null;
|
|
readonly message: string | null;
|
|
readonly outputTruncated: boolean;
|
|
readonly completionToken?: string;
|
|
}
|
|
|
|
class CutterBridgeRequestFailure extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly requestSent: boolean,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export interface WindowsCutterDescriptorReader {
|
|
verifyDirectory(directory: string): boolean;
|
|
readDescriptor(directory: string, name: string, maxBytes: number): Buffer;
|
|
}
|
|
interface CutterBridgeClientOptions {
|
|
readonly platform?: NodeJS.Platform;
|
|
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
|
|
}
|
|
|
|
export type CutterBridgeSession = Omit<
|
|
z.infer<typeof descriptorSchema>,
|
|
"token" | "host" | "port"
|
|
>;
|
|
export interface CutterBridgeDiscovery {
|
|
readonly sessions: readonly CutterBridgeSession[];
|
|
readonly bridge_directory: string;
|
|
readonly discovery_status:
|
|
| "sessions_found"
|
|
| "no_live_bridge_found"
|
|
| "bridge_directory_unavailable"
|
|
| "bridge_directory_insecure"
|
|
| "bridge_directory_capacity_exceeded";
|
|
readonly bridge_directory_security:
|
|
| "private_verified"
|
|
| "not_private"
|
|
| "unverified_platform_acl"
|
|
| "not_checked";
|
|
}
|
|
|
|
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
|
|
export class CutterBridgeClient {
|
|
readonly #directory: string;
|
|
readonly #platform: NodeJS.Platform;
|
|
readonly #windowsPrivateReader: WindowsCutterDescriptorReader | undefined;
|
|
readonly #pendingAcknowledgements = new Map<string, Set<string>>();
|
|
|
|
constructor(
|
|
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
options: CutterBridgeClientOptions = {},
|
|
) {
|
|
const configured = environment.REA_CUTTER_BRIDGE_DIR;
|
|
const localAppData = environment.LOCALAPPDATA;
|
|
this.#platform = options.platform ?? process.platform;
|
|
this.#windowsPrivateReader =
|
|
options.windowsPrivateReader ??
|
|
(this.#platform === "win32"
|
|
? {
|
|
verifyDirectory: (directory) =>
|
|
z
|
|
.object({ privateDacl: z.literal(true) })
|
|
.safeParse(
|
|
requireWindowsNativeAuthority().call(
|
|
"cutter_bridge_verify_directory",
|
|
[directory],
|
|
),
|
|
).success,
|
|
readDescriptor: (directory, name, maxBytes) =>
|
|
z
|
|
.instanceof(Buffer)
|
|
.parse(
|
|
requireWindowsNativeAuthority().call(
|
|
"cutter_bridge_read_descriptor",
|
|
[directory, name, maxBytes],
|
|
),
|
|
),
|
|
}
|
|
: undefined);
|
|
this.#directory =
|
|
configured ??
|
|
(this.#platform === "win32" && localAppData
|
|
? join(localAppData, "REA", "CutterBridge")
|
|
: join(homedir(), ".cache", "rea", "cutter-bridge"));
|
|
}
|
|
|
|
async listSessions(): Promise<CutterBridgeDiscovery> {
|
|
const loaded = await this.#readDescriptors();
|
|
if (loaded.error !== undefined)
|
|
return {
|
|
sessions: [],
|
|
bridge_directory: this.#directory,
|
|
discovery_status: loaded.error,
|
|
bridge_directory_security: loaded.security,
|
|
};
|
|
const descriptors = await mapDiscovery(
|
|
loaded.descriptors,
|
|
async (descriptor) => {
|
|
try {
|
|
const status = responseSchema.safeParse(
|
|
await this.#request(
|
|
descriptor,
|
|
serializeRequest(descriptor, {
|
|
kind: "status",
|
|
session_id: descriptor.session_id,
|
|
}),
|
|
),
|
|
);
|
|
if (
|
|
!status.success ||
|
|
!status.data.ok ||
|
|
status.data.session_id !== descriptor.session_id
|
|
)
|
|
return undefined;
|
|
return {
|
|
session_id: descriptor.session_id,
|
|
pid: descriptor.pid,
|
|
document_generation:
|
|
status.data.document_generation ?? descriptor.document_generation,
|
|
current_file:
|
|
status.data.current_file === undefined
|
|
? descriptor.current_file
|
|
: status.data.current_file,
|
|
cutter_version:
|
|
status.data.cutter_version ?? descriptor.cutter_version,
|
|
identity_status: "partial" as const,
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
);
|
|
const sessions = descriptors.filter(
|
|
(value): value is CutterBridgeSession => value !== undefined,
|
|
);
|
|
return {
|
|
sessions,
|
|
bridge_directory: this.#directory,
|
|
discovery_status:
|
|
sessions.length > 0 ? "sessions_found" : "no_live_bridge_found",
|
|
bridge_directory_security: loaded.security,
|
|
};
|
|
}
|
|
|
|
async execute(input: {
|
|
readonly sessionId: string;
|
|
readonly expectedGeneration: number;
|
|
readonly command: string;
|
|
readonly json: boolean;
|
|
readonly signal?: AbortSignal;
|
|
}): Promise<CutterBridgeExecution> {
|
|
input.signal?.throwIfAborted();
|
|
const descriptor = await this.#find(input.sessionId);
|
|
input.signal?.throwIfAborted();
|
|
const requestId = randomUUID();
|
|
const commandRequest = serializeRequest(descriptor, {
|
|
kind: "command",
|
|
request_id: requestId,
|
|
expected_generation: input.expectedGeneration,
|
|
command: input.command,
|
|
json: input.json,
|
|
});
|
|
await this.#recoverCompletedCommands(descriptor, input.signal);
|
|
await this.#retryPendingAcknowledgements(descriptor);
|
|
input.signal?.throwIfAborted();
|
|
let completionToken: string | undefined;
|
|
let rawResponse: unknown;
|
|
try {
|
|
rawResponse = await this.#request(
|
|
descriptor,
|
|
commandRequest,
|
|
input.signal,
|
|
(ordinal) => {
|
|
completionToken = ordinal;
|
|
},
|
|
);
|
|
} catch (cause) {
|
|
if (!(cause instanceof CutterBridgeRequestFailure) || !cause.requestSent)
|
|
throw cause;
|
|
return {
|
|
requestId,
|
|
output: null,
|
|
currentFile: descriptor.current_file,
|
|
documentGeneration: input.expectedGeneration,
|
|
identityStatus: "partial",
|
|
cutterVersion: descriptor.cutter_version,
|
|
executionState: "unknown",
|
|
error: "transport-response-missing",
|
|
message: `${cause.message}; the command may have completed; do not retry automatically`,
|
|
outputTruncated: false,
|
|
...(completionToken === undefined ? {} : { completionToken }),
|
|
};
|
|
}
|
|
const parsed = responseSchema.safeParse(rawResponse);
|
|
if (!parsed.success)
|
|
return {
|
|
requestId,
|
|
output: null,
|
|
currentFile: descriptor.current_file,
|
|
documentGeneration: input.expectedGeneration,
|
|
identityStatus: "partial",
|
|
cutterVersion: descriptor.cutter_version,
|
|
executionState: "unknown",
|
|
error: "malformed-bridge-response",
|
|
message:
|
|
"Cutter may have completed the command, but its response was invalid; do not retry automatically",
|
|
outputTruncated: false,
|
|
...(completionToken === undefined ? {} : { completionToken }),
|
|
};
|
|
const response = parsed.data;
|
|
const executionState: CutterBridgeExecutionState = response.ok
|
|
? response.execution_state === undefined ||
|
|
response.execution_state === "complete"
|
|
? "complete"
|
|
: response.execution_state === "not_started"
|
|
? "not_started"
|
|
: "unknown"
|
|
: response.execution_state === "failed"
|
|
? "failed"
|
|
: response.execution_state === "not_started"
|
|
? "not_started"
|
|
: "unknown";
|
|
if (executionState !== "unknown")
|
|
await this.#acknowledgeCompletion(descriptor, requestId);
|
|
if (response.error === "active-document-generation-changed")
|
|
throw new Error(
|
|
"Cutter's active document generation changed. List sessions again and select the current generation.",
|
|
);
|
|
return {
|
|
requestId,
|
|
output: response.output ?? null,
|
|
currentFile:
|
|
response.current_file === undefined
|
|
? descriptor.current_file
|
|
: response.current_file,
|
|
documentGeneration:
|
|
response.document_generation ?? input.expectedGeneration,
|
|
identityStatus: "partial",
|
|
cutterVersion: response.cutter_version ?? descriptor.cutter_version,
|
|
executionState,
|
|
error: response.ok ? null : (response.error ?? "cutter-command-failed"),
|
|
message: response.message ?? null,
|
|
outputTruncated: response.output_truncated ?? false,
|
|
...(completionToken === undefined ? {} : { completionToken }),
|
|
};
|
|
}
|
|
|
|
/** Confirm completion by exact receipt or authenticated connection watermark. */
|
|
async confirmCommandCompletion(
|
|
sessionId: string,
|
|
requestId: string,
|
|
completionToken?: string,
|
|
): Promise<Result<boolean, AnalysisError>> {
|
|
let descriptor: z.infer<typeof descriptorSchema>;
|
|
try {
|
|
descriptor = await this.#find(sessionId);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"cutter",
|
|
"cutter_command",
|
|
cause instanceof Error ? cause.message : "Cutter session unavailable",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
try {
|
|
const raw = await this.#request(
|
|
descriptor,
|
|
serializeRequest(descriptor, {
|
|
kind: "status",
|
|
session_id: sessionId,
|
|
}),
|
|
);
|
|
const parsed = responseSchema.safeParse(raw);
|
|
if (
|
|
!parsed.success ||
|
|
!parsed.data.ok ||
|
|
parsed.data.session_id !== sessionId
|
|
)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"cutter_command_status",
|
|
"The bridge status response did not contain a valid session identity.",
|
|
{ cause: parsed.success ? undefined : parsed.error },
|
|
),
|
|
);
|
|
const completed = (parsed.data.completed_commands ?? []).some(
|
|
(receipt) => receipt.request_id === requestId,
|
|
);
|
|
if (completed) await this.#acknowledgeCompletion(descriptor, requestId);
|
|
return ok(
|
|
completed ||
|
|
(completionToken !== undefined &&
|
|
parsed.data.completed_connection !== undefined &&
|
|
parsed.data.completed_connection >= completionToken),
|
|
);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"cutter",
|
|
"cutter_command_status",
|
|
cause instanceof Error ? cause.message : "Cutter status failed",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async #recoverCompletedCommands(
|
|
descriptor: z.infer<typeof descriptorSchema>,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
const raw = await this.#request(
|
|
descriptor,
|
|
serializeRequest(descriptor, {
|
|
kind: "status",
|
|
session_id: descriptor.session_id,
|
|
}),
|
|
signal,
|
|
);
|
|
const status = responseSchema.safeParse(raw);
|
|
if (
|
|
!status.success ||
|
|
!status.data.ok ||
|
|
status.data.session_id !== descriptor.session_id
|
|
)
|
|
throw new AnalysisOutputError(
|
|
"cutter_command_status",
|
|
"The bridge status response did not contain a valid session identity.",
|
|
{ cause: status.success ? undefined : status.error },
|
|
);
|
|
if (status.data.completed_connection === undefined)
|
|
throw new AnalysisOutputError(
|
|
"cutter_command_status",
|
|
"The bridge did not provide authenticated proof that earlier connections reached a terminal state.",
|
|
);
|
|
const pending =
|
|
this.#pendingAcknowledgements.get(descriptor.session_id) ?? new Set();
|
|
for (const receipt of status.data.completed_commands ?? [])
|
|
pending.add(receipt.request_id);
|
|
if (pending.size > 0)
|
|
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
|
}
|
|
|
|
async #acknowledgeCompletion(
|
|
descriptor: z.infer<typeof descriptorSchema>,
|
|
requestId: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
const raw = await this.#request(
|
|
descriptor,
|
|
serializeRequest(descriptor, {
|
|
kind: "ack",
|
|
request_id: requestId,
|
|
}),
|
|
);
|
|
const acknowledged = acknowledgementSchema.safeParse(raw);
|
|
if (!acknowledged.success)
|
|
throw new Error(
|
|
"Cutter did not confirm the completion receipt acknowledgement.",
|
|
);
|
|
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
|
pending?.delete(requestId);
|
|
if (pending?.size === 0)
|
|
this.#pendingAcknowledgements.delete(descriptor.session_id);
|
|
return true;
|
|
} catch {
|
|
const pending =
|
|
this.#pendingAcknowledgements.get(descriptor.session_id) ??
|
|
new Set<string>();
|
|
pending.add(requestId);
|
|
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async #retryPendingAcknowledgements(
|
|
descriptor: z.infer<typeof descriptorSchema>,
|
|
): Promise<void> {
|
|
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
|
if (pending === undefined) return;
|
|
for (const requestId of pending) {
|
|
if (!(await this.#acknowledgeCompletion(descriptor, requestId)))
|
|
throw new AnalysisCapabilityUnavailableError(
|
|
"cutter",
|
|
"cutter_command",
|
|
"A retained Cutter completion receipt could not be acknowledged; no new command was sent.",
|
|
);
|
|
}
|
|
}
|
|
|
|
async #find(sessionId: string): Promise<z.infer<typeof descriptorSchema>> {
|
|
const loaded = await this.#readDescriptors();
|
|
if (loaded.error === "bridge_directory_capacity_exceeded")
|
|
throw new AnalysisResourceConstraintError(
|
|
"cutter_command",
|
|
"transport",
|
|
"Cutter descriptor discovery exceeded its candidate budget; no command was sent.",
|
|
{
|
|
max_descriptor_candidates: MAX_DESCRIPTOR_CANDIDATES,
|
|
bridge_directory: this.#directory,
|
|
},
|
|
);
|
|
if (loaded.error !== undefined)
|
|
throw new AnalysisCapabilityUnavailableError(
|
|
"cutter",
|
|
"cutter_command",
|
|
`Cutter descriptor discovery failed: ${loaded.error}`,
|
|
);
|
|
const descriptor = loaded.descriptors.find(
|
|
(candidate) => candidate.session_id === sessionId,
|
|
);
|
|
if (descriptor === undefined)
|
|
throw new Error("Cutter bridge session is unavailable or stale");
|
|
return descriptor;
|
|
}
|
|
|
|
async listDescriptors(): Promise<
|
|
readonly z.infer<typeof descriptorSchema>[]
|
|
> {
|
|
const loaded = await this.#readDescriptors();
|
|
return loaded.descriptors;
|
|
}
|
|
|
|
async #readDescriptors(): Promise<{
|
|
readonly descriptors: readonly z.infer<typeof descriptorSchema>[];
|
|
readonly security:
|
|
| "private_verified"
|
|
| "not_private"
|
|
| "unverified_platform_acl"
|
|
| "not_checked";
|
|
readonly error?:
|
|
| "bridge_directory_unavailable"
|
|
| "bridge_directory_insecure"
|
|
| "bridge_directory_capacity_exceeded";
|
|
}> {
|
|
let directoryInfo;
|
|
try {
|
|
directoryInfo = await lstat(this.#directory);
|
|
} catch (cause) {
|
|
const code = (cause as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT")
|
|
return { descriptors: [], security: "not_checked" };
|
|
return {
|
|
descriptors: [],
|
|
security: "not_checked",
|
|
error: "bridge_directory_unavailable",
|
|
};
|
|
}
|
|
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
|
return {
|
|
descriptors: [],
|
|
security: "not_private",
|
|
error: "bridge_directory_insecure",
|
|
};
|
|
const currentUid =
|
|
this.#platform !== "win32" ? process.getuid?.() : undefined;
|
|
const posix = currentUid !== undefined;
|
|
if (
|
|
posix &&
|
|
(directoryInfo.uid !== currentUid || (directoryInfo.mode & 0o077) !== 0)
|
|
)
|
|
return {
|
|
descriptors: [],
|
|
security: "not_private",
|
|
error: "bridge_directory_insecure",
|
|
};
|
|
let security:
|
|
| "private_verified"
|
|
| "not_private"
|
|
| "unverified_platform_acl" = posix
|
|
? "private_verified"
|
|
: "unverified_platform_acl";
|
|
const windowsPrivateReader = this.#windowsPrivateReader;
|
|
if (!posix) {
|
|
if (this.#platform !== "win32" || windowsPrivateReader === undefined)
|
|
return {
|
|
descriptors: [],
|
|
security,
|
|
error: "bridge_directory_insecure",
|
|
};
|
|
try {
|
|
if (!windowsPrivateReader.verifyDirectory(this.#directory))
|
|
return {
|
|
descriptors: [],
|
|
security: "not_private",
|
|
error: "bridge_directory_insecure",
|
|
};
|
|
security = "private_verified";
|
|
} catch {
|
|
return {
|
|
descriptors: [],
|
|
security,
|
|
error: "bridge_directory_insecure",
|
|
};
|
|
}
|
|
}
|
|
const entries: string[] = [];
|
|
try {
|
|
const directory = await opendir(this.#directory);
|
|
for await (const entry of directory) {
|
|
if (!/^cutter-\d+-[0-9a-f-]{36}\.json$/iu.test(entry.name)) continue;
|
|
if (entries.length === MAX_DESCRIPTOR_CANDIDATES)
|
|
return {
|
|
descriptors: [],
|
|
security,
|
|
error: "bridge_directory_capacity_exceeded",
|
|
};
|
|
entries.push(entry.name);
|
|
}
|
|
} catch {
|
|
return {
|
|
descriptors: [],
|
|
security: "not_checked",
|
|
error: "bridge_directory_unavailable",
|
|
};
|
|
}
|
|
const loaded = await mapDiscovery(entries, async (entry) => {
|
|
let file;
|
|
try {
|
|
let bytes: Buffer;
|
|
if (this.#platform === "win32") {
|
|
bytes = windowsPrivateReader!.readDescriptor(
|
|
this.#directory,
|
|
entry,
|
|
MAX_DESCRIPTOR_BYTES,
|
|
);
|
|
} else {
|
|
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
|
file = await open(join(this.#directory, entry), flags);
|
|
const info = await file.stat();
|
|
if (
|
|
!info.isFile() ||
|
|
info.size > MAX_DESCRIPTOR_BYTES ||
|
|
info.uid !== currentUid ||
|
|
(info.mode & 0o077) !== 0
|
|
)
|
|
return undefined;
|
|
const boundedBytes = await readBoundedFileBytes(
|
|
file,
|
|
MAX_DESCRIPTOR_BYTES,
|
|
);
|
|
if (boundedBytes === undefined) return undefined;
|
|
bytes = boundedBytes;
|
|
}
|
|
if (bytes.byteLength > MAX_DESCRIPTOR_BYTES) return undefined;
|
|
const parsed = descriptorSchema.safeParse(
|
|
JSON.parse(bytes.toString("utf8")) as unknown,
|
|
);
|
|
if (
|
|
!parsed.success ||
|
|
entry !== `cutter-${parsed.data.pid}-${parsed.data.session_id}.json`
|
|
)
|
|
return undefined;
|
|
return parsed.data;
|
|
} catch {
|
|
return undefined;
|
|
} finally {
|
|
await file?.close().catch(() => undefined);
|
|
}
|
|
});
|
|
return {
|
|
descriptors: loaded.filter(
|
|
(value): value is z.infer<typeof descriptorSchema> =>
|
|
value !== undefined,
|
|
),
|
|
security,
|
|
};
|
|
}
|
|
|
|
#request(
|
|
descriptor: z.infer<typeof descriptorSchema>,
|
|
requestBytes: AuthenticatedRequestBytes,
|
|
signal?: AbortSignal,
|
|
onAuthenticated?: (connectionOrdinal: string) => void,
|
|
): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
const connection = createConnection({
|
|
host: descriptor.host,
|
|
port: descriptor.port,
|
|
});
|
|
const frames = new CutterBridgeResponseFrames();
|
|
let settled = false;
|
|
let requestSent = false;
|
|
let authenticated = false;
|
|
const clientNonce = randomBytes(32).toString("hex");
|
|
let serverNonce = "";
|
|
let connectionOrdinal: string | undefined;
|
|
const finish = (complete: () => void): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
complete();
|
|
};
|
|
const rejectRequest = (message: string): void =>
|
|
finish(() =>
|
|
reject(new CutterBridgeRequestFailure(message, requestSent)),
|
|
);
|
|
const timer = setTimeout(() => {
|
|
rejectRequest("Cutter bridge request timed out");
|
|
connection.destroy();
|
|
}, 25_000);
|
|
const onAbort = (): void => {
|
|
rejectRequest("Cutter bridge request was cancelled");
|
|
connection.destroy();
|
|
};
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
if (signal?.aborted) onAbort();
|
|
connection.once("connect", () => {
|
|
connection.write(
|
|
`${JSON.stringify({ kind: "rea-auth-v2", nonce: clientNonce })}\n`,
|
|
);
|
|
});
|
|
connection.on("data", (chunk: Buffer) => {
|
|
try {
|
|
frames.push(chunk, (line) => {
|
|
const frame: unknown = JSON.parse(line.toString("utf8"));
|
|
if (!authenticated) {
|
|
const challenge = z
|
|
.object({
|
|
session_id: z.string().uuid(),
|
|
nonce: z.string().regex(/^[0-9a-f]{64}$/),
|
|
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
|
})
|
|
.parse(frame);
|
|
if (challenge.session_id !== descriptor.session_id)
|
|
throw new Error("unexpected peer session");
|
|
serverNonce = challenge.nonce;
|
|
connectionOrdinal = challenge.nonce.slice(0, 16);
|
|
const expected = createHmac("sha256", descriptor.token)
|
|
.update(
|
|
`rea-cutter-server-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}`,
|
|
)
|
|
.digest();
|
|
if (
|
|
!timingSafeEqual(expected, Buffer.from(challenge.proof, "hex"))
|
|
)
|
|
throw new Error("peer proof failed");
|
|
onAuthenticated?.(connectionOrdinal);
|
|
const proof = createHmac("sha256", descriptor.token)
|
|
.update(
|
|
`rea-cutter-client-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}:${requestBytes}`,
|
|
)
|
|
.digest("hex");
|
|
authenticated = true;
|
|
requestSent = true;
|
|
connection.write(
|
|
`${JSON.stringify({ request: requestBytes, proof })}\n`,
|
|
);
|
|
return true;
|
|
} else {
|
|
const envelope = z
|
|
.object({
|
|
response: z.string(),
|
|
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
|
})
|
|
.parse(frame);
|
|
const expected = createHmac("sha256", descriptor.token)
|
|
.update(
|
|
`rea-cutter-response-v2:${descriptor.session_id}:${clientNonce}:${serverNonce}:${envelope.response}`,
|
|
)
|
|
.digest();
|
|
if (
|
|
!timingSafeEqual(expected, Buffer.from(envelope.proof, "hex"))
|
|
)
|
|
throw new Error("response proof failed");
|
|
const response: unknown = JSON.parse(envelope.response);
|
|
connection.end();
|
|
finish(() => resolve(response));
|
|
return false;
|
|
}
|
|
});
|
|
} catch {
|
|
rejectRequest("Cutter bridge peer authentication or response failed");
|
|
connection.destroy();
|
|
}
|
|
});
|
|
connection.once("error", (cause) =>
|
|
rejectRequest(`Cutter bridge is unavailable: ${cause.message}`),
|
|
);
|
|
connection.once("close", () =>
|
|
rejectRequest(
|
|
"Cutter bridge closed before returning a complete response",
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|