Compare commits

..
Author SHA1 Message Date
morluto 64228f8144 test(browser): verify typed worker refusals across navigations 2026-10-15 03:37:16 +08:00
morluto 576abe196f test(process): use owned host-aware settlement fixtures 2026-10-15 03:37:16 +08:00
morluto b2d2e43068 fix(mcp): authenticate inventory facts before prompt completion 2026-10-15 03:37:15 +08:00
morluto acae8a5382 refactor(artifacts): share inspection validation and prune legacy fixtures 2026-10-15 03:31:06 +08:00
morluto 1deb6e87bf test: isolate host discovery and update current capture fixtures 2026-10-15 03:25:16 +08:00
morluto fd3d29bc8d fix(artifacts): bind inspection provenance to every retained inventory 2026-10-15 03:25:16 +08:00
morluto 27faf8b983 fix(javascript): keep source-module semantics independent of sibling bytes 2026-10-15 03:25:16 +08:00
morluto 3a1aa3fd03 fix(evidence)!: reject missing recorded inventory and capture facts
BREAKING CHANGE: Inventory Evidence must explicitly report integrity_contradictions, and browser capture Evidence must report every resolved budget. Caller input defaults remain supported; stored observations are never filled with inferred defaults.
2026-10-15 03:11:37 +08:00
morluto 3b272dc8e2 style(apple): normalize class parser module formatting 2026-10-15 03:08:26 +08:00
morluto 5bea037552 refactor(apple): remove unused class identity re-export 2026-10-15 03:07:39 +08:00
morluto 2c36f8d8e5 style(test): format coverage regression fixtures 2026-10-15 03:06:59 +08:00
morluto 054be92874 docs: align workflow recovery and current Evidence contracts 2026-10-15 03:06:26 +08:00
morluto 67f4b79b32 fix(browser)!: expose capture budgets and queue admitted content reads
BREAKING CHANGE: Browser capture results require resolved limits, event families, and omission facts. Caller-selected budgets replace hidden retention ceilings; admitted content reads queue behind bounded concurrency.
2026-10-15 03:06:26 +08:00
morluto b01db67586 test(hopper): verify external exit recovery and retained ownership 2026-10-15 03:06:26 +08:00
morluto 3c4bd35180 fix(jeb): probe live operations with owned discovery deadlines 2026-10-15 03:06:26 +08:00
morluto b647e4a4e8 fix(cli): preserve Cutter choices and reject invalid runtime arguments 2026-10-15 03:06:26 +08:00
morluto 7f2f0b78c4 fix(ghidra): preserve MIPS overrides and filter entry metadata 2026-10-15 03:06:26 +08:00
morluto 61141032f7 fix(windows): explain missing native artifacts and verify output drain 2026-10-15 03:06:26 +08:00
morluto 303dc2aa73 fix(setup): own Hopper replacement staging and committed outcomes 2026-10-15 03:06:26 +08:00
morluto 98c3c527d8 fix(apple): distinguish JVM resources and UIKit event connections 2026-10-15 03:06:26 +08:00
morluto 5f986d4869 fix(process): attribute finite trace omissions and cleanup states 2026-10-15 03:06:26 +08:00
morluto b9539e9c1c fix(functions): preserve coverage diagnostics separately from blockers 2026-10-15 03:06:26 +08:00
morluto 92e17ccfce fix(javascript): preserve module identity and boundary fields 2026-10-15 03:06:26 +08:00
morluto 9392590f2f fix(artifacts)!: authenticate complete inspection and summary inputs
BREAKING CHANGE: Artifact inventories require scoped coverage. Obsolete coverage_status and prose-based coverage inference are removed. Inspection carriers authenticate every retained inventory substep.
2026-10-15 03:06:26 +08:00
morluto ef0c8005bc refactor(evidence)!: require current coverage commitments
BREAKING CHANGE: Evidence and analysis snapshots require explicit nullable relationship coverage and include it in their identity commitments. Historical records without the field are rejected; saved files are not migrated.
2026-10-15 03:06:26 +08:00
28 changed files with 1282 additions and 1631 deletions

No files matched your search

+937 -85
View File
File diff suppressed because it is too large. Load diff
-12
View File
@@ -142,18 +142,6 @@ Setup registers `rea` in `~/.kimi-code/mcp.json`, or
creating a `.rea.backup` before updating an existing file. Kimi Code infers
stdio from the `command` entry, so setup does not add a `type` field. The
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
Setup selects the compact input-schema advertisement for new or refreshed Kimi
registrations unless their stored configuration or setup environment explicitly
selects `REA_MCP_INPUT_SCHEMA_PROFILE=full` or `compact`. This keeps REA's
canonical tool validation unchanged while avoiding a Moonshot Kimi Code 2.2.0
failure observed with the full catalog: its request was rejected because a
single tool schema exceeded the provider's size limit. The limit is
provider-specific and not an MCP protocol limit. `rea doctor --client kimi`
reports an older registration with no explicit profile as stale. To restore the
compact profile, run `rea setup --client kimi` or add
`REA_MCP_INPUT_SCHEMA_PROFILE=compact` to the registration's `env`, then restart
Kimi Code. An explicit `full` profile remains respected and can still exceed
Moonshot's limit.
Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
`.agents/skills`, so no separate skill root is required; setup still installs
and verifies the matching skill files through the normal shared-skill flow.
@@ -1,439 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdir,
readFile,
writeFile,
} from "node:fs/promises";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { estimateTokenCount } from "tokenx";
const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const outputDirectory = resolve(
process.env.REA_MCP_STARTUP_OUTPUT ?? join(sourceRoot, ".cache/mcp-startup-probe"),
);
const iterations = 5;
const requestTimeoutMs = 180_000;
const packageManifestPath = resolve(
sourceRoot,
".cache/verified-package/manifest.json",
);
const packageArchivePath = resolve(
sourceRoot,
".cache/verified-package/package.tgz",
);
const packageManifest = JSON.parse(await readFile(packageManifestPath, "utf8"));
const packageArchive = await readFile(packageArchivePath);
const packageSha256 = createHash("sha256").update(packageArchive).digest("hex");
const packageLockSha256 = createHash("sha256")
.update(await readFile(join(sourceRoot, "package-lock.json")))
.digest("hex");
assert.equal(packageSha256, packageManifest.sha256, "verified package digest mismatch");
assert.equal(
packageManifest.sourceSha,
process.env.REA_MCP_STARTUP_QUALIFIED_SHA,
"package must match the exact qualification source",
);
assert.equal(process.version, "v24.18.0", "probe must use pinned Node 24.18.0");
assert.equal(process.platform, "win32");
await mkdir(outputDirectory, { recursive: true });
const metricsPreloadPath = join(outputDirectory, "server-metrics-preload.cjs");
await writeFile(
metricsPreloadPath,
`const fs = require("node:fs");\n` +
`const started = process.hrtime.bigint();\n` +
`const startedUsage = process.resourceUsage();\n` +
`const metricsPath = process.env.REA_MCP_STARTUP_METRICS_PATH;\n` +
`process.on("exit", () => {\n` +
` if (metricsPath === undefined) return;\n` +
` const ended = process.hrtime.bigint();\n` +
` const usage = process.resourceUsage();\n` +
` fs.writeFileSync(metricsPath, JSON.stringify({\n` +
` startedAt: new Date(Date.now() - Number(ended - started) / 1e6).toISOString(),\n` +
` processElapsedMs: Number(ended - started) / 1e6,\n` +
` userCPUTimeDeltaMicros: usage.userCPUTime - startedUsage.userCPUTime,\n` +
` systemCPUTimeDeltaMicros: usage.systemCPUTime - startedUsage.systemCPUTime,\n` +
` resourceUsageMaxRssRaw: usage.maxRSS,\n` +
` resourceUsageMaxRssUnit: "raw Node process.resourceUsage().maxRSS value; platform unit not converted",\n` +
` exitRssBytes: process.memoryUsage().rss\n` +
` }) + "\\n");\n` +
`});\n`,
"utf8",
);
const report = {
schemaVersion: 1,
host: {
platform: process.platform,
architecture: process.arch,
nodeVersion: process.version,
npmVersion: process.env.REA_MCP_STARTUP_NPM_VERSION ?? null,
cpuCount: Number(process.env.NUMBER_OF_PROCESSORS ?? 0) || null,
osRelease: await import("node:os").then(({ release }) => release()),
},
diagnosticSha: process.env.GITHUB_SHA,
sourcePackageLockSha256: packageLockSha256,
package: {
version: packageManifest.packageVersion,
sourceSha: packageManifest.sourceSha,
qualifiedRunId: packageManifest.runId,
nodeVersion: packageManifest.nodeVersion,
sha256: packageSha256,
},
conditions: {
sourceEntry: "checked-out scripts/rea.mjs and locally source-built dist; first process starts after npm ci, build and artifact restoration",
packagedEntry: "fresh npm install of the exact verified-release-package/package.tgz into an isolated prefix",
samplesPerEntry: iterations,
firstSampleMeaning: "first fresh server process after build or install; runner OS/filesystem/AV cache is uncontrolled and must not be called cold",
subsequentSamples: "four additional fresh Node server processes; runner page cache may remain warm",
providerDiscovery: "USERPROFILE/HOME/APPDATA/LOCALAPPDATA isolated per sample; no target selected and no provider engine launched",
requestTimeoutMs,
processFamilyMemory: "unmeasured; only the server Node process resourceUsage is reported",
tokenEstimate: "tokenx estimate over every actual JSON-RPC message sent and received, including initialize, both tools/list calls, notifications and full results; estimate is not model billing",
mcpExchanges: "complete parsed MCP JSON-RPC message objects are retained per sample, with monotonic elapsed offsets",
instrumentation: "a minimal --require preload records one server Node process resourceUsage row on normal process exit",
},
entries: [],
exchangeTotals: [],
};
const installPrefix = process.env.REA_MCP_STARTUP_INSTALL_PREFIX;
assert.ok(installPrefix, "REA_MCP_STARTUP_INSTALL_PREFIX is required");
const entryOptions = [
{
name: "source-tree",
packageRoot: sourceRoot,
entrypoint: join(sourceRoot, "scripts/rea.mjs"),
},
{
name: "installed-package",
packageRoot: resolve(installPrefix),
entrypoint: resolve(
installPrefix,
"node_modules/rea-agents/scripts/rea.mjs",
),
},
];
const selectedName = process.argv[2];
assert.ok(
entryOptions.some(({ name }) => name === selectedName),
"pass either source-tree or installed-package",
);
const previousReportPath = join(outputDirectory, "mcp-startup-report.json");
if (selectedName === "installed-package") {
try {
const previousReport = JSON.parse(await readFile(previousReportPath, "utf8"));
report.entries.push(...previousReport.entries);
report.exchangeTotals.push(...(previousReport.exchangeTotals ?? []));
report.totalEstimatedTokenCount = previousReport.totalEstimatedTokenCount ?? 0;
} catch (cause) {
if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "ENOENT")
throw cause;
}
}
for (const entry of entryOptions.filter(({ name }) => name === selectedName)) {
assert.ok(entry.packageRoot, `${entry.name}: package root is required`);
const entryReport = {
name: entry.name,
packageRoot: entry.packageRoot,
serverEntrypoint: entry.entrypoint,
samples: [],
};
report.entries.push(entryReport);
for (let iteration = 1; iteration <= iterations; iteration += 1) {
const sampleStartedAt = new Date().toISOString();
const sampleStarted = performance.now();
const messageLog = [];
const stderrChunks = [];
const metricsPath = join(
outputDirectory,
`${entry.name}-${String(iteration).padStart(2, "0")}-server.json`,
);
const serverEnvironment = isolatedServerEnvironment(
entry.name,
iteration,
metricsPreloadPath,
metricsPath,
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entry.entrypoint, "mcp"],
cwd: entry.packageRoot,
env: serverEnvironment,
stderr: "pipe",
});
transport.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
const elapsed = () => +(performance.now() - sampleStarted).toFixed(2);
observeTransport(transport, messageLog, elapsed);
const client = new Client({ name: "rea-1685-startup-probe", version: "1" });
const sample = {
iteration,
firstProcessAfterBuildOrInstall: iteration === 1,
startedAt: sampleStartedAt,
launchElapsedMs: null,
initializeElapsedMs: null,
firstToolsListElapsedMs: null,
firstToolsListDurationMs: null,
warmToolsListElapsedMs: null,
warmToolsListDurationMs: null,
toolCount: null,
firstToolsListResponseBytes: null,
warmToolsListResponseBytes: null,
firstToolsListWireRequests: 0,
warmToolsListWireRequests: 0,
serverVersion: null,
closeRequestedElapsedMs: null,
serverExitMetrics: null,
serverExitMetricsError: null,
stderr: "",
stderrTruncated: false,
stderrArtifact: null,
stderrBytes: null,
stderrSha256: null,
messages: messageLog,
error: null,
completed: false,
};
entryReport.samples.push(sample);
try {
const originalStart = transport.start.bind(transport);
let startBegin;
transport.start = async (...arguments_) => {
startBegin = performance.now();
await originalStart(...arguments_);
sample.launchElapsedMs = +(performance.now() - startBegin).toFixed(2);
};
const connectBegin = performance.now();
await client.connect(transport, { timeout: requestTimeoutMs });
sample.initializeElapsedMs = +(performance.now() - connectBegin).toFixed(2);
sample.serverVersion = client.getServerVersion() ?? null;
const priorToolListRequests = countToolListRequests(messageLog);
const firstListBegin = performance.now();
const firstList = await client.listTools(undefined, {
timeout: requestTimeoutMs,
});
const firstListToolRequests = countToolListRequests(messageLog);
sample.firstToolsListDurationMs = +(performance.now() - firstListBegin).toFixed(2);
sample.firstToolsListElapsedMs = elapsed();
sample.firstToolsListWireRequests =
firstListToolRequests - priorToolListRequests;
sample.toolCount = firstList.tools.length;
sample.firstToolsListResponseBytes = Buffer.byteLength(
JSON.stringify(firstList),
);
const warmListBegin = performance.now();
const warmList = await client.listTools(undefined, {
timeout: requestTimeoutMs,
cacheMode: "refresh",
});
sample.warmToolsListWireRequests =
countToolListRequests(messageLog) - firstListToolRequests;
sample.warmToolsListDurationMs = +(performance.now() - warmListBegin).toFixed(2);
sample.warmToolsListElapsedMs = elapsed();
sample.warmToolsListResponseBytes = Buffer.byteLength(
JSON.stringify(warmList),
);
assert.equal(warmList.tools.length, firstList.tools.length);
assert.ok(sample.firstToolsListWireRequests > 0, "first tools/list must reach the server");
assert.ok(sample.warmToolsListWireRequests > 0, "warm tools/list must reach the server");
sample.completed = true;
} catch (cause) {
sample.error = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
} finally {
sample.closeRequestedElapsedMs = elapsed();
try {
await client.close();
} catch (cause) {
sample.error ??= `MCP client close failed: ${cause instanceof Error ? cause.message : String(cause)}`;
}
try {
await transport.close();
} catch (cause) {
sample.error ??= `stdio transport close failed: ${cause instanceof Error ? cause.message : String(cause)}`;
}
const stderr = Buffer.concat(stderrChunks);
const stderrPath = join(
outputDirectory,
`${entry.name}-${String(iteration).padStart(2, "0")}.stderr.log`,
);
await writeFile(stderrPath, stderr);
sample.stderrArtifact = stderrPath;
sample.stderrBytes = stderr.length;
sample.stderrSha256 = createHash("sha256").update(stderr).digest("hex");
sample.stderr = stderr.toString("utf8");
if (stderr.length > 1_000_000) {
sample.stderr = stderr.subarray(-1_000_000).toString("utf8");
sample.stderrTruncated = true;
}
try {
sample.serverExitMetrics = await readExitMetrics(metricsPath, 15_000);
} catch (cause) {
sample.serverExitMetricsError = cause instanceof Error ? cause.message : String(cause);
}
sample.totalElapsedMs = elapsed();
sample.estimatedTokenCount = messageLog.reduce(
(total, { json }) => total + estimateTokenCount(json),
0,
);
sample.messageCount = messageLog.length;
}
}
}
report.exchangeTotals = report.entries.flatMap(({ name, samples }) =>
samples.map(({ iteration, messages, estimatedTokenCount, messageCount }) => ({
entry: name,
iteration,
messageCount,
estimatedTokenCount,
transmittedUtf8Bytes: messages.reduce((total, { utf8Bytes }) => total + utf8Bytes, 0),
})),
);
report.totalEstimatedTokenCount = report.exchangeTotals.reduce(
(total, { estimatedTokenCount }) => total + estimatedTokenCount,
0,
);
const reportPath = join(outputDirectory, "mcp-startup-report.json");
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({
reportPath,
diagnosticSha: report.diagnosticSha,
runtimeQualifiedSha: report.package.sourceSha,
packageSha256,
nodeVersion: process.version,
totalEstimatedTokenCount: report.totalEstimatedTokenCount,
entries: report.entries.map(({ name, samples }) => ({
name,
samples: samples.map((sample) => ({
iteration: sample.iteration,
launchElapsedMs: sample.launchElapsedMs,
initializeElapsedMs: sample.initializeElapsedMs,
firstToolsListDurationMs: sample.firstToolsListDurationMs,
warmToolsListDurationMs: sample.warmToolsListDurationMs,
toolCount: sample.toolCount,
serverMaxRssRaw: sample.serverExitMetrics?.resourceUsageMaxRssRaw ?? null,
completed: sample.completed,
error: sample.error,
})),
})),
})}\n`);
if (
report.entries.some(({ samples }) =>
samples.some(({ completed, serverExitMetrics }) => !completed || serverExitMetrics === null),
)
)
process.exitCode = 1;
function isolatedServerEnvironment(entryName, iteration, preloadPath, metricsPath) {
const sampleRoot = join(
tmpdir(),
"rea-1685-mcp-startup",
`${entryName}-${String(iteration).padStart(2, "0")}`,
);
const home = join(sampleRoot, "home");
const appData = join(sampleRoot, "appdata");
const localAppData = join(sampleRoot, "localappdata");
const temp = join(sampleRoot, "temp");
for (const path of [home, appData, localAppData, temp])
requireMkdir(path);
const environment = {};
for (const key of [
"PATH",
"SystemRoot",
"WINDIR",
"ComSpec",
"SystemDrive",
"ProgramFiles",
"ProgramW6432",
]) {
const value = process.env[key];
if (value !== undefined) environment[key] = value;
}
Object.assign(environment, {
USERPROFILE: home,
HOME: home,
APPDATA: appData,
LOCALAPPDATA: localAppData,
TEMP: temp,
TMP: temp,
NODE_OPTIONS: `--require=${preloadPath}`,
REA_MCP_STARTUP_METRICS_PATH: metricsPath,
});
return environment;
}
function requireMkdir(path) {
mkdirSync(path, { recursive: true });
}
function observeTransport(transport, messages, elapsed) {
const originalSend = transport.send.bind(transport);
transport.send = async (message, ...arguments_) => {
recordMessage("client-to-server", message, messages, elapsed);
return originalSend(message, ...arguments_);
};
let messageHandler = transport.onmessage;
Object.defineProperty(transport, "onmessage", {
configurable: true,
get: () => messageHandler,
set: (handler) => {
messageHandler =
typeof handler === "function"
? (message) => {
recordMessage("server-to-client", message, messages, elapsed);
return handler(message);
}
: handler;
},
});
}
function recordMessage(direction, message, messages, elapsed) {
const json = JSON.stringify(message);
messages.push({
direction,
method: typeof message?.method === "string" ? message.method : null,
id: typeof message?.id === "string" || typeof message?.id === "number" ? message.id : null,
elapsedMs: elapsed(),
utf8Bytes: Buffer.byteLength(json),
json,
});
}
function countToolListRequests(messages) {
return messages.filter(
(message) =>
message.direction === "client-to-server" && message.method === "tools/list",
).length;
}
async function readExitMetrics(path, timeoutMs) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch (cause) {
if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "ENOENT")
throw cause;
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
}
}
throw new Error(`Server process did not write exit metrics within ${timeoutMs} ms`);
}
@@ -1,33 +0,0 @@
export interface GhidraVerifierTimeouts {
readonly clientOptions: {
readonly startupTimeoutMs: number;
};
readonly requestOptions: {
readonly timeout: number;
};
}
/** Resolve one verifier-owned timeout policy before target I/O or allocation. */
export function resolveGhidraVerifierTimeouts(
environment: Readonly<Record<string, string | undefined>>,
): GhidraVerifierTimeouts;
export interface GhidraVerifierMcpClient {
connect(
transport: unknown,
options: GhidraVerifierTimeouts["requestOptions"],
): Promise<void>;
callTool(
request: unknown,
options: GhidraVerifierTimeouts["requestOptions"],
): Promise<unknown>;
}
/** Bind every verifier MCP operation to the same owned request budget. */
export function createGhidraVerifierMcpOperations(
client: GhidraVerifierMcpClient,
requestOptions: GhidraVerifierTimeouts["requestOptions"],
): {
connect(transport: unknown): Promise<void>;
callTool(request: unknown): Promise<unknown>;
};
-31
View File
@@ -1,31 +0,0 @@
import { parseConfig } from "../../dist/config/parseConfig.js";
const FILE_OFFSET_OPERATION_TIMEOUT_MS = 240_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/** Resolve one verifier-owned timeout policy before target I/O or allocation. */
export function resolveGhidraVerifierTimeouts(environment) {
const configuration = parseConfig(environment);
if (!configuration.ok) throw configuration.error;
const startupTimeoutMs = configuration.value.ghidraStartupTimeoutMs;
// A cold public query includes provider startup before its analysis request.
// Preserve the operation budget after the caller-selected startup deadline.
const publicRequestTimeoutMs =
startupTimeoutMs + FILE_OFFSET_OPERATION_TIMEOUT_MS;
if (publicRequestTimeoutMs > MAX_TIMER_DELAY_MS)
throw new RangeError(
"The Ghidra startup and verification operation budgets exceed the Node.js timer limit.",
);
return {
clientOptions: { startupTimeoutMs },
requestOptions: { timeout: publicRequestTimeoutMs },
};
}
/** Bind every verifier MCP operation to the same owned request budget. */
export function createGhidraVerifierMcpOperations(client, requestOptions) {
return {
connect: (transport) => client.connect(transport, requestOptions),
callTool: (request) => client.callTool(request, requestOptions),
};
}
@@ -1,16 +0,0 @@
export interface GhidraFileOffsetVerification {
readonly cli_mcp_parity: true;
readonly explicit_and_numeric_offsets: true;
readonly invalid_and_unmapped_offsets: true;
readonly mapped_offset_without_procedure: true;
readonly ambiguous_mappings: "verified" | "unavailable-single-section";
readonly source_unchanged: true;
readonly cleanup: "complete";
}
/** Exercise file coordinates through public CLI/MCP using a source-owned PE fixture. */
export function verifyGhidraFileOffsets(
targetPath: string,
entrypoint: string,
procedure: string,
): Promise<GhidraFileOffsetVerification>;
+20 -24
View File
@@ -14,10 +14,6 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import {
createGhidraVerifierMcpOperations,
resolveGhidraVerifierTimeouts,
} from "./ghidra-verifier-timeouts.mjs";
import {
requireMcpOperationResult,
requireMcpToolError,
@@ -30,22 +26,17 @@ export async function verifyGhidraFileOffsets(
entrypoint,
procedure,
) {
const environment = {
...process.env,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const { requestOptions } = resolveGhidraVerifierTimeouts(environment);
const timeoutMs = requestOptions.timeout;
const original = await readFile(targetPath);
const workspace = await mkdtemp(join(tmpdir(), "rea-file-offsets-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
...environment,
...process.env,
TEMP: runtime,
TMP: runtime,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const client = new Client({ name: "rea-file-offsets", version: "1" });
const transport = new StdioClientTransport({
@@ -54,19 +45,21 @@ export async function verifyGhidraFileOffsets(
env,
stderr: "pipe",
});
const mcp = createGhidraVerifierMcpOperations(client, requestOptions);
let connected = false;
let complete = false;
let primaryFailure;
const call = async (name, arguments_ = {}) => {
const result = await mcp.callTool({ name, arguments: arguments_ });
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240_000 },
);
const projected = requireMcpOperationResult(result, name);
return ["open_binary", "binary_session", "close_binary"].includes(name)
? projected
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
};
try {
await mcp.connect(transport);
await client.connect(transport);
connected = true;
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
const session = await call("binary_session");
@@ -97,12 +90,12 @@ export async function verifyGhidraFileOffsets(
),
);
}
const invalid = await mcp.callTool({
const invalid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x20000000000000" },
});
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
const unmapped = await mcp.callTool({
const unmapped = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x100000" },
});
@@ -129,7 +122,7 @@ export async function verifyGhidraFileOffsets(
assert.equal(headerOffset.normalized_result.file_offset, 0);
for (const selector of ["file-offset:0x0", "0x0"]) {
const missing = requireMcpToolError(
await mcp.callTool({
await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: selector },
}),
@@ -154,7 +147,7 @@ export async function verifyGhidraFileOffsets(
"ghidra",
"--json",
],
{ env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
@@ -171,7 +164,7 @@ export async function verifyGhidraFileOffsets(
"ghidra",
"--json",
],
{ env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
).then(
() => {
throw new Error(
@@ -190,10 +183,13 @@ export async function verifyGhidraFileOffsets(
const ambiguousPath = join(workspace, "ambiguous.exe");
await writeFile(ambiguousPath, alias);
await call("open_binary", { path: ambiguousPath, provider_id: "ghidra" });
const ambiguous = await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
});
const ambiguous = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
},
{ timeout: 240_000 },
);
assert.ok(
JSON.stringify(requireMcpToolError(ambiguous)).includes(
"multiple loaded mappings",
-3
View File
@@ -27,7 +27,6 @@ import {
} from "../dist/ghidra/GhidraSessionValues.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { resolveGhidraVerifierTimeouts } from "./lib/ghidra-verifier-timeouts.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
@@ -48,7 +47,6 @@ if (
)
throw new Error("Windows Ghidra must not advertise database mutation");
const installDir = process.env.GHIDRA_INSTALL_DIR;
const { clientOptions } = resolveGhidraVerifierTimeouts(process.env);
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
"Set GHIDRA_INSTALL_DIR to the absolute root of Ghidra 12.1.4.",
@@ -118,7 +116,6 @@ const client = new GhidraClient({
transport: "authenticated-loopback-tcp",
providerVersion: SUPPORTED_GHIDRA_VERSION,
profileDigest: profile.value.profile.digest,
...clientOptions,
});
let coordinates;
@@ -13,9 +13,8 @@ const environmentValue = (
platform: NodeJS.Platform,
): string | undefined => {
if (platform !== "win32") return environment[name];
const target = name.toLowerCase();
const key = Object.keys(environment).findLast(
(candidate) => candidate.toLowerCase() === target,
const key = Object.keys(environment).find(
(candidate) => candidate.toLowerCase() === name,
);
return key === undefined ? undefined : environment[key];
};
+10 -29
View File
@@ -28,7 +28,6 @@ import {
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { dshDocument } from "./DshCordisPatch.js";
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
interface ClientRegistrationStatusBase {
@@ -216,12 +215,6 @@ export const readClientRegistrationInspections = async (
}
: client;
const registration = parseRegistration(raw, effectiveClient);
const registrationEnvironment =
client.name === "kimi"
? clientRegistrationEnvironment(parsed)
: undefined;
if (registrationEnvironment !== undefined && !registrationEnvironment.ok)
throw registrationEnvironment.error;
const command: RegistrationCommand = [
registration.command,
...registration.args,
@@ -232,16 +225,12 @@ export const readClientRegistrationInspections = async (
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
const alignment = registrationAligned({
const alignment = registrationAligned(
registration,
client: effectiveClient,
effectiveClient,
currentCommandPath,
platform: options.platform ?? process.platform,
kimiInputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
registrationEnvironment.value.REA_MCP_INPUT_SCHEMA_PROFILE !==
undefined,
});
options.platform ?? process.platform,
);
record(
configuredStatus(
effectiveClient,
@@ -295,19 +284,12 @@ const registrationActivation = (
? "disabled"
: "enabled";
const registrationAligned = ({
registration,
client,
currentCommandPath,
platform,
kimiInputSchemaProfileSelected,
}: {
readonly registration: z.output<typeof registrationSchema>;
readonly client: SetupClient;
readonly currentCommandPath: string | undefined;
readonly platform: NodeJS.Platform;
readonly kimiInputSchemaProfileSelected: boolean;
}): "aligned" | "stale" | "unknown" => {
const registrationAligned = (
registration: z.output<typeof registrationSchema>,
client: SetupClient,
currentCommandPath: string | undefined,
platform: NodeJS.Platform,
): "aligned" | "stale" | "unknown" => {
const command = [registration.command, ...registration.args];
const identity = classifyClientRegistrationCommand(
command,
@@ -328,7 +310,6 @@ const registrationAligned = ({
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
)
return "stale";
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
if (
(client.format === "kimi" ||
client.format === "omp" ||
+1 -9
View File
@@ -683,18 +683,10 @@ const clientConfigurationDesired = (
"fragment",
);
if (!environment.ok) throw environment.error;
const registrationEnvironment =
client.name === "kimi" &&
environment.value.REA_MCP_INPUT_SCHEMA_PROFILE === undefined
? {
...environment.value,
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
}
: environment.value;
const registration = clientRegistrationEntry(
format,
launcher,
registrationEnvironment,
environment.value,
);
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
if (
@@ -7,7 +7,6 @@ import { describe, expect, it } from "vitest";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js";
import { functionDossierSchema } from "../../domain/hopperValues.js";
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
import {
@@ -433,284 +432,45 @@ it("refuses an object-dense borrowed capture before making a full schema clone",
expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed);
});
it.each(["summary", "capture", "api-surface"] as const)(
"rejects inline accessors before parsing a %s view",
(kind) => {
it("rejects accessor-backed inline Evidence before all view projections", () => {
const views = [
{ kind: "summary" },
{ kind: "capture" },
{ kind: "api-surface" },
] as const;
for (const view of views) {
let accessed = false;
const evidence = {
get normalized_result() {
accessed = true;
throw new Error("Evidence must be admitted before parsing");
throw new Error("Evidence accessor must not be invoked");
},
};
const input = {
const result = inspectAnalysisView({
source: { kind: "inline", evidence },
view: { kind },
};
const parsed = inspectAnalysisViewInputSchema.parse(input);
if (parsed.source.kind !== "inline")
throw new Error("Expected inline input");
expect(parsed.source.evidence).toBe(evidence);
expect(inspectAnalysisView(input)).toMatchObject({
view,
});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "evidence", "normalized_result"] }],
},
error: { _tag: "AnalysisInputError" },
});
expect(accessed).toBe(false);
}
const parent = analysisViewCaptureEvidence();
for (const view of views)
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: analysisViewCaptureEvidence() },
view: { kind },
source: { kind: "inline", evidence: parent },
view,
}).ok,
).toBe(true);
},
);
it.each(["summary", "capture", "api-surface"] as const)(
"rejects nested, hidden and inherited inline accessors for %s",
(kind) => {
const parent = analysisViewCaptureEvidence();
let accessed = false;
const getter = () => {
accessed = true;
throw new Error("Inline accessors must remain inert");
};
const nested = {
get value() {
return getter();
},
};
const hidden = Object.defineProperty({}, "value", { get: getter });
const array = Object.defineProperty([null], 0, { get: getter });
const inherited: unknown = Object.create({
get value() {
return getter();
},
});
const cyclic: Record<string, unknown> = {};
cyclic["self"] = cyclic;
const cases: { data: unknown; path: (string | number)[] }[] = [
{ data: nested, path: ["value"] },
{ data: hidden, path: ["value"] },
{ data: array, path: [0] },
{ data: inherited, path: [] },
{ data: cyclic, path: ["self"] },
];
for (const { data, path } of cases) {
// A complete envelope reaches payload parsing and Evidence authentication.
const input = {
source: {
kind: "inline" as const,
evidence: { ...parent, raw_result: data },
},
view: { kind },
};
for (const result of [
inspectAnalysisView(input),
inspectAnalysisViewValidated(
inspectAnalysisViewInputSchema.parse(input),
),
])
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "evidence", "raw_result", ...path] }],
},
});
}
expect(accessed).toBe(false);
},
);
it.each([
["own map", "map", false],
["own iterator", Symbol.iterator, false],
["own constructor", "constructor", false],
["inherited map", "map", true],
["inherited iterator", Symbol.iterator, true],
] as const)(
"rejects array %s accessors without invoking them",
(_name, key, inherited) => {
const parent = analysisViewCaptureEvidence();
let accessed = false;
const array: unknown[] = [];
const owner: object = inherited ? Object.create(Array.prototype) : array;
Object.defineProperty(owner, key, {
get() {
accessed = true;
throw new Error("Array behavior must remain inert");
},
});
if (inherited) Object.setPrototypeOf(array, owner);
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: array } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(accessed).toBe(false);
},
);
it("rejects callable array overrides and subclass species before parsing", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const callback = () => {
invoked = true;
throw new Error("Array callbacks must remain inert");
};
class CustomArray extends Array<unknown> {
static override get [Symbol.species](): ArrayConstructor {
return callback();
}
}
for (const array of [
Object.defineProperty([], "map", { value: callback }),
Object.defineProperty([], Symbol.iterator, { value: callback }),
new CustomArray(),
])
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: array } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(invoked).toBe(false);
});
it.each(["summary", "capture", "api-surface"] as const)(
"preserves ordinary inline Evidence for %s",
(kind) => {
const parent = analysisViewCaptureEvidence();
const before = JSON.stringify(parent);
const result = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value)).toEqual(result.value);
expect(result.value.evidence_links).toEqual([parent.evidence_id]);
expect(result.value.normalized_result).toMatchObject({
kind,
parent_evidence_id: parent.evidence_id,
});
expect(JSON.stringify(parent)).toBe(before);
expect(Object.isFrozen(parent)).toBe(false);
},
);
it("rejects executable constructor values before schema plain-object checks", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const constructor = () => undefined;
Object.defineProperty(constructor, "prototype", {
get() {
invoked = true;
throw new Error("Constructor metadata must remain inert");
},
});
for (const kind of ["summary", "capture", "api-surface"] as const) {
expect(
inspectAnalysisView({
source: {
kind: "inline",
evidence: { ...parent, raw_result: { constructor } },
},
view: { kind },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
}
expect(invoked).toBe(false);
});
it("rejects inline proxy values before reflection or parsing can run traps", () => {
const parent = analysisViewCaptureEvidence();
let invoked = false;
const trap = () => {
invoked = true;
throw new Error("Proxy behavior must remain inert");
};
const revoked = Proxy.revocable([], {});
revoked.revoke();
for (const value of [
new Proxy({}, { getPrototypeOf: trap, ownKeys: trap, get: trap }),
new Proxy(() => undefined, { get: trap }),
revoked.proxy,
])
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: { ...parent, raw_result: value } },
view: { kind },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(invoked).toBe(false);
});
it("keeps ignored array metadata inert through parsing and projection", () => {
const capture = analysisViewCaptureFixture();
const parent = createEvidence(
{
path: capture.artifact.path,
sha256: capture.artifact.sha256!,
format: "file",
},
{ id: "fixture", name: "fixture", version: "1" },
{
operation: "inspect_web_network_capture",
parameters: { sensitive_value_count: 0 },
result: capture,
rawResult: [],
},
);
if (!Array.isArray(parent.raw_result)) throw new Error("Expected raw array");
const array = parent.raw_result;
let invoked = false;
const getter = () => {
invoked = true;
throw new Error("Ignored array metadata must remain inert");
};
for (let index = 0; index < 10_000; index += 1)
Object.defineProperty(array, `metadata-${index}`, { get: getter });
Object.defineProperty(array, Symbol("metadata"), { get: getter });
for (const kind of ["summary", "capture", "api-surface"] as const) {
const result = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([parent.evidence_id]);
}
expect(invoked).toBe(false);
expect(parseEvidence(parent).raw_result).toEqual([]);
});
it("keeps inline-envelope admission live alongside capture validation and grouping", () => {
// Each clone alone fits; both graphs coexist while the view is projected.
const capture = analysisViewObjectDenseCapture(40_000);
const parent = analysisViewCaptureEvidence(capture);
for (const kind of ["summary", "capture"] as const) {
const projected = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind },
});
if (!projected.ok) throw projected.error;
expect(projected.value.evidence_links).toEqual([parent.evidence_id]);
}
const retained = inspectAnalysisView(
{
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
@@ -6,7 +6,10 @@ import type { EvidenceLookup } from "../EvidenceInputResolver.js";
import { resolveEvidenceInput } from "../EvidenceInputResolver.js";
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
@@ -24,7 +27,6 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js";
/** Authenticate a selected-view request and project already completed Evidence. */
export const inspectAnalysisView = (
@@ -56,11 +58,8 @@ export const inspectAnalysisViewValidated = (
input.view.include_query_parameter_names === true,
)
: undefined;
if (input.source.kind === "inline") {
const admitted =
accumulator === undefined
? admitAnalysisViewData(input.source.evidence, true)
: accumulator.admitInlineEvidence(input.source.evidence);
if (input.source.kind === "inline" && accumulator !== undefined) {
const admitted = accumulator.admitInlineEvidence(input.source.evidence);
if (!admitted.ok) return admitted;
}
const resolved = resolveAnalysisViewSource(input.source, lookup);
@@ -166,6 +165,18 @@ const resolveAnalysisViewSource = (
): Result<Evidence, AnalysisError> => {
if (source.kind === "retained-evidence")
return resolveEvidenceInput(source, lookup);
const invalidDataPath = inlineEvidenceInvalidDataPath(source.evidence);
if (invalidDataPath !== undefined)
return err(
new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, [
{
path: ["source", "evidence", ...invalidDataPath],
reason: "invalid_type",
message:
"Inline Evidence must contain plain JSON data properties without accessors or cycles.",
},
]),
);
try {
return ok(parseEvidence(source.evidence));
} catch (cause: unknown) {
@@ -192,6 +203,53 @@ const resolveAnalysisViewSource = (
}
};
/** Reject non-JSON object behavior before Zod reads caller-owned Evidence. */
const inlineEvidenceInvalidDataPath = (
root: unknown,
): readonly (string | number)[] | undefined => {
const active = new Set<object>();
const pending: {
readonly value: unknown;
readonly path: readonly (string | number)[];
readonly exit?: object;
}[] = [{ value: root, path: [] }];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) continue;
if (current.exit !== undefined) {
active.delete(current.exit);
continue;
}
const { value, path } = current;
if (typeof value !== "object" || value === null) continue;
const isArray = Array.isArray(value);
const prototype = Object.getPrototypeOf(value);
if (
(isArray && prototype !== Array.prototype) ||
(!isArray && prototype !== Object.prototype && prototype !== null) ||
active.has(value)
)
return path;
active.add(value);
pending.push({ value, path, exit: value });
const keys = Object.getOwnPropertyNames(value);
for (let index = keys.length - 1; index >= 0; index -= 1) {
const key = keys[index];
if (key === undefined) continue;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
const childPath = [
...path,
isArray && /^(0|[1-9]\d*)$/u.test(key) ? Number(key) : key,
];
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return childPath;
pending.push({ value: descriptor.value, path: childPath });
}
}
return undefined;
};
const createAnalysisViewEvidence = (
input: InspectAnalysisViewInput,
parent: Evidence,
+1 -2
View File
@@ -9,7 +9,6 @@ import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js";
import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { analysisDetailSchema } from "./analysisDetail.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
@@ -35,7 +34,7 @@ export const artifactExtractionInputSchema = z.strictObject({
/** Provider input containing the destination chosen by the local adapter. */
export const artifactExtractionExecutionSchema =
artifactExtractionInputSchema.extend({
output_root: localPathStringSchema,
output_root: z.string().min(1),
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
+5 -7
View File
@@ -3,6 +3,7 @@ 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";
@@ -15,7 +16,6 @@ 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";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
const descriptorSchema = z.object({
session_id: z.string().uuid(),
@@ -282,7 +282,6 @@ export class CutterBridgeClient {
configured,
this.#platform,
localAppData,
homeDirectoryFromEnvironment(environment, this.#platform),
);
}
@@ -895,17 +894,16 @@ const resolveBridgeDirectory = (
configured: string | undefined,
platform: NodeJS.Platform,
localAppData: string | undefined,
homeDirectory: string,
): string => {
if (configured !== undefined && configured !== "") {
if (configured === "~") return homeDirectory;
if (configured === "~") return homedir();
if (configured.startsWith("~/"))
return join(homeDirectory, configured.slice(2));
return join(homedir(), configured.slice(2));
if (platform === "win32" && configured.startsWith("~\\"))
return join(homeDirectory, configured.slice(2));
return join(homedir(), configured.slice(2));
return configured;
}
return platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homeDirectory, ".cache", "rea", "cutter-bridge");
: join(homedir(), ".cache", "rea", "cutter-bridge");
};
@@ -1,4 +1,4 @@
import { expect, it, vi } from "vitest";
import { expect, it } from "vitest";
import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js";
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
@@ -73,43 +73,6 @@ it("admits the capture schema clone and grouping against the same budget", () =>
expect(accumulator.page(0, 1).groups).toEqual([]);
});
it.each(["admitCaptureClone", "admitInlineEvidence"] as const)(
"%s refuses dense array expansion without enumerating its keys",
(method) => {
const array = Array.from({ length: 100_000 }, () => null);
let enumerated = false;
let inspected = 0;
const ownNames = Object.getOwnPropertyNames;
const ownDescriptor = Object.getOwnPropertyDescriptor;
const keys = vi
.spyOn(Object, "getOwnPropertyNames")
.mockImplementation((value) => {
if (value === array) enumerated = true;
return ownNames(value);
});
const descriptors = vi
.spyOn(Object, "getOwnPropertyDescriptor")
.mockImplementation((value, key) => {
if (value === array) inspected += 1;
return ownDescriptor(value, key);
});
try {
expect(
new WebNetworkApiSurfaceAccumulator(4096)[method](array),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
});
expect(enumerated).toBe(false);
expect(inspected).toBeGreaterThan(0);
expect(inspected).toBeLessThan(16);
} finally {
keys.mockRestore();
descriptors.mockRestore();
}
},
);
it("charges non-enumerable fields that the capture schema would clone", () => {
const value = Object.defineProperty({}, "records", {
value: Array.from({ length: 100 }, () => ({ reported: {} })),
@@ -1,5 +1,8 @@
import { z } from "zod";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
@@ -7,7 +10,7 @@ import {
parseUrlQueryParameterNames,
} from "../recordedTransportUrl.js";
import { err, ok, type Result } from "../result.js";
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
type WebNetworkCaptureRecord,
@@ -86,6 +89,9 @@ const ENTRY_BYTES = 128;
const RECORD_SCRATCH_BYTES = 512;
const SEGMENT_SCRATCH_BYTES = 512;
const CAPTURE_CONTAINER_BYTES = 512;
// Covers cloned member slots and per-member validation/entries/map scratch.
// Scalar strings remain shared references, so their text is not copied here.
const CAPTURE_MEMBER_BYTES = 256;
const textBytes = (length: number): number => 32 + length * 14;
const segmentRule = (value: string): Rule | null => {
@@ -150,15 +156,87 @@ export class WebNetworkApiSurfaceAccumulator {
/** Reserve schema output and validation scratch while the borrowed parent stays live. */
admitCaptureClone(value: unknown): Result<void, AnalysisError> {
return admitAnalysisViewData(value, false, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
);
return this.#admitCaptureValue(value, new Set(), 0, false);
}
/** Admit the complete portable envelope before cloning or authenticating it. */
admitInlineEvidence(value: unknown): Result<void, AnalysisError> {
return admitAnalysisViewData(value, true, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
return this.#admitCaptureValue(value, new Set(), 0, true);
}
#admitCaptureValue(
value: unknown,
ancestors: Set<object>,
depth: number,
inline: boolean,
): Result<void, AnalysisError> {
// Capture records add three levels before reported; inline Evidence adds one.
if (
depth > MAX_JSON_DEPTH + (inline ? 4 : 3) ||
(typeof value === "object" && value !== null && ancestors.has(value))
)
return this.#invalidCapture();
if (!this.#retain(CAPTURE_MEMBER_BYTES)) return this.#overBudget();
// Evidence authentication encodes strings and object keys for hashing.
// Keep that allowance live alongside the envelope clone and projection.
if (
inline &&
typeof value === "string" &&
!this.#retain(textBytes(value.length))
)
return this.#overBudget();
if (typeof value !== "object" || value === null) return ok(undefined);
if (!this.#retain(CAPTURE_CONTAINER_BYTES)) return this.#overBudget();
ancestors.add(value);
// Walk depth-first without building a second capture graph. Member charges
// include key enumeration; aliases count each time the schema copies them.
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, index);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return this.#invalidCapture();
const admitted = this.#admitCaptureValue(
descriptor.value,
ancestors,
depth + 1,
inline,
);
if (!admitted.ok) return admitted;
}
} else {
const prototype: unknown = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null)
return this.#invalidCapture();
// Zod reads named fields even when they are non-enumerable.
for (const key of Object.getOwnPropertyNames(value)) {
if (inline && !this.#retain(textBytes(key.length)))
return this.#overBudget();
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return this.#invalidCapture();
const admitted = this.#admitCaptureValue(
descriptor.value,
ancestors,
depth + 1,
inline,
);
if (!admitted.ok) return admitted;
}
}
ancestors.delete(value);
return ok(undefined);
}
#invalidCapture(): Result<never, AnalysisError> {
return err(
new AnalysisInputError("inspect_analysis_view", undefined, [
{
path: ["source"],
reason: "invalid_value",
message:
"Parent capture must contain bounded ordinary JSON data without cycles or accessors.",
},
]),
);
}
@@ -1,98 +0,0 @@
import { isProxy } from "node:util/types";
import { AnalysisInputError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
// Covers cloned member slots and per-member validation/entries/map scratch.
const MEMBER_BYTES = 256;
const CONTAINER_BYTES = 512;
const textBytes = (length: number): number => 32 + length * 14;
/** Inspect borrowed data without invoking accessors; only API-surface reserves bytes. */
export const admitAnalysisViewData = (
value: unknown,
inline: boolean,
reserve?: (bytes: number) => Result<void, AnalysisError>,
): Result<void, AnalysisError> => {
const ancestors = new Set<object>();
// A single bounded stack preserves diagnostics without per-node path copies.
const path: (string | number)[] = inline
? ["source", "evidence"]
: ["source"];
const invalid = (): Result<never, AnalysisError> =>
err(
new AnalysisInputError("inspect_analysis_view", undefined, [
{
path: [...path],
reason: "invalid_value",
message:
"Parent data must contain bounded ordinary JSON data without cycles or accessors.",
},
]),
);
const visit = (item: unknown, depth: number): Result<void, AnalysisError> => {
// Proxy reflection and callable constructor metadata can execute caller code.
if (isProxy(item) || typeof item === "function") return invalid();
// Capture records add three levels before reported; inline Evidence adds one.
if (
depth > MAX_JSON_DEPTH + (inline ? 4 : 3) ||
(typeof item === "object" && item !== null && ancestors.has(item))
)
return invalid();
const member = reserve?.(
MEMBER_BYTES +
(inline && typeof item === "string" ? textBytes(item.length) : 0) +
(typeof item === "object" && item !== null ? CONTAINER_BYTES : 0),
);
if (member !== undefined && !member.ok) return member;
if (typeof item !== "object" || item === null) return ok(undefined);
ancestors.add(item);
const child = (key: string | number): Result<void, AnalysisError> => {
path.push(key);
const descriptor = Object.getOwnPropertyDescriptor(item, key);
const admitted =
descriptor === undefined || !Object.hasOwn(descriptor, "value")
? invalid()
: visit(descriptor.value, depth + 1);
path.pop();
return admitted;
};
// Walk depth-first without copying the graph. Aliases are examined each time
// the schema copies them, so byte reservations retain the same accounting.
if (Array.isArray(item)) {
// JSON validation iterates arrays and cloning calls map (which reads
// constructor/species). Reject those traversal overrides without building
// an input-sized key list. Other array metadata is omitted by JSON cloning.
if (
Object.getPrototypeOf(item) !== Array.prototype ||
Object.hasOwn(item, Symbol.iterator) ||
Object.hasOwn(item, "map") ||
Object.hasOwn(item, "constructor")
)
return invalid();
for (let index = 0; index < item.length; index += 1) {
const admitted = child(index);
if (!admitted.ok) return admitted;
}
} else {
const prototype: unknown = Object.getPrototypeOf(item);
if (prototype !== Object.prototype && prototype !== null)
return invalid();
// Zod reads named fields even when they are non-enumerable.
for (const key of Object.getOwnPropertyNames(item)) {
// Authentication hashes strings and keys alongside the schema clone.
const keyAdmission = inline
? reserve?.(textBytes(key.length))
: undefined;
if (keyAdmission !== undefined && !keyAdmission.ok) return keyAdmission;
const admitted = child(key);
if (!admitted.ok) return admitted;
}
}
ancestors.delete(item);
return ok(undefined);
};
return visit(value, 0);
};
+1 -2
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
import { localPathStringSchema } from "../localPath.js";
/**
* The selected file is intact but is another kind of file, so another tool
@@ -21,7 +20,7 @@ export class KeyedArchiveKindError extends TypeError {
/** Select one archive within the active bundle and optionally one named root. */
export const keyedArchiveInputSchema = z.strictObject({
path: localPathStringSchema.default("."),
path: z.string().min(1).default("."),
root: z.string().optional(),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(20_000).default(20_000),
+15 -20
View File
@@ -25,24 +25,19 @@ it("canonicalizes selected Windows variables without inheriting ambient values",
expect(Object.isFrozen(snapshot)).toBe(true);
});
it("keeps the last caller value for Windows aliases, including undefined", () => {
const snapshot = snapshotEnvironment(
{
PATH: "upper",
path: "lower",
COMSPEC: "upper-cmd",
ComSpec: "mixed-cmd",
TEMP: "ambient-temp",
temp: undefined,
},
"win32",
);
expect(snapshot).toEqual({
COMSPEC: "mixed-cmd",
PATH: "lower",
TEMP: undefined,
});
expect(Object.keys(snapshot)).toEqual(["COMSPEC", "PATH", "TEMP"]);
expect(Object.isFrozen(snapshot)).toBe(true);
it("uses Node's first lexicographic Windows key even when it is undefined", () => {
expect(
snapshotEnvironment(
{
path: "lower",
Path: "mixed",
PATH: "upper",
ComSpec: "mixed-cmd",
COMSPEC: "upper-cmd",
Temp: "mixed-temp",
TEMP: undefined,
},
"win32",
),
).toEqual({ PATH: "upper", COMSPEC: "upper-cmd", TEMP: undefined });
});
+8 -8
View File
@@ -1,16 +1,16 @@
/** Snapshot selected variables while preserving platform and caller precedence. */
/** Snapshot caller-selected variables using Node child-process key semantics. */
export const snapshotEnvironment = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): Readonly<NodeJS.ProcessEnv> => {
if (platform !== "win32") return Object.freeze({ ...environment });
const selected = new Map<string, string | undefined>();
// Fold aliases in caller order so an explicit later override wins before
// this canonical environment crosses a Windows process boundary.
for (const [key, value] of Object.entries(environment))
selected.set(key.toUpperCase(), value);
const snapshot: NodeJS.ProcessEnv = {};
for (const key of [...selected.keys()].sort())
snapshot[key] = selected.get(key);
// Node sorts keys lexicographically and forwards the first case-insensitive
// match on Windows. Canonical keys also make subsequent lookups deterministic.
for (const key of Object.keys(environment).sort()) {
const canonicalKey = key.toUpperCase();
if (!Object.hasOwn(snapshot, canonicalKey))
snapshot[canonicalKey] = environment[key];
}
return Object.freeze(snapshot);
};
+11 -4
View File
@@ -8,6 +8,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort.
import type { PromptCompletionKind } from "../contracts/promptContracts.js";
import { currentMcpRequestSignal } from "./EvidenceMcpServer.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
@@ -180,11 +181,17 @@ const evidenceValues = (
if (kind === "evidence") return [evidence.evidence_id];
if (kind === "capture")
return isProcessCaptureEvidence(evidence) ? [evidence.evidence_id] : [];
if (
evidence.operation === "inventory_artifact" ||
evidence.operation === "inspect_artifact"
)
if (evidence.operation === "inventory_artifact")
return artifactInventoryValues(kind, evidence);
if (evidence.operation === "inspect_artifact") {
const inspection = artifactInspectionResultSchema.safeParse(
evidence.normalized_result,
);
if (!inspection.success) return [];
return inspection.data.substeps.flatMap(({ evidence: inventoryEvidence }) =>
artifactInventoryValues(kind, inventoryEvidence),
);
}
return [];
};
-28
View File
@@ -212,31 +212,3 @@ it.each([
});
},
);
it("registers the selected Windows package runner after canonicalizing environment aliases", async () => {
const environment = snapshotEnvironment(
{
NPM_COMMAND: "run",
npm_command: "exec",
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
},
"win32",
);
expect(environment).toEqual({
NPM_COMMAND: "exec",
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
});
expect(invokingPackageRunner(environment, "win32")).toBe("npx");
const host = systemSetupHost(
createDoctorHostFixture({ platform: "win32" }),
environment,
);
try {
expect(host.registrationCommand).toEqual(
packageRunnerRegistrationCommand("npx", "win32"),
);
} finally {
await host.close?.();
}
});
+5 -20
View File
@@ -179,10 +179,7 @@ describe("TTY setup dry run", () => {
describe("interactive setup journey", () => {
it("shows detected and absent clients without preselecting detected clients", async () => {
const result = await runJourney(
[
step("Which agents should use REA?", "\u001b[B"),
step("Not detected · configuration is still available", "\r"),
],
[step("Which agents should use REA?", "\u001b[B\r")],
false,
[actions[0]],
);
@@ -362,14 +359,7 @@ describe("interactive setup completion", () => {
{
cwd: process.cwd(),
env: {
...Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!["npm_command", "npm_config_user_agent"].includes(
key.toLowerCase(),
),
),
),
...process.env,
npm_command: npmCommand,
npm_config_user_agent: userAgent,
NO_COLOR: "1",
@@ -437,7 +427,6 @@ const runJourney = async (
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
NO_COLOR: "1",
TERM: "xterm-256color",
},
@@ -475,13 +464,9 @@ const runJourney = async (
finish(new Error(`Setup journey returned no decision:\n${output}`));
return;
}
const raw = output.slice(markerIndex + decisionMarker.length);
const jsonStart = raw.indexOf("{");
const jsonEnd = raw.lastIndexOf("}");
const serialized =
jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart
? raw.slice(jsonStart, jsonEnd + 1)
: raw.split(/\r?\n/u, 1)[0];
const serialized = output
.slice(markerIndex + decisionMarker.length)
.split(/\r?\n/u, 1)[0];
if (serialized === undefined) {
finish(
new Error(`Setup journey returned an empty decision:\n${output}`),
@@ -38,13 +38,9 @@ const exists = async (path: string): Promise<boolean> => {
}
};
const expectedRegistration = (
launcher: readonly string[],
profile = "compact",
) => ({
const expectedRegistration = (launcher: readonly string[]) => ({
command: launcher[0],
args: launcher.slice(1),
env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
});
@@ -163,132 +159,6 @@ describe("Kimi Code registration lifecycle", () => {
});
});
describe("Kimi Code input-schema profile selection", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-schema-profile-");
const client = kimiPath(home, process.platform, {});
if (client?.markerPath === undefined)
throw new Error("missing Kimi Code client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
it("preserves an explicitly stored full profile", async () => {
const { home, client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
command: "old-rea",
args: ["mcp"],
env: { REA_MCP_INPUT_SCHEMA_PROFILE: "full" },
},
},
}),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(command, "full"));
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command });
});
it.each(["full", "compact"] as const)(
"uses the caller's explicit %s profile",
async (profile) => {
const { client } = await kimiClient();
expect(
(
await configureClientConfiguration(
client,
{ REA_MCP_INPUT_SCHEMA_PROFILE: profile },
command,
)
).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(command, profile));
},
);
it.each([
["full", "compact"],
["compact", "full"],
] as const)(
"lets explicit setup %s replace stored %s",
async (overrideProfile, storedProfile) => {
const { client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
command: "old-rea",
args: ["mcp"],
env: { REA_MCP_INPUT_SCHEMA_PROFILE: storedProfile },
},
},
}),
);
expect(
(
await configureClientConfiguration(
client,
{ REA_MCP_INPUT_SCHEMA_PROFILE: overrideProfile },
command,
)
).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(command, overrideProfile));
},
);
it("reports an omitted-profile registration stale until setup adds compact", async () => {
const { home, client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
command: command[0],
args: command.slice(1),
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
},
},
}),
);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "stale", command });
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(command));
});
});
describe("Kimi Code package launchers", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-launcher-");
+86 -187
View File
@@ -1,5 +1,5 @@
import { ok as resultOk } from "../../../src/domain/result.js";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -14,19 +14,16 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
import { PROCESS_CAPTURE_REFERENCE } from "../../../src/contracts/investigationExamples.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import {
artifactInspectionResultSchema,
createArtifactInspection,
} from "../../../src/domain/artifactInspection.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js";
import { createPromptCompletionSource } from "../../../src/server/promptCompletion.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js";
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
let directory: string | undefined;
@@ -213,74 +210,69 @@ describe("guided prompt completion from investigation records", () => {
});
describe("guided prompt completion from retained records", () => {
it("offers identifiers from every authenticated page in one inspection inventory", async () => {
it("offers identifiers from every retained inspection inventory substep", async () => {
const session = createTestBinarySession(() => client([]));
const { evidence: inspectionEvidence, inventory: completeInventory } =
await createPagedInspectionEvidence();
const inspectionEvidence = createCompositeInspectionEvidence();
expect(session.recordEvidence(inspectionEvidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
const pages = artifactInspectionResultSchema.parse(
inspectionEvidence.normalized_result,
).substeps;
const firstPage = pages[0]?.evidence.normalized_result;
const laterPage = pages[1]?.evidence.normalized_result;
if (firstPage === undefined || laterPage === undefined)
throw new Error("paged inspection fixture must contain two pages");
expect(
artifactInventoryResultSchema.parse(firstPage).occurrences.length,
).toBeLessThan(completeInventory.occurrences.length);
expect(
artifactInventoryResultSchema.parse(laterPage).occurrences.length,
).toBeGreaterThan(0);
const expectedManifests = [
ARTIFACT_COMPARISON_EXAMPLE.left,
ARTIFACT_COMPARISON_EXAMPLE.right,
]
.map(
({ normalized_result }) =>
artifactInventoryResultSchema.parse(normalized_result).manifest
.manifest_id,
)
.sort();
const expectedOccurrences = [
ARTIFACT_COMPARISON_EXAMPLE.left,
ARTIFACT_COMPARISON_EXAMPLE.right,
]
.flatMap(({ normalized_result }) =>
artifactInventoryResultSchema
.parse(normalized_result)
.occurrences.map(({ occurrence_id }) => occurrence_id),
)
.sort();
expect(await completion.complete("manifest", "agm_")).toEqual([
completeInventory.manifest.manifest_id,
]);
expect(await completion.complete("manifest", "agm_")).toEqual(
expectedManifests,
);
expect(await completion.complete("occurrence", "occ_")).toEqual(
completeInventory.occurrences
.map(({ occurrence_id }) => occurrence_id)
.sort(),
expectedOccurrences,
);
await session.close();
});
it.each([
"child",
"coverage",
"substep-link",
"declared-subject",
"carrier-subject",
] as const)(
"rejects inspection completions when %s is invalid",
async (corruption) => {
const session = createTestBinarySession(() => client([]));
const { evidence: inspectionEvidence } =
await createPagedInspectionEvidence(corruption);
expect(session.recordEvidence(inspectionEvidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
expect(await completion.complete("manifest", "agm_")).toEqual([]);
expect(await completion.complete("occurrence", "occ_")).toEqual([]);
await session.close();
},
);
it("still completes a valid standalone inventory Evidence", async () => {
it("skips an inner inventory whose payload no longer matches its Evidence ID", async () => {
const session = createTestBinarySession(() => client([]));
const inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left;
expect(session.recordEvidence(inventoryEvidence).ok).toBe(true);
const inspectionEvidence = createCompositeInspectionEvidence(true);
const corruptedInner = artifactInspectionResultSchema.parse(
inspectionEvidence.normalized_result,
).substeps[1]?.evidence;
if (corruptedInner === undefined)
throw new Error("composite fixture must contain a second inventory");
expect(corruptedInner.evidence_id).toBe(
ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id,
);
expect(
artifactInventoryResultSchema.safeParse(corruptedInner.normalized_result)
.success,
).toBe(true);
expect(() => parseEvidence(corruptedInner)).toThrow();
expect(session.recordEvidence(inspectionEvidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
const firstInventory = artifactInventoryResultSchema.parse(
ARTIFACT_COMPARISON_EXAMPLE.left.normalized_result,
);
expect(await completion.complete("manifest", "agm_")).toEqual([
inventory.manifest.manifest_id,
firstInventory.manifest.manifest_id,
]);
expect(await completion.complete("occurrence", "occ_")).toEqual(
inventory.occurrences.map(({ occurrence_id }) => occurrence_id).sort(),
firstInventory.occurrences.map(({ occurrence_id }) => occurrence_id),
);
await session.close();
@@ -359,145 +351,52 @@ const fixtureEnvironment = {
isolation: "process" as const,
};
type InspectionCorruption =
| "child"
| "coverage"
| "substep-link"
| "declared-subject"
| "carrier-subject";
const createPagedInspectionEvidence = async (
corruption?: InspectionCorruption,
) => {
directory = await createTestTempDirectory("rea-prompt-inspection-pages-");
const path = join(directory, "fixture.app");
await mkdir(path);
await Promise.all([
writeFile(join(path, "first.js"), "first();"),
writeFile(join(path, "second.js"), "second();"),
]);
const inventoryResult = await inventoryArtifact(path);
const source = createEvidence(
{
path,
sha256: inventoryResult.manifest.root_sha256,
format: inventoryResult.manifest.root_format,
},
fixtureProvider,
{
operation: "inventory_artifact",
parameters: {},
result: jsonValueSchema.parse(inventoryResult),
confidence: "observed",
authority: "shipped-artifact",
},
const createCompositeInspectionEvidence = (corruptSecondInventory = false) => {
const leftInspection = createArtifactInspection(
ARTIFACT_COMPARISON_EXAMPLE.left,
);
const pages = fragmentInventoryEvidence(source, 2);
const firstPage = pages[0];
if (firstPage === undefined) throw new Error("missing first inventory page");
const base = createArtifactInspection(firstPage);
const { inspection_id: _inspectionId, ...baseSemantic } = base;
const substeps = pages.map((evidence) => ({
substep_id: `ais_${digestCanonicalValue(
{ operation: "inventory_artifact", evidence_id: evidence.evidence_id },
"Artifact inspection",
)}`,
operation: "inventory_artifact" as const,
status: "completed" as const,
evidence_id: evidence.evidence_id,
evidence,
}));
const linkedEvidenceIds = [
...base.evidence_links,
...pages.map(({ evidence_id }) => evidence_id),
].sort();
let inspection = {
...baseSemantic,
substeps,
evidence_links: [...new Set(linkedEvidenceIds)],
coverage: {
...base.coverage,
substeps_completed: pages.length,
},
};
if (corruption === "child") {
const first = inspection.substeps[0];
if (first === undefined) throw new Error("missing first inspection step");
const rightInspection = createArtifactInspection(
ARTIFACT_COMPARISON_EXAMPLE.right,
);
const substeps = [...leftInspection.substeps, ...rightInspection.substeps];
if (corruptSecondInventory) {
const secondSubstep = substeps[1];
if (secondSubstep === undefined)
throw new Error("composite fixture must contain a second inventory");
const inventory = artifactInventoryResultSchema.parse(
first.evidence.normalized_result,
secondSubstep.evidence.normalized_result,
);
inspection = {
...inspection,
substeps: [
{
...first,
evidence: {
...first.evidence,
normalized_result: {
...inventory,
limitations: [...inventory.limitations, "Changed after sealing"],
},
},
substeps[1] = {
...secondSubstep,
evidence: {
...secondSubstep.evidence,
normalized_result: {
...inventory,
coverage: { ...inventory.coverage, status: "unknown" },
},
...inspection.substeps.slice(1),
],
};
} else if (corruption === "coverage") {
inspection = {
...inspection,
coverage: {
...inspection.coverage,
substeps_completed: inspection.substeps.length + 1,
},
};
} else if (corruption === "substep-link") {
const first = inspection.substeps[0];
if (first === undefined) throw new Error("missing first inspection step");
inspection = {
...inspection,
substeps: [
{ ...first, evidence_id: `ev_${"f".repeat(64)}` },
...inspection.substeps.slice(1),
],
};
} else if (corruption === "declared-subject") {
inspection = {
...inspection,
subject: {
...inspection.subject,
root_artifact_id: `art_${"f".repeat(64)}`,
},
};
}
const semantic = inspection;
const result = {
...semantic,
inspection_id: `ai_${digestCanonicalValue(semantic, "Artifact inspection")}`,
};
const target = source.subject;
if (target === null) throw new Error("fixture inventory has no subject");
const evidence = createEvidence(
{
path: target.local_path,
sha256:
corruption === "carrier-subject"
? "a".repeat(64)
: target.digest.sha256,
format: target.format,
const inspection = artifactInspectionResultSchema.parse({
...leftInspection,
substeps,
evidence_links: [
...leftInspection.evidence_links,
...rightInspection.evidence_links,
],
coverage: {
...leftInspection.coverage,
substeps_completed: substeps.length,
},
fixtureProvider,
{
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "shipped-artifact",
evidenceLinks: result.evidence_links,
},
);
return { evidence, inventory: inventoryResult };
});
return createEvidence(undefined, fixtureProvider, {
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(inspection),
confidence: "observed",
authority: "shipped-artifact",
evidenceLinks: inspection.evidence_links,
});
};
const client = (
@@ -3,7 +3,6 @@ import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
@@ -11,7 +10,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -1217,18 +1216,8 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
directory = undefined;
});
it("uses the selected caller home for tilde and default bridge discovery", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-cutter-selected-home-"));
const selectedHome = join(directory, "selected-home");
const tildeDirectory = join(selectedHome, "bridge");
const defaultDirectory = join(
selectedHome,
".cache",
"rea",
"cutter-bridge",
);
await mkdir(tildeDirectory, { recursive: true, mode: 0o700 });
await mkdir(defaultDirectory, { recursive: true, mode: 0o700 });
it("expands a tilde bridge directory before discovering its live producer", async () => {
directory = await mkdtemp(join(homedir(), ".rea-cutter-tilde-"));
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const token = "synthetic-cutter-bridge-token-for-tilde-test";
server = authenticatedServer(token, sessionId, (request, respond) => {
@@ -1242,22 +1231,18 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
});
});
const port = await listen(server);
await writeDescriptor(tildeDirectory, sessionId, port, token, {
currentFile: "/fixture/sample",
documentGeneration: 3,
});
await writeDescriptor(defaultDirectory, sessionId, port, token, {
await writeDescriptor(directory, sessionId, port, token, {
currentFile: "/fixture/sample",
documentGeneration: 3,
});
const tildeResult = await createClient({
HOME: selectedHome,
REA_CUTTER_BRIDGE_DIR: "~/bridge",
const configured = `~/${relative(homedir(), directory)}`;
const result = await createClient({
REA_CUTTER_BRIDGE_DIR: configured,
}).listSessions();
expect(tildeResult).toMatchObject({
bridge_directory: tildeDirectory,
expect(result).toMatchObject({
bridge_directory: directory,
discovery_status: "sessions_found",
sessions: [
{
@@ -1267,37 +1252,6 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
},
],
});
const defaultResult = await createClient({
HOME: selectedHome,
}).listSessions();
expect(defaultResult).toMatchObject({
bridge_directory: defaultDirectory,
discovery_status: "sessions_found",
sessions: [{ session_id: sessionId }],
});
});
it("prefers Windows USERPROFILE over HOME when expanding a bridge path", async () => {
const selectedHome = join(tmpdir(), "rea-cutter-userprofile-home");
const ambientHome = join(tmpdir(), "rea-cutter-ambient-home");
const result = await new CutterBridgeClient(
{
HOME: ambientHome,
USERPROFILE: selectedHome,
REA_CUTTER_BRIDGE_DIR: "~\\bridge",
},
{
platform: "win32",
windowsPrivateReader: {
verifyDirectory: () => true,
readDescriptor: () => Buffer.from("{}"),
},
},
).listSessions();
expect(result.bridge_directory).toBe(join(selectedHome, "bridge"));
expect(result.discovery_status).toBe("no_live_bridge_found");
});
it("uses the packaged Windows authority for descriptor verification and reads", async () => {
@@ -1,74 +0,0 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createGhidraVerifierMcpOperations,
resolveGhidraVerifierTimeouts,
} from "../../../../scripts/lib/ghidra-verifier-timeouts.mjs";
import { verifyGhidraFileOffsets } from "../../../../scripts/lib/real-ghidra-file-offsets.mjs";
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const FILE_OFFSET_OPERATION_TIMEOUT_MS = 240_000;
afterEach(() => vi.unstubAllEnvs());
describe("Ghidra verifier timeout ownership", () => {
it("preserves the selected startup budget for the direct client and public requests", () => {
expect(
resolveGhidraVerifierTimeouts({
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
}),
).toEqual({
clientOptions: { startupTimeoutMs: 600_000 },
requestOptions: { timeout: 840_000 },
});
});
it("accepts the exact combined Node timer limit", () => {
const startupTimeoutMs =
MAX_TIMER_DELAY_MS - FILE_OFFSET_OPERATION_TIMEOUT_MS;
expect(
resolveGhidraVerifierTimeouts({
REA_GHIDRA_STARTUP_TIMEOUT_MS: String(startupTimeoutMs),
}),
).toEqual({
clientOptions: { startupTimeoutMs },
requestOptions: { timeout: MAX_TIMER_DELAY_MS },
});
});
it("uses the public request budget for initialization and every tool call", async () => {
const transport = { name: "transport" };
const request = { name: "procedure_pseudo_code" };
const response = { content: [] };
const connect = vi.fn(async () => undefined);
const callTool = vi.fn(async () => response);
const requestOptions = { timeout: 840_000 };
const operations = createGhidraVerifierMcpOperations(
{ connect, callTool },
requestOptions,
);
await operations.connect(transport);
await expect(operations.callTool(request)).resolves.toBe(response);
expect(connect).toHaveBeenCalledWith(transport, requestOptions);
expect(callTool).toHaveBeenCalledWith(request, requestOptions);
});
it("rejects a combined timer overflow before reading the target", async () => {
vi.stubEnv(
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
String(MAX_TIMER_DELAY_MS - FILE_OFFSET_OPERATION_TIMEOUT_MS + 1),
);
const absentTarget = join(tmpdir(), `rea-absent-${randomUUID()}`);
await expect(
verifyGhidraFileOffsets(absentTarget, "absent", "0x0"),
).rejects.toThrow(
"The Ghidra startup and verification operation budgets exceed the Node.js timer limit.",
);
});
});