Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8adc2c6b1 | ||
|
|
7b90bc5a67 | ||
|
|
94072364c7 | ||
|
|
e103a89889 | ||
|
|
edd9939879 | ||
|
|
1e38a038c6 | ||
|
|
6e25cc3c76 | ||
|
|
1f1bb95d53 | ||
|
|
3370bab50b | ||
|
|
69582df9c3 | ||
|
|
303008f35d | ||
|
|
55246a6425 | ||
|
|
4eb3b5335a | ||
|
|
fb3fa1dc5f | ||
|
|
ecd92970a4 | ||
|
|
ff34e7aeff | ||
|
|
53ba7e7b31 | ||
|
|
0cd9f47ff1 | ||
|
|
502e665212 | ||
|
|
b478a8b011 |
@@ -66,6 +66,21 @@ 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,7 +8,10 @@ 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 { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
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) {
|
||||
@@ -50,7 +53,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: 60_000,
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
port,
|
||||
|
||||
@@ -9,7 +9,10 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
@@ -47,7 +50,6 @@ 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;
|
||||
@@ -92,7 +94,7 @@ try {
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS,
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
|
||||
@@ -23,7 +23,9 @@ 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";
|
||||
|
||||
@@ -1500,6 +1502,145 @@ 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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
};
|
||||
@@ -14,6 +14,10 @@ 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 {
|
||||
@@ -61,20 +65,6 @@ 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;
|
||||
@@ -330,7 +320,9 @@ const parseRegistration = (
|
||||
args,
|
||||
});
|
||||
}
|
||||
const registration = registrationSchema.parse(value);
|
||||
const registration = registrationSchemaFor(client.format ?? "json").parse(
|
||||
value,
|
||||
);
|
||||
if (
|
||||
(client.format === "vscode" || client.format === "copilot_cli") &&
|
||||
registration.type !== "stdio"
|
||||
|
||||
@@ -404,6 +404,18 @@ 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>> = {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -152,6 +153,15 @@ 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);
|
||||
});
|
||||
@@ -185,6 +195,116 @@ 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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
applyRegistrationStartupTimeout,
|
||||
clientRegistrationEntry,
|
||||
clientConfigurationValuesEqual,
|
||||
clientServerListedDisabled,
|
||||
@@ -13,9 +14,11 @@ 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";
|
||||
@@ -27,7 +30,6 @@ 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 {
|
||||
@@ -444,7 +446,7 @@ const inspectClientDocument = async (
|
||||
throwCleanupFailure(fallback.error);
|
||||
return { status: "invalid", remediation: fallback.error.detail };
|
||||
}
|
||||
return { status: "create" };
|
||||
return { status: "create", dialect: clientRegistrationDialect(client) };
|
||||
}
|
||||
return {
|
||||
status: "invalid",
|
||||
@@ -474,12 +476,26 @@ const inspectClientDocument = async (
|
||||
parsed,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
return { status: "already_current", dialect: parsed.dialect };
|
||||
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.
|
||||
@@ -490,21 +506,15 @@ 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";
|
||||
|
||||
@@ -699,17 +709,17 @@ const clientConfigurationDesired = (
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!environment.ok) throw environment.error;
|
||||
const registration = clientRegistrationEntry(
|
||||
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(
|
||||
format,
|
||||
launcher,
|
||||
environment.value,
|
||||
parsed?.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
registration,
|
||||
);
|
||||
return {
|
||||
...registration,
|
||||
...(client.name === "codex" || client.name === "grok_build"
|
||||
? {
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
describeClientRegistrationRetention,
|
||||
type ClientRegistrationDialect,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { describeDshRegistrationRetention } from "./DshCordisPatch.js";
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
linuxHopperInstallDisclosure,
|
||||
@@ -230,6 +235,9 @@ const plannedClientWrites = (
|
||||
inspection.backupPath !== undefined
|
||||
? { backupPath: inspection.backupPath }
|
||||
: {}),
|
||||
...(inspection.dialect === undefined
|
||||
? {}
|
||||
: { dialect: inspection.dialect }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -247,6 +255,7 @@ const setupPlan = (input: {
|
||||
readonly client: SetupClient;
|
||||
readonly operation: "create" | "update";
|
||||
readonly backupPath?: string;
|
||||
readonly dialect?: ClientRegistrationDialect;
|
||||
}[];
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly linuxPackageFamily?: LinuxPackageFamily;
|
||||
@@ -284,13 +293,13 @@ const setupPlan = (input: {
|
||||
: []),
|
||||
...input.clients
|
||||
.filter(({ client }) => client.format !== "unsupported")
|
||||
.map(({ client, operation, backupPath }): SetupAction => ({
|
||||
.map(({ client, operation, backupPath, dialect }): SetupAction => ({
|
||||
id: `configure_client:${client.name}`,
|
||||
kind: "configure_client",
|
||||
label: client.displayName ?? client.name,
|
||||
target: client.configPath,
|
||||
detail: clientConfigurationDetail(
|
||||
client,
|
||||
{ ...client, ...(dialect === undefined ? {} : { dialect }) },
|
||||
input.command,
|
||||
input.providerEnvironment,
|
||||
input.installHopper,
|
||||
@@ -334,7 +343,7 @@ const majorVersion = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
|
||||
const clientConfigurationDetail = (
|
||||
client: SetupClient,
|
||||
client: SetupClient & { readonly dialect?: ClientRegistrationDialect },
|
||||
command: readonly string[],
|
||||
environment: SetupProviderEnvironment,
|
||||
installHopper: boolean,
|
||||
@@ -342,10 +351,19 @@ 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."
|
||||
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
|
||||
? ` 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}`;
|
||||
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,3 +1,4 @@
|
||||
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";
|
||||
@@ -45,10 +46,16 @@ export type ClientConfigurationResult =
|
||||
|
||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||
export type ClientConfigurationInspection =
|
||||
| { readonly status: "already_current" }
|
||||
| {
|
||||
readonly status: "already_current";
|
||||
/** Dialect the registration writer will use when this file is parsed. */
|
||||
readonly dialect?: ClientRegistrationDialect;
|
||||
}
|
||||
| {
|
||||
readonly status: "create" | "update";
|
||||
readonly backupPath?: string;
|
||||
/** Dialect the registration writer will use for this file. */
|
||||
readonly dialect?: ClientRegistrationDialect;
|
||||
}
|
||||
| {
|
||||
readonly status: "invalid";
|
||||
|
||||
@@ -36,6 +36,22 @@ 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"],
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -23,6 +24,7 @@ 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;
|
||||
@@ -33,12 +35,22 @@ 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;
|
||||
@@ -53,23 +65,23 @@ export interface BrowserStartupOptions {
|
||||
export const waitForBrowserDevtoolsPort = async (
|
||||
options: BrowserStartupOptions,
|
||||
): Promise<number> => {
|
||||
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||
const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 25;
|
||||
const startedAt = performance.now();
|
||||
const terminal = childTermination(options, startedAt);
|
||||
const terminal = childTermination(options, startedAt, timeoutMs);
|
||||
const deadline = startedAt + timeoutMs;
|
||||
|
||||
while (true) {
|
||||
const terminalState = currentTerminalState(options.child);
|
||||
if (terminalState !== null)
|
||||
throw startupError(options, startedAt, terminalState);
|
||||
throw startupError(options, startedAt, timeoutMs, terminalState);
|
||||
|
||||
const port = await readDevtoolsPort(options.activePortPath);
|
||||
if (port !== null) return port;
|
||||
|
||||
const remainingMs = deadline - performance.now();
|
||||
if (remainingMs <= 0)
|
||||
throw startupError(options, startedAt, { failure: "timeout" });
|
||||
throw startupError(options, startedAt, timeoutMs, { failure: "timeout" });
|
||||
|
||||
const outcome = await Promise.race([
|
||||
terminal,
|
||||
@@ -88,11 +100,12 @@ 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, {
|
||||
startupError(options, startedAt, startupTimeoutMs, {
|
||||
failure: "spawn-error",
|
||||
cause,
|
||||
}),
|
||||
@@ -103,6 +116,7 @@ const childTermination = (
|
||||
startupError(
|
||||
options,
|
||||
startedAt,
|
||||
startupTimeoutMs,
|
||||
options.child.signalCode === null
|
||||
? { failure: "exited" }
|
||||
: { failure: "signalled" },
|
||||
@@ -120,6 +134,7 @@ const currentTerminalState = (child: ChildProcess): TerminalState | null => {
|
||||
const startupError = (
|
||||
options: BrowserStartupOptions,
|
||||
startedAt: number,
|
||||
startupTimeoutMs: number,
|
||||
state: TerminalState,
|
||||
): BrowserStartupError =>
|
||||
new BrowserStartupError(
|
||||
@@ -127,6 +142,7 @@ 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(),
|
||||
@@ -156,9 +172,10 @@ 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)}; 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)}; 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}`;
|
||||
|
||||
@@ -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({
|
||||
...ghidraFunctionDossier(),
|
||||
...functionDossierSchema.parse(ghidraFunctionDossier()),
|
||||
facet_availability: {
|
||||
comments: { available: false, reason: "Typed comments unavailable" },
|
||||
references: { available: false, reason: "Typed references unavailable" },
|
||||
|
||||
@@ -128,6 +128,49 @@ 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> = {
|
||||
@@ -187,47 +230,14 @@ 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", () => {
|
||||
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(
|
||||
"rejects malformed facet availability instead of treating it as absence",
|
||||
rejectsMalformedFacetAvailability,
|
||||
);
|
||||
it(
|
||||
"keeps differing generated names ambiguous and stable names mismatched",
|
||||
keepsGeneratedNamesAmbiguous,
|
||||
);
|
||||
|
||||
it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])(
|
||||
"recognizes the explicit symbol %s without a generated-name prefix false positive",
|
||||
|
||||
@@ -1459,7 +1459,7 @@ it.each(["idb_close", "idb_list", "connection_close"])(
|
||||
});
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
} finally {
|
||||
producer.beforeCall = undefined;
|
||||
delete producer.beforeCall;
|
||||
producer.close = originalClose;
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ 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 +=
|
||||
@@ -784,20 +785,40 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ 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. */
|
||||
@@ -371,13 +373,33 @@ 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();
|
||||
|
||||
@@ -121,3 +121,40 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,6 +109,11 @@ 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());
|
||||
@@ -236,12 +241,49 @@ 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",
|
||||
@@ -254,7 +296,9 @@ 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 BINARY" : "";
|
||||
scope === "selected_table"
|
||||
? ` WHERE tbl_name = ? COLLATE ${foldDependents ? "NOCASE" : "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}`,
|
||||
);
|
||||
@@ -280,13 +324,10 @@ const readSchema = (
|
||||
"format",
|
||||
`SQLite schema/table inventory mismatch: ${value.name}`,
|
||||
);
|
||||
const identifier = sqliteIdentifierKey(value.name);
|
||||
const possibleShadowParent =
|
||||
listed.type === "table"
|
||||
? unresolvedVirtualTables.find((parent) =>
|
||||
identifier.startsWith(parent.prefix),
|
||||
)
|
||||
: undefined;
|
||||
value.name === selectedName
|
||||
? selectedShadowParent
|
||||
: shadowParentOf(value.name, listed);
|
||||
const table: SqliteDatabase["schema"]["tables"][number] = {
|
||||
name: value.name,
|
||||
...(scope === "inventory" ? {} : { sql: value.sql }),
|
||||
@@ -349,7 +390,7 @@ const readSchema = (
|
||||
} else {
|
||||
const trigger = {
|
||||
name: value.name,
|
||||
table: value.tbl_name,
|
||||
table: triggerTable(value.tbl_name),
|
||||
...(scope === "inventory" ? {} : { sql: value.sql }),
|
||||
};
|
||||
budget.retain(trigger);
|
||||
@@ -370,11 +411,7 @@ const readRows = (
|
||||
): SqliteDatabase["rows"] => {
|
||||
const { encoding, budget } = context;
|
||||
if (input.table === undefined) return null;
|
||||
if (table === undefined)
|
||||
throw new SqliteInspectionFailure(
|
||||
"selection",
|
||||
`Selected ordinary table does not exist with that exact name: ${input.table}`,
|
||||
);
|
||||
if (table === undefined) throw missingSelectedTable(input.table);
|
||||
if (table.kind === "unknown")
|
||||
throw new SqliteInspectionFailure(
|
||||
"selection",
|
||||
|
||||
@@ -67,6 +67,7 @@ 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;
|
||||
@@ -222,6 +223,18 @@ 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;
|
||||
@@ -255,6 +268,7 @@ 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,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from "../../support/process/processFixture.js";
|
||||
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
BrowserStartupError,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../../../src/browser/BrowserProcessStartup.js";
|
||||
@@ -86,15 +88,68 @@ 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,6 +50,7 @@ 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({
|
||||
@@ -73,6 +74,7 @@ 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, {
|
||||
@@ -174,6 +176,7 @@ 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",
|
||||
@@ -184,6 +187,7 @@ 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" });
|
||||
).toEqual({ status: "already_current", dialect: format });
|
||||
expect(
|
||||
await configureClientConfiguration(client, providers, command),
|
||||
).toEqual({ status: "unchanged" });
|
||||
|
||||
@@ -46,6 +46,7 @@ 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(
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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,6 +696,49 @@ 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,4 +168,49 @@ 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" });
|
||||
).toEqual({ status: "create", dialect: "pi" });
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
|
||||
).toMatchObject({ status: "configured" });
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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";
|
||||
@@ -151,6 +157,121 @@ 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,6 +130,71 @@ 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) => {
|
||||
|
||||
@@ -22,6 +22,73 @@ 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();
|
||||
@@ -59,72 +126,10 @@ 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", 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(
|
||||
"re-observes externally mutable state even when a document is explicit and a snapshot is imported",
|
||||
reobservesExternallyMutableState,
|
||||
);
|
||||
it("rejects unsupported target kinds and reports incomplete upstream profiles on first analysis", async () => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
|
||||
Reference in New Issue
Block a user