Files
reverse-engineering-anything/tests/boundary/mcp/fridaLifecycleMcp.test.ts
T
dbd556043c fix(providers): name JEB, IDA, and one-shot command deadlines (#6014)
* fix(providers): name JEB, IDA, and one-shot command deadlines

Wire JEB catalog discovery to REA_JEB_MCP_TIMEOUT_MS, name the IDA
registration timeoutMs, and add command_timeout_ms for one-shot objdump
and rizin. Debugger, Frida, and Cutter caps stay fixed and say so.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(providers): pass remaining deadlines and name the cap that fired

Catalog discovery now sends the JEB budget still remaining to the SDK on
connect and every tools/list page. Objdump and rizin preflight share one
command_timeout_ms across stat and digest; the command still receives that
budget again. Architecture Evidence keeps a selected timeout, and Frida and
Rizin name the capture limit that actually stopped output.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(frida): preserve non-timeout device lookup failures

getDeviceById is a lookup deadline only when Frida rejects with
"Device not found" after the wait. A device-manager or backend failure
stays a capability error. Align the assertions that still expected the
previous capacity and capture sentences.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 04:01:02 +08:00

1437 lines
47 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { setImmediate } from "node:timers/promises";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type {
Device,
DeviceManager,
FridaMessage,
Script,
Session,
} from "frida";
import { expect, it, onTestFinished, vi } from "vitest";
import { FridaScriptLoadCancelledError } from "../../../src/application/frida/FridaInstrumentationPort.js";
import type {
FridaInstrumentationPort,
FridaScriptObservation,
} from "../../../src/application/frida/FridaInstrumentationPort.js";
import type { EvidenceWriter } from "../../../src/application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import type { AnalysisPartialObservation } from "../../../src/domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
import { fridaSessionStatusSchema } from "../../../src/domain/fridaSessionStatus.js";
import type { Evidence } from "../../../src/domain/evidence.js";
import { jsonObjectSchema } from "../../../src/domain/jsonValue.js";
import { nativeCallPartialObservationSchema } from "../../../src/domain/native/nativeCallPartialObservation.js";
import { err, ok } from "../../../src/domain/result.js";
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerFridaTools } from "../../../src/server/registerFridaTools.js";
import {
createToolResultDelivery,
ToolResultDelivery,
} from "../../../src/server/toolResult.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const gate = () => {
let markEntered = () => {};
let release = () => {};
const entered = new Promise<void>((resolve) => {
markEntered = resolve;
});
const released = new Promise<void>((resolve) => {
release = resolve;
});
return {
entered,
release,
async wait() {
markEntered();
await released;
},
};
};
const nativeFixture = () => {
const created: string[] = [];
const loaded: string[] = [];
const unloaded: string[] = [];
const scripts: Script[] = [];
const scriptsAtResume: string[][] = [];
const controls = {
attach: async () => {},
create: async () => {},
load: async () => {},
resume: async () => {},
detach: async () => {},
afterLoad: () => {},
discover: async () => {},
unloadFailures: 0,
detachFailures: 0,
detachCalls: 0,
resumes: 0,
remoteRemovals: 0,
removalFailures: 0,
message: undefined as FridaMessage | undefined,
};
let detached = false;
let onDetached: (() => void) | undefined;
const markDetached = () => {
detached = true;
onDetached?.();
};
const nativeSession: Session = {
pid: 512,
detached: {
connect(listener) {
onDetached = listener;
},
disconnect() {
onDetached = undefined;
},
},
isDetached: () => detached,
async detach() {
controls.detachCalls += 1;
await controls.detach();
if (controls.detachFailures > 0) {
controls.detachFailures -= 1;
throw new Error("transient detach failure");
}
markDetached();
},
async createScript(source) {
created.push(source);
await controls.create();
let destroyed = false;
let listener:
| ((message: FridaMessage, data: Buffer | null) => void)
| undefined;
const script: Script = {
get isDestroyed() {
return destroyed;
},
message: {
connect(handler) {
listener = handler;
},
disconnect() {
listener = undefined;
},
},
async load() {
await controls.load();
loaded.push(source);
listener?.(
controls.message ?? { type: "send", payload: source },
null,
);
controls.afterLoad();
},
async unload() {
unloaded.push(source);
if (controls.unloadFailures > 0) {
controls.unloadFailures -= 1;
throw new Error("transient unload failure");
}
destroyed = true;
},
};
scripts.push(script);
return script;
},
};
const device: Device = {
id: "local",
name: "Local fixture",
type: "local",
async enumerateProcesses() {
await controls.discover();
return [{ pid: 512, name: "fixture" }];
},
async getProcessByPid() {
return { pid: 512, name: "fixture" };
},
async spawn() {
return 512;
},
async resume() {
scriptsAtResume.push(
created.filter((_, index) => scripts[index]?.isDestroyed === false),
);
controls.resumes += 1;
await controls.resume();
},
async attach() {
await controls.attach();
return nativeSession;
},
};
const deviceManager: DeviceManager = {
async enumerateDevices() {
return [device];
},
async getDeviceById() {
return device;
},
async addRemoteDevice() {
return device;
},
async removeRemoteDevice() {
controls.remoteRemovals += 1;
if (controls.removalFailures > 0) {
controls.removalFailures -= 1;
throw new Error("transient remote removal failure");
}
},
};
return {
controls,
created,
loaded,
unloaded,
scripts,
scriptsAtResume,
deviceManager,
markDetached,
};
};
const connectedFixture = async () => {
const native = nativeFixture();
const manager = new FridaInstrumentationManager({
deviceManager: native.deviceManager,
});
const session = createTestBinarySession(() => {
throw new Error("Binary provider must not start");
});
const server = createServer(
{ kind: "session", session },
{ fridaInstrumentation: manager },
);
const client = new Client({ name: "frida-lifecycle", version: "1" });
onTestFinished(async () => {
native.controls.detachFailures = 0;
native.controls.removalFailures = 0;
await client.close();
await server.close();
await manager.closeAll();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const start = async () => {
const result = await client.callTool({
name: "start_frida_session",
arguments: {
mode: "spawn",
program: "/fixture",
device_id: "local",
},
});
return toolContract("start_frida_session").outputSchema.parse(
result.structuredContent,
).session_id;
};
const load = (sessionId: string, source: string, signal?: AbortSignal) =>
client.callTool(
{
name: "load_frida_script",
arguments: { session_id: sessionId, source_kind: "inline", source },
},
signal === undefined ? undefined : { signal },
);
return { ...native, manager, session, server, client, start, load };
};
it.each(["attached", "during-resume", "after-resume"] as const)(
"preserves observed detachment through resume settlement: %s",
async (timing) => {
const fixture = await connectedFixture();
const held = gate();
onTestFinished(held.release);
fixture.controls.resume = held.wait;
const id = await fixture.start();
const state = async () => {
const result = await fixture.client.callTool({
name: "frida_session_status",
arguments: { session_id: id },
});
return fridaSessionStatusSchema.parse(
toolContract("frida_session_status").outputSchema.parse(
result.structuredContent,
).normalized_result,
).state;
};
const resuming = fixture.client.callTool({
name: "resume_frida_session",
arguments: { session_id: id },
});
await held.entered;
if (timing === "during-resume") fixture.markDetached();
expect(await state()).toBe(
timing === "during-resume" ? "detached" : "paused",
);
held.release();
const resumed = await resuming;
expect(resumed.isError).not.toBe(true);
expect(resumed.structuredContent).toEqual({ state: "running" });
if (timing === "after-resume") fixture.markDetached();
const expected = timing === "attached" ? "running" : "detached";
expect(await state()).toBe(expected);
expect(await state()).toBe(expected);
expect(fixture.manager.status(id)?.targetResumeObserved).toBe(true);
},
);
it("retains detached state and resume evidence when remote cleanup needs retry", async () => {
const fixture = await connectedFixture();
const started = await fixture.client.callTool({
name: "start_frida_session",
arguments: {
mode: "spawn",
program: "/fixture",
remote: { address: "127.0.0.1:27042" },
},
});
const id = toolContract("start_frida_session").outputSchema.parse(
started.structuredContent,
).session_id;
fixture.controls.removalFailures = 1;
const close = () =>
fixture.client.callTool({
name: "close_frida_session",
arguments: { session_id: id },
});
expect((await close()).isError).toBe(true);
const result = await fixture.client.callTool({
name: "frida_session_status",
arguments: { session_id: id },
});
expect(
fridaSessionStatusSchema.parse(
toolContract("frida_session_status").outputSchema.parse(
result.structuredContent,
).normalized_result,
).state,
).toBe("detached");
expect(fixture.manager.status(id)?.targetResumeObserved).toBe(true);
expect(fixture.controls.detachCalls).toBe(1);
expect(fixture.controls.resumes).toBe(1);
expect((await close()).isError).not.toBe(true);
expect(fixture.manager.status(id)).toBeUndefined();
expect(fixture.controls.detachCalls).toBe(1);
expect(fixture.controls.resumes).toBe(1);
expect(fixture.controls.remoteRemovals).toBe(2);
});
it.each([
{ cancel: false, detachFails: false },
{ cancel: true, detachFails: false },
{ cancel: false, detachFails: true },
{ cancel: true, detachFails: true },
])(
"classifies server completion after final detach (cancel=$cancel, detachFails=$detachFails)",
async ({ cancel, detachFails }) => {
const fixture = await connectedFixture();
const held = gate();
onTestFinished(held.release);
fixture.controls.detach = held.wait;
fixture.controls.detachFailures = detachFails ? 1 : 0;
let serverSignal: AbortSignal | undefined;
const load = fixture.manager.loadScript.bind(fixture.manager);
const loadSpy = vi
.spyOn(fixture.manager, "loadScript")
.mockImplementation((id, source, signal) => {
serverSignal = signal;
return load(id, source, signal);
});
const success = vi.spyOn(
ToolResultDelivery.prototype,
"toEvidenceToolResult",
);
const partial = vi.spyOn(
ToolResultDelivery.prototype,
"toRecordedPartialErrorToolResult",
);
onTestFinished(() => {
loadSpy.mockRestore();
success.mockRestore();
partial.mockRestore();
});
const controller = new AbortController();
const pending = fixture.client
.callTool(
{
name: "instrument_with_frida",
arguments: {
mode: "attach",
pid: 512,
device_id: "local",
source_kind: "inline",
source: "completed-before-detach",
duration_ms: 0,
},
},
{ signal: controller.signal },
)
.then(
(value) => ({ kind: "result" as const, value }),
(cause: unknown) => ({ kind: "rejected" as const, cause }),
);
await held.entered;
expect(fixture.unloaded).toEqual(["completed-before-detach"]);
if (cancel) {
controller.abort(new Error("cancel during final detach"));
await fixture.client.ping();
}
expect(serverSignal?.aborted).toBe(cancel);
const serverReason: unknown = serverSignal?.reason;
held.release();
const result = await pending;
await vi.waitFor(() =>
expect(success.mock.calls.length + partial.mock.calls.length).toBe(1),
);
expect(fixture.controls.detachCalls).toBe(1);
expect(fixture.controls.resumes).toBe(0);
const records = fixture.session
.exportEvidenceBundle()
.records.filter((record) => record.operation === "instrument_with_frida");
expect(records).toHaveLength(1);
const evidence = records[0];
expect(evidence).toMatchObject({
normalized_result: {
messages: [{ type: "send", payload: "completed-before-detach" }],
cleanup_error: detachFails
? expect.stringContaining("transient detach failure")
: null,
},
});
const sessionId = String(evidence?.parameters.session_id);
expect(fixture.manager.status(sessionId) !== undefined).toBe(detachFails);
if (cancel) {
expect(result.kind).toBe("rejected");
expect(success).not.toHaveBeenCalled();
const error = partial.mock.calls[0]?.[0];
expect(error).toBeInstanceOf(AnalysisCancelledError);
expect(error?.cause).toBe(serverReason);
if (detachFails)
expect(error?.cleanup).toMatchObject({ resources: [sessionId] });
else expect(error?.cleanup).toBeUndefined();
expect(error?.partialObservation).toEqual(evidence);
expect(evidence).toMatchObject({
normalized_result: {
instrumentation_cleanup: detachFails
? "incomplete_or_unknown"
: "released",
target_state_before_cleanup: "running",
target_resume_observed: false,
target_liveness_after_cleanup: "unknown",
},
});
} else {
expect(result.kind).toBe("result");
if (result.kind !== "result") throw result.cause;
expect(result.value.isError).not.toBe(true);
expect(partial).not.toHaveBeenCalled();
expect(success).toHaveBeenCalledOnce();
}
if (detachFails) {
const retried = await fixture.client.callTool({
name: "close_frida_session",
arguments: { session_id: sessionId },
});
expect(retried.isError).not.toBe(true);
expect(fixture.controls.detachCalls).toBe(2);
expect(fixture.manager.status(sessionId)).toBeUndefined();
expect(fixture.unloaded).toEqual(["completed-before-detach"]);
}
},
);
it.each([
["a trailing high surrogate", "x".repeat(32_765) + "\ud800"],
["repeated high surrogates", "\ud800".repeat(10_922)],
])(
"retains bounded Frida error text through MCP for %s",
async (_name, description) => {
const { controls, client, start, load } = await connectedFixture();
controls.message = { type: "error", description };
const sessionId = await start();
const response = await load(sessionId, "// inert fixture");
expect(response.isError).not.toBe(true);
const evidence = toolContract("load_frida_script").outputSchema.parse(
response.structuredContent,
);
const result = evidence.normalized_result;
expect(result.messages_truncated).toBe(false);
const retained = jsonObjectSchema.parse(result.messages[0]);
expect(retained.description_truncated).toBe(true);
expect(
Buffer.byteLength(JSON.stringify(retained.description)) - 2,
).toBeLessThanOrEqual(32_768);
const status = await client.callTool({
name: "frida_session_status",
arguments: { session_id: sessionId },
});
expect(status.isError).not.toBe(true);
expect(
toolContract("frida_session_status").outputSchema.parse(
status.structuredContent,
),
).toMatchObject({
normalized_result: { messages: [retained], messages_truncated: false },
});
},
);
it("preserves the native resume failure after MCP cancellation reaches the server", async () => {
const fixture = await connectedFixture();
const held = gate();
onTestFinished(held.release);
fixture.controls.resume = async () => {
await held.wait();
fixture.controls.resume = async () => {};
throw new Error("native resume rejected");
};
const delivery = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult");
onTestFinished(() => delivery.mockRestore());
let serverSignal: AbortSignal | undefined;
const resume = fixture.manager.resumeSession.bind(fixture.manager);
const resumeSpy = vi
.spyOn(fixture.manager, "resumeSession")
.mockImplementation((id, signal) => {
serverSignal = signal;
return resume(id, signal);
});
onTestFinished(() => resumeSpy.mockRestore());
const controller = new AbortController();
const pending = fixture.client
.callTool(
{
name: "instrument_with_frida",
arguments: {
mode: "spawn",
program: "/fixture",
device_id: "local",
source_kind: "inline",
source: "resume-failure",
duration_ms: 0,
},
},
{ signal: controller.signal },
)
.catch((cause: unknown) => cause);
await held.entered;
controller.abort(new Error("cancel pending resume"));
await fixture.client.ping();
expect(serverSignal?.aborted).toBe(true);
held.release();
await pending;
await vi.waitFor(() => expect(delivery).toHaveBeenCalledOnce());
expect(delivery.mock.calls[0]?.[0]).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
operation: "resume_frida_session",
message: expect.stringContaining("native resume rejected"),
},
});
expect(fixture.controls.detachCalls).toBe(1);
expect(fixture.unloaded).toEqual(["resume-failure"]);
expect(
fixture.session
.exportEvidenceBundle()
.records.filter((record) => record.operation === "instrument_with_frida"),
).toEqual([]);
});
it("declares non-destructive session mutation when status retains Evidence", async () => {
const { client, start } = await connectedFixture();
const id = await start();
const bundle = async () => {
const response = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(response.isError).not.toBe(true);
return evidenceBundleSchema.parse(
toolContract("get_evidence_bundle").outputSchema.parse(
response.structuredContent,
).result,
);
};
expect((await bundle()).records).toHaveLength(0);
const response = await client.callTool({
name: "frida_session_status",
arguments: { session_id: id },
});
expect(response.isError).not.toBe(true);
const evidence = toolContract("frida_session_status").outputSchema.parse(
response.structuredContent,
);
expect(evidence).toMatchObject({
operation: "frida_session_status",
parameters: { session_id: id },
normalized_result: {
session_id: id,
state: "paused",
mode: "spawn",
scripts: [],
messages: [],
messages_truncated: false,
},
});
expect((await bundle()).records).toEqual([evidence]);
const tools = (await client.listTools()).tools;
expect(
tools.find((tool) => tool.name === "frida_session_status")?.annotations,
).toMatchObject({ readOnlyHint: false, destructiveHint: false });
const inventory = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(inventory.isError).not.toBe(true);
const available = expandToolAvailability(
toolContract("binary_session").outputSchema.parse(
inventory.structuredContent,
).result.tool_availability,
);
expect(
available.find((tool) => tool.name === "frida_session_status"),
).toMatchObject({
effects: {
mutatesSession: true,
mutatesTarget: false,
executesForeignCode: false,
mayDiscardData: false,
},
annotations: { read_only: false, destructive: false },
});
const missing = await client.callTool({
name: "frida_session_status",
arguments: { session_id: "00000000-0000-4000-8000-000000000000" },
});
expect(parseMcpToolError(missing)).toMatchObject({
error: { code: "invalid_request" },
});
expect((await bundle()).records).toEqual([evidence]);
});
type PartialRecording = "acknowledged" | "failed" | "unavailable";
type PartialObservation = "evidence" | "non-evidence" | "unavailable";
const unusedFridaPortMethod = (): never => {
throw new Error("Unused Frida test port method");
};
const nonEvidencePartialObservation = (): AnalysisPartialObservation =>
nativeCallPartialObservationSchema.parse({
kind: "native-call-observation",
target: {
path: "/fixture/target",
sha256: "b".repeat(64),
architecture: "arm64",
arguments: [],
environment: {},
working_directory: null,
},
process: {
pid: null,
stdout: null,
stderr: null,
other_stops: [],
},
debugger: { version: null },
events: [],
coverage: { status: "partial", reason: "cancelled" },
limitations: [],
});
const callPartialFridaLoadError = async (
recording: PartialRecording,
partial: PartialObservation,
) => {
const script: FridaScriptObservation = {
scriptId: "00000000-0000-4000-8000-000000000002",
sourceKind: "inline",
sourcePath: null,
sourceSha256: "a".repeat(64),
messages: [{ type: "send", payload: "partial" }],
messagesTruncated: false,
};
const failure =
partial === "evidence"
? new FridaScriptLoadCancelledError(script)
: new AnalysisCancelledError(
"load_frida_script",
partial === "non-evidence"
? { partialObservation: nonEvidencePartialObservation() }
: {},
);
const provider: FridaInstrumentationPort = {
async listDevices() {
return unusedFridaPortMethod();
},
async listProcesses() {
return unusedFridaPortMethod();
},
async startSession() {
return unusedFridaPortMethod();
},
async loadScript() {
return err(failure);
},
async resumeSession() {
return unusedFridaPortMethod();
},
async unloadScript() {
return unusedFridaPortMethod();
},
status() {
return unusedFridaPortMethod();
},
async closeSession() {
return unusedFridaPortMethod();
},
async closeAll() {
return unusedFridaPortMethod();
},
};
const attempts: Evidence[] = [];
const recordEvidence: EvidenceWriter["recordEvidence"] | undefined =
recording === "unavailable"
? undefined
: (evidence) => {
attempts.push(evidence);
return recording === "acknowledged"
? ok("added" as const)
: err(new EvidenceIntegrityError("sentinel record failure"));
};
const delivery = createToolResultDelivery(1024 * 1024);
const server = new EvidenceMcpServer(
{ name: "frida-partial-probe", version: "1" },
{ capabilities: {} },
recordEvidence,
delivery,
);
registerFridaTools(server, provider, silentLogger, delivery, recordEvidence);
const client = new Client({
name: "frida-partial-probe-client",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const response = await client.callTool({
name: "load_frida_script",
arguments: {
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "inline",
source: "send('partial');",
},
});
return {
response,
error: parseMcpToolError(response).error,
attempts,
};
} finally {
await client.close();
await server.close();
}
};
it("projects acknowledged Frida partial Evidence as a retained reference", async () => {
const { response, error, attempts } = await callPartialFridaLoadError(
"acknowledged",
"evidence",
);
expect(response.isError).toBe(true);
expect(response.structuredContent).toBeUndefined();
expect(attempts).toHaveLength(1);
const details = jsonObjectSchema.parse(error.details);
expect(details.partial_observation).toEqual({
kind: "retained-evidence",
evidence_id: attempts[0]?.evidence_id,
});
});
it("preserves inline Frida partial Evidence when its writer rejects it", async () => {
const { response, error, attempts } = await callPartialFridaLoadError(
"failed",
"evidence",
);
expect(response.isError).toBe(true);
expect(response.structuredContent).toBeUndefined();
expect(attempts).toHaveLength(1);
expect(error.code).toBe("cancelled");
const details = jsonObjectSchema.parse(error.details);
expect(details.partial_observation).toEqual(attempts[0]);
});
it.each([
["unavailable writer", "unavailable", "evidence"],
["non-Evidence partial", "acknowledged", "non-evidence"],
["missing partial", "acknowledged", "unavailable"],
] as const)(
"preserves Frida error projection for %s",
async (_, recording, partial) => {
const { response, error, attempts } = await callPartialFridaLoadError(
recording,
partial,
);
expect(response.isError).toBe(true);
expect(response.structuredContent).toBeUndefined();
const details = jsonObjectSchema.parse(error.details);
if (partial === "evidence") {
expect(details.partial_observation).toMatchObject({
operation: "load_frida_script",
normalized_result: {
script_id: "00000000-0000-4000-8000-000000000002",
},
});
} else if (partial === "non-evidence") {
expect(details.partial_observation).toEqual(
nonEvidencePartialObservation(),
);
} else {
expect(details).not.toHaveProperty("partial_observation");
}
expect(attempts).toHaveLength(
partial === "evidence" && recording !== "unavailable" ? 1 : 0,
);
},
);
const scriptLoadFixture = async (stage: "create" | "load" = "load") => {
const fixture = await connectedFixture();
const id = await fixture.start();
const original = await fixture.load(id, "original");
const held = gate();
onTestFinished(held.release);
fixture.controls[stage] = held.wait;
return { fixture, id, original, held };
};
const pendingShutdown = async (server: { close(): Promise<void> }) => {
let closed = false;
const closing = server
.close()
.then(
() => undefined,
(cause: unknown) => cause,
)
.finally(() => {
closed = true;
});
await setImmediate();
expect(closed).toBe(false);
return { closing };
};
const retryCancelledScriptUnload = async (
fixture: Awaited<ReturnType<typeof connectedFixture>>,
sessionId: string,
scriptId = fixture.manager.status(sessionId)?.scripts[1]?.scriptId,
) => {
if (scriptId === undefined)
throw new Error("Cancelled script was not retained");
const retry = await fixture.client.callTool({
name: "unload_frida_script",
arguments: {
session_id: sessionId,
script_id: scriptId,
},
});
expect(retry.isError).not.toBe(true);
};
const retainedScriptObservation = async (
fixture: Awaited<ReturnType<typeof connectedFixture>>,
sessionId: string,
source: string,
) => {
const response = await fixture.client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
const bundle = evidenceBundleSchema.parse(
toolContract("get_evidence_bundle").outputSchema.parse(
response.structuredContent,
).result,
);
const sourceSha256 = createHash("sha256").update(source).digest("hex");
const records = bundle.records.filter((record) => {
const result = record.normalized_result;
return (
record.operation === "load_frida_script" &&
result !== null &&
typeof result === "object" &&
!Array.isArray(result) &&
result.source_sha256 === sourceSha256
);
});
expect(records).toHaveLength(1);
expect(records[0]?.parameters).toEqual({ session_id: sessionId });
const observation = records[0]?.normalized_result;
if (
observation === undefined ||
observation === null ||
typeof observation !== "object" ||
Array.isArray(observation)
)
throw new Error("Missing retained script observation");
expect(observation).toMatchObject({
script_id: expect.any(String),
source_kind: "inline",
source_path: null,
source_sha256: sourceSha256,
messages_truncated: false,
});
return observation;
};
it.each(["load", "resume", "unload"] as const)(
"finishes an earlier MCP %s request before closing its Frida session",
async (operation) => {
const fixture = await connectedFixture();
const id = await fixture.start();
const original = await fixture.load(id, "original");
const scriptId = toolContract("load_frida_script").outputSchema.parse(
original.structuredContent,
).normalized_result.script_id;
const pending = fixture.client.callTool({
name: {
load: "load_frida_script",
resume: "resume_frida_session",
unload: "unload_frida_script",
}[operation],
arguments: {
session_id: id,
...(operation === "load"
? { source_kind: "inline", source: "last-script" }
: {}),
...(operation === "unload" ? { script_id: scriptId } : {}),
},
});
const closing = fixture.client.callTool({
name: "close_frida_session",
arguments: { session_id: id },
});
expect((await pending).isError).not.toBe(true);
expect((await closing).isError).not.toBe(true);
expect(fixture.loaded).toEqual(
operation === "load" ? ["original", "last-script"] : ["original"],
);
expect(fixture.unloaded).toEqual(fixture.loaded);
expect(fixture.controls.detachCalls).toBe(1);
expect(fixture.controls.resumes).toBe(1);
expect(fixture.manager.status(id)).toBeUndefined();
},
);
it.each(["load", "resume", "unload"] as const)(
"does not dispatch a cancelled queued Frida %s through MCP",
async (operation) => {
const { fixture, id, original, held } = await scriptLoadFixture();
const scriptId = toolContract("load_frida_script").outputSchema.parse(
original.structuredContent,
).normalized_result.script_id;
const first = fixture.load(id, "blocking");
await held.entered;
const controller = new AbortController();
const cancelled =
operation === "load"
? fixture.load(id, "cancelled", controller.signal)
: fixture.client.callTool(
{
name:
operation === "resume"
? "resume_frida_session"
: "unload_frida_script",
arguments: {
session_id: id,
...(operation === "unload" ? { script_id: scriptId } : {}),
},
},
{ signal: controller.signal },
);
const rejected = expect(cancelled).rejects.toThrow(
"cancel queued operation",
);
await fixture.client.ping();
controller.abort(new Error("cancel queued operation"));
await fixture.client.ping();
await rejected;
held.release();
await first;
await fixture.load(id, "barrier");
expect(fixture.created).toEqual(["original", "blocking", "barrier"]);
expect(fixture.unloaded).toEqual([]);
expect(fixture.controls.resumes).toBe(0);
expect(fixture.manager.status(id)?.scripts).toHaveLength(3);
},
);
it.each(["create", "load"] as const)(
"releases only the new script when MCP cancellation arrives during native %s",
async (stage) => {
const { fixture, id, held } = await scriptLoadFixture(stage);
const controller = new AbortController();
const cancelled = fixture.load(id, "cancelled-native", controller.signal);
const rejected = expect(cancelled).rejects.toThrow(
"cancel native operation",
);
await held.entered;
controller.abort(new Error("cancel native operation"));
await fixture.client.ping();
await rejected;
held.release();
await fixture.load(id, "barrier");
await fixture.client.ping();
expect(fixture.unloaded).toEqual(["cancelled-native"]);
expect(fixture.loaded).toEqual(
stage === "create"
? ["original", "barrier"]
: ["original", "cancelled-native", "barrier"],
);
expect(fixture.controls.detachCalls).toBe(0);
expect(fixture.controls.resumes).toBe(0);
expect(fixture.manager.status(id)?.scripts).toHaveLength(2);
const observation = await retainedScriptObservation(
fixture,
id,
"cancelled-native",
);
expect(observation).toMatchObject({
messages:
stage === "create"
? []
: [expect.objectContaining({ payload: "cancelled-native" })],
script_cleanup: "released",
cleanup_error: null,
});
},
);
it("retains cancellation source facts without claiming native creation when no script handle returns", async () => {
const { fixture, id, held } = await scriptLoadFixture("create");
fixture.controls.create = async () => {
await held.wait();
throw new Error("native create rejected");
};
const controller = new AbortController();
const loading = fixture
.load(id, "no-returned-handle", controller.signal)
.catch((cause: unknown) => cause);
await held.entered;
controller.abort(new Error("cancel while creating"));
await fixture.client.ping();
held.release();
await loading;
fixture.controls.create = async () => {};
await fixture.load(id, "barrier");
await fixture.client.ping();
const observation = await retainedScriptObservation(
fixture,
id,
"no-returned-handle",
);
expect(observation).toMatchObject({
messages: [],
script_cleanup: "no_handle",
cleanup_error: null,
});
expect(fixture.unloaded).toEqual([]);
expect(fixture.scripts).toHaveLength(2);
expect(fixture.controls.detachCalls).toBe(0);
expect(fixture.controls.resumes).toBe(0);
});
it("retains original malformed file identity and decoded representation through a rejected cancelled MCP load", async () => {
const { fixture, id, held } = await scriptLoadFixture();
const directory = await mkdtemp(join(tmpdir(), "rea-frida-file-cancel-"));
onTestFinished(() => rm(directory, { recursive: true, force: true }));
const path = join(directory, "script.js");
const bytes = Buffer.from([0x2f, 0x2f, 0xff, 0x0a]);
await writeFile(path, bytes);
const decodedSource = bytes.toString("utf8");
fixture.controls.afterLoad = () => {
if (fixture.loaded.at(-1) === decodedSource)
throw new Error("native rejection after message");
};
const controller = new AbortController();
const loading = fixture.client
.callTool(
{
name: "load_frida_script",
arguments: { session_id: id, source_kind: "file", path },
},
{ signal: controller.signal },
)
.catch((cause: unknown) => cause);
await held.entered;
controller.abort(new Error("cancel file load"));
await fixture.client.ping();
held.release();
await loading;
await fixture.load(id, "barrier");
await fixture.client.ping();
const response = await fixture.client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
const bundle = evidenceBundleSchema.parse(
toolContract("get_evidence_bundle").outputSchema.parse(
response.structuredContent,
).result,
);
const records = bundle.records.filter(
(record) =>
record.operation === "load_frida_script" &&
JSON.stringify(record.normalized_result).includes(path),
);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
normalized_result: {
source_kind: "file",
source_path: path,
source_sha256: createHash("sha256").update(bytes).digest("hex"),
messages: [expect.objectContaining({ payload: decodedSource })],
script_cleanup: "released",
cleanup_error: null,
},
raw_result: {
source_representation: {
encoding: "utf8",
decoding: "replacement",
loaded_source_sha256: createHash("sha256")
.update(decodedSource)
.digest("hex"),
},
},
limitations: expect.arrayContaining([
expect.stringContaining("Invalid UTF-8"),
]),
});
expect(fixture.scripts[0]?.isDestroyed).toBe(false);
expect(fixture.scripts[1]?.isDestroyed).toBe(true);
expect(fixture.controls.detachCalls).toBe(0);
});
it.each([
{ cancel: true, unloadFailures: 0 },
{ cancel: true, unloadFailures: 1 },
{ cancel: false, unloadFailures: 0 },
])(
"retains rejected native-load observations through cancellation=$cancel and unload failures=$unloadFailures",
async ({ cancel, unloadFailures }) => {
const { fixture, id, held } = await scriptLoadFixture();
const source = "rejecting-native";
fixture.controls.afterLoad = () => {
if (fixture.loaded.at(-1) === source)
throw new Error("native load rejected after its message");
};
fixture.controls.unloadFailures = unloadFailures;
const controller = new AbortController();
const loading = fixture.load(id, source, controller.signal);
const outcome = loading.then(
(value) => ({ kind: "result" as const, value }),
(cause: unknown) => ({ kind: "rejected" as const, cause }),
);
await held.entered;
if (cancel) {
controller.abort(new Error("cancel before native rejection"));
await fixture.client.ping();
}
held.release();
const result = await outcome;
await fixture.load(id, "barrier");
await fixture.client.ping();
expect(fixture.controls.detachCalls).toBe(0);
expect(fixture.controls.resumes).toBe(0);
expect(fixture.scripts[0]?.isDestroyed).toBe(false);
expect(fixture.scripts[1]?.isDestroyed).toBe(unloadFailures === 0);
if (!cancel) {
expect(result.kind).toBe("result");
if (result.kind !== "result") throw result.cause;
expect(parseMcpToolError(result.value).error.code).toBe(
"capability_unavailable",
);
expect(
fixture.session
.exportEvidenceBundle()
.records.filter(
(record) =>
record.operation === "load_frida_script" &&
JSON.stringify(record.normalized_result).includes(source),
),
).toEqual([]);
return;
}
expect(result.kind).toBe("rejected");
const observation = await retainedScriptObservation(fixture, id, source);
expect(observation).toMatchObject({
messages: [expect.objectContaining({ payload: source })],
script_cleanup:
unloadFailures === 0 ? "released" : "incomplete_or_unknown",
cleanup_error:
unloadFailures === 0 ? null : expect.stringContaining("unload"),
});
if (unloadFailures > 0) {
if (typeof observation.script_id !== "string")
throw new Error("Missing candidate script identity");
await retryCancelledScriptUnload(fixture, id, observation.script_id);
}
expect(fixture.scripts[1]?.isDestroyed).toBe(true);
expect(fixture.manager.status(id)?.scripts).toHaveLength(2);
expect(
(
await fixture.client.callTool({
name: "frida_session_status",
arguments: { session_id: id },
})
).isError,
).not.toBe(true);
},
);
it("awaits an admitted attach on server shutdown and retries failed native cleanup", async () => {
const fixture = await connectedFixture();
const held = gate();
onTestFinished(held.release);
fixture.controls.attach = held.wait;
fixture.controls.detachFailures = 1;
const starting = fixture.client
.callTool({
name: "start_frida_session",
arguments: {
mode: "attach",
pid: 512,
device_id: "local",
},
})
.catch(() => undefined);
await held.entered;
const { closing } = await pendingShutdown(fixture.server);
expect(fixture.controls.detachCalls).toBe(0);
held.release();
expect(await closing).toBeInstanceOf(AggregateError);
await starting;
expect(fixture.controls.detachCalls).toBe(1);
await fixture.server.close();
expect(fixture.controls.detachCalls).toBe(2);
await fixture.server.close();
expect(fixture.controls.detachCalls).toBe(2);
const afterClose = await fixture.manager.startSession({
mode: "attach",
pid: 512,
});
expect(afterClose).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
});
});
it("drains remote discovery before shutdown and retains failed connection cleanup", async () => {
const fixture = await connectedFixture();
const held = gate();
onTestFinished(held.release);
fixture.controls.discover = held.wait;
fixture.controls.removalFailures = 2;
const discovering = fixture.client
.callTool({
name: "list_frida_processes",
arguments: {
remote: { address: "127.0.0.1:27042" },
},
})
.catch(() => undefined);
await held.entered;
const { closing } = await pendingShutdown(fixture.server);
held.release();
expect(await closing).toBeInstanceOf(AggregateError);
await discovering;
expect(fixture.controls.remoteRemovals).toBe(2);
await fixture.server.close();
expect(fixture.controls.remoteRemovals).toBe(3);
});
it.each(["create", "load"] as const)(
"retains the identity of a cancelled native %s for an explicit MCP unload retry",
async (stage) => {
const { fixture, id, held } = await scriptLoadFixture(stage);
fixture.controls.unloadFailures = 1;
const controller = new AbortController();
const cancelled = fixture.load(id, "cancelled-native", controller.signal);
const rejected = expect(cancelled).rejects.toThrow(
"cancel retained script",
);
await held.entered;
controller.abort(new Error("cancel retained script"));
await fixture.client.ping();
await rejected;
held.release();
await fixture.load(id, "barrier");
await fixture.client.ping();
expect(fixture.manager.status(id)?.scripts).toHaveLength(3);
const observation = await retainedScriptObservation(
fixture,
id,
"cancelled-native",
);
expect(observation).toMatchObject({
messages:
stage === "create"
? []
: [expect.objectContaining({ payload: "cancelled-native" })],
script_cleanup: "incomplete_or_unknown",
cleanup_error: expect.stringContaining("transient unload failure"),
});
const scriptId = observation.script_id;
if (typeof scriptId !== "string")
throw new Error("Missing cancelled script identity");
expect(fixture.manager.status(id)?.scripts[1]?.scriptId).toBe(scriptId);
const resuming = await fixture.client.callTool({
name: "resume_frida_session",
arguments: { session_id: id },
});
expect(resuming.isError).toBe(true);
expect(fixture.controls.resumes).toBe(0);
await retryCancelledScriptUnload(fixture, id, scriptId);
expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]);
expect(fixture.loaded).toEqual(
stage === "create"
? ["original", "barrier"]
: ["original", "cancelled-native", "barrier"],
);
expect(fixture.manager.status(id)?.scripts).toHaveLength(2);
expect(fixture.controls.detachCalls).toBe(0);
},
);
it.each([0, 1])(
"settles cancelled native-load cleanup before a queued resume (unload failures: %i)",
async (unloadFailures) => {
const { fixture, id, held } = await scriptLoadFixture();
fixture.controls.unloadFailures = unloadFailures;
const controller = new AbortController();
const loading = fixture.load(id, "cancelled-native", controller.signal);
const rejected = expect(loading).rejects.toThrow(
"cancel before queued resume",
);
await held.entered;
const resume = () =>
fixture.client.callTool({
name: "resume_frida_session",
arguments: { session_id: id },
});
const resuming = resume();
await fixture.client.ping();
controller.abort(new Error("cancel before queued resume"));
await fixture.client.ping();
await rejected;
held.release();
const resumed = await resuming;
await fixture.client.ping();
expect(fixture.unloaded).toEqual(["cancelled-native"]);
expect(fixture.scriptsAtResume).toEqual(
unloadFailures === 0 ? [["original"]] : [],
);
expect(fixture.controls.detachCalls).toBe(0);
expect(fixture.session.exportEvidenceBundle().records).toContainEqual(
expect.objectContaining({
operation: "load_frida_script",
normalized_result: expect.objectContaining({
messages: [expect.objectContaining({ payload: "cancelled-native" })],
script_cleanup:
unloadFailures === 0 ? "released" : "incomplete_or_unknown",
}),
}),
);
if (unloadFailures === 0) {
expect(resumed.isError).not.toBe(true);
expect(fixture.manager.status(id)?.scripts).toHaveLength(1);
return;
}
expect(resumed.isError).toBe(true);
expect(fixture.manager.status(id)?.state).toBe("paused");
expect((await resume()).isError).toBe(true);
await retryCancelledScriptUnload(fixture, id);
expect((await resume()).isError).not.toBe(true);
expect(fixture.scriptsAtResume).toEqual([["original"]]);
},
);
it.each([0, 1, 2, 3, 4, 8])(
"keeps MCP load Evidence consistent with queued resume across settlement timing %i",
async (microtasks) => {
const { fixture, id, held } = await scriptLoadFixture();
const controller = new AbortController();
fixture.controls.afterLoad = () => {
let remaining = microtasks;
const abort = () => {
if (remaining-- > 0) queueMicrotask(abort);
else controller.abort(new Error("cancel around load completion"));
};
abort();
};
const loading = fixture.load(id, "settling-native", controller.signal);
const observed = loading.catch(() => undefined);
await held.entered;
const resuming = fixture.client.callTool({
name: "resume_frida_session",
arguments: { session_id: id },
});
await fixture.client.ping();
held.release();
await observed;
expect((await resuming).isError).not.toBe(true);
fixture.controls.afterLoad = () => {};
await fixture.load(id, "barrier");
await fixture.client.ping();
const records = fixture.session
.exportEvidenceBundle()
.records.filter(
(record) =>
record.operation === "load_frida_script" &&
JSON.stringify(record.normalized_result).includes("settling-native"),
);
expect(records).toHaveLength(1);
const recorded = records[0]?.normalized_result;
if (
recorded === null ||
typeof recorded !== "object" ||
Array.isArray(recorded)
)
throw new Error("Missing script load Evidence");
if (recorded.script_cleanup === "released") {
expect(fixture.scriptsAtResume).toEqual([["original"]]);
expect(fixture.unloaded).toEqual(["settling-native"]);
} else {
expect(recorded.script_cleanup).toBeUndefined();
expect(fixture.scriptsAtResume).toEqual([
["original", "settling-native"],
]);
expect(fixture.unloaded).toEqual([]);
}
},
);