Compare commits

..
Author SHA1 Message Date
morlutoandLcokHcak 9a3b4106b2 fix(android): default unknown invoke coverage to JSON null
Keep direct_invokes_exhaustive normalization in the Android result contract so its parsed output is JSON-safe. Omitted or explicitly undefined provider coverage becomes null, while false and true remain distinct. Preserve the exact DEX descriptor and direct-invoke evidence introduced by PR #2439.

Backports the Android-only part of main commit 6e25cc3c76 for older PR branches. Captured main already contains this behavior. Tests, checks, builds, typechecks, lint and CI reruns were intentionally skipped at user request; inspected the producing protocol, adapter mapping and named result contract.

Co-authored-by: LcokHcak <j1637848276@gmail.com>
2026-10-13 22:55:16 +08:00
32 changed files with 185 additions and 1403 deletions
-15
View File
@@ -66,21 +66,6 @@ copy's path and the restore failure.
Setup also retains valid environment overrides in REA's existing server entry,
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
Detected provider settings replace only matching environment keys.
Other fields you added to that entry, such as OMP's `timeout` or Codex's
`tool_timeout_sec`, are kept. Setup rewrites only the launch keys (`command`,
`args`, `type` or `transport`, and the environment map) and REA's
client-specific keys. It removes remote-server keys such as `url`, `headers`,
`oauth_resource`, and `tcp` that contradict the local command, and an entry-level
`enabled: false`, ZCode's `enable: false`, or (outside Grok Build)
`disabled: true`, so the registration is active. A registration field that
does not parse, including a non-boolean `enabled` or `disabled` and a
malformed `startup_timeout_sec` or `tools`, is
removed too. A `tools` value is removed except on GitHub Copilot CLI, which
replaces it with `["*"]`; Codex rejects a `tools` sequence. An existing entry
without a `command` is a remote server and is replaced whole. DeepSeek Harness
keeps other fields on the existing Cordis server and replaces `serverName`,
`transport`, `command`, and `args`. A legacy OpenCode entry moved into the
native `mcp.servers` table keeps only its environment.
Setup and doctor report a malformed REA environment map or invalid runtime
setting before setup writes files. Repair the named setting in the client
configuration; setup does not silently discard the rest of the map.
@@ -8,10 +8,7 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
waitForBrowserDevtoolsPort,
} from "../../dist/browser/BrowserProcessStartup.js";
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
export async function startRuntimeFixtureBrowser(executable, origin) {
@@ -53,7 +50,7 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
stderr: () => stderr,
// Match the broader real-browser verifier's allowance for cold Chrome
// startup on shared runners; early process failures still reject at once.
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
timeoutMs: 60_000,
});
return {
port,
+3 -5
View File
@@ -9,10 +9,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
waitForBrowserDevtoolsPort,
} from "../dist/browser/BrowserProcessStartup.js";
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
import {
inspectWebPageInputSchema,
@@ -50,6 +47,7 @@ import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs";
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
@@ -94,7 +92,7 @@ try {
executable,
activePortPath: join(profile, "DevToolsActivePort"),
stderr: () => stderr,
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS,
});
const endpoint = `http://127.0.0.1:${String(port)}`;
const provider = new CdpBrowserProvider();
@@ -23,9 +23,7 @@ import {
} from "jsonc-parser";
import { z } from "zod";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registrationFieldAccepted } from "./ClientRegistrationShape.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -1502,145 +1500,6 @@ export const clientRegistrationEntry = (
}
};
/**
* Launcher and transport keys REA owns in its entry. A key absent from REA's
* new entry is removed, so a stale `type: "http"` cannot outlive the stdio
* registration and an emptied environment map is not left behind.
*/
const REGISTRATION_LAUNCHER_KEYS: Readonly<Record<string, true>> = {
command: true,
args: true,
env: true,
environment: true,
type: true,
transport: true,
};
/** Remote-server keys that contradict the local command entry REA writes. */
const REGISTRATION_REMOTE_KEYS: Readonly<Record<string, true>> = {
url: true,
httpUrl: true,
serverUrl: true,
headers: true,
http_headers: true,
env_http_headers: true,
bearer_token_env_var: true,
oauth: true,
// Codex serializes `--oauth-resource` as `oauth_resource` and rejects it on
// a command entry (`oauth_resource is not supported for stdio`).
oauth_resource: true,
// Qwen treats `tcp` as a websocket endpoint and prefers it over `command`,
// so a command entry that still has `tcp` stays remote.
tcp: true,
};
/** Grok registration status ignores the entry `disabled` flag. */
const keepsGrokDisabledFlag = (dialect: ClientRegistrationDialect): boolean =>
dialect === "grok";
/**
* Copilot rewrites `tools` as a string array. Other clients do not: Codex
* expects a map and rejects a sequence (`invalid type: sequence, expected a map`).
*/
const keepsCopilotTools = (dialect: ClientRegistrationDialect): boolean =>
dialect === "copilot_cli";
/**
* Whether setup replaces or removes an existing entry key. Setup registers an
* active server, so entry-level switches that turn it off are removed too,
* including ZCode's native `enable: false`. A registration-status field that
* does not parse is removed as well, using the same schemas doctor uses, so
* setup cannot report success for a value doctor will reject. Grok Build's
* boolean entry `disabled` is kept.
*/
const registrationManagedKey = (
dialect: ClientRegistrationDialect,
key: string,
value: unknown,
): boolean =>
Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key) ||
Object.hasOwn(REGISTRATION_REMOTE_KEYS, key) ||
!registrationFieldAccepted(dialect, key, value) ||
(key === "tools" && dialect !== "toml" && !keepsCopilotTools(dialect)) ||
(key === "enabled" && value === false) ||
(key === "enable" && value === false && dialect === "zcode") ||
(key === "disabled" && value === true && !keepsGrokDisabledFlag(dialect));
/**
* Apply REA's new entry over an existing local command entry. REA's keys take
* their new values in place, and every other key the user added (for example
* OMP `timeout` or Codex `tool_timeout_sec`) is kept. An existing entry without
* a `command` is a remote server, which REA replaces whole.
*/
export const retainClientRegistrationFields = (
dialect: ClientRegistrationDialect,
existing: unknown,
registration: Readonly<Record<string, unknown>>,
): Record<string, unknown> => {
const current = objectSchema.safeParse(existing);
if (!current.success || !Object.hasOwn(current.data, "command"))
return { ...registration };
const retained = Object.fromEntries(
Object.entries(current.data).flatMap(([key, value]): [string, unknown][] =>
Object.hasOwn(registration, key)
? [[key, registration[key]]]
: registrationManagedKey(dialect, key, value)
? []
: [[key, value]],
),
);
return { ...retained, ...registration };
};
/** Codex and Grok Build always receive REA's startup timeout. */
export const applyRegistrationStartupTimeout = (
clientName: string,
registration: Readonly<Record<string, unknown>>,
): Record<string, unknown> =>
clientName === "codex" || clientName === "grok_build"
? {
...registration,
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: { ...registration };
/**
* Approval-plan text for the retention rules above. Rewritten keys are the
* ones the fresh entry sets beyond its launcher, so the sentence cannot name
* a field the writer does not replace.
*/
export const describeClientRegistrationRetention = (
clientName: string,
dialect: ClientRegistrationDialect,
): string => {
const replaced = Object.entries(
applyRegistrationStartupTimeout(
clientName,
clientRegistrationEntry(dialect, ["rea", "mcp"], {}),
),
)
.filter(([key]) => !Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key} with ${JSON.stringify(value)}`);
const removed = keepsGrokDisabledFlag(dialect)
? "Remote-server keys, an enabled: false switch, and registration fields that do not parse are removed. A boolean disabled value is kept."
: "Remote-server keys, switches that turn the server off, and registration fields that do not parse are removed.";
const tools = keepsCopilotTools(dialect)
? ""
: dialect === "toml"
? " Invalid Codex tools values are removed; valid per-tool approval maps are kept."
: " A tools value is removed.";
const replacement =
replaced.length === 0
? `${removed}${tools}`
: `Setup replaces ${replaced.join(" and ")}. ${removed}${tools}`;
const legacy =
dialect === "opencode_v2"
? " A legacy OpenCode entry moved into the native server table keeps only its environment."
: "";
return `On an existing local command entry, other user fields are kept. ${replacement} When that entry is remote, its other fields are replaced.${legacy}`;
};
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
z
@@ -1,67 +0,0 @@
import { z } from "zod";
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
/**
* Registration-status fields doctor parses on every client. User fields
* outside this schema, such as OMP `timeout`, are not registration-status
* fields. Setup accepts a retained value only when this schema does.
*/
export const registrationSchema = z
.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
tools: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
enabled: z.boolean().optional(),
})
.passthrough();
const codexRegistrationSchema = registrationSchema.extend({
tool_timeout_sec: z.number().optional(),
tools: z
.record(
z.string(),
z
.object({
approval_mode: z
.enum(["auto", "prompt", "writes", "approve"])
.optional(),
output_token_limit: z.number().int().positive().optional(),
})
.strict(),
)
.optional(),
required: z.boolean().optional(),
cwd: z.string().optional(),
env_vars: z.array(z.string()).optional(),
enabled_tools: z.array(z.string()).optional(),
disabled_tools: z.array(z.string()).optional(),
});
const zcodeRegistrationSchema = registrationSchema.extend({
enable: z.boolean().optional(),
});
/** Client-specific parsing is shared by retention and registration diagnostics. */
export const registrationSchemaFor = (dialect: ClientRegistrationDialect) =>
dialect === "toml"
? codexRegistrationSchema
: dialect === "zcode"
? zcodeRegistrationSchema
: registrationSchema;
/** Whether a retained field satisfies the selected client's known contract. */
export const registrationFieldAccepted = (
dialect: ClientRegistrationDialect,
key: string,
value: unknown,
): boolean => {
const shape: Readonly<Record<string, z.ZodType>> =
registrationSchemaFor(dialect).shape;
const field = Object.hasOwn(shape, key) ? shape[key] : undefined;
return field === undefined || field.safeParse(value).success;
};
+15 -7
View File
@@ -14,10 +14,6 @@ import { resolve } from "node:path";
import { z } from "zod";
import {
registrationSchema,
registrationSchemaFor,
} from "./ClientRegistrationShape.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import {
@@ -65,6 +61,20 @@ export type UnhealthyClientRegistrationStatus = Exclude<
{ readonly state: "aligned" }
>;
const registrationSchema = z
.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
tools: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
enabled: z.boolean().optional(),
})
.passthrough();
interface ClientRegistrationReadOptions {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
@@ -320,9 +330,7 @@ const parseRegistration = (
args,
});
}
const registration = registrationSchemaFor(client.format ?? "json").parse(
value,
);
const registration = registrationSchema.parse(value);
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
-12
View File
@@ -404,18 +404,6 @@ export const serializeDshDocument = (
throw new ConfigurationError("Missing Cordis edit plan");
return document.cordis.source.serialize(document.cordis.desired);
};
/** Approval-plan text for the launcher keys `DshCordisDocument` replaces. */
export const describeDshRegistrationRetention = (
command: readonly string[],
): string => {
const entry = dshClientRegistrationEntry(command);
const replaced = [...LAUNCHER_KEYS]
.filter((key) => Object.hasOwn(entry, key))
.sort((left, right) => left.localeCompare(right))
.map((key) => `${key} with ${JSON.stringify(entry[key])}`);
return `Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to ${JSON.stringify(DSH_CLIENT_PACKAGE)}. Setup replaces ${replaced.join(", ")}. Environment overrides are merged.`;
};
export const dshClientRegistrationEntry = (
command: readonly string[],
environment: Readonly<Record<string, string>> = {},
-120
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { runSetup } from "./Setup.js";
import type { SetupProgressEvent } from "./SetupTypes.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
@@ -153,15 +152,6 @@ describe("setup workflow", () => {
expect(result.plannedActions.map(({ id }) => id)).toEqual([
"configure_client:codex",
]);
expect(result.plannedActions[0]?.detail).toContain(
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
);
expect(result.plannedActions[0]?.detail).toContain(
"registration fields that do not parse are removed",
);
expect(result.plannedActions[0]?.detail).toContain(
"Invalid Codex tools values are removed; valid per-tool approval maps are kept.",
);
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
});
@@ -195,116 +185,6 @@ describe("setup workflow", () => {
});
});
describe("setup approval disclosure", () => {
it("keeps Grok Build's entry disabled flag in the approval plan", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "grok_build",
displayName: "Grok Build",
configPath: "/grok.toml",
format: "grok",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["grok_build"] },
host,
);
expect(result.plannedActions[0]?.detail).toContain(
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
);
expect(result.plannedActions[0]?.detail).toContain(
"A boolean disabled value is kept.",
);
});
it("discloses that Copilot tool selection is replaced", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "copilot_cli",
displayName: "GitHub Copilot CLI",
configPath: "/copilot.json",
format: "copilot_cli",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["copilot_cli"] },
host,
);
expect(result.plannedActions[0]?.detail).toContain(
`tools with ${JSON.stringify(["*"])}`,
);
});
it("discloses that a legacy OpenCode entry keeps only its environment", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "opencode",
displayName: "OpenCode",
configPath: "/opencode.json",
format: "opencode",
},
];
host.clientInspections.set("opencode", {
status: "update",
dialect: "opencode_v2",
});
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["opencode"] },
host,
);
const detail = result.plannedActions[0]?.detail ?? "";
expect(detail).toContain(
"A legacy OpenCode entry moved into the native server table keeps only its environment.",
);
expect(detail).not.toContain(`enabled with ${JSON.stringify(true)}`);
});
it("describes Cordis fields the Harness writer replaces", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "dsh",
displayName: "DeepSeek Harness",
configPath: "/cordis.patch.yml",
format: "dsh",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["dsh"] },
host,
);
const detail = result.plannedActions[0]?.detail ?? "";
const [executable = "rea", ...args] = host.registrationCommand;
expect(detail).toContain(
'Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to "@deepseek-ai/dsh-mcp-client".',
);
expect(detail).toContain(`serverName with ${JSON.stringify("rea")}`);
expect(detail).toContain(`transport with ${JSON.stringify("stdio")}`);
expect(detail).toContain(`command with ${JSON.stringify(executable)}`);
expect(detail).toContain(`args with ${JSON.stringify(args)}`);
expect(detail).not.toContain("Remote-server keys");
});
});
describe("setup workflow lifecycle and provider planning", () => {
it("reports append-only lifecycle progress for each selected action", async () => {
const host = new FakeSetupHost();
+27 -37
View File
@@ -5,7 +5,6 @@ import {
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import {
applyRegistrationStartupTimeout,
clientRegistrationEntry,
clientConfigurationValuesEqual,
clientServerListedDisabled,
@@ -14,11 +13,9 @@ import {
OMP_DISABLED_SERVERS_KEY,
parseClientConfiguration,
parseZcodeFallbackConfiguration,
retainClientRegistrationFields,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationDocument,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
@@ -30,6 +27,7 @@ import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.j
import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
import {
@@ -446,7 +444,7 @@ const inspectClientDocument = async (
throwCleanupFailure(fallback.error);
return { status: "invalid", remediation: fallback.error.detail };
}
return { status: "create", dialect: clientRegistrationDialect(client) };
return { status: "create" };
}
return {
status: "invalid",
@@ -476,26 +474,12 @@ const inspectClientDocument = async (
parsed,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current", dialect: parsed.dialect };
return { status: "already_current" };
const fallback = await readZcodeFallbackServers(client, parsed);
if (!fallback.ok) {
throwCleanupFailure(fallback.error);
return { status: "invalid", remediation: fallback.error.detail };
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: backupSelection.value.backupPath,
dialect: parsed.dialect,
};
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
// Malformed configuration is reported with the invalid remediation.
@@ -506,15 +490,21 @@ const inspectClientDocument = async (
"The existing configuration is malformed. Repair it before setup applies any other changes.",
};
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: backupSelection.value.backupPath,
};
};
const clientRegistrationDialect = (
client: SetupClient,
): ClientRegistrationDialect =>
client.format === undefined || client.format === "unsupported"
? "json"
: client.format;
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
@@ -709,17 +699,17 @@ const clientConfigurationDesired = (
providerEnvironment,
);
if (!environment.ok) throw environment.error;
const registration = applyRegistrationStartupTimeout(
client.name,
clientRegistrationEntry(format, launcher, environment.value),
);
// Keep fields the user added to REA's own entry, such as OMP `timeout`. A
// legacy OpenCode V1 entry migrating into a V2 table keeps only its
// environment, because its other fields belong to the V1 shape. The approval
// plan states that exception through describeClientRegistrationRetention.
return retainClientRegistrationFields(
const registration = clientRegistrationEntry(
format,
parsed?.servers[PRODUCT_IDENTITY.mcpServerKey],
registration,
launcher,
environment.value,
);
return {
...registration,
...(client.name === "codex" || client.name === "grok_build"
? {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
};
};
+5 -23
View File
@@ -1,10 +1,5 @@
import { join } from "node:path";
import {
describeClientRegistrationRetention,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { describeDshRegistrationRetention } from "./DshCordisPatch.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
@@ -235,9 +230,6 @@ const plannedClientWrites = (
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
...(inspection.dialect === undefined
? {}
: { dialect: inspection.dialect }),
},
]
: [],
@@ -255,7 +247,6 @@ const setupPlan = (input: {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
readonly dialect?: ClientRegistrationDialect;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
@@ -293,13 +284,13 @@ const setupPlan = (input: {
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath, dialect }): SetupAction => ({
.map(({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
{ ...client, ...(dialect === undefined ? {} : { dialect }) },
client,
input.command,
input.providerEnvironment,
input.installHopper,
@@ -343,7 +334,7 @@ const majorVersion = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientConfigurationDetail = (
client: SetupClient & { readonly dialect?: ClientRegistrationDialect },
client: SetupClient,
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
@@ -351,19 +342,10 @@ const clientConfigurationDetail = (
const entries = Object.entries(environment)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.map(([name, value]) => `${name}=${value}`);
const dialect =
client.dialect ??
(client.format === undefined || client.format === "unsupported"
? "json"
: client.format);
const retainedFields =
dialect === "dsh"
? describeDshRegistrationRetention(command)
: describeClientRegistrationRetention(client.name, dialect);
const environmentDetail =
entries.length === 0
? ` No additional provider environment variables. Existing server environment overrides are retained. ${retainedFields}`
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys. ${retainedFields}`;
? " No additional provider environment variables. Existing server environment overrides are retained."
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
const hopperDetail = installHopper
? environment.HOPPER_LAUNCHER_PATH === undefined
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
+1 -8
View File
@@ -1,4 +1,3 @@
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js";
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
@@ -46,16 +45,10 @@ export type ClientConfigurationResult =
/** Read-only state used to block unsafe setup plans before any mutation. */
export type ClientConfigurationInspection =
| {
readonly status: "already_current";
/** Dialect the registration writer will use when this file is parsed. */
readonly dialect?: ClientRegistrationDialect;
}
| { readonly status: "already_current" }
| {
readonly status: "create" | "update";
readonly backupPath?: string;
/** Dialect the registration writer will use for this file. */
readonly dialect?: ClientRegistrationDialect;
}
| {
readonly status: "invalid";
-16
View File
@@ -36,22 +36,6 @@ const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => {
};
describe("NIBArchive decoder", () => {
it("keeps leading U+FEFF in length-prefixed keys and class names", () => {
const archive = encodeArchive({
classes: ["\uFEFFWidget"],
objects: [{ classIndex: 0, values: { count: 1, "\uFEFFcount": 2 } }],
});
expect(decodeNibArchive(archive).objects).toEqual([
{
id: 0,
class_name: "\uFEFFWidget",
values: { count: 1, "\uFEFFcount": 2 },
},
]);
const invalid = Buffer.from(archive);
invalid[invalid.indexOf(Buffer.from("\uFEFFcount"))] = 0xff;
expect(() => decodeNibArchive(invalid)).toThrow(/not valid UTF-8/u);
});
it("decodes bounded object, key, class, and reference tables", () => {
const archive = encodeArchive({
classes: ["NSView\0", "NSString\0"],
+6 -23
View File
@@ -14,7 +14,6 @@ export class BrowserStartupError extends Error {
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
@@ -24,7 +23,6 @@ export class BrowserStartupError extends Error {
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
@@ -35,22 +33,12 @@ export class BrowserStartupError extends Error {
this.failure = diagnostics.failure;
this.executable = diagnostics.executable;
this.elapsedMs = diagnostics.elapsedMs;
this.startupTimeoutMs = diagnostics.startupTimeoutMs;
this.exitCode = diagnostics.exitCode;
this.signalCode = diagnostics.signalCode;
this.stderr = diagnostics.stderr;
}
}
/**
* Single owner of the wait for Chrome to publish its DevToolsActivePort.
*
* Callers that need a different budget pass `timeoutMs`; nobody else should
* restate this number, because a per-caller literal silently changes how long
* a cold Chrome launch is tolerated before the failure names its ceiling.
*/
export const BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS = 60_000;
/** Owned child process and startup policy for Chrome CDP discovery. */
export interface BrowserStartupOptions {
readonly child: ChildProcess;
@@ -65,23 +53,23 @@ export interface BrowserStartupOptions {
export const waitForBrowserDevtoolsPort = async (
options: BrowserStartupOptions,
): Promise<number> => {
const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
const timeoutMs = options.timeoutMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 25;
const startedAt = performance.now();
const terminal = childTermination(options, startedAt, timeoutMs);
const terminal = childTermination(options, startedAt);
const deadline = startedAt + timeoutMs;
while (true) {
const terminalState = currentTerminalState(options.child);
if (terminalState !== null)
throw startupError(options, startedAt, timeoutMs, terminalState);
throw startupError(options, startedAt, terminalState);
const port = await readDevtoolsPort(options.activePortPath);
if (port !== null) return port;
const remainingMs = deadline - performance.now();
if (remainingMs <= 0)
throw startupError(options, startedAt, timeoutMs, { failure: "timeout" });
throw startupError(options, startedAt, { failure: "timeout" });
const outcome = await Promise.race([
terminal,
@@ -100,12 +88,11 @@ type TerminalState =
const childTermination = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
): Promise<BrowserStartupError> =>
new Promise((resolve) => {
options.child.once("error", (cause: unknown) => {
resolve(
startupError(options, startedAt, startupTimeoutMs, {
startupError(options, startedAt, {
failure: "spawn-error",
cause,
}),
@@ -116,7 +103,6 @@ const childTermination = (
startupError(
options,
startedAt,
startupTimeoutMs,
options.child.signalCode === null
? { failure: "exited" }
: { failure: "signalled" },
@@ -134,7 +120,6 @@ const currentTerminalState = (child: ChildProcess): TerminalState | null => {
const startupError = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
state: TerminalState,
): BrowserStartupError =>
new BrowserStartupError(
@@ -142,7 +127,6 @@ const startupError = (
failure: state.failure,
executable: options.executable,
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
startupTimeoutMs,
exitCode: options.child.exitCode,
signalCode: options.child.signalCode,
stderr: options.stderr(),
@@ -172,10 +156,9 @@ const browserStartupMessage = (
diagnostics: {
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
},
): string =>
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; startup_timeout_ms=${String(diagnostics.startupTimeoutMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
+1 -1
View File
@@ -795,7 +795,7 @@ it("rejects unsupported parent operations and incompatible collections", () => {
it("preserves selected native facet unavailability instead of an exhausted empty page", () => {
const parent = nativeParent({
...functionDossierSchema.parse(ghidraFunctionDossier()),
...ghidraFunctionDossier(),
facet_availability: {
comments: { available: false, reason: "Typed comments unavailable" },
references: { available: false, reason: "Typed references unavailable" },
+41 -51
View File
@@ -128,49 +128,6 @@ const observe = (
},
);
const rejectsMalformedFacetAvailability = (): void => {
expect(
functionDossierSchema.safeParse({
...dossier("return 0;", "0x1000"),
facet_availability: { cfg: { available: false, reason: "" } },
}).success,
).toBe(false);
expect(
functionDossierSchema.safeParse({
...dossier("return 0;", "0x1000"),
facet_availability: { cfg: undefined },
}).success,
).toBe(false);
};
const keepsGeneratedNamesAmbiguous = (): void => {
const named = (base: "0x1000" | "0x2000", name: string) =>
functionDossierSchema.parse({
...dossier("return 0;", base),
procedure: {
...procedure(base, name),
signature: "int helper(void)",
locals: [],
},
});
const generated = compareFunctions(
observe("1", named("0x1000", "sub_1000")),
observe("2", named("0x2000", "sub_2000")),
);
expect(generated.function_match.status).toBe("ambiguous");
expect(generated.status).toBe("unknown");
expect(
generated.dimensions.find(({ dimension }) => dimension === "identity"),
).toMatchObject({ status: "unknown" });
const stable = compareFunctions(
observe("3", named("0x1000", "firstFunction")),
observe("4", named("0x2000", "secondFunction")),
);
expect(stable.function_match.status).toBe("mismatched");
expect(stable.status).toBe("changed");
};
describe("function comparison normalized identity", () => {
it("retains unresolved call details alongside missing facet coverage", () => {
const value: Record<string, unknown> = {
@@ -230,14 +187,47 @@ describe("function comparison normalized identity", () => {
),
).toMatchObject({ status: "unknown", left_count: null, right_count: null });
});
it(
"rejects malformed facet availability instead of treating it as absence",
rejectsMalformedFacetAvailability,
);
it(
"keeps differing generated names ambiguous and stable names mismatched",
keepsGeneratedNamesAmbiguous,
);
it("rejects malformed facet availability instead of treating it as absence", () => {
expect(
functionDossierSchema.safeParse({
...dossier("return 0;", "0x1000"),
facet_availability: { cfg: { available: false, reason: "" } },
}).success,
).toBe(false);
expect(
functionDossierSchema.safeParse({
...dossier("return 0;", "0x1000"),
facet_availability: { cfg: undefined },
}).success,
).toBe(false);
});
it("keeps differing generated names ambiguous and stable names mismatched", () => {
const named = (base: "0x1000" | "0x2000", name: string) =>
functionDossierSchema.parse({
...dossier("return 0;", base),
procedure: {
...procedure(base, name),
signature: "int helper(void)",
locals: [],
},
});
const generated = compareFunctions(
observe("1", named("0x1000", "sub_1000")),
observe("2", named("0x2000", "sub_2000")),
);
expect(generated.function_match.status).toBe("ambiguous");
expect(generated.status).toBe("unknown");
expect(
generated.dimensions.find(({ dimension }) => dimension === "identity"),
).toMatchObject({ status: "unknown" });
const stable = compareFunctions(
observe("3", named("0x1000", "firstFunction")),
observe("4", named("0x2000", "secondFunction")),
);
expect(stable.function_match.status).toBe("mismatched");
expect(stable.status).toBe("changed");
});
it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])(
"recognizes the explicit symbol %s without a generated-name prefix false positive",
+1 -1
View File
@@ -1459,7 +1459,7 @@ it.each(["idb_close", "idb_list", "connection_close"])(
});
expect(result.error.cleanupIncomplete).toBe(true);
} finally {
delete producer.beforeCall;
producer.beforeCall = undefined;
producer.close = originalClose;
await client.close();
}
@@ -83,7 +83,6 @@ export class JavaScriptAnalysisWorker {
readonly #resultBudgetBytes: number;
/** Null when the runtime enforces the V8 heap flags instead. */
readonly #processMemoryLimitBytes: number | null;
readonly #onStdinError = (): void => {};
readonly #onStdout = (chunk: Buffer | string): void => {
try {
this.#pending +=
@@ -785,40 +784,20 @@ export class JavaScriptAnalysisWorker {
if (this.#supervisor !== undefined) {
const supervisor = this.#supervisor;
const stopped = await supervisor.stop();
supervisor.launch.process.stdout?.off("data", this.#onStdout);
if (stopped.status === "incomplete") {
try {
const stdin = supervisor.launch.process.stdin;
if (
stdin !== undefined &&
stdin !== null &&
!stdin.destroyed &&
!stdin.writableEnded
) {
stdin.off("error", this.#onStdinError);
stdin.once("error", this.#onStdinError);
stdin.end();
}
} catch {
// Cooperative protocol stdin may already be closed or broken.
}
this.#resetState();
pendingCleanup.add(this);
return this.#cleanupError(stopped.reason, original);
}
supervisor.launch.process.stdout?.off("data", this.#onStdout);
supervisor.dispose();
this.#supervisor = undefined;
}
this.#resetState();
return undefined;
}
#resetState(): void {
this.#ready = false;
this.#frames = [];
this.#pending = "";
this.#decoder = new StringDecoder("utf8");
this.#protocolFailure = undefined;
return undefined;
}
}
-22
View File
@@ -61,8 +61,6 @@ export interface ProviderProcessHandle extends Pick<
/** True once an enforced process memory limit reported an allocation failure. */
readonly memoryLimitReached?: boolean;
kill(signal?: NodeJS.Signals | number): boolean;
unref?(): void;
ref?(): void;
}
/** Process handle returned by a provider-specific launcher. */
@@ -373,33 +371,13 @@ export class ProviderProcessSupervisor {
this.launch.process.off("error", this.#onError);
}
/** Release event-loop references so incomplete cleanup does not prevent host exit. */
unref(): void {
const process = this.launch.process;
process.unref?.();
(process.stdin as { unref?: () => void } | null | undefined)?.unref?.();
(process.stdout as { unref?: () => void } | null | undefined)?.unref?.();
(process.stderr as { unref?: () => void } | null | undefined)?.unref?.();
}
/** Restore event-loop references when retrying supervised process cleanup. */
ref(): void {
const process = this.launch.process;
process.ref?.();
(process.stdin as { ref?: () => void } | null | undefined)?.ref?.();
(process.stdout as { ref?: () => void } | null | undefined)?.ref?.();
(process.stderr as { ref?: () => void } | null | undefined)?.ref?.();
}
async #stop(
options: ProviderProcessStopOptions,
): Promise<ProviderProcessStopResult> {
this.ref();
let completed = false;
try {
const result = await this.#stopWithin(options);
completed = result.status !== "incomplete";
if (!completed) this.unref();
return result;
} finally {
if (completed) this.dispose();
-37
View File
@@ -121,40 +121,3 @@ it("reports a reached process memory limit only when the handle observed one", (
supervisor.dispose();
}
});
it("unreferences process and stream handles when stop reports incomplete cleanup", async () => {
const unrefProcess = vi.fn();
const unrefStdin = vi.fn();
const unrefStdout = vi.fn();
const unrefStderr = vi.fn();
const producer = Object.assign(new EventEmitter(), {
stdout: Object.assign(new PassThrough(), { unref: unrefStdout }),
stdin: Object.assign(new PassThrough(), { unref: unrefStdin }),
stderr: Object.assign(new PassThrough(), { unref: unrefStderr }),
exitCode: null,
signalCode: null,
kill: () => false,
unref: unrefProcess,
});
const supervisor = new ProviderProcessSupervisor({
process: producer,
ownsProcessLifetime: true,
cleanup: async () => ({
cleaned: false,
reason: "process table could not be inspected: spawn EPERM",
}),
});
try {
const result = await supervisor.stop();
expect(result).toMatchObject({
status: "incomplete",
reason: "process table could not be inspected: spawn EPERM",
});
expect(unrefProcess).toHaveBeenCalledTimes(1);
expect(unrefStdin).toHaveBeenCalledTimes(1);
expect(unrefStdout).toHaveBeenCalledTimes(1);
expect(unrefStderr).toHaveBeenCalledTimes(1);
} finally {
supervisor.dispose();
}
});
+13 -50
View File
@@ -109,11 +109,6 @@ class OutputBudget {
}
const quoteIdentifier = (name: string): string =>
`"${name.replaceAll('"', '""')}"`;
const missingSelectedTable = (name: string): SqliteInspectionFailure =>
new SqliteInspectionFailure(
"selection",
`Selected ordinary table does not exist with that exact name: ${name}`,
);
// SQLite folds only ASCII letters when matching virtual/shadow table names.
const sqliteIdentifierKey = (name: string): string =>
name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
@@ -241,49 +236,12 @@ const readSchema = (
metadata.set(value.name, value);
}
}
// A trigger stores its target as written in CREATE TRIGGER, which SQLite
// resolves by ASCII case folding; report the stored table or view name.
// A selected-table scan returns only the selected table's dependents, and
// other scopes fold stored names once, only when a spelling differs.
let foldedNames: Map<string, string> | undefined;
const triggerTable = (written: string): string => {
if (selectedName !== undefined) return selectedName;
if (metadata.has(written)) return written;
foldedNames ??= new Map(
[...metadata.keys()].map((name) => [sqliteIdentifierKey(name), name]),
);
return foldedNames.get(sqliteIdentifierKey(written)) ?? written;
};
const unresolvedVirtualTables = [...metadata.values()]
.filter((value) => value.type === "virtual" && value.ncol === 0)
.map((value) => ({
name: value.name,
prefix: sqliteIdentifierKey(`${value.name}_`),
}));
const shadowParentOf = (
name: string,
listed: z.output<typeof tableRow>,
): (typeof unresolvedVirtualTables)[number] | undefined => {
if (listed.type !== "table") return undefined;
const identifier = sqliteIdentifierKey(name);
return unresolvedVirtualTables.find((parent) =>
identifier.startsWith(parent.prefix),
);
};
const selected =
selectedName === undefined ? undefined : metadata.get(selectedName);
if (selectedName !== undefined && selected === undefined)
throw missingSelectedTable(selectedName);
const selectedShadowParent =
selectedName === undefined || selected === undefined
? undefined
: shadowParentOf(selectedName, selected);
// NOCASE folds only ASCII letters, as SQLite does when it resolves the
// table named by an index or trigger. Fold only for an ordinary table whose
// rows can be selected; a selection refused later keeps the exact filter,
// so its failure does not depend on declarations under another spelling.
const foldDependents =
selected?.type === "table" && selectedShadowParent === undefined;
const schema: SqliteDatabase["schema"] = {
scope,
completeness: "complete",
@@ -296,9 +254,7 @@ const readSchema = (
// duplicate large names or definitions beyond SQLite's native record limit.
const declaration = scope === "inventory" ? "" : ", CAST(sql AS BLOB) AS sql";
const selection =
scope === "selected_table"
? ` WHERE tbl_name = ? COLLATE ${foldDependents ? "NOCASE" : "BINARY"}`
: "";
scope === "selected_table" ? " WHERE tbl_name = ? COLLATE BINARY" : "";
const statement = db.prepare(
`SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage${declaration} FROM main.sqlite_schema${selection}`,
);
@@ -324,10 +280,13 @@ const readSchema = (
"format",
`SQLite schema/table inventory mismatch: ${value.name}`,
);
const identifier = sqliteIdentifierKey(value.name);
const possibleShadowParent =
value.name === selectedName
? selectedShadowParent
: shadowParentOf(value.name, listed);
listed.type === "table"
? unresolvedVirtualTables.find((parent) =>
identifier.startsWith(parent.prefix),
)
: undefined;
const table: SqliteDatabase["schema"]["tables"][number] = {
name: value.name,
...(scope === "inventory" ? {} : { sql: value.sql }),
@@ -390,7 +349,7 @@ const readSchema = (
} else {
const trigger = {
name: value.name,
table: triggerTable(value.tbl_name),
table: value.tbl_name,
...(scope === "inventory" ? {} : { sql: value.sql }),
};
budget.retain(trigger);
@@ -411,7 +370,11 @@ const readRows = (
): SqliteDatabase["rows"] => {
const { encoding, budget } = context;
if (input.table === undefined) return null;
if (table === undefined) throw missingSelectedTable(input.table);
if (table === undefined)
throw new SqliteInspectionFailure(
"selection",
`Selected ordinary table does not exist with that exact name: ${input.table}`,
);
if (table.kind === "unknown")
throw new SqliteInspectionFailure(
"selection",
-14
View File
@@ -67,7 +67,6 @@ export class WindowsOwnedProcess extends EventEmitter {
#closed = false;
#failure: string | undefined;
#timer: NodeJS.Timeout | undefined;
#unreferenced = false;
#cleanup: Promise<ProcessCleanupResult> | undefined;
readonly #memoryLimitBytes: number | undefined;
#memoryLimitReached = false;
@@ -223,18 +222,6 @@ export class WindowsOwnedProcess extends EventEmitter {
return this.#cleanup;
}
/** Release event-loop references so unsettled jobs do not prevent host exit. */
unref(): void {
this.#unreferenced = true;
this.#timer?.unref();
}
/** Restore event-loop references when retrying process lifecycle observation. */
ref(): void {
this.#unreferenced = false;
this.#timer?.ref();
}
async #stop(): Promise<ProcessCleanupResult> {
if (this.#closed) return { cleaned: true, signaled: false };
let timer: NodeJS.Timeout | undefined;
@@ -268,7 +255,6 @@ export class WindowsOwnedProcess extends EventEmitter {
#schedule(): void {
if (this.#closed || this.#timer !== undefined) return;
this.#timer = setTimeout(() => this.#poll(), 25);
if (this.#unreferenced) this.#timer.unref();
}
#poll(): void {
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
@@ -10,7 +9,6 @@ import {
} from "../../support/process/processFixture.js";
import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
BrowserStartupError,
waitForBrowserDevtoolsPort,
} from "../../../src/browser/BrowserProcessStartup.js";
@@ -88,68 +86,15 @@ describe("browser process startup", () => {
failure: "timeout",
exitCode: null,
signalCode: null,
startupTimeoutMs: 20,
});
expect(String(failure)).toContain("stderr=<empty>");
expect(String(failure)).toContain("startup_timeout_ms=20");
} finally {
child.kill("SIGKILL");
expect(await waitForExit(child, 5_000)).toBe(true);
}
});
it("names the owner's deadline when a caller omits one", async ({
processes,
}) => {
const root = await temporaryRoot();
const child = processes.spawn(process.execPath, [
"-e",
"setTimeout(() => process.exit(3), 10)",
]);
const failure = await waitForBrowserDevtoolsPort({
child,
executable: process.execPath,
activePortPath: join(root, "DevToolsActivePort"),
stderr: () => "owner default fixture",
pollIntervalMs: 5,
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(BrowserStartupError);
expect(failure).toMatchObject({
failure: "exited",
startupTimeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
});
expect(String(failure)).toContain(
`startup_timeout_ms=${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)}`,
);
});
it("keeps one owner for the DevToolsActivePort deadline", async () => {
expect(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS).toBe(60_000);
const callers = [
"../../../scripts/verify-real-browser.mjs",
"../../../scripts/lib/browser-runtime-fixture-lifecycle.mjs",
];
for (const caller of callers) {
const source = await readFile(new URL(caller, import.meta.url), "utf8");
expect(devtoolsPortDeadlines(source)).toEqual([
["BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS"],
]);
}
});
});
const temporaryRoot = async (): Promise<string> => {
return createTestTempDirectory("rea-browser-startup-");
};
const devtoolsPortDeadlines = (source: string): string[][] => {
const calls = /waitForBrowserDevtoolsPort\(\{(?<body>[\s\S]*?)\}\)/gu;
const deadlines = /\btimeoutMs:\s*(?<value>[^\n,]+)/gu;
return [...source.matchAll(calls)].map((call) =>
[...String(call.groups?.body).matchAll(deadlines)].map((deadline) =>
String(deadline.groups?.value).trim(),
),
);
};
@@ -50,7 +50,6 @@ describe.each([
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
dialect: client.format,
});
expect(await clientConfigurationAligned(client, {}, command)).toBe(false);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
@@ -74,7 +73,6 @@ describe.each([
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
dialect: client.format,
});
expect(
await readClientRegistrationStatuses(home, currentCommandPath, {
@@ -176,7 +174,6 @@ it.each(["", "\n \n", "\uFEFF"])(
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
dialect: "json",
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "configured",
@@ -187,7 +184,6 @@ it.each(["", "\n \n", "\uFEFF"])(
});
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
dialect: "json",
});
},
);
@@ -68,7 +68,7 @@ it.each([
);
expect(
await inspectClientConfiguration(client, providers, command),
).toEqual({ status: "already_current", dialect: format });
).toEqual({ status: "already_current" });
expect(
await configureClientConfiguration(client, providers, command),
).toEqual({ status: "unchanged" });
@@ -46,7 +46,6 @@ it("previews and creates a current recovery backup while preserving the first ba
expect(await inspectClientConfiguration(client, {}, ["rea", "mcp"])).toEqual({
status: "update",
backupPath: recoveryPath,
dialect: "json",
});
expect(
@@ -1,318 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
clientRegistrationEntry,
parseClientConfiguration,
serializeClientConfiguration,
type ClientRegistrationDialect,
} from "../../../src/application/ClientConfigurationDocument.js";
import {
clientConfigurationAligned,
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import type { SetupClient } from "../../../src/application/SupportedClients.js";
import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const command = ["rea", "mcp"];
const environment = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" };
/** Wrap an entry in the server table each dialect reads. */
const configurationWith = (
dialect: ClientRegistrationDialect,
entry: unknown,
): Record<string, unknown> => {
switch (dialect) {
case "opencode":
return { mcp: { rea: entry } };
case "opencode_v2":
return { mcp: { servers: { rea: entry } } };
case "vscode":
return { servers: { rea: entry } };
case "toml":
case "grok":
case "hermes":
return { mcp_servers: { rea: entry } };
default:
return { mcpServers: { rea: entry } };
}
};
it.each([
["cursor", "json", "json", { timeout: 600000, trust: true }],
["vscode", "vscode", "vscode", { envFile: ".env" }],
[
"omp",
"omp",
"omp",
{ timeout: 0, enabled: true, instructions: "Use for binaries." },
],
["pi", "pi", "pi", { timeout: 0 }],
["copilot_cli", "copilot_cli", "copilot_cli", { timeout: 120000 }],
["commandcode", "commandcode", "commandcode", { timeout: 300 }],
["opencode", "opencode", "opencode", { timeout: 600000 }],
["opencode", "opencode", "opencode_v2", { timeout: 600000 }],
[
"codex",
"toml",
"toml",
{
tool_timeout_sec: 900,
enabled_tools: ["analyze_function"],
tools: { binary_session: { approval_mode: "approve" } },
},
],
["grok_build", "grok", "grok", { tool_timeout_sec: 900 }],
["hermes", "hermes", "hermes", { timeout: 600, connect_timeout: 60 }],
] as const)(
"keeps %s fields REA does not manage (%s, %s entry)",
async (name, format, dialect, extras) => {
const root = await createTestTempDirectory("rea-registration-fields-");
const configPath = join(root, "config");
const client: SetupClient = { name, format, configPath };
const existing = {
...clientRegistrationEntry(dialect, ["rea-old", "mcp"], environment),
...extras,
// Remote and disabling keys contradict the active stdio entry REA writes.
url: "https://stale.example/mcp",
...(Object.hasOwn(extras, "enabled") ? {} : { enabled: false }),
};
await writeFile(
configPath,
serializeClientConfiguration(
configurationWith(dialect, existing),
format,
),
);
expect(await inspectClientConfiguration(client, {}, command)).toMatchObject(
{ status: "update" },
);
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "configured" });
const configured = await readFile(configPath, "utf8");
expect(parseClientConfiguration(configured, format).servers.rea).toEqual({
...clientRegistrationEntry(dialect, command, environment),
...(name === "codex" || name === "grok_build"
? {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
...extras,
});
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
dialect,
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(await readFile(configPath, "utf8")).toBe(configured);
},
);
it("drops Codex oauth_resource from a local command entry", async () => {
const root = await createTestTempDirectory(
"rea-registration-oauth-resource-",
);
const configPath = join(root, "config.toml");
const client: SetupClient = { name: "codex", format: "toml", configPath };
const existing = {
...clientRegistrationEntry("toml", ["rea-old", "mcp"], environment),
tool_timeout_sec: 900,
oauth_resource: "https://stale.example/resource",
};
await writeFile(
configPath,
serializeClientConfiguration(configurationWith("toml", existing), "toml"),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "toml").servers
.rea,
).toEqual({
...clientRegistrationEntry("toml", command, environment),
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
tool_timeout_sec: 900,
});
});
it("drops a Codex tools array from a local command entry", async () => {
const root = await createTestTempDirectory("rea-registration-codex-tools-");
const configPath = join(root, "config.toml");
const client: SetupClient = { name: "codex", format: "toml", configPath };
const existing = {
...clientRegistrationEntry("toml", ["rea-old", "mcp"], environment),
tool_timeout_sec: 900,
tools: ["*"],
};
await writeFile(
configPath,
serializeClientConfiguration(configurationWith("toml", existing), "toml"),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "toml").servers
.rea,
).toEqual({
...clientRegistrationEntry("toml", command, environment),
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
tool_timeout_sec: 900,
});
});
it("drops malformed registration fields from a local command entry", async () => {
const root = await createTestTempDirectory("rea-registration-schema-");
const configPath = join(root, "mcp.json");
const client: SetupClient = { name: "cursor", format: "json", configPath };
const existing = {
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
timeout: 600000,
startup_timeout_sec: "30",
tools: "*",
};
await writeFile(
configPath,
serializeClientConfiguration(configurationWith("json", existing), "json"),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
.rea,
).toEqual({
...clientRegistrationEntry("json", command, environment),
timeout: 600000,
});
});
it("drops malformed activation switches from a local command entry", async () => {
const root = await createTestTempDirectory("rea-registration-switches-");
const configPath = join(root, "mcp.json");
const client: SetupClient = { name: "cursor", format: "json", configPath };
const existing = {
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
timeout: 600000,
enabled: "false",
disabled: "true",
};
await writeFile(
configPath,
serializeClientConfiguration(configurationWith("json", existing), "json"),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
.rea,
).toEqual({
...clientRegistrationEntry("json", command, environment),
timeout: 600000,
});
});
it("drops a Qwen tcp endpoint from a local command entry", async () => {
const root = await createTestTempDirectory("rea-registration-tcp-");
const configPath = join(root, "settings.json");
const client: SetupClient = {
name: "qwen_code",
format: "json",
configPath,
};
const existing = {
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
timeout: 600000,
tcp: "ws://stale.example/mcp",
};
await writeFile(
configPath,
serializeClientConfiguration(configurationWith("json", existing), "json"),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
.rea,
).toEqual({
...clientRegistrationEntry("json", command, environment),
timeout: 600000,
});
});
it("replaces a remote server entry instead of merging its fields", async () => {
const root = await createTestTempDirectory("rea-registration-remote-");
const configPath = join(root, "mcp.json");
const client: SetupClient = { name: "cursor", format: "json", configPath };
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
rea: {
url: "https://stale.example/mcp",
headers: { Authorization: "Bearer stale" },
timeout: 5000,
},
},
}),
);
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
{ status: "configured" },
);
expect(
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
.rea,
).toEqual(clientRegistrationEntry("json", command, {}));
});
it.each([
["tool_timeout_sec", "900"],
["enabled_tools", "analyze_function"],
["disabled_tools", [123]],
] as const)(
"repairs invalid Codex field %s before reporting alignment",
async (field, value) => {
const root = await createTestTempDirectory("rea-codex-field-repair-");
const configPath = join(root, "config.toml");
const client: SetupClient = { name: "codex", format: "toml", configPath };
await writeFile(
configPath,
serializeClientConfiguration(
{
mcp_servers: {
rea: {
command: "rea",
args: ["mcp"],
[field]: value,
timeout_note: "keep",
},
},
},
"toml",
),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const entry = parseClientConfiguration(
await readFile(configPath, "utf8"),
"toml",
).servers.rea;
expect(entry).not.toHaveProperty(field);
expect(entry).toHaveProperty("timeout_note", "keep");
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("unchanged");
},
);
@@ -696,49 +696,6 @@ describe("NIB projection byte amplification", () => {
});
});
cliTest(
"compiled CLI preserves distinct NIB keys and class identity",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-nib-name-identity-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true });
await writeFile(
join(bundle, "Contents", "MacOS", "App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(bundle, "Contents", "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
await writeFile(
join(resources, "Identity.nib"),
encodeNibArchiveFixture({
classes: ["\uFEFFWidget"],
objects: [{ classIndex: 0, values: { count: 1, "\uFEFFcount": 2 } }],
}),
);
const output = await cli.run({
arguments: ["decode-interface-builder", bundle, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = interfaceBuilderAnalysisSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.graph.nodes).toContainEqual(
expect.objectContaining({
attributes: expect.objectContaining({
class_name: "\uFEFFWidget",
nibValues: { count: 1, "\uFEFFcount": 2 },
}),
}),
);
expect(JSON.stringify(result)).toContain("\uFEFFWidget");
},
);
cliTest(
"compiled CLI preserves a good archive and reports repeated key and data omissions",
async ({ cli }) => {
@@ -168,49 +168,4 @@ describe("OMP disabledServers denylist", () => {
expect(await status()).toMatchObject({ state: "invalid" });
expect(join(home, ".omp", "agent", "mcp.json")).toBe(client.configPath);
});
it("keeps the entry's timeout through setup and reports it aligned", async () => {
const { home, client } = await ompClient();
const entry = resolve("scripts/rea.mjs");
const current = [process.execPath, entry, "mcp"];
const env = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" };
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
type: "stdio",
command: join(home, "old", "bun"),
args: [join(home, "old", "rea.mjs"), "mcp"],
env,
timeout: 0,
},
},
}),
);
expect(
(await configureClientConfiguration(client, {}, current)).status,
).toBe("configured");
const configured = await readFile(client.configPath, "utf8");
expect(parseJsonc(configured)).toEqual({
mcpServers: {
rea: {
type: "stdio",
command: process.execPath,
args: [entry, "mcp"],
env,
timeout: 0,
},
},
});
expect(await configureClientConfiguration(client, {}, current)).toEqual({
status: "unchanged",
});
expect(await readFile(client.configPath, "utf8")).toBe(configured);
expect(
(
await readClientRegistrationStatuses(home, entry, { environment: {} })
).find(({ client: name }) => name === "omp"),
).toMatchObject({ state: "aligned", remediation: null });
});
});
@@ -281,7 +281,7 @@ describe("Pi strict JSON parsing", () => {
});
expect(
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toEqual({ status: "create", dialect: "pi" });
).toEqual({ status: "create" });
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toMatchObject({ status: "configured" });
@@ -1,13 +1,7 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
@@ -157,121 +151,6 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a
}
});
it("ends worker stdin and unreferences process handles when cleanup reports inspection uncertainty", async () => {
let stdinEnded = false;
let unreferenced = false;
let permitCleanup = false;
let childStdin: typeof process.stdin | undefined;
const worker = new JavaScriptAnalysisWorker(
{ heapMb: 128, timeoutMs: 30_000 },
async (request) => {
const launched = await spawnOwnedProviderProcess(request);
const originalUnref = launched.process.unref;
launched.process.unref = () => {
unreferenced = true;
originalUnref?.call(launched.process);
};
childStdin = launched.process.stdin as unknown as typeof process.stdin;
launched.process.stdin?.once("finish", () => {
stdinEnded = true;
});
return {
...launched,
cleanup: async () =>
permitCleanup
? launched.cleanup === undefined
? cleanupOwnedProcessGroup(launched.ownership)
: launched.cleanup()
: {
cleaned: false,
reason: "process table could not be inspected: spawn EPERM",
},
};
},
);
try {
const file = source("app.mjs", "export const observed = 7;");
const result = await worker.analyze(file, 100);
if (!result.ok) throw result.error.error;
const closing = worker.close();
await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError);
await expect(closing).rejects.toMatchObject({
cause: {
message: "process table could not be inspected: spawn EPERM",
},
});
expect(childStdin?.writableEnded || stdinEnded).toBe(true);
expect(unreferenced).toBe(true);
const errorListeners = childStdin?.listenerCount("error");
for (let retry = 0; retry < 3; retry++)
await expect(worker.close()).rejects.toBeInstanceOf(ProviderCleanupError);
expect(childStdin?.listenerCount("error")).toBeLessThanOrEqual(
errorListeners ?? 0,
);
permitCleanup = true;
await worker.close();
} finally {
permitCleanup = true;
await worker.close().catch(() => {});
}
});
it("lets a separate host exit while its live worker cleanup remains incomplete", async () => {
const root = await createTestTempDirectory("rea-worker-incomplete-host-");
const ownershipPath = join(root, "ownership.json");
const workerUrl = new URL(
"../../../dist/javascript/analysis/JavaScriptAnalysisWorker.js",
import.meta.url,
).href;
const launcherUrl = new URL(
"../../../dist/process/ProviderProcess.js",
import.meta.url,
).href;
const script = `
import { writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { JavaScriptAnalysisWorker } from ${JSON.stringify(workerUrl)};
import { spawnOwnedProviderProcess } from ${JSON.stringify(launcherUrl)};
const worker = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 10000 }, async request => {
const entry = pathToFileURL(request.arguments.at(-1)).href;
const launched = await spawnOwnedProviderProcess({ ...request, arguments: [
...request.arguments.slice(0, -1), "--input-type=module", "-e",
"const interval = setInterval(() => {}, 1000); setTimeout(() => clearInterval(interval), 60000); await import(" + JSON.stringify(entry) + ");"
] });
// The launcher is deliberately orphaned when this host exits; cleanup still
// requires its run token and executable identity, rather than its old parent.
const { expectedParentPid, ...ownership } = launched.ownership;
await writeFile(${JSON.stringify(ownershipPath)}, JSON.stringify(ownership));
return { ...launched, cleanup: async () => ({ cleaned: false, reason: "fixture inspection unavailable" }) };
});
const analyzed = await worker.analyze(${JSON.stringify(source("app.mjs", "export const value = 1;"))}, 100);
if (!analyzed.ok) throw analyzed.error.error;
try { await worker.close(); throw new Error("cleanup unexpectedly completed"); }
catch (error) {
if (error.cleanupIncomplete !== true) throw error;
process.stdout.write(JSON.stringify({ code: "cleanup_incomplete", reason: error.cause.message }) + "\\n");
}
`;
try {
const result = await promisify(execFile)(
process.execPath,
["--input-type=module", "-e", script],
{ timeout: 10_000 },
);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "cleanup_incomplete",
reason: "fixture inspection unavailable",
});
} finally {
const ownership = JSON.parse(
await readFile(ownershipPath, "utf8"),
) as OwnedProcessGroup;
expect(await cleanupOwnedProcessGroup(ownership)).toMatchObject({
cleaned: true,
});
}
});
// The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit.
const resourceFailureWorker = (heapLimit: string) =>
new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) =>
@@ -130,71 +130,6 @@ it("accounts for JSON escape expansion of a stored declaration in the reply limi
]);
});
it("attributes triggers declared with another ASCII case to the stored table name", async () => {
const path = await createDatabase(`
CREATE TABLE Users(id INTEGER PRIMARY KEY, email TEXT);
CREATE INDEX users_email ON users(email);
CREATE TRIGGER audit_insert AFTER INSERT ON users BEGIN SELECT 1; END;
CREATE TRIGGER audit_delete AFTER DELETE ON USERS BEGIN SELECT 1; END;
CREATE VIEW recent AS SELECT 1;
CREATE TRIGGER recent_insert INSTEAD OF INSERT ON Recent BEGIN SELECT 1; END;
`);
const selected = inspectSqliteDatabaseSnapshot(path, {
path,
table: "Users",
});
expect(selected.schema).toMatchObject({
scope: "selected_table",
completeness: "complete",
tables: [{ name: "Users" }],
indexes: [{ name: "users_email", table: "Users" }],
views: [],
});
expect(
selected.schema.triggers.map(({ name, table }) => ({ name, table })),
).toEqual([
{ name: "audit_insert", table: "Users" },
{ name: "audit_delete", table: "Users" },
]);
const inventory = inspectSqliteDatabaseSnapshot(path, { path });
expect(inventory.schema.triggers).toEqual([
{ name: "audit_insert", table: "Users" },
{ name: "audit_delete", table: "Users" },
{ name: "recent_insert", table: "recent" },
]);
});
it.each([
{
selection: "a differently cased table",
schema: "CREATE TABLE Users(id INTEGER PRIMARY KEY)",
trigger: "AFTER INSERT ON Users",
table: "users",
},
{
selection: "an exact view",
schema: "CREATE VIEW Recent AS SELECT 1 AS id",
trigger: "INSTEAD OF INSERT ON recent",
table: "Recent",
},
])(
"refuses $selection before expanding declarations under another spelling",
async ({ schema, trigger, table }) => {
const path = await createDatabase(`
${schema};
CREATE TRIGGER expanded ${trigger} BEGIN /*${"\u0001".repeat(3 * 1024 * 1024)}*/ SELECT 1; END;
`);
expect(() =>
inspectSqliteDatabaseSnapshot(path, { path, table }),
).toThrowError(
expect.objectContaining({
reason: "selection",
message: `Selected ordinary table does not exist with that exact name: ${table}`,
}),
);
},
);
it.each(["UTF-8", "UTF-16le", "UTF-16be"])(
"preserves exact %s schema text when decoding native bytes",
async (encoding) => {
+66 -71
View File
@@ -22,73 +22,6 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const reobservesExternallyMutableState = async (): Promise<void> => {
const { root, target } = await createIdaTarget();
roots.push(root);
const path = join(root, "registration.json");
await writeFile(path, JSON.stringify({ command: "fixture" }));
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
if (!config.ok) throw config.error;
const producer = new RecordingIdaMcp(target);
const session = createTestBinarySession(
new IdaProvider(config.value, {}, () => producer),
);
expect((await session.open(target.path)).ok).toBe(true);
const query = { procedure: "main", document: target.path };
const first = await session.execute("procedure_pseudo_code", query);
expect(first.ok && first.value.result).toContain("42");
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
expect(snapshot.value.entries).toEqual([]);
const profile = session.analysisProfile();
if (profile === undefined) throw new Error("Missing profile");
const cached = createAnalysisExecution(
"stale output",
IDA_PROVIDER_IDENTITY,
{
analysisProfile: profile,
subject: {
path: target.path,
sha256: target.sha256,
format: target.format,
...(target.architecture === undefined
? {}
: { architecture: target.architecture }),
},
},
);
snapshot.value.entries.push(
createAnalysisSnapshotEntry({
target: snapshotTarget(target),
binding: snapshotBinding(profile),
operation: "procedure_pseudo_code",
parameters: query,
execution: cached,
}),
);
snapshot.value.evidence_bundle = createEvidenceBundle([
...snapshot.value.evidence_bundle.records,
createEvidence(target, IDA_PROVIDER_IDENTITY, {
operation: "procedure_pseudo_code",
parameters: query,
result: cached.result,
rawResult: cached.rawResult,
analysisProfile: profile,
}),
]);
expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false);
expect(session.allowsSnapshotReplay("binary_overview")).toBe(false);
producer.pseudocode = "int main(void) { return 99; }";
const second = await session.execute("procedure_pseudo_code", query);
expect(second.ok && second.value.result).toContain("99");
expect(
producer.calls.filter(({ name }) => name === "decompile_function"),
).toHaveLength(2);
await session.close();
expect(producer.closes).toBe(1);
};
describe("IDA provider composition", () => {
it("discovers without launching and commits adapter semantics without persisting credentials", async () => {
const { root, target } = await createIdaTarget();
@@ -126,10 +59,72 @@ describe("IDA provider composition", () => {
.find(({ operation }) => operation === "procedure_callers")?.available,
).toBe(false);
});
it(
"re-observes externally mutable state even when a document is explicit and a snapshot is imported",
reobservesExternallyMutableState,
);
it("re-observes externally mutable state even when a document is explicit and a snapshot is imported", async () => {
const { root, target } = await createIdaTarget();
roots.push(root);
const path = join(root, "registration.json");
await writeFile(path, JSON.stringify({ command: "fixture" }));
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
if (!config.ok) throw config.error;
const producer = new RecordingIdaMcp(target);
const session = createTestBinarySession(
new IdaProvider(config.value, {}, () => producer),
);
expect((await session.open(target.path)).ok).toBe(true);
const query = { procedure: "main", document: target.path };
const first = await session.execute("procedure_pseudo_code", query);
expect(first.ok && first.value.result).toContain("42");
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
expect(snapshot.value.entries).toEqual([]);
const profile = session.analysisProfile();
if (profile === undefined) throw new Error("Missing profile");
const cached = createAnalysisExecution(
"stale output",
IDA_PROVIDER_IDENTITY,
{
analysisProfile: profile,
subject: {
path: target.path,
sha256: target.sha256,
format: target.format,
...(target.architecture === undefined
? {}
: { architecture: target.architecture }),
},
},
);
snapshot.value.entries.push(
createAnalysisSnapshotEntry({
target: snapshotTarget(target),
binding: snapshotBinding(profile),
operation: "procedure_pseudo_code",
parameters: query,
execution: cached,
}),
);
snapshot.value.evidence_bundle = createEvidenceBundle([
...snapshot.value.evidence_bundle.records,
createEvidence(target, IDA_PROVIDER_IDENTITY, {
operation: "procedure_pseudo_code",
parameters: query,
result: cached.result,
rawResult: cached.rawResult,
analysisProfile: profile,
}),
]);
expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false);
expect(session.allowsSnapshotReplay("binary_overview")).toBe(false);
producer.pseudocode = "int main(void) { return 99; }";
const second = await session.execute("procedure_pseudo_code", query);
expect(second.ok && second.value.result).toContain("99");
expect(
producer.calls.filter(({ name }) => name === "decompile_function"),
).toHaveLength(2);
await session.close();
expect(producer.closes).toBe(1);
});
it("rejects unsupported target kinds and reports incomplete upstream profiles on first analysis", async () => {
const { root, target } = await createIdaTarget();
roots.push(root);