Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330d37ad2f | ||
|
|
fffb074193 | ||
|
|
09e7f07108 | ||
|
|
1bbf4bd16c | ||
|
|
07a501e82c | ||
|
|
ebecf06f6e | ||
|
|
7e0c7b1244 | ||
|
|
d9cd239c5e | ||
|
|
11f64f653e | ||
|
|
9b7ec809e6 | ||
|
|
170bfc7b3f | ||
|
|
591c87c4b6 | ||
|
|
bfd76c5d16 | ||
|
|
9ac91e0c1e | ||
|
|
30a9139b87 | ||
|
|
a9588be4eb | ||
|
|
fc789d19ff | ||
|
|
4ff2dadf66 | ||
|
|
1a06c82772 | ||
|
|
7d885d632c | ||
|
|
57a23f0a2d | ||
|
|
d2a53beb3d | ||
|
|
a8887a9a85 | ||
|
|
45d5e80144 | ||
|
|
50dd9fcad1 | ||
|
|
6add17a317 | ||
|
|
8c0e431895 | ||
|
|
ceb77f6697 | ||
|
|
fc7e41ff3b | ||
|
|
cb4ff50202 | ||
|
|
b47055edbe | ||
|
|
0405c9d361 | ||
|
|
db8128dc0d | ||
|
|
2abfe53dbd | ||
|
|
efbc486b9b | ||
|
|
11b747f29d |
No files matched your search
+79
-931
File diff suppressed because it is too large.
Load diff
@@ -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>;
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
@@ -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-");
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user