Compare commits

..
Author SHA1 Message Date
morluto a8adc2c6b1 fix(setup): retain Codex per-tool approval maps 2026-10-14 01:10:13 +08:00
REA Maintainers 7b90bc5a67 fix(setup): reconcile client plans and native registration fields 2026-10-13 22:55:53 +08:00
DragoloneandGitHub 94072364c7 fix(sqlite): keep triggers whose declaration spells the table in another case (#2807)
* fix(sqlite): keep triggers whose declaration spells the table in another case

SQLite stores a trigger's target in sqlite_schema.tbl_name exactly as
written in CREATE TRIGGER and resolves it by ASCII case folding, while an
index stores the table's own name. The selected_table scope filtered
tbl_name with COLLATE BINARY, so a trigger declared ON users for table
Users was omitted while the schema still reported complete. Match
dependents with COLLATE NOCASE, and report each trigger's table as the
stored table or view name so the selected-table contract still holds.

* fix(sqlite): refuse an inexact table before the case-folded schema scan

With the NOCASE expansion, a selection such as users for table Users
expanded the stored table's declarations before readRows reported the
missing exact name, so large trigger definitions could surface as an
output-limit failure instead of the selection error. Check the exact
table name against PRAGMA table_list first and share the selection
failure with readRows.

* fix(sqlite): fold dependent names only for a row-selectable table

An exact view, virtual or possible shadow table passed the exact-name
guard, so the NOCASE scan could still expand a differently cased trigger
past the reply budget before readRows refused the selection. Fold case
only when the selection is an ordinary table that is not a possible
shadow table, and keep the exact filter otherwise so every refused
selection fails as it did before the case-folding change.

* fix(sqlite): resolve trigger tables without folding unrelated names

The stored-name lookup case-folded every PRAGMA table_list name up front,
so a selected-table inspection of a tiny table paid for large unrelated
identifiers. A selected-table scan only returns the selected table's
dependents, so report the selected name directly; other scopes use an
exact stored name when one matches and fold the stored names once, only
when a trigger spells its target differently.

* perf(sqlite): fold each table name once when matching shadow parents

The extracted shadow-parent helper folded the table name inside the find
callback, once per unresolved virtual table, and the selected table was
checked both before and during the schema scan. Fold the name once per
call as before, and reuse the selected table's result in the scan.
2026-10-13 22:37:47 +08:00
e103a89889 fix(artifacts): preserve NIB key and class name identity (#2109)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:36:06 +08:00
edd9939879 fix(browser): give the DevToolsActivePort wait one deadline owner (#2265)
* Single owner for the Chrome DevToolsActivePort startup deadline

Cold Chrome startup tolerance was spelled three ways for one wait: the helper
fell back to 15,000 ms, the runtime fixture lifecycle passed a literal 60,000,
and the real-browser verifier kept a local 60,000 constant. A per-caller literal
quietly changes how long a lane tolerates a cold browser before it fails, and
the failure reported only elapsed_ms, so a reader could not see which ceiling
was hit.

Export BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS as the owner, use it for the helper
fallback and both call sites, and report startup_timeout_ms in
BrowserStartupError. A source guard keeps callers from restating the number.

* Pin the DevToolsActivePort deadline guard to the startup call argument

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:35:45 +08:00
1e38a038c6 fix(process): release handles and close worker on incomplete cleanup (#2321)
* fix(process): unreference handles on incomplete provider process stop

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* fix(javascript): close worker and release handles on incomplete cleanup

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* fix(javascript): defer worker stdin close until incomplete cleanup

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* test(javascript): assert cleanup error instance on inspection uncertainty

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

---------

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:34:54 +08:00
morluto 6e25cc3c76 fix(tests): clear lint and typecheck errors on main
Split two over-long test functions into module-level helpers
(max-lines-per-function): IdaSessionClient transport cases, IDA provider
composition, and function comparison identity cases keep their names and
assertions.

Also fix three typecheck errors: default omitted
direct_invokes_exhaustive to null so the result schema stays JsonValue
compatible, parse the ghidra dossier before spreading it, and delete the
optional beforeCall hook instead of assigning undefined under
exactOptionalPropertyTypes.
2026-10-13 22:32:01 +08:00
REA Maintainers 1f1bb95d53 fix(setup): align activation schemas and approval disclosure 2026-10-13 21:32:35 +08:00
morluto 3370bab50b style(setup): format retained field validation 2026-10-13 20:20:42 +08:00
morluto 69582df9c3 fix(setup): validate retained Codex registration fields 2026-10-13 20:10:19 +08:00
xeropresence 303008f35d fix(setup): drop ZCode's enable: false from retained entries
Main's ZCode registration treats a native enable: false as stale and expects setup to repair it. Entry retention kept that switch, so setup reported unchanged while doctor still reported stale.
2026-10-12 21:49:21 -04:00
Cursor Agentandxeropresence 55246a6425 fix(setup): match retained fields to the client that writes them
Codex drops a tools array that its stdio config rejects, and the Harness approval plan lists the Cordis launcher keys the writer replaces.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence 4eb3b5335a fix(setup): describe the inspected OpenCode registration dialect
The approval plan uses the dialect configuration inspection parsed, so a native OpenCode server table is not described as rewriting enabled.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence fb3fa1dc5f refactor(setup): accept retained fields from the registration schema
Setup keeps a registration field only when the schema doctor parses accepts it, and the OpenCode legacy disclosure follows that client's dialect.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ecd92970a4 fix(setup): retain only registration fields doctor can parse
Setup kept malformed startup_timeout_sec and tools values that doctor
rejects, and the approval plan restated retention rules by client name.
Both now use the registration schema and the entry the writer builds.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ff34e7aeff fix(setup): drop non-boolean activation switches from retained entries
A string such as enabled: "false" was kept and then accepted as the desired
entry, so setup could report success while doctor rejected the registration.
Those values are removed with the other managed switches.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 53ba7e7b31 fix(setup): drop stale Qwen tcp endpoints and name rewritten entry keys
Qwen prefers a tcp websocket endpoint over command, so a command entry that
still has tcp stays remote. Setup now removes that key. The approval plan
names startup_timeout_sec and Copilot tools when setup replaces them.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 0cd9f47ff1 fix(setup): disclose remote-key removal in the approval plan
A local command entry still drops stale remote-server keys and off switches.
The plan now says those fields are removed, and that Grok Build keeps its
entry disabled flag.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 502e665212 fix(setup): drop stale Codex oauth_resource and disclose remote replacement
A command entry that still carries oauth_resource is rejected by Codex for
stdio. Setup now treats that key as remote and removes it. The approval plan
keeps other fields only for a local command entry and says a remote entry's
other fields are replaced.
2026-10-12 21:35:28 -04:00
xeropresence b478a8b011 fix(setup): preserve user fields on an existing REA client entry 2026-10-12 21:35:28 -04:00
32 changed files with 1403 additions and 185 deletions
+15
View File
@@ -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,
+5 -3
View File
@@ -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;
};
+7 -15
View File
@@ -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"
+12
View File
@@ -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>> = {},
+120
View File
@@ -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();
+37 -27
View File
@@ -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,
}
: {}),
};
};
+23 -5
View File
@@ -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."
+8 -1
View File
@@ -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";
+16
View File
@@ -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"],
+23 -6
View File
@@ -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}`;
+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({
...ghidraFunctionDossier(),
...functionDossierSchema.parse(ghidraFunctionDossier()),
facet_availability: {
comments: { available: false, reason: "Typed comments unavailable" },
references: { available: false, reason: "Typed references unavailable" },
+51 -41
View File
@@ -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",
+1 -1
View File
@@ -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;
}
}
+22
View File
@@ -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();
+37
View File
@@ -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();
}
});
+50 -13
View File
@@ -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",
+14
View File
@@ -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) => {
+71 -66
View File
@@ -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);