Compare commits

..
Author SHA1 Message Date
morluto 330d37ad2f test(perf): measure Windows MCP startup on current artifact 2026-10-15 04:40:58 +08:00
morluto fffb074193 chore: integrate current main verification fixes 2026-10-15 04:06:41 +08:00
morluto 09e7f07108 fix(setup): select compact input schemas for Kimi registrations 2026-10-15 04:06:41 +08:00
morluto 1bbf4bd16c fix(process): preserve caller overrides during Windows environment normalization 2026-10-15 04:06:41 +08:00
morluto 07a501e82c fix(mcp): share inspection admission for prompt completion 2026-10-15 04:06:41 +08:00
morluto ebecf06f6e fix(setup): integrate Windows runner and ConPTY corrections 2026-10-15 04:04:17 +08:00
morluto 7e0c7b1244 fix(cutter): resolve bridge paths from the selected caller home 2026-10-15 04:01:36 +08:00
Ysopwnable d9cd239c5e fix(test): honor Ghidra startup budgets in Windows verification (#3417)
* fix(test): honor Ghidra startup budgets in file-offset verification

* fix(test): preserve selected startup budgets in direct Ghidra verification

* test(ghidra): cover verifier timeout budgets

* fix(test): preserve Ghidra MCP request budgets
2026-10-15 03:50:50 +08:00
morluto 11f64f653e test(browser): verify typed worker refusals across navigations 2026-10-15 03:43:29 +08:00
morluto 9b7ec809e6 test(process): use owned host-aware settlement fixtures 2026-10-15 03:43:29 +08:00
morluto 170bfc7b3f fix(mcp): authenticate inventory facts before prompt completion 2026-10-15 03:43:29 +08:00
morluto 591c87c4b6 refactor(artifacts): share inspection validation and prune legacy fixtures 2026-10-15 03:43:29 +08:00
morluto bfd76c5d16 test: isolate host discovery and update current capture fixtures 2026-10-15 03:43:29 +08:00
morluto 9ac91e0c1e fix(artifacts): bind inspection provenance to every retained inventory 2026-10-15 03:43:29 +08:00
morluto 30a9139b87 fix(javascript): keep source-module semantics independent of sibling bytes 2026-10-15 03:43:29 +08:00
morluto a9588be4eb 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:43:29 +08:00
morluto fc789d19ff style(apple): normalize class parser module formatting 2026-10-15 03:43:29 +08:00
morluto 4ff2dadf66 refactor(apple): remove unused class identity re-export 2026-10-15 03:43:29 +08:00
morluto 1a06c82772 style(test): format coverage regression fixtures 2026-10-15 03:43:29 +08:00
morluto 7d885d632c docs: align workflow recovery and current Evidence contracts 2026-10-15 03:43:29 +08:00
morluto 57a23f0a2d 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:43:29 +08:00
morluto d2a53beb3d test(hopper): verify external exit recovery and retained ownership 2026-10-15 03:43:29 +08:00
morluto a8887a9a85 fix(jeb): probe live operations with owned discovery deadlines 2026-10-15 03:43:29 +08:00
morluto 45d5e80144 fix(cli): preserve Cutter choices and reject invalid runtime arguments 2026-10-15 03:43:29 +08:00
morluto 50dd9fcad1 fix(ghidra): preserve MIPS overrides and filter entry metadata 2026-10-15 03:43:29 +08:00
morluto 6add17a317 fix(windows): explain missing native artifacts and verify output drain 2026-10-15 03:43:29 +08:00
morluto 8c0e431895 fix(setup): own Hopper replacement staging and committed outcomes 2026-10-15 03:43:29 +08:00
morluto ceb77f6697 fix(apple): distinguish JVM resources and UIKit event connections 2026-10-15 03:43:29 +08:00
morluto fc7e41ff3b fix(process): attribute finite trace omissions and cleanup states 2026-10-15 03:43:29 +08:00
morluto cb4ff50202 fix(functions): preserve coverage diagnostics separately from blockers 2026-10-15 03:43:28 +08:00
morluto b47055edbe fix(javascript): preserve module identity and boundary fields 2026-10-15 03:43:28 +08:00
morluto 0405c9d361 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:43:28 +08:00
morluto db8128dc0d 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:43:28 +08:00
Swatantra Yadav 2abfe53dbd fix(setup): preserve overridden runner variables and test isolation on Windows
Support case-insensitive environment value lookup in ClientRegistrationIdentity while preserving JavaScript property order precedence with findLast(). Add unit regression coverage for ambient uppercase npm variables on Windows. Isolate USERPROFILE in cliSetup tests so child processes do not touch the host profile directory. Harden JSON decision extraction in runJourney against ConPTY trailing ANSI escape codes. Deterministically sequence multiselect prompt navigation before confirmation. Filter ambient runner variables case-insensitively before launching interactive setup completion tests.
2026-10-15 00:56:31 +05:30
qorvrandqorvr efbc486b9b fix(contracts): reject NUL bytes in keyed archive and extraction paths (#3606)
Co-authored-by: qorvr <330186599+qorvr@users.noreply.github.com>
2026-10-15 03:14:31 +08:00
11b747f29d fix(analysis-view): unify inert inline Evidence admission
Share inline Evidence admission before authentication across analysis views.
Reject executable object behavior before reflection, inspect arrays
incrementally, preserve inert JSON-omitted metadata and existing budgets.
Keep precise error locations with one bounded path stack.

Validated base failures, 48 source tests, 71 related public-boundary tests,
27 final CLI/MCP tests and eleven calls against a genuine Chromium HAR;
source identity, selected output and capacity accounting remain preserved.

Co-authored-by: Zhong-fan <205188959+Zhong-fan@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 03:13:28 +08:00
28 changed files with 1625 additions and 1276 deletions

No files matched your search

+79 -931
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -142,6 +142,18 @@ 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.
@@ -0,0 +1,439 @@
#!/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`);
}
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,31 @@
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),
};
}
@@ -0,0 +1,16 @@
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>;
+24 -20
View File
@@ -14,6 +14,10 @@ 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,
@@ -26,17 +30,22 @@ 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 = {
...process.env,
...environment,
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({
@@ -45,21 +54,19 @@ 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 client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240_000 },
);
const result = await mcp.callTool({ name, arguments: arguments_ });
const projected = requireMcpOperationResult(result, name);
return ["open_binary", "binary_session", "close_binary"].includes(name)
? projected
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
};
try {
await client.connect(transport);
await mcp.connect(transport);
connected = true;
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
const session = await call("binary_session");
@@ -90,12 +97,12 @@ export async function verifyGhidraFileOffsets(
),
);
}
const invalid = await client.callTool({
const invalid = await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x20000000000000" },
});
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
const unmapped = await client.callTool({
const unmapped = await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x100000" },
});
@@ -122,7 +129,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 client.callTool({
await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: selector },
}),
@@ -147,7 +154,7 @@ export async function verifyGhidraFileOffsets(
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
{ env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
@@ -164,7 +171,7 @@ export async function verifyGhidraFileOffsets(
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
{ env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
).then(
() => {
throw new Error(
@@ -183,13 +190,10 @@ 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 client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
},
{ timeout: 240_000 },
);
const ambiguous = await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
});
assert.ok(
JSON.stringify(requireMcpToolError(ambiguous)).includes(
"multiple loaded mappings",
+3
View File
@@ -27,6 +27,7 @@ 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";
@@ -47,6 +48,7 @@ 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.",
@@ -116,6 +118,7 @@ const client = new GhidraClient({
transport: "authenticated-loopback-tcp",
providerVersion: SUPPORTED_GHIDRA_VERSION,
profileDigest: profile.value.profile.digest,
...clientOptions,
});
let coordinates;
@@ -13,8 +13,9 @@ const environmentValue = (
platform: NodeJS.Platform,
): string | undefined => {
if (platform !== "win32") return environment[name];
const key = Object.keys(environment).find(
(candidate) => candidate.toLowerCase() === name,
const target = name.toLowerCase();
const key = Object.keys(environment).findLast(
(candidate) => candidate.toLowerCase() === target,
);
return key === undefined ? undefined : environment[key];
};
+29 -10
View File
@@ -28,6 +28,7 @@ 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 {
@@ -215,6 +216,12 @@ 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,
@@ -225,12 +232,16 @@ export const readClientRegistrationInspections = async (
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
const alignment = registrationAligned(
const alignment = registrationAligned({
registration,
effectiveClient,
client: effectiveClient,
currentCommandPath,
options.platform ?? process.platform,
);
platform: options.platform ?? process.platform,
kimiInputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
registrationEnvironment.value.REA_MCP_INPUT_SCHEMA_PROFILE !==
undefined,
});
record(
configuredStatus(
effectiveClient,
@@ -284,12 +295,19 @@ const registrationActivation = (
? "disabled"
: "enabled";
const registrationAligned = (
registration: z.output<typeof registrationSchema>,
client: SetupClient,
currentCommandPath: string | undefined,
platform: NodeJS.Platform,
): "aligned" | "stale" | "unknown" => {
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 command = [registration.command, ...registration.args];
const identity = classifyClientRegistrationCommand(
command,
@@ -310,6 +328,7 @@ const registrationAligned = (
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
)
return "stale";
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
if (
(client.format === "kimi" ||
client.format === "omp" ||
+9 -1
View File
@@ -683,10 +683,18 @@ 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,
environment.value,
registrationEnvironment,
);
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
if (
@@ -7,6 +7,7 @@ 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 {
@@ -432,45 +433,284 @@ it("refuses an object-dense borrowed capture before making a full schema clone",
expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed);
});
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) {
it.each(["summary", "capture", "api-surface"] as const)(
"rejects inline accessors before parsing a %s view",
(kind) => {
let accessed = false;
const evidence = {
get normalized_result() {
accessed = true;
throw new Error("Evidence accessor must not be invoked");
throw new Error("Evidence must be admitted before parsing");
},
};
const result = inspectAnalysisView({
const input = {
source: { kind: "inline", evidence },
view,
});
expect(result).toMatchObject({
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({
ok: false,
error: { _tag: "AnalysisInputError" },
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["source", "evidence", "normalized_result"] }],
},
});
expect(accessed).toBe(false);
}
const parent = analysisViewCaptureEvidence();
for (const view of views)
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view,
source: { kind: "inline", evidence: analysisViewCaptureEvidence() },
view: { kind },
}).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,10 +6,7 @@ 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,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
@@ -27,6 +24,7 @@ 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 = (
@@ -58,8 +56,11 @@ export const inspectAnalysisViewValidated = (
input.view.include_query_parameter_names === true,
)
: undefined;
if (input.source.kind === "inline" && accumulator !== undefined) {
const admitted = accumulator.admitInlineEvidence(input.source.evidence);
if (input.source.kind === "inline") {
const admitted =
accumulator === undefined
? admitAnalysisViewData(input.source.evidence, true)
: accumulator.admitInlineEvidence(input.source.evidence);
if (!admitted.ok) return admitted;
}
const resolved = resolveAnalysisViewSource(input.source, lookup);
@@ -165,18 +166,6 @@ 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) {
@@ -203,53 +192,6 @@ 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,
+2 -1
View File
@@ -9,6 +9,7 @@ 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({
@@ -34,7 +35,7 @@ export const artifactExtractionInputSchema = z.strictObject({
/** Provider input containing the destination chosen by the local adapter. */
export const artifactExtractionExecutionSchema =
artifactExtractionInputSchema.extend({
output_root: z.string().min(1),
output_root: localPathStringSchema,
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
+7 -5
View File
@@ -3,7 +3,6 @@ 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";
@@ -16,6 +15,7 @@ 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,6 +282,7 @@ export class CutterBridgeClient {
configured,
this.#platform,
localAppData,
homeDirectoryFromEnvironment(environment, this.#platform),
);
}
@@ -894,16 +895,17 @@ const resolveBridgeDirectory = (
configured: string | undefined,
platform: NodeJS.Platform,
localAppData: string | undefined,
homeDirectory: string,
): string => {
if (configured !== undefined && configured !== "") {
if (configured === "~") return homedir();
if (configured === "~") return homeDirectory;
if (configured.startsWith("~/"))
return join(homedir(), configured.slice(2));
return join(homeDirectory, configured.slice(2));
if (platform === "win32" && configured.startsWith("~\\"))
return join(homedir(), configured.slice(2));
return join(homeDirectory, configured.slice(2));
return configured;
}
return platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge");
: join(homeDirectory, ".cache", "rea", "cutter-bridge");
};
@@ -1,4 +1,4 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js";
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
@@ -73,6 +73,43 @@ 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,8 +1,5 @@
import { z } from "zod";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../analysisErrorCore.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import {
@@ -10,7 +7,7 @@ import {
parseUrlQueryParameterNames,
} from "../recordedTransportUrl.js";
import { err, ok, type Result } from "../result.js";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
type WebNetworkCaptureRecord,
@@ -89,9 +86,6 @@ 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 => {
@@ -156,87 +150,15 @@ export class WebNetworkApiSurfaceAccumulator {
/** Reserve schema output and validation scratch while the borrowed parent stays live. */
admitCaptureClone(value: unknown): Result<void, AnalysisError> {
return this.#admitCaptureValue(value, new Set(), 0, false);
return admitAnalysisViewData(value, false, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
);
}
/** Admit the complete portable envelope before cloning or authenticating it. */
admitInlineEvidence(value: unknown): Result<void, AnalysisError> {
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.",
},
]),
return admitAnalysisViewData(value, true, (bytes) =>
this.#retain(bytes) ? ok(undefined) : this.#overBudget(),
);
}
@@ -0,0 +1,98 @@
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);
};
+2 -1
View File
@@ -1,6 +1,7 @@
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
@@ -20,7 +21,7 @@ export class KeyedArchiveKindError extends TypeError {
/** Select one archive within the active bundle and optionally one named root. */
export const keyedArchiveInputSchema = z.strictObject({
path: z.string().min(1).default("."),
path: localPathStringSchema.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),
+20 -15
View File
@@ -25,19 +25,24 @@ it("canonicalizes selected Windows variables without inheriting ambient values",
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 });
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);
});
+8 -8
View File
@@ -1,16 +1,16 @@
/** Snapshot caller-selected variables using Node child-process key semantics. */
/** Snapshot selected variables while preserving platform and caller precedence. */
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 = {};
// 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];
}
for (const key of [...selected.keys()].sort())
snapshot[key] = selected.get(key);
return Object.freeze(snapshot);
};
+4 -11
View File
@@ -8,7 +8,6 @@ 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";
@@ -181,17 +180,11 @@ const evidenceValues = (
if (kind === "evidence") return [evidence.evidence_id];
if (kind === "capture")
return isProcessCaptureEvidence(evidence) ? [evidence.evidence_id] : [];
if (evidence.operation === "inventory_artifact")
if (
evidence.operation === "inventory_artifact" ||
evidence.operation === "inspect_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,3 +212,31 @@ 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?.();
}
});
+20 -5
View File
@@ -179,7 +179,10 @@ 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\r")],
[
step("Which agents should use REA?", "\u001b[B"),
step("Not detected · configuration is still available", "\r"),
],
false,
[actions[0]],
);
@@ -359,7 +362,14 @@ describe("interactive setup completion", () => {
{
cwd: process.cwd(),
env: {
...process.env,
...Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!["npm_command", "npm_config_user_agent"].includes(
key.toLowerCase(),
),
),
),
npm_command: npmCommand,
npm_config_user_agent: userAgent,
NO_COLOR: "1",
@@ -427,6 +437,7 @@ const runJourney = async (
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
NO_COLOR: "1",
TERM: "xterm-256color",
},
@@ -464,9 +475,13 @@ const runJourney = async (
finish(new Error(`Setup journey returned no decision:\n${output}`));
return;
}
const serialized = output
.slice(markerIndex + decisionMarker.length)
.split(/\r?\n/u, 1)[0];
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];
if (serialized === undefined) {
finish(
new Error(`Setup journey returned an empty decision:\n${output}`),
@@ -38,9 +38,13 @@ const exists = async (path: string): Promise<boolean> => {
}
};
const expectedRegistration = (launcher: readonly string[]) => ({
const expectedRegistration = (
launcher: readonly string[],
profile = "compact",
) => ({
command: launcher[0],
args: launcher.slice(1),
env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
});
@@ -159,6 +163,132 @@ 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-");
+187 -86
View File
@@ -1,5 +1,5 @@
import { ok as resultOk } from "../../../src/domain/result.js";
import { rm, writeFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -14,16 +14,19 @@ 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, parseEvidence } from "../../../src/domain/evidence.js";
import { createEvidence } 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;
@@ -210,69 +213,74 @@ describe("guided prompt completion from investigation records", () => {
});
describe("guided prompt completion from retained records", () => {
it("offers identifiers from every retained inspection inventory substep", async () => {
it("offers identifiers from every authenticated page in one inspection inventory", async () => {
const session = createTestBinarySession(() => client([]));
const inspectionEvidence = createCompositeInspectionEvidence();
const { evidence: inspectionEvidence, inventory: completeInventory } =
await createPagedInspectionEvidence();
expect(session.recordEvidence(inspectionEvidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
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();
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);
expect(await completion.complete("manifest", "agm_")).toEqual(
expectedManifests,
);
expect(await completion.complete("manifest", "agm_")).toEqual([
completeInventory.manifest.manifest_id,
]);
expect(await completion.complete("occurrence", "occ_")).toEqual(
expectedOccurrences,
completeInventory.occurrences
.map(({ occurrence_id }) => occurrence_id)
.sort(),
);
await session.close();
});
it("skips an inner inventory whose payload no longer matches its Evidence ID", async () => {
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 () => {
const session = createTestBinarySession(() => client([]));
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 inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left;
expect(session.recordEvidence(inventoryEvidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
const firstInventory = artifactInventoryResultSchema.parse(
ARTIFACT_COMPARISON_EXAMPLE.left.normalized_result,
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
);
expect(await completion.complete("manifest", "agm_")).toEqual([
firstInventory.manifest.manifest_id,
inventory.manifest.manifest_id,
]);
expect(await completion.complete("occurrence", "occ_")).toEqual(
firstInventory.occurrences.map(({ occurrence_id }) => occurrence_id),
inventory.occurrences.map(({ occurrence_id }) => occurrence_id).sort(),
);
await session.close();
@@ -351,52 +359,145 @@ const fixtureEnvironment = {
isolation: "process" as const,
};
const createCompositeInspectionEvidence = (corruptSecondInventory = false) => {
const leftInspection = createArtifactInspection(
ARTIFACT_COMPARISON_EXAMPLE.left,
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 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 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 inventory = artifactInventoryResultSchema.parse(
secondSubstep.evidence.normalized_result,
first.evidence.normalized_result,
);
substeps[1] = {
...secondSubstep,
evidence: {
...secondSubstep.evidence,
normalized_result: {
...inventory,
coverage: { ...inventory.coverage, status: "unknown" },
inspection = {
...inspection,
substeps: [
{
...first,
evidence: {
...first.evidence,
normalized_result: {
...inventory,
limitations: [...inventory.limitations, "Changed after sealing"],
},
},
},
...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 inspection = artifactInspectionResultSchema.parse({
...leftInspection,
substeps,
evidence_links: [
...leftInspection.evidence_links,
...rightInspection.evidence_links,
],
coverage: {
...leftInspection.coverage,
substeps_completed: substeps.length,
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,
},
});
return createEvidence(undefined, fixtureProvider, {
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(inspection),
confidence: "observed",
authority: "shipped-artifact",
evidenceLinks: inspection.evidence_links,
});
fixtureProvider,
{
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "shipped-artifact",
evidenceLinks: result.evidence_links,
},
);
return { evidence, inventory: inventoryResult };
};
const client = (
@@ -3,6 +3,7 @@ import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
@@ -10,7 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join, relative } from "node:path";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -1216,8 +1217,18 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
directory = undefined;
});
it("expands a tilde bridge directory before discovering its live producer", async () => {
directory = await mkdtemp(join(homedir(), ".rea-cutter-tilde-"));
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 });
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const token = "synthetic-cutter-bridge-token-for-tilde-test";
server = authenticatedServer(token, sessionId, (request, respond) => {
@@ -1231,18 +1242,22 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => {
});
});
const port = await listen(server);
await writeDescriptor(directory, sessionId, port, token, {
await writeDescriptor(tildeDirectory, sessionId, port, token, {
currentFile: "/fixture/sample",
documentGeneration: 3,
});
await writeDescriptor(defaultDirectory, sessionId, port, token, {
currentFile: "/fixture/sample",
documentGeneration: 3,
});
const configured = `~/${relative(homedir(), directory)}`;
const result = await createClient({
REA_CUTTER_BRIDGE_DIR: configured,
const tildeResult = await createClient({
HOME: selectedHome,
REA_CUTTER_BRIDGE_DIR: "~/bridge",
}).listSessions();
expect(result).toMatchObject({
bridge_directory: directory,
expect(tildeResult).toMatchObject({
bridge_directory: tildeDirectory,
discovery_status: "sessions_found",
sessions: [
{
@@ -1252,6 +1267,37 @@ 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 () => {
@@ -0,0 +1,74 @@
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.",
);
});
});