mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 14:26:07 +00:00
1608 lines
56 KiB
TypeScript
1608 lines
56 KiB
TypeScript
import { readFile, readdir, rm } from "node:fs/promises";
|
|
import { sep } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
createIdaTarget,
|
|
RecordingIdaMcp,
|
|
} from "../../tests/fixtures/idaMcp.js";
|
|
import { IdaSessionClient } from "./IdaSessionClient.js";
|
|
import { functionDossierSchema } from "../domain/hopperValues.js";
|
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
|
|
|
|
const roots: string[] = [];
|
|
const windowsRuntimes = new Set<WindowsPrivateRuntime>();
|
|
beforeEach(() => {
|
|
if (process.platform !== "win32") return;
|
|
const create = WindowsPrivateRuntime.create;
|
|
vi.spyOn(WindowsPrivateRuntime, "create").mockImplementation(
|
|
(parent, prefix) => {
|
|
const runtime = create(parent, prefix);
|
|
windowsRuntimes.add(runtime);
|
|
return runtime;
|
|
},
|
|
);
|
|
});
|
|
afterEach(async () => {
|
|
try {
|
|
// These pure producer fixtures spawn no IDA worker. After the retention
|
|
// assertions, release only the actual native owners this test created.
|
|
for (const runtime of windowsRuntimes) {
|
|
await runtime.close();
|
|
windowsRuntimes.delete(runtime);
|
|
}
|
|
for (const root of roots.splice(0))
|
|
await rm(root, { recursive: true, force: true });
|
|
} finally {
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
/** A failed close whose diagnostic names exactly these unverified sessions. */
|
|
const reportsSessions = (...sessions: readonly object[]) => ({
|
|
error: {
|
|
diagnostics: { upstream_sessions: { state: "unverified", sessions } },
|
|
},
|
|
});
|
|
const LIVE_PEER = {
|
|
session_id: "unrelated-live-peer",
|
|
backend: "worker",
|
|
owned: false,
|
|
} as const;
|
|
|
|
const fixture = async (
|
|
mode: "attached" | "headless" = "attached",
|
|
timeoutMs = 1000,
|
|
protocol?: "native",
|
|
) => {
|
|
const { root, target } = await createIdaTarget();
|
|
roots.push(root);
|
|
const producer = new RecordingIdaMcp(target, mode);
|
|
const client = new IdaSessionClient(
|
|
{
|
|
command: "fixture",
|
|
args: [],
|
|
env: {},
|
|
mode,
|
|
...(protocol === undefined ? {} : { protocol }),
|
|
timeoutMs,
|
|
workspaceRoot: root,
|
|
},
|
|
target,
|
|
producer,
|
|
);
|
|
return { root, target, producer, client };
|
|
};
|
|
|
|
describe("headless IDA producer pagination", () => {
|
|
it("follows reported cursors for modern inventories, pseudocode, and bare-hex assembly without losing pages", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name, args) => {
|
|
const first = args.offset === 0;
|
|
const cursor = first ? { next: 1 } : { done: true };
|
|
if (name === "decompile")
|
|
return { code: first ? "first line" : "second line", cursor };
|
|
if (name === "disasm")
|
|
return {
|
|
asm: {
|
|
name: "main",
|
|
start_ea: "0x1000",
|
|
lines: [
|
|
{
|
|
addr: first ? "1000" : "1001",
|
|
instruction: first ? "push rbp" : "ret",
|
|
},
|
|
],
|
|
},
|
|
cursor,
|
|
};
|
|
if (name === "find_regex")
|
|
return {
|
|
matches: [
|
|
{
|
|
addr: first ? "0x3000" : "0x3002",
|
|
string: first ? "a.b" : "a+b",
|
|
},
|
|
],
|
|
cursor,
|
|
};
|
|
if (name === "list_funcs") {
|
|
const page = z
|
|
.object({ offset: z.number() })
|
|
.parse(args.queries).offset;
|
|
return [
|
|
{
|
|
data: [
|
|
{
|
|
addr: page === 0 ? "0x1000" : "0x2000",
|
|
name: page === 0 ? "main" : "other",
|
|
size: "0x20",
|
|
},
|
|
],
|
|
next_offset: page === 0 ? 1 : null,
|
|
},
|
|
];
|
|
}
|
|
return undefined;
|
|
};
|
|
const inventory = await client.execute("list_procedures", {});
|
|
expect(inventory.ok && inventory.value.result).toHaveLength(2);
|
|
const strings = await client.execute("list_strings", {});
|
|
expect(strings.ok && strings.value.result).toHaveLength(2);
|
|
const result = await client.execute("analyze_function", {
|
|
procedure: "main",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const dossier = functionDossierSchema.parse(result.value.result);
|
|
expect(dossier.pseudocode).toBe("first line\nsecond line");
|
|
expect(dossier.assembly).toEqual(["0x1000: push rbp", "0x1001: ret"]);
|
|
expect(dossier).toMatchObject({
|
|
procedure: {
|
|
classification: null,
|
|
body: {
|
|
available: false,
|
|
reason: "IDA MCP does not report complete function body ranges.",
|
|
},
|
|
},
|
|
native_api: null,
|
|
native_value_flow: null,
|
|
});
|
|
await client.close();
|
|
});
|
|
});
|
|
|
|
describe("attached IDA readiness and queued cancellation", () => {
|
|
it("allows ready-only work after an attached provider has initialized", async () => {
|
|
const { producer, client } = await fixture("attached");
|
|
await expect(client.executeIfReady("list_procedures", {})).resolves.toBe(
|
|
undefined,
|
|
);
|
|
|
|
const startup = await client.execute("list_procedures", {});
|
|
if (!startup.ok) throw startup.error;
|
|
const result = await client.executeIfReady("list_procedures", {});
|
|
if (result === undefined)
|
|
throw new Error("expected ready attached IDA dispatch");
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toEqual([
|
|
expect.objectContaining({ address: "0x1000", value: "main" }),
|
|
]);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_open"),
|
|
).toHaveLength(0);
|
|
await client.close();
|
|
});
|
|
|
|
it("returns cold and pending startup calls immediately, then cancels queued work before dispatch", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
await expect(client.executeIfReady("list_procedures", {})).resolves.toBe(
|
|
undefined,
|
|
);
|
|
expect(producer.connects).toBe(0);
|
|
|
|
let enterOpen: (() => void) | undefined;
|
|
let finishOpen: (() => void) | undefined;
|
|
const openEntered = new Promise<void>((resolve) => {
|
|
enterOpen = resolve;
|
|
});
|
|
const openGate = new Promise<void>((resolve) => {
|
|
finishOpen = resolve;
|
|
});
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_open") {
|
|
enterOpen?.();
|
|
await openGate;
|
|
}
|
|
};
|
|
const startup = client.execute("list_procedures", {});
|
|
await openEntered;
|
|
await expect(client.executeIfReady("list_strings", {})).resolves.toBe(
|
|
undefined,
|
|
);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_open"),
|
|
).toHaveLength(1);
|
|
finishOpen?.();
|
|
const started = await startup;
|
|
if (!started.ok) throw started.error;
|
|
const warm = await client.executeIfReady("list_procedures", {});
|
|
if (warm === undefined) throw new Error("expected ready IDA dispatch");
|
|
if (!warm.ok) throw warm.error;
|
|
expect(warm.value.result).toEqual([
|
|
expect.objectContaining({ address: "0x1000", value: "main" }),
|
|
]);
|
|
|
|
let enterRead: (() => void) | undefined;
|
|
let finishRead: (() => void) | undefined;
|
|
const readEntered = new Promise<void>((resolve) => {
|
|
enterRead = resolve;
|
|
});
|
|
const readGate = new Promise<void>((resolve) => {
|
|
finishRead = resolve;
|
|
});
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "list_funcs") {
|
|
enterRead?.();
|
|
await readGate;
|
|
}
|
|
};
|
|
const active = client.execute("list_procedures", {});
|
|
await readEntered;
|
|
const controller = new AbortController();
|
|
const queued = client.executeIfReady(
|
|
"list_strings",
|
|
{},
|
|
{
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
controller.abort();
|
|
await expect(queued).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
finishRead?.();
|
|
const activeResult = await active;
|
|
if (!activeResult.ok) throw activeResult.error;
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "list_funcs"),
|
|
).toHaveLength(3);
|
|
expect(producer.calls.some(({ name }) => name === "find_regex")).toBe(
|
|
false,
|
|
);
|
|
await client.close();
|
|
});
|
|
});
|
|
|
|
describe("attached IDA input identity and validation", () => {
|
|
it("accepts a normalized spelling of the bound document path", async () => {
|
|
const { target, producer, client } = await fixture();
|
|
const equivalent =
|
|
process.platform === "win32"
|
|
? target.path.toLowerCase().replaceAll("\\", "/")
|
|
: target.path.split(sep).join(`${sep}.${sep}`);
|
|
|
|
const result = await client.execute("list_procedures", {
|
|
document: equivalent,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(producer.calls.some(({ name }) => name === "list_functions")).toBe(
|
|
true,
|
|
);
|
|
await client.close();
|
|
});
|
|
|
|
it("rejects an unrelated document before connecting or opening a provider", async () => {
|
|
const { producer, client } = await fixture();
|
|
const result = await client.execute("list_procedures", {
|
|
document: "/unrelated/input",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe("AnalysisInputError");
|
|
expect(result.error.message).toContain("list_procedures");
|
|
}
|
|
expect(producer.connects).toBe(0);
|
|
expect(producer.calls).toEqual([]);
|
|
await client.close();
|
|
});
|
|
it("preserves hexadecimal-looking function names as names unless the caller uses an explicit 0x address", async () => {
|
|
const { producer, client } = await fixture();
|
|
producer.overrideCall = (name, args) =>
|
|
name === "get_function_by_name" && args.name === "f"
|
|
? { address: "0x1000", name: "f", size: "0x20" }
|
|
: undefined;
|
|
const found = await client.execute("procedure_address", { procedure: "f" });
|
|
expect(found.ok && found.value.result).toBe("0x1000");
|
|
expect(
|
|
producer.calls.some(({ name }) => name === "get_function_by_address"),
|
|
).toBe(false);
|
|
await client.close();
|
|
});
|
|
it("preserves omitted optional arguments and rejects explicit null before connecting", async () => {
|
|
const { producer, client } = await fixture();
|
|
for (const [operation, parameters] of [
|
|
["list_procedures", { document: null }],
|
|
["list_strings", { address: null }],
|
|
["procedure_pseudo_code", { procedure: null }],
|
|
] as const) {
|
|
const result = await client.execute(operation, parameters);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisInputError",
|
|
operation,
|
|
issues: [{ path: Object.keys(parameters), reason: "invalid_type" }],
|
|
});
|
|
}
|
|
expect(producer.connects).toBe(0);
|
|
expect(producer.calls).toEqual([]);
|
|
expect((await client.execute("list_procedures", {})).ok).toBe(true);
|
|
expect((await client.execute("list_strings", {})).ok).toBe(true);
|
|
expect(
|
|
(await client.execute("procedure_pseudo_code", { procedure: "main" })).ok,
|
|
).toBe(true);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "decompile_function"),
|
|
).toHaveLength(1);
|
|
await client.close();
|
|
});
|
|
});
|
|
|
|
describe("attached IDA function observations", () => {
|
|
it("resolves interior addresses and call sites to canonical entries, preserves externals, and reports unknown body evidence", async () => {
|
|
const { producer, client } = await fixture();
|
|
const result = await client.execute("analyze_function", {
|
|
procedure: "0x1004",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const dossier = functionDossierSchema.parse(result.value.result);
|
|
expect(dossier.procedure.address).toBe("0x1000");
|
|
expect(dossier.callers.map((caller) => caller.address)).toEqual(["0x1000"]);
|
|
expect(dossier.callees.map((callee) => callee.address)).toEqual(["0x2000"]);
|
|
expect(dossier.procedure.body.available).toBe(false);
|
|
expect(dossier.comments).toEqual([]);
|
|
expect(result.value.rawResult).toMatchObject({
|
|
observations: expect.arrayContaining([
|
|
expect.objectContaining({ tool: "get_callers" }),
|
|
]),
|
|
});
|
|
expect(result.value.provider.version).toBe("1");
|
|
expect(producer.serverInfo()).toMatchObject({
|
|
version: "sdk-version-is-not-ida",
|
|
});
|
|
await client.close();
|
|
expect(producer.calls.some(({ name }) => name.startsWith("idb_"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
it("returns complete paged inventory and distinguishes literal from regex search", async () => {
|
|
const { producer, client } = await fixture();
|
|
producer.overrideCall = (name, args) =>
|
|
name === "list_functions"
|
|
? {
|
|
data: [
|
|
{
|
|
address: args.offset === 0 ? "0x1000" : "0x2000",
|
|
name: "main",
|
|
size: "0x10",
|
|
},
|
|
],
|
|
next_offset: args.offset === 0 ? 100 : null,
|
|
}
|
|
: undefined;
|
|
const inventory = await client.execute("list_procedures", {});
|
|
expect(inventory.ok && inventory.value.result).toHaveLength(2);
|
|
for (const [pattern, mode, count] of [
|
|
["a.b", "literal", 1],
|
|
["a+b", "literal", 0],
|
|
["a.b", "regex", 1],
|
|
] as const) {
|
|
const found = await client.execute("search_strings", { pattern, mode });
|
|
expect(found.ok && found.value.result).toHaveLength(count);
|
|
}
|
|
expect(
|
|
(await client.execute("search_strings", { pattern: "[", mode: "regex" }))
|
|
.ok,
|
|
).toBe(false);
|
|
await client.close();
|
|
});
|
|
it("rejects mismatched identities and target switches rather than returning a successful observation", async () => {
|
|
const { producer, client, target } = await fixture();
|
|
producer.inputSha256 = "a".repeat(64);
|
|
const mismatch = await client.execute("health", {});
|
|
expect(mismatch.ok).toBe(false);
|
|
if (!mismatch.ok) expect(mismatch.error.message).toContain("SHA-256");
|
|
producer.inputSha256 = target.sha256;
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "decompile_function") producer.imageBase = "0x2000";
|
|
};
|
|
const changed = await client.execute("procedure_pseudo_code", {
|
|
procedure: "main",
|
|
});
|
|
expect(changed.ok).toBe(false);
|
|
if (!changed.ok) expect(changed.error.message).toContain("changed");
|
|
await client.close();
|
|
});
|
|
it("rejects malformed producer data, non-advancing cursors, unsupported mutation, and missing explicit addresses", async () => {
|
|
const { producer, client } = await fixture();
|
|
expect(
|
|
(
|
|
await client.execute("set_address_name", {
|
|
address: "0x1000",
|
|
name: "changed",
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
expect(producer.connects).toBe(0);
|
|
expect((await client.execute("xrefs", {})).ok).toBe(false);
|
|
const invalidAddress = await client.execute("xrefs", {
|
|
address: "not-an-address",
|
|
});
|
|
expect(invalidAddress.ok).toBe(false);
|
|
if (!invalidAddress.ok)
|
|
expect(invalidAddress.error._tag).toBe("AnalysisInputError");
|
|
expect(producer.connects).toBe(0);
|
|
producer.overrideCall = (name) =>
|
|
name === "list_functions" ? { data: [], next_offset: 0 } : undefined;
|
|
expect((await client.execute("list_procedures", {})).ok).toBe(false);
|
|
producer.overrideCall = (name) =>
|
|
name === "decompile_function" ? { unexpected: true } : undefined;
|
|
const malformed = await client.execute("procedure_pseudo_code", {
|
|
procedure: "main",
|
|
});
|
|
expect(malformed.ok).toBe(false);
|
|
if (!malformed.ok) expect(malformed.error._tag).toBe("AnalysisOutputError");
|
|
await client.close();
|
|
});
|
|
it("drains in-flight read-only work on cancellation before closing its proxy", async () => {
|
|
const { producer, client } = await fixture();
|
|
await client.execute("health", {});
|
|
let release: (() => void) | undefined;
|
|
let notify: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
notify = resolve;
|
|
});
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "decompile_function") {
|
|
notify?.();
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
}
|
|
};
|
|
const controller = new AbortController();
|
|
const call = client.execute(
|
|
"procedure_pseudo_code",
|
|
{ procedure: "main" },
|
|
{ signal: controller.signal },
|
|
);
|
|
await started;
|
|
controller.abort();
|
|
const close = client.close();
|
|
expect(producer.closes).toBe(0);
|
|
release?.();
|
|
const result = await call;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect((await close).ok).toBe(true);
|
|
expect(producer.closes).toBe(1);
|
|
});
|
|
});
|
|
|
|
const busyHealth = (busyTool?: JsonValue): JsonValue => ({
|
|
status: "busy",
|
|
uptime_sec: 1.041,
|
|
...(busyTool === undefined ? {} : { busy_tool: busyTool }),
|
|
busy_sec: 0,
|
|
queued_calls: 0,
|
|
});
|
|
const healthCalls = (producer: { calls: { name: string }[] }) =>
|
|
producer.calls.filter(({ name }) => name === "server_health").length;
|
|
|
|
const expectCleanClose = async (
|
|
producer: { closes: number },
|
|
client: { close: () => Promise<{ ok: boolean }> },
|
|
): Promise<void> => {
|
|
expect((await client.close()).ok, "the owned worker is released").toBe(true);
|
|
expect(producer.closes, "the upstream connection is closed once").toBe(1);
|
|
};
|
|
|
|
describe("headless IDA transient busy health", () => {
|
|
it("re-reads server_health until the worker reports ok", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
let busyReads = 2;
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" && busyReads-- > 0
|
|
? busyHealth("config_json_get")
|
|
: undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
expect(result.ok, "a transient busy worker is waited out").toBe(true);
|
|
expect(
|
|
healthCalls(producer),
|
|
"two busy reads and the ok read that settles the identity",
|
|
).toBe(3);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
|
|
it("returns a retryable timeout naming the busy tool at the registration deadline", async () => {
|
|
const { producer, client } = await fixture("headless", 300);
|
|
const busy = busyHealth("config_json_get");
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? busy : undefined;
|
|
const started = Date.now();
|
|
|
|
const result = await client.execute("health", {});
|
|
const elapsed = Date.now() - started;
|
|
|
|
if (result.ok) throw new Error("expected a busy timeout");
|
|
expect(result.error._tag, "the neutral timeout class is used").toBe(
|
|
"AnalysisTimeoutError",
|
|
);
|
|
const projected = projectAnalysisError(result.error);
|
|
expect(projected.code, "the outcome is a provider timeout").toBe(
|
|
"provider_timeout",
|
|
);
|
|
expect(projected.retryable, "the caller may retry").toBe(true);
|
|
expect(
|
|
projected.details,
|
|
"the observed busy tool is named exactly in the operation",
|
|
).toMatchObject({
|
|
operation: "server_health: headless worker still busy in config_json_get",
|
|
timeout_ms: 300,
|
|
});
|
|
expect(
|
|
result.error.message,
|
|
"the error message names the busy tool",
|
|
).toContain("config_json_get");
|
|
expect(
|
|
`${result.error.message} ${projected.message}`,
|
|
"no Hopper wording leaks in",
|
|
).not.toContain("Hopper");
|
|
expect(result.error.cause, "the raw busy payload is the cause").toEqual(
|
|
busy,
|
|
);
|
|
expect(
|
|
elapsed,
|
|
"the wait runs to the deadline, not less",
|
|
).toBeGreaterThanOrEqual(290);
|
|
expect(
|
|
healthCalls(producer),
|
|
"reads are spaced by the retry delay, not a tight loop",
|
|
).toBeLessThanOrEqual(6);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
|
|
it("never invents a busy tool the worker did not report", async () => {
|
|
const { producer, client } = await fixture("headless", 300);
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? busyHealth() : undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
if (result.ok) throw new Error("expected a busy timeout");
|
|
expect(
|
|
projectAnalysisError(result.error).details,
|
|
"an omitted busy_tool is reported as exactly unknown",
|
|
).toMatchObject({
|
|
operation: "server_health: headless worker still busy in unknown",
|
|
});
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
|
|
it.each([
|
|
["free-form text", "token=abc123 /private<home> secret"],
|
|
["an over-long name", "x".repeat(200)],
|
|
])(
|
|
"reports %s from the worker as unknown instead of echoing it",
|
|
async (_label, tool) => {
|
|
const { producer, client } = await fixture("headless", 300);
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? busyHealth(tool) : undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
if (result.ok) throw new Error("expected a busy timeout");
|
|
expect(
|
|
projectAnalysisError(result.error).details,
|
|
"only a tool-name-shaped value is repeated",
|
|
).toMatchObject({
|
|
operation: "server_health: headless worker still busy in unknown",
|
|
});
|
|
expect(
|
|
result.error.message,
|
|
"the producer text never reaches the error message",
|
|
).not.toContain(tool);
|
|
await expectCleanClose(producer, client);
|
|
},
|
|
);
|
|
|
|
it("ends the wait at once when the caller aborts", async () => {
|
|
const { producer, client } = await fixture("headless", 30_000);
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? busyHealth("config_json_get") : undefined;
|
|
const controller = new AbortController();
|
|
const started = Date.now();
|
|
const call = client.execute("health", {}, { signal: controller.signal });
|
|
while (healthCalls(producer) < 2) {
|
|
if (Date.now() - started > 5_000) throw new Error("never polled");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
|
|
controller.abort();
|
|
const result = await call;
|
|
|
|
if (result.ok) throw new Error("expected cancellation");
|
|
expect(result.error._tag, "cancellation keeps today's outcome").toBe(
|
|
"AnalysisCancelledError",
|
|
);
|
|
expect(
|
|
Date.now() - started,
|
|
"the wait does not run to the registration deadline",
|
|
).toBeLessThan(5_000);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA busy health after an operation", () => {
|
|
const withInventory = (
|
|
producer: RecordingIdaMcp,
|
|
afterObservation: (read: number) => JsonValue | undefined,
|
|
) => {
|
|
let health = 0;
|
|
producer.overrideCall = (name) => {
|
|
if (name === "list_funcs")
|
|
return [
|
|
{
|
|
data: [{ addr: "0x1000", name: "main", size: "0x20" }],
|
|
next_offset: null,
|
|
},
|
|
];
|
|
if (name !== "server_health") return undefined;
|
|
health += 1;
|
|
return health < 2 ? undefined : afterObservation(health);
|
|
};
|
|
};
|
|
|
|
it("waits out a busy observation taken after the operation", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
withInventory(producer, (read) =>
|
|
read === 2 ? busyHealth("config_json_get") : undefined,
|
|
);
|
|
|
|
const result = await client.execute("list_procedures", {});
|
|
|
|
expect(result.ok, "the operation succeeds once the worker settles").toBe(
|
|
true,
|
|
);
|
|
expect(healthCalls(producer), "before, busy after, ok after").toBe(3);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
|
|
it("maps a cancellation during that wait to cancelled", async () => {
|
|
const { producer, client } = await fixture("headless", 30_000);
|
|
withInventory(producer, () => busyHealth("config_json_get"));
|
|
const controller = new AbortController();
|
|
const call = client.execute(
|
|
"list_procedures",
|
|
{},
|
|
{ signal: controller.signal },
|
|
);
|
|
const started = Date.now();
|
|
while (healthCalls(producer) < 3) {
|
|
if (Date.now() - started > 5_000) throw new Error("never polled");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
|
|
controller.abort();
|
|
const result = await call;
|
|
|
|
if (result.ok) throw new Error("expected cancellation");
|
|
expect(result.error._tag, "the after-observation wait is cancellable").toBe(
|
|
"AnalysisCancelledError",
|
|
);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA non-busy health payloads", () => {
|
|
it.each([
|
|
["an unknown status", { status: "starting" }],
|
|
["a non-string busy_tool", busyHealth(5)],
|
|
["a payload without a status", { uptime_sec: 1 }],
|
|
])(
|
|
"keeps %s as unreadable output without waiting",
|
|
async (_label, payload) => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? jsonValueSchema.parse(payload) : undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
if (result.ok) throw new Error("expected unreadable output");
|
|
expect(
|
|
projectAnalysisError(result.error).code,
|
|
"only the narrow busy shape enters the wait",
|
|
).toBe("unreadable_output");
|
|
expect(healthCalls(producer), "no re-read happened").toBe(1);
|
|
await expectCleanClose(producer, client);
|
|
},
|
|
);
|
|
|
|
it("keeps the busy-shape reason when a busy payload is invalid", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health" ? busyHealth(5) : undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
if (result.ok) throw new Error("expected unreadable output");
|
|
expect(
|
|
projectAnalysisError(result.error).details,
|
|
"the diagnostic names the invalid busy_tool, not the ok-schema fields",
|
|
).toMatchObject({ reason: expect.stringContaining("busy_tool") });
|
|
expect(
|
|
JSON.stringify(projectAnalysisError(result.error).details),
|
|
"the generic ok-schema field errors are not reported for a busy payload",
|
|
).not.toContain("input_path");
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
|
|
it("stops waiting as soon as a later read is malformed", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
let reads = 0;
|
|
producer.overrideCall = (name) =>
|
|
name === "server_health"
|
|
? reads++ === 0
|
|
? busyHealth("config_json_get")
|
|
: busyHealth(5)
|
|
: undefined;
|
|
|
|
const result = await client.execute("health", {});
|
|
|
|
if (result.ok) throw new Error("expected unreadable output");
|
|
expect(
|
|
projectAnalysisError(result.error).code,
|
|
"a malformed re-read is not treated as busy",
|
|
).toBe("unreadable_output");
|
|
expect(
|
|
healthCalls(producer),
|
|
"exactly the busy read and the bad read",
|
|
).toBe(2);
|
|
await expectCleanClose(producer, client);
|
|
});
|
|
});
|
|
|
|
describe("IDA compatibility profile mismatch", () => {
|
|
it("reports the native profile for an incompatible catalog", async () => {
|
|
const { producer, client } = await fixture("attached", 1000, "native");
|
|
producer.tools = [];
|
|
const result = await client.execute("health", {});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected incompatible native catalog");
|
|
expect(projectAnalysisError(result.error).details).toMatchObject({
|
|
diagnostics: {
|
|
mode: "attached",
|
|
expected_profile: "native",
|
|
missing_tools: expect.arrayContaining(["decompile"]),
|
|
},
|
|
});
|
|
expect(producer.calls).toEqual([]);
|
|
expect((await client.close()).ok).toBe(true);
|
|
});
|
|
|
|
it("names every missing tool so the caller can identify the upstream profile", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.tools = [
|
|
"idb_open",
|
|
"idb_list",
|
|
"server_health",
|
|
"list_funcs",
|
|
"lookup_funcs",
|
|
"find_regex",
|
|
"decompile",
|
|
"disasm",
|
|
"callees",
|
|
"xrefs_to",
|
|
];
|
|
const result = await client.execute("health", {});
|
|
expect(result.ok, "an incomplete profile cannot serve analysis").toBe(
|
|
false,
|
|
);
|
|
if (result.ok) return;
|
|
const projected = projectAnalysisError(result.error);
|
|
expect(
|
|
projected.details,
|
|
"the absent tool and the expected profile are both reported",
|
|
).toMatchObject({
|
|
provider_id: "ida",
|
|
diagnostics: {
|
|
mode: "headless",
|
|
expected_profile: "database-supervisor",
|
|
missing_tools: ["idb_close"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reports the legacy profile when an attached producer serves modern tools", async () => {
|
|
const { producer, client } = await fixture("attached");
|
|
producer.tools = ["server_health", "lookup_funcs", "decompile", "disasm"];
|
|
const result = await client.execute("health", {});
|
|
expect(result.ok, "an incomplete profile cannot serve analysis").toBe(
|
|
false,
|
|
);
|
|
if (result.ok) return;
|
|
expect(
|
|
projectAnalysisError(result.error).details,
|
|
"attached mode names the legacy profile it expected",
|
|
).toMatchObject({
|
|
diagnostics: { mode: "attached", expected_profile: "legacy-1.4" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("headless IDA lifecycle boundaries", () => {
|
|
it("never closes an unowned worker even when open echoes the requested private identity", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.owned = false;
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
const closed = await client.close();
|
|
expect(closed.ok).toBe(false);
|
|
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
|
|
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA session ownership and cleanup", () => {
|
|
it("shares an in-flight close across concurrent cleanup callers", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
|
|
let release: (() => void) | undefined;
|
|
let notify: (() => void) | undefined;
|
|
const closeStarted = new Promise<void>((resolve) => {
|
|
notify = resolve;
|
|
});
|
|
producer.beforeCall = async (name) => {
|
|
if (name !== "idb_close") return;
|
|
notify?.();
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
};
|
|
|
|
const first = client.close();
|
|
await closeStarted;
|
|
const second = client.close();
|
|
release?.();
|
|
const results = await Promise.all([first, second]);
|
|
|
|
expect(results.every((result) => result.ok)).toBe(true);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_close"),
|
|
).toHaveLength(1);
|
|
expect(producer.closes).toBe(1);
|
|
});
|
|
|
|
it("retains the workspace after an unconfirmed open, even when inventory is empty, and never retries startup implicitly", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_open")
|
|
throw new Error("Timed out while the worker may still be opening");
|
|
};
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_open"),
|
|
).toHaveLength(1);
|
|
const closed = await client.close();
|
|
expect(closed.ok).toBe(false);
|
|
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
expect(producer.closes).toBe(0);
|
|
});
|
|
it("opens a private digest-verified copy, scopes requests, closes without saving, and removes only its workspace", async () => {
|
|
const { producer, client, root, target } = await fixture("headless");
|
|
const original = await readFile(target.path);
|
|
const result = await client.execute("analyze_function", {
|
|
procedure: "main",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
expect(producer.inputPath).not.toBe(target.path);
|
|
expect(await readFile(producer.inputPath)).toEqual(original);
|
|
expect(
|
|
functionDossierSchema
|
|
.parse(result.value.result)
|
|
.limitations.some((value) => value.includes("Direct callers")),
|
|
).toBe(true);
|
|
const open = producer.calls.find(({ name }) => name === "idb_open");
|
|
expect(open?.args).toMatchObject({
|
|
mode: "force_headless",
|
|
run_auto_analysis: true,
|
|
});
|
|
for (const call of producer.calls.filter(
|
|
({ name }) => !["idb_open", "idb_list"].includes(name),
|
|
))
|
|
expect(call.args.database).toBe(producer.database);
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(
|
|
producer.calls.find(({ name }) => name === "idb_close")?.args.save,
|
|
).toBe(false);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
expect(await readFile(target.path)).toEqual(original);
|
|
});
|
|
it("retains the private workspace and reports cleanup failure when worker release is unverified", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
await client.execute("health", {});
|
|
const sessionId = producer.database;
|
|
producer.failClose = true;
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result).toMatchObject(
|
|
reportsSessions({
|
|
session_id: sessionId,
|
|
backend: "worker",
|
|
owned: true,
|
|
input_path: producer.inputPath,
|
|
}),
|
|
);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA reaped worker cleanup", () => {
|
|
it("recovers a reaped worker only after inventory confirms its session and private input are absent", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const sessionId = producer.database;
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_close") producer.database = undefined;
|
|
};
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_close"
|
|
? { error: `Session not found: ${sessionId}` }
|
|
: undefined;
|
|
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(producer.calls.map(({ name }) => name).slice(-2)).toEqual([
|
|
"idb_close",
|
|
"idb_list",
|
|
]);
|
|
expect(producer.closes).toBe(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
|
|
it("retains resources when an absent-session close is followed by a live private-input peer", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const sessionId = producer.database;
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_close") producer.database = undefined;
|
|
if (name === "idb_list" && producer.database === undefined) {
|
|
producer.database = "unrelated-live-peer";
|
|
producer.owned = false;
|
|
}
|
|
};
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_close"
|
|
? { error: `Session not found: ${sessionId}` }
|
|
: undefined;
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
|
|
// The released session must not be named as the remaining worker.
|
|
expect(result).toMatchObject(reportsSessions(LIVE_PEER));
|
|
expect(producer.closes).toBe(0);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
|
|
// A retry whose inventory is unavailable keeps the observed blocker.
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_list") throw new Error("inventory unavailable");
|
|
};
|
|
const retried = await client.close();
|
|
expect(retried.ok).toBe(false);
|
|
expect(retried).toMatchObject(reportsSessions(LIVE_PEER));
|
|
});
|
|
|
|
it("retries independent inventory verification after an absent-session response", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const sessionId = producer.database;
|
|
let inventoryUnavailable = true;
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_close") producer.database = undefined;
|
|
if (name === "idb_list" && inventoryUnavailable) {
|
|
inventoryUnavailable = false;
|
|
throw new Error("Inventory temporarily unavailable");
|
|
}
|
|
};
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_close"
|
|
? { error: `Session not found: ${sessionId}` }
|
|
: undefined;
|
|
|
|
expect((await client.close()).ok).toBe(false);
|
|
expect(producer.closes).toBe(0);
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_close"),
|
|
).toHaveLength(1);
|
|
expect(producer.closes).toBe(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
|
|
it("does not treat a different missing session identity as proof of release", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_close") producer.database = undefined;
|
|
};
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_close"
|
|
? { error: "Session not found: another-session" }
|
|
: undefined;
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_list"),
|
|
).toHaveLength(1);
|
|
expect(producer.closes).toBe(0);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
/** A rejected open response, with helpers for separately inventoried workers. */
|
|
const rejectedOpenFixture = async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_open" ? { error: "open did not complete" } : undefined;
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
const open = producer.calls.find(({ name }) => name === "idb_open")?.args;
|
|
const entry = (session_id: string, owned: boolean) => ({
|
|
session_id,
|
|
input_path: String(open?.input_path),
|
|
owned,
|
|
backend: "worker",
|
|
is_active: true,
|
|
});
|
|
return {
|
|
producer,
|
|
client,
|
|
requested: String(open?.preferred_session_id),
|
|
entry,
|
|
};
|
|
};
|
|
|
|
describe("headless IDA cleanup diagnostics", () => {
|
|
it("does not name a worker whose close was confirmed when inventory then fails", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_list") throw new Error("inventory unavailable");
|
|
};
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
expect(producer.calls.map(({ name }) => name)).toContain("idb_close");
|
|
expect(result).toMatchObject(reportsSessions());
|
|
});
|
|
|
|
it("retains peers and confirmed ownership from successful startup when close fails", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name) => {
|
|
if (name === "idb_list")
|
|
return {
|
|
sessions: [
|
|
{
|
|
session_id: String(producer.database),
|
|
input_path: producer.inputPath,
|
|
owned: true,
|
|
backend: "worker",
|
|
is_active: true,
|
|
},
|
|
{
|
|
session_id: "startup-peer",
|
|
input_path: producer.inputPath,
|
|
owned: false,
|
|
backend: "worker",
|
|
is_active: false,
|
|
},
|
|
],
|
|
};
|
|
if (name === "idb_close") return { error: "close rejected" };
|
|
return undefined;
|
|
};
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const result = await client.close();
|
|
expect(result).toMatchObject(
|
|
reportsSessions(
|
|
{
|
|
session_id: producer.database,
|
|
backend: "worker",
|
|
owned: true,
|
|
input_path: producer.inputPath,
|
|
},
|
|
{
|
|
session_id: "startup-peer",
|
|
backend: "worker",
|
|
owned: false,
|
|
input_path: producer.inputPath,
|
|
is_active: false,
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
it("preserves an observed conflicting input binding after close", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const requested = String(producer.database);
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_list"
|
|
? {
|
|
sessions: [
|
|
{
|
|
session_id: requested,
|
|
input_path: "/different/input",
|
|
owned: false,
|
|
backend: "worker",
|
|
is_active: true,
|
|
},
|
|
],
|
|
}
|
|
: undefined;
|
|
const result = await client.close();
|
|
expect(result).toMatchObject(
|
|
reportsSessions({
|
|
session_id: requested,
|
|
backend: "worker",
|
|
owned: false,
|
|
input_path: "/different/input",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps peers seen beside the requested worker when its close fails", async () => {
|
|
const { producer, client, requested, entry } = await rejectedOpenFixture();
|
|
producer.overrideCall = (name) => {
|
|
if (name === "idb_list")
|
|
return {
|
|
sessions: [
|
|
entry(requested, true),
|
|
entry("unrelated-live-peer", false),
|
|
],
|
|
};
|
|
if (name === "idb_close") return { error: "close rejected" };
|
|
return undefined;
|
|
};
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
expect(result).toMatchObject(
|
|
reportsSessions(
|
|
{ session_id: requested, backend: "worker", owned: true },
|
|
LIVE_PEER,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA cleanup diagnostics for observed peers", () => {
|
|
it("drops the requested worker from blockers once its close is confirmed", async () => {
|
|
const { producer, client, requested, entry } = await rejectedOpenFixture();
|
|
let inventories = 0;
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_list" && ++inventories > 1)
|
|
throw new Error("inventory unavailable");
|
|
};
|
|
producer.overrideCall = (name) => {
|
|
if (name === "idb_list")
|
|
return {
|
|
sessions: [
|
|
entry(requested, true),
|
|
entry("unrelated-live-peer", false),
|
|
],
|
|
};
|
|
if (name === "idb_close")
|
|
return {
|
|
success: true,
|
|
session_id: requested,
|
|
saved: false,
|
|
owned: true,
|
|
backend: "worker",
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
expect(result).toMatchObject(reportsSessions(LIVE_PEER));
|
|
});
|
|
|
|
it("reports a blocking GUI session by its backend", async () => {
|
|
const { producer, client } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const sessionId = producer.database;
|
|
producer.overrideCall = (name) => {
|
|
if (name === "idb_close")
|
|
return { error: `Session not found: ${sessionId}` };
|
|
if (name === "idb_list")
|
|
return {
|
|
sessions: [
|
|
{
|
|
session_id: "",
|
|
input_path: producer.inputPath,
|
|
owned: false,
|
|
backend: "gui",
|
|
is_active: true,
|
|
},
|
|
],
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
expect(result).toMatchObject(
|
|
reportsSessions({ session_id: "", backend: "gui", owned: false }),
|
|
);
|
|
});
|
|
});
|
|
|
|
/** A completed open response is not proof that its private Worker was ever released. */
|
|
describe("headless IDA unresolved successful-open ownership", () => {
|
|
it.each(["matching", "foreign-input", "malformed"] as const)(
|
|
"retains the private input after %s success with incomplete initial discovery",
|
|
async (kind) => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
let hidden = true;
|
|
let ownedId: string | undefined;
|
|
let privateInput: string | undefined;
|
|
producer.beforeCall = async (name, args) => {
|
|
if (name !== "idb_open") return;
|
|
ownedId = z.string().parse(args.preferred_session_id);
|
|
privateInput = z.string().parse(args.input_path);
|
|
producer.database = ownedId;
|
|
producer.inputPath = privateInput;
|
|
};
|
|
producer.overrideCall = (name) => {
|
|
if (name === "idb_open") {
|
|
const originalId = z.string().parse(ownedId);
|
|
const originalInput = z.string().parse(privateInput);
|
|
if (kind === "foreign-input")
|
|
return {
|
|
success: true,
|
|
session: {
|
|
session_id: "foreign-session",
|
|
input_path: "/foreign/input",
|
|
},
|
|
};
|
|
if (kind === "malformed")
|
|
return { success: true, session: { session_id: originalId } };
|
|
return {
|
|
success: true,
|
|
session: { session_id: originalId, input_path: originalInput },
|
|
};
|
|
}
|
|
if (name === "idb_list" && hidden) return { sessions: [] };
|
|
return undefined;
|
|
};
|
|
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
const incomplete = await client.close();
|
|
expect(incomplete.ok).toBe(false);
|
|
if (!incomplete.ok) expect(incomplete.error.cleanupIncomplete).toBe(true);
|
|
expect(producer.closes).toBe(0);
|
|
expect(producer.database).toBe(ownedId);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(
|
|
false,
|
|
);
|
|
|
|
// A later positive private ownership record permits ordinary save=false
|
|
// cleanup, without ever adopting the foreign open-response identity.
|
|
hidden = false;
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(producer.calls.filter(({ name }) => name === "idb_close")).toEqual(
|
|
[{ name: "idb_close", args: { database: ownedId, save: false } }],
|
|
);
|
|
expect(producer.closes).toBe(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
},
|
|
);
|
|
|
|
it("preserves cleanup after a completed, explicit idb_open rejection", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_open"
|
|
? { error: "Input was rejected before creating a session" }
|
|
: undefined;
|
|
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false);
|
|
expect(producer.closes).toBe(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
|
|
it("retains cleanup when a rejected open leaves the requested ID on another input", async () => {
|
|
const { producer, client, requested } = await rejectedOpenFixture();
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_list"
|
|
? {
|
|
sessions: [
|
|
{
|
|
session_id: requested,
|
|
input_path: "/different/input",
|
|
owned: false,
|
|
backend: "worker",
|
|
is_active: true,
|
|
},
|
|
],
|
|
}
|
|
: undefined;
|
|
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(producer.closes).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("headless IDA cleanup verification", () => {
|
|
it("retries failed worker release through the retained connection before removing its workspace", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
producer.failClose = true;
|
|
expect((await client.close()).ok).toBe(false);
|
|
expect(producer.closes).toBe(0);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
producer.failClose = false;
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_close"),
|
|
).toHaveLength(2);
|
|
expect(producer.closes).toBe(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
it("retries post-close inventory verification without closing an already released database again", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
let inventoryFails = true;
|
|
producer.beforeCall = async (name) => {
|
|
if (
|
|
name === "idb_list" &&
|
|
producer.database === undefined &&
|
|
inventoryFails
|
|
)
|
|
throw new Error(
|
|
"Worker inventory unavailable after acknowledged close",
|
|
);
|
|
};
|
|
expect((await client.close()).ok).toBe(false);
|
|
expect(producer.closes).toBe(0);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
inventoryFails = false;
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "idb_close"),
|
|
).toHaveLength(1);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
it("releases a privately owned worker after malformed open output using verified session inventory", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
producer.beforeCall = async (name, args) => {
|
|
if (name === "idb_open") {
|
|
producer.database = z.string().parse(args.preferred_session_id);
|
|
producer.inputPath = z.string().parse(args.input_path);
|
|
}
|
|
};
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_open"
|
|
? jsonValueSchema.parse({
|
|
success: true,
|
|
session: { session_id: producer.database },
|
|
})
|
|
: undefined;
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(
|
|
producer.calls.find(({ name }) => name === "idb_close")?.args.database,
|
|
).toMatch(/^rea-/u);
|
|
expect(await readdir(root)).toEqual(["target.elf"]);
|
|
});
|
|
});
|
|
|
|
it("retains a conflicting requested session ID during pre-close recovery", async () => {
|
|
const { producer, client, root } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_list"
|
|
? {
|
|
sessions: [
|
|
{
|
|
session_id: String(producer.database),
|
|
input_path: "/conflicting/input",
|
|
owned: true,
|
|
backend: "worker",
|
|
is_active: false,
|
|
},
|
|
],
|
|
}
|
|
: undefined;
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
const requested = String(producer.database);
|
|
const result = await client.close();
|
|
expect(result).toMatchObject(
|
|
reportsSessions({
|
|
session_id: requested,
|
|
input_path: "/conflicting/input",
|
|
owned: true,
|
|
backend: "worker",
|
|
is_active: false,
|
|
}),
|
|
);
|
|
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false);
|
|
expect(
|
|
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each(["unavailable", "malformed"] as const)(
|
|
"does not invent a worker after explicit rejection and %s inventory",
|
|
async (kind) => {
|
|
const { producer, client } = await fixture("headless");
|
|
producer.overrideCall = (name) =>
|
|
name === "idb_open"
|
|
? { error: "rejected before worker creation" }
|
|
: name === "idb_list" && kind === "malformed"
|
|
? {}
|
|
: undefined;
|
|
expect((await client.execute("health", {})).ok).toBe(false);
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "idb_list" && kind === "unavailable")
|
|
throw new Error("inventory unavailable");
|
|
};
|
|
const result = await client.close();
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected retained cleanup");
|
|
expect(result.error).not.toHaveProperty("diagnostics.upstream_sessions");
|
|
},
|
|
);
|
|
|
|
it.each(["idb_close", "idb_list", "connection_close"])(
|
|
"preserves transport stderr through cleanup at %s",
|
|
async (stage) => {
|
|
const { producer, client } = await fixture("headless");
|
|
expect((await client.execute("health", {})).ok).toBe(true);
|
|
const failure = new ProviderAdapterError("ida", "transport", {
|
|
diagnostics: {
|
|
reason: "upstream transport closed",
|
|
},
|
|
capturedOutput: {
|
|
stdout: "",
|
|
stderr: "IDA shutdown diagnostic",
|
|
truncated: false,
|
|
},
|
|
});
|
|
const originalClose = producer.close.bind(producer);
|
|
producer.beforeCall = async (name) => {
|
|
if (name === stage) throw failure;
|
|
};
|
|
if (stage === "connection_close")
|
|
producer.close = async () => {
|
|
throw failure;
|
|
};
|
|
try {
|
|
const result = await client.close();
|
|
if (result.ok) throw new Error("expected cleanup failure");
|
|
expect(result.error.capturedOutput?.stderr).toBe(
|
|
"IDA shutdown diagnostic",
|
|
);
|
|
expect(result.error.diagnostics).toMatchObject({
|
|
failure_reason: "upstream transport closed",
|
|
});
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
} finally {
|
|
delete producer.beforeCall;
|
|
producer.close = originalClose;
|
|
await client.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("binds retained transport diagnostics to the requested operation and recovery", async () => {
|
|
const { producer, client } = await fixture("attached");
|
|
await client.execute("health", {});
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "list_functions")
|
|
throw new ProviderAdapterError("ida", "transport", {
|
|
diagnostics: { reason: "upstream closed" },
|
|
capturedOutput: {
|
|
stdout: "",
|
|
stderr: "IDA diagnostic",
|
|
truncated: false,
|
|
},
|
|
});
|
|
};
|
|
const result = await client.execute("list_procedures", {});
|
|
if (result.ok) throw new Error("expected upstream failure");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
details: {
|
|
operation: "list_procedures",
|
|
diagnostics: {
|
|
reason: "upstream closed",
|
|
recovery: expect.stringContaining("docs/ida-provider.md"),
|
|
},
|
|
captured_output: { stderr: "IDA diagnostic" },
|
|
},
|
|
});
|
|
await client.close();
|
|
});
|
|
|
|
describe("attached IDA legacy string inventory", () => {
|
|
it("fetches legacy strings and literal searches in single upstream calls", async () => {
|
|
const { producer, client } = await fixture();
|
|
const all = Array.from({ length: 250 }, (_, i) => ({
|
|
address: `0x${(0x3000 + i).toString(16)}`,
|
|
length: 5,
|
|
string: `hello-${i}`,
|
|
}));
|
|
producer.overrideCall = (name, args) => {
|
|
if (name !== "list_strings") return undefined;
|
|
const offset = Number(args.offset ?? 0);
|
|
const count = Number(args.count ?? 100);
|
|
const data = count === 0 ? all : all.slice(offset, offset + count);
|
|
return {
|
|
data,
|
|
next_offset:
|
|
count === 0 || offset + count >= all.length ? null : offset + count,
|
|
};
|
|
};
|
|
const listed = await client.execute("list_strings", {});
|
|
expect(listed.ok && listed.value.result).toHaveLength(250);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "list_strings")[0]?.args
|
|
.count,
|
|
).toBe(0);
|
|
expect(
|
|
producer.calls.filter(({ name }) => name === "list_strings"),
|
|
).toHaveLength(1);
|
|
await client.close();
|
|
});
|
|
});
|
|
|
|
it("retains transport diagnostics on a cancellation regardless of stderr", async () => {
|
|
const { producer, client } = await fixture("attached");
|
|
await client.execute("health", {});
|
|
const controller = new AbortController();
|
|
producer.beforeCall = async (name) => {
|
|
if (name === "list_functions") {
|
|
controller.abort();
|
|
throw new ProviderAdapterError("ida", "transport", {
|
|
diagnostics: { reason: "upstream closed after cancellation" },
|
|
capturedOutput: {
|
|
stdout: "",
|
|
stderr: "shutdown diagnostic",
|
|
truncated: false,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
const result = await client.execute(
|
|
"list_procedures",
|
|
{},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("expected cancellation");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cancelled",
|
|
details: {
|
|
diagnostics: { reason: "upstream closed after cancellation" },
|
|
captured_output: { stderr: "shutdown diagnostic" },
|
|
},
|
|
});
|
|
await client.close();
|
|
});
|