From 344fc714c1a80da09434004f3e1f8643173ace20 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 00:18:26 +0530 Subject: [PATCH 01/12] fix(frida): drain admitted operations and honor queued cancellation --- docs/frida-instrumentation.md | 4 +- .../frida/FridaInstrumentationPort.ts | 7 +- .../frida/FridaInstrumentationService.ts | 50 ++- src/frida/FridaInstrumentationManager.ts | 133 +++++- src/server/registerFridaTools.ts | 43 +- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 416 ++++++++++++++++++ 6 files changed, 620 insertions(+), 33 deletions(-) create mode 100644 tests/boundary/mcp/fridaLifecycleMcp.test.ts diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md index c93c76465c3..2f760dad7c8 100644 --- a/docs/frida-instrumentation.md +++ b/docs/frida-instrumentation.md @@ -6,8 +6,8 @@ For remote devices, pass a host with an optional port (`host[:port]`; bracket IP MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages. -Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released. +Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released. -Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. +Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script; the session and previously loaded scripts remain available. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility. diff --git a/src/application/frida/FridaInstrumentationPort.ts b/src/application/frida/FridaInstrumentationPort.ts index 39c51eb268d..f97a076758a 100644 --- a/src/application/frida/FridaInstrumentationPort.ts +++ b/src/application/frida/FridaInstrumentationPort.ts @@ -103,11 +103,16 @@ export interface FridaInstrumentationPort { loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise>; - resumeSession(sessionId: string): Promise>; + resumeSession( + sessionId: string, + signal?: AbortSignal, + ): Promise>; unloadScript( sessionId: string, scriptId: string, + signal?: AbortSignal, ): Promise>; status(sessionId: string): FridaSessionStatus | undefined; closeSession( diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index c359850d660..4984d201990 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -181,8 +181,46 @@ export class FridaInstrumentationService { async loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { - const loaded = await this.#provider.loadScript(sessionId, source); + if (signal?.aborted) + return err( + new AnalysisCancelledError("load_frida_script", { + cause: signal.reason, + }), + ); + const loaded = await this.#provider.loadScript(sessionId, source, signal); + if (loaded.ok && signal?.aborted) { + const unloaded = await this.#provider.unloadScript( + sessionId, + loaded.value.scriptId, + ); + const cleanupError = unloaded.ok ? null : unloaded.error.message; + return err( + new AnalysisCancelledError("load_frida_script", { + cause: signal.reason, + partialObservation: fridaEvidence( + "load_frida_script", + { + ...scriptOutput(loaded.value), + cleanup_error: cleanupError, + script_cleanup: unloaded.ok + ? "released" + : "incomplete_or_unknown", + }, + { session_id: sessionId }, + ), + ...(unloaded.ok + ? {} + : { + cleanup: { + reason: unloaded.error.message, + resources: [sessionId, loaded.value.scriptId], + }, + }), + }), + ); + } return loaded.ok ? ok( fridaEvidence("load_frida_script", scriptOutput(loaded.value), { @@ -192,12 +230,12 @@ export class FridaInstrumentationService { : loaded; } - resumeSession(sessionId: string) { - return this.#provider.resumeSession(sessionId); + resumeSession(sessionId: string, signal?: AbortSignal) { + return this.#provider.resumeSession(sessionId, signal); } - unloadScript(sessionId: string, scriptId: string) { - return this.#provider.unloadScript(sessionId, scriptId); + unloadScript(sessionId: string, scriptId: string, signal?: AbortSignal) { + return this.#provider.unloadScript(sessionId, scriptId, signal); } status(sessionId: string): FridaSessionStatus | undefined { @@ -243,6 +281,7 @@ export class FridaInstrumentationService { const loaded = await this.#provider.loadScript( started.value.sessionId, input.source, + signal, ); if (signal?.aborted) return this.#cancelSession( @@ -267,6 +306,7 @@ export class FridaInstrumentationService { if (started.value.mode === "spawn") { const resumed = await this.#provider.resumeSession( started.value.sessionId, + signal, ); if (signal?.aborted) return this.#cancelSession( diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 406258e5a7c..8aa284dfeed 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -25,6 +25,7 @@ import type { import type { OptionalProviderLoadFailure } from "../application/OptionalObservationProviders.js"; import { AnalysisAccessDeniedError, + AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; @@ -114,6 +115,9 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { readonly #deviceManagerLoader: () => Promise; #deviceManager: DeviceManager | undefined; #availabilityFailure: OptionalProviderLoadFailure | undefined; + readonly #operations = new Set>(); + #closing = false; + #closeAllPromise: Promise | undefined; constructor(options: FridaInstrumentationManagerOptions = {}) { this.#deviceManager = options.deviceManager; @@ -126,7 +130,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { return this.#availabilityFailure; } - async listDevices(remote?: FridaRemoteConnection): Promise< + listDevices(remote?: FridaRemoteConnection) { + return this.#withOperation("list_frida_devices", () => + this.#listDevices(remote), + ); + } + + async #listDevices(remote?: FridaRemoteConnection): Promise< Result< { readonly devices: readonly FridaDeviceObservation[]; @@ -176,7 +186,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - async listProcesses(selector: FridaDeviceSelector): Promise< + listProcesses(selector: FridaDeviceSelector) { + return this.#withOperation("list_frida_processes", () => + this.#listProcesses(selector), + ); + } + + async #listProcesses(selector: FridaDeviceSelector): Promise< Result< { readonly deviceId: string; @@ -235,7 +251,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - async startSession( + startSession(input: StartFridaSessionInput) { + return this.#withOperation("start_frida_session", () => + this.#startSession(input), + ); + } + + async #startSession( input: StartFridaSessionInput, ): Promise> { let manager: DeviceManager | undefined; @@ -326,16 +348,25 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { - return this.#withSessionLock(sessionId, () => - this.#loadScript(sessionId, source), + return this.#withOperation( + "load_frida_script", + () => + this.#withSessionLock(sessionId, () => + this.#loadScript(sessionId, source, signal), + ), + signal, ); } async #loadScript( sessionId: string, source: FridaScriptSource, + signal?: AbortSignal, ): Promise> { + if (signal?.aborted) + return err(cancelledOperation("load_frida_script", signal)); const managed = this.#sessions.get(sessionId); if (managed === undefined || managed.state === "detached") return err(unknownFridaSession("load_frida_script", sessionId)); @@ -368,6 +399,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ); } + if (signal?.aborted) + return err(cancelledOperation("load_frida_script", signal)); const sourceSha256 = createHash("sha256") .update(scriptSource) .digest("hex"); @@ -391,6 +424,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { }; managed.scripts.set(scriptId, entry); nativeScript.message.connect(onMessage); + if (signal?.aborted) + throw cancelledOperation("load_frida_script", signal); await nativeScript.load(); return ok({ scriptId, @@ -418,9 +453,21 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } - resumeSession(sessionId: string): Promise> { - return this.#withSessionLock(sessionId, () => - this.#resumeSession(sessionId), + resumeSession( + sessionId: string, + signal?: AbortSignal, + ): Promise> { + return this.#withOperation( + "resume_frida_session", + () => + this.#withSessionLock(sessionId, () => + signal?.aborted + ? Promise.resolve( + err(cancelledOperation("resume_frida_session", signal)), + ) + : this.#resumeSession(sessionId), + ), + signal, ); } @@ -452,9 +499,19 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { unloadScript( sessionId: string, scriptId: string, + signal?: AbortSignal, ): Promise> { - return this.#withSessionLock(sessionId, () => - this.#unloadScript(sessionId, scriptId), + return this.#withOperation( + "unload_frida_script", + () => + this.#withSessionLock(sessionId, () => + signal?.aborted + ? Promise.resolve( + err(cancelledOperation("unload_frida_script", signal)), + ) + : this.#unloadScript(sessionId, scriptId), + ), + signal, ); } @@ -627,7 +684,17 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { return ok({ targetResumeObserved }); } - async closeAll(): Promise { + closeAll(): Promise { + this.#closing = true; + this.#closeAllPromise ??= this.#closeAll().catch((cause: unknown) => { + this.#closeAllPromise = undefined; + throw cause; + }); + return this.#closeAllPromise; + } + + async #closeAll(): Promise { + await Promise.allSettled(this.#operations); const startupFailures = ( await Promise.all( [...this.#pendingStartups].map((pending) => @@ -718,6 +785,32 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } } + #withOperation( + name: string, + operation: () => Promise>, + signal?: AbortSignal, + ): Promise> { + if (this.#closing) + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "frida", + name, + "Frida instrumentation is shutting down.", + ), + ), + ); + const pending = Promise.resolve().then(() => + signal?.aborted ? err(cancelledOperation(name, signal)) : operation(), + ); + this.#operations.add(pending); + const release = () => { + this.#operations.delete(pending); + }; + void pending.then(release, release); + return pending; + } + async #withSessionLock( sessionId: string, operation: () => Promise, @@ -1224,6 +1317,18 @@ const providerError = ( remote?: FridaRemoteConnection, cleanupReason?: string, ): AnalysisError => { + if (cause instanceof AnalysisCancelledError) + return new AnalysisCancelledError(operation, { + cause: cause.cause, + ...(cleanupReason === undefined + ? {} + : { + cleanup: { + reason: cleanupReason, + resources: ["Frida provider operation"], + }, + }), + }); const reason = sanitizeError(cause, remote); return new AnalysisCapabilityUnavailableError("frida", operation, reason, { cause: remote === undefined ? cause : new Error(reason), @@ -1240,3 +1345,9 @@ const providerError = ( const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); + +const cancelledOperation = ( + operation: string, + signal: AbortSignal, +): AnalysisCancelledError => + new AnalysisCancelledError(operation, { cause: signal.reason }); diff --git a/src/server/registerFridaTools.ts b/src/server/registerFridaTools.ts index 5125f105cec..fb2e06bb4fd 100644 --- a/src/server/registerFridaTools.ts +++ b/src/server/registerFridaTools.ts @@ -136,24 +136,35 @@ export const registerFridaTools = ( server.registerTool( load.name, toolRegistrationOptions(load), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, load.name, () => input.source_kind === "inline" && input.source !== undefined - ? service.loadScript(input.session_id, { - sourceKind: "inline", - source: input.source, - }) + ? service.loadScript( + input.session_id, + { + sourceKind: "inline", + source: input.source, + }, + context.mcpReq.signal, + ) : input.source_kind === "file" && input.path !== undefined - ? service.loadScript(input.session_id, { - sourceKind: "file", - path: input.path, - }) + ? service.loadScript( + input.session_id, + { + sourceKind: "file", + path: input.path, + }, + context.mcpReq.signal, + ) : Promise.resolve({ ok: false as const, error: new AnalysisInputError(load.name), }), ); - if (!result.ok) return delivery.toCallToolResult(result, load); + if (!result.ok) { + recordPartialEvidence(result.error, recordEvidence); + return delivery.toCallToolResult(result, load); + } return delivery.toEvidenceToolResult( result.value, load, @@ -164,9 +175,9 @@ export const registerFridaTools = ( server.registerTool( resume.name, toolRegistrationOptions(resume), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, resume.name, () => - service.resumeSession(input.session_id), + service.resumeSession(input.session_id, context.mcpReq.signal), ); return result.ok ? delivery.toCallToolResult( @@ -179,9 +190,13 @@ export const registerFridaTools = ( server.registerTool( unload.name, toolRegistrationOptions(unload), - async (input) => { + async (input, context: ServerContext) => { const result = await logToolExecution(logger, unload.name, () => - service.unloadScript(input.session_id, input.script_id), + service.unloadScript( + input.session_id, + input.script_id, + context.mcpReq.signal, + ), ); return result.ok ? delivery.toCallToolResult( diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts new file mode 100644 index 00000000000..f6a88b8fb6f --- /dev/null +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -0,0 +1,416 @@ +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 } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; + +const gate = () => { + let markEntered = () => {}; + let release = () => {}; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + const released = new Promise((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 controls = { + attach: async () => {}, + create: async () => {}, + load: async () => {}, + discover: async () => {}, + unloadFailures: 0, + detachFailures: 0, + detachCalls: 0, + resumes: 0, + remoteRemovals: 0, + removalFailures: 0, + }; + let detached = false; + const nativeSession: Session = { + pid: 512, + detached: { connect() {}, disconnect() {} }, + isDetached: () => detached, + async detach() { + controls.detachCalls += 1; + if (controls.detachFailures > 0) { + controls.detachFailures -= 1; + throw new Error("transient detach failure"); + } + detached = true; + }, + 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?.({ type: "send", payload: source }, null); + }, + 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() { + controls.resumes += 1; + }, + 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, deviceManager }; +}; + +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(["load", "resume", "unload"] as const)( + "does not dispatch a cancelled queued Frida %s through MCP", + 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 held = gate(); + onTestFinished(held.release); + fixture.controls.load = held.wait; + 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 = await connectedFixture(); + const id = await fixture.start(); + await fixture.load(id, "original"); + const held = gate(); + onTestFinished(held.release); + fixture.controls[stage] = held.wait; + 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); + if (stage === "load") { + const records = fixture.session.exportEvidenceBundle().records; + expect(records).toContainEqual( + expect.objectContaining({ + operation: "load_frida_script", + normalized_result: expect.objectContaining({ + messages: [ + expect.objectContaining({ payload: "cancelled-native" }), + ], + script_cleanup: "released", + }), + }), + ); + } + }, +); + +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; + let closed = false; + const closing = fixture.server + .close() + .then( + () => undefined, + (cause: unknown) => cause, + ) + .finally(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + 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; + let closed = false; + const closing = fixture.server + .close() + .then( + () => undefined, + (cause: unknown) => cause, + ) + .finally(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + 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("retains a cancelled script whose unload fails for an explicit MCP retry", async () => { + const fixture = await connectedFixture(); + const id = await fixture.start(); + await fixture.load(id, "original"); + const held = gate(); + onTestFinished(held.release); + fixture.controls.load = held.wait; + 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); + expect(fixture.session.exportEvidenceBundle().records).toContainEqual( + expect.objectContaining({ + operation: "load_frida_script", + normalized_result: expect.objectContaining({ + script_cleanup: "incomplete_or_unknown", + cleanup_error: expect.stringContaining("transient unload failure"), + }), + }), + ); + const scriptId = fixture.manager.status(id)?.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: id, + script_id: scriptId, + }, + }); + expect(retry.isError).not.toBe(true); + expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]); + expect(fixture.manager.status(id)?.scripts).toHaveLength(2); + expect(fixture.controls.detachCalls).toBe(0); +}); From 32af2ab2104a5876bd6808080de42239fcc88a10 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 12:52:51 +0530 Subject: [PATCH 02/12] fix(frida): settle cancelled loads before queued resume --- docs/frida-instrumentation.md | 2 +- .../frida/FridaInstrumentationPort.ts | 16 +- .../frida/FridaInstrumentationService.test.ts | 72 +++++++++ .../frida/FridaInstrumentationService.ts | 60 ++++---- src/frida/FridaInstrumentationManager.ts | 55 ++++++- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 144 +++++++++++++++++- 6 files changed, 306 insertions(+), 43 deletions(-) diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md index 2f760dad7c8..718af30c248 100644 --- a/docs/frida-instrumentation.md +++ b/docs/frida-instrumentation.md @@ -8,6 +8,6 @@ MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released. -Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script; the session and previously loaded scripts remain available. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. +Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility. diff --git a/src/application/frida/FridaInstrumentationPort.ts b/src/application/frida/FridaInstrumentationPort.ts index f97a076758a..39fa30936b2 100644 --- a/src/application/frida/FridaInstrumentationPort.ts +++ b/src/application/frida/FridaInstrumentationPort.ts @@ -1,4 +1,8 @@ -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + AnalysisError, + AnalysisErrorOptions, +} from "../../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import type { OptionalProviderLoadFailure } from "../OptionalObservationProviders.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { Result } from "../../domain/result.js"; @@ -60,6 +64,15 @@ export interface FridaScriptObservation { readonly messagesTruncated: boolean; } +export class FridaScriptLoadCancelledError extends AnalysisCancelledError { + constructor( + readonly script: FridaScriptObservation, + options?: AnalysisErrorOptions, + ) { + super("load_frida_script", options); + } +} + export interface FridaSessionStatus extends FridaSessionObservation { /** True only after REA has successfully resumed this spawned target. */ readonly targetResumeObserved: boolean; @@ -100,6 +113,7 @@ export interface FridaInstrumentationPort { startSession( input: StartFridaSessionInput, ): Promise>; + /** Own cancellation and new-script cleanup before returning a final load outcome. */ loadScript( sessionId: string, source: FridaScriptSource, diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index c1251f9a527..dc25bc62b98 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; +import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js"; import { FridaInstrumentationService } from "./FridaInstrumentationService.js"; import { @@ -569,3 +570,74 @@ it("cleans up a session and returns typed cancellation after a pending start", a if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]); }); + +it("retains a cancelled provider load's source and messages in one-shot Evidence", async () => { + const controller = new AbortController(); + const script = { + ...createScriptObservation(), + messages: [{ type: "send", payload: "cancelled-load" }], + }; + const service = new FridaInstrumentationService( + createProvider({ + async loadScript() { + controller.abort(new Error("cancel during native load")); + return { + ok: false, + error: new FridaScriptLoadCancelledError(script, { + cause: controller.signal.reason, + }), + }; + }, + }), + ); + const result = await service.instrument( + { + mode: "spawn", + program: "/fixture", + source: { sourceKind: "inline", source: "send('cancelled-load')" }, + durationMs: 0, + }, + controller.signal, + ); + if (result.ok) throw new Error("Cancelled instrumentation succeeded"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + source_kind: "inline", + source_sha256: script.sourceSha256, + messages: script.messages, + instrumentation_cleanup: "released", + }, + }); +}); + +it("preserves a provider-committed load when cancellation arrives during result delivery", async () => { + const controller = new AbortController(); + let unloads = 0; + const script = createScriptObservation(); + const service = new FridaInstrumentationService( + createProvider({ + loadScript() { + const completed = Promise.resolve({ ok: true as const, value: script }); + void completed.then(() => + controller.abort(new Error("late cancellation")), + ); + return completed; + }, + async unloadScript() { + unloads += 1; + return { ok: true, value: null }; + }, + }), + ); + const loaded = await service.loadScript( + createSession().sessionId, + { sourceKind: "inline", source: "send('ready')" }, + controller.signal, + ); + expect(controller.signal.aborted).toBe(true); + if (!loaded.ok) throw loaded.error; + expect(loaded.value.normalized_result).toMatchObject({ + script_id: script.scriptId, + }); + expect(unloads).toBe(0); +}); diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index 4984d201990..3cba20d4166 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -12,6 +12,7 @@ import { AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import type { FridaDeviceSelector, FridaInstrumentationPort, @@ -190,37 +191,8 @@ export class FridaInstrumentationService { }), ); const loaded = await this.#provider.loadScript(sessionId, source, signal); - if (loaded.ok && signal?.aborted) { - const unloaded = await this.#provider.unloadScript( - sessionId, - loaded.value.scriptId, - ); - const cleanupError = unloaded.ok ? null : unloaded.error.message; - return err( - new AnalysisCancelledError("load_frida_script", { - cause: signal.reason, - partialObservation: fridaEvidence( - "load_frida_script", - { - ...scriptOutput(loaded.value), - cleanup_error: cleanupError, - script_cleanup: unloaded.ok - ? "released" - : "incomplete_or_unknown", - }, - { session_id: sessionId }, - ), - ...(unloaded.ok - ? {} - : { - cleanup: { - reason: unloaded.error.message, - resources: [sessionId, loaded.value.scriptId], - }, - }), - }), - ); - } + if (!loaded.ok && loaded.error instanceof FridaScriptLoadCancelledError) + return err(cancelledScriptEvidence(sessionId, loaded.error)); return loaded.ok ? ok( fridaEvidence("load_frida_script", scriptOutput(loaded.value), { @@ -283,13 +255,17 @@ export class FridaInstrumentationService { input.source, signal, ); + let scriptObservation: FridaScriptObservation | undefined; + if (loaded.ok) scriptObservation = loaded.value; + else if (loaded.error instanceof FridaScriptLoadCancelledError) + scriptObservation = loaded.error.script; if (signal?.aborted) return this.#cancelSession( "instrument_with_frida", started.value, input, signal.reason, - loaded.ok ? loaded.value : undefined, + scriptObservation, ); if (!loaded.ok) { const closed = await this.#provider.closeSession(started.value.sessionId); @@ -502,3 +478,23 @@ const sessionStatusOutput = (status: FridaSessionStatus) => ({ messages: status.messages, messages_truncated: status.messagesTruncated, }); + +const cancelledScriptEvidence = ( + sessionId: string, + error: FridaScriptLoadCancelledError, +): AnalysisCancelledError => + new AnalysisCancelledError(error.operation, { + cause: error.cause, + cleanup: error.cleanup, + partialObservation: fridaEvidence( + error.operation, + { + ...scriptOutput(error.script), + cleanup_error: error.cleanup?.reason ?? null, + script_cleanup: error.cleanupIncomplete + ? "incomplete_or_unknown" + : "released", + }, + { session_id: sessionId }, + ), + }); diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 8aa284dfeed..fc5a1652665 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -22,6 +22,7 @@ import type { FridaSessionStatus, StartFridaSessionInput, } from "../application/frida/FridaInstrumentationPort.js"; +import { FridaScriptLoadCancelledError } from "../application/frida/FridaInstrumentationPort.js"; import type { OptionalProviderLoadFailure } from "../application/OptionalObservationProviders.js"; import { AnalysisAccessDeniedError, @@ -59,7 +60,7 @@ interface ManagedScript { readonly name: string; readonly script: Script; readonly onMessage: (message: FridaMessage, data: Buffer | null) => void; - released: boolean; + state: "active" | "cleanup-pending" | "released"; } interface ManagedRemoteConnection { @@ -420,21 +421,38 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { name: scriptName, script: nativeScript, onMessage, - released: false, + state: "active", }; managed.scripts.set(scriptId, entry); nativeScript.message.connect(onMessage); if (signal?.aborted) throw cancelledOperation("load_frida_script", signal); await nativeScript.load(); - return ok({ + const observation: FridaScriptObservation = { scriptId, sourceKind: source.sourceKind, sourcePath: source.sourceKind === "file" ? source.path : null, sourceSha256, messages: [...(managed.messagesByScript.get(scriptId) ?? [])], messagesTruncated: managed.messagesTruncated, - }); + }; + if (signal?.aborted) { + const cleanupReason = await this.#releaseScript(managed, entry); + return err( + new FridaScriptLoadCancelledError(observation, { + cause: signal.reason, + ...(cleanupReason === undefined + ? {} + : { + cleanup: { + reason: cleanupReason, + resources: [sessionId, scriptId], + }, + }), + }), + ); + } + return ok(observation); } catch (cause: unknown) { const entry = managed.scripts.get(scriptId); const cleanupReason = @@ -485,6 +503,26 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { "Only a paused process spawned by this session can be resumed.", ), ); + const pendingScripts = [...managed.scripts.values()].filter( + (entry) => entry.state === "cleanup-pending" && !entry.script.isDestroyed, + ); + if (pendingScripts.length > 0) { + const reason = + "Frida script cleanup is incomplete; unload the retained scripts or close the session before resuming."; + return err( + new AnalysisCapabilityUnavailableError( + "frida", + "resume_frida_session", + reason, + { + cleanup: { + reason, + resources: pendingScripts.map((entry) => entry.id), + }, + }, + ), + ); + } try { await managed.device.resume(managed.pid); managed.state = "running"; @@ -544,13 +582,14 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { parentSessionReleased = false, ): Promise { if (parentSessionReleased || entry.script.isDestroyed) - entry.released = true; - if (!entry.released) { + entry.state = "released"; + if (entry.state !== "released") { + entry.state = "cleanup-pending"; try { await entry.script.unload(); - entry.released = true; + entry.state = "released"; } catch (cause: unknown) { - if (entry.script.isDestroyed) entry.released = true; + if (entry.script.isDestroyed) entry.state = "released"; else return sanitizeError(cause, managed.remote); } } diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index f6a88b8fb6f..d0f3e75a810 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -39,10 +39,12 @@ const nativeFixture = () => { const loaded: string[] = []; const unloaded: string[] = []; const scripts: Script[] = []; + const scriptsAtResume: string[][] = []; const controls = { attach: async () => {}, create: async () => {}, load: async () => {}, + afterLoad: () => {}, discover: async () => {}, unloadFailures: 0, detachFailures: 0, @@ -87,6 +89,7 @@ const nativeFixture = () => { await controls.load(); loaded.push(source); listener?.({ type: "send", payload: source }, null); + controls.afterLoad(); }, async unload() { unloaded.push(source); @@ -116,6 +119,9 @@ const nativeFixture = () => { return 512; }, async resume() { + scriptsAtResume.push( + created.filter((_, index) => scripts[index]?.isDestroyed === false), + ); controls.resumes += 1; }, async attach() { @@ -141,7 +147,15 @@ const nativeFixture = () => { } }, }; - return { controls, created, loaded, unloaded, scripts, deviceManager }; + return { + controls, + created, + loaded, + unloaded, + scripts, + scriptsAtResume, + deviceManager, + }; }; const connectedFixture = async () => { @@ -414,3 +428,131 @@ it("retains a cancelled script whose unload fails for an explicit MCP retry", as 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 = await connectedFixture(); + const id = await fixture.start(); + await fixture.load(id, "original"); + const held = gate(); + onTestFinished(held.release); + fixture.controls.load = held.wait; + 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); + const scriptId = fixture.manager.status(id)?.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: id, + script_id: scriptId, + }, + }); + expect(retry.isError).not.toBe(true); + 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 = await connectedFixture(); + const id = await fixture.start(); + await fixture.load(id, "original"); + const held = gate(); + onTestFinished(held.release); + fixture.controls.load = held.wait; + 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([]); + } + }, +); From 68bf1aeb9f9b625f68994f6a943e0306a841304d Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 13:23:49 +0530 Subject: [PATCH 03/12] test(frida): share lifecycle fixtures without duplicating assertions --- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 128 ++++++++----------- 1 file changed, 52 insertions(+), 76 deletions(-) diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index d0f3e75a810..cfec25c5c59 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -207,18 +207,56 @@ const connectedFixture = async () => { return { ...native, manager, session, server, client, start, load }; }; +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 }) => { + 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>, + sessionId: string, +) => { + const 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); +}; + it.each(["load", "resume", "unload"] as const)( "does not dispatch a cancelled queued Frida %s through MCP", async (operation) => { - const fixture = await connectedFixture(); - const id = await fixture.start(); - const original = await fixture.load(id, "original"); + const { fixture, id, original, held } = await scriptLoadFixture(); const scriptId = toolContract("load_frida_script").outputSchema.parse( original.structuredContent, ).normalized_result.script_id; - const held = gate(); - onTestFinished(held.release); - fixture.controls.load = held.wait; const first = fixture.load(id, "blocking"); await held.entered; const controller = new AbortController(); @@ -258,12 +296,7 @@ it.each(["load", "resume", "unload"] as const)( it.each(["create", "load"] as const)( "releases only the new script when MCP cancellation arrives during native %s", async (stage) => { - const fixture = await connectedFixture(); - const id = await fixture.start(); - await fixture.load(id, "original"); - const held = gate(); - onTestFinished(held.release); - fixture.controls[stage] = held.wait; + 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( @@ -319,18 +352,7 @@ it("awaits an admitted attach on server shutdown and retries failed native clean }) .catch(() => undefined); await held.entered; - let closed = false; - const closing = fixture.server - .close() - .then( - () => undefined, - (cause: unknown) => cause, - ) - .finally(() => { - closed = true; - }); - await setImmediate(); - expect(closed).toBe(false); + const { closing } = await pendingShutdown(fixture.server); expect(fixture.controls.detachCalls).toBe(0); held.release(); expect(await closing).toBeInstanceOf(AggregateError); @@ -365,18 +387,7 @@ it("drains remote discovery before shutdown and retains failed connection cleanu }) .catch(() => undefined); await held.entered; - let closed = false; - const closing = fixture.server - .close() - .then( - () => undefined, - (cause: unknown) => cause, - ) - .finally(() => { - closed = true; - }); - await setImmediate(); - expect(closed).toBe(false); + const { closing } = await pendingShutdown(fixture.server); held.release(); expect(await closing).toBeInstanceOf(AggregateError); await discovering; @@ -386,12 +397,7 @@ it("drains remote discovery before shutdown and retains failed connection cleanu }); it("retains a cancelled script whose unload fails for an explicit MCP retry", async () => { - const fixture = await connectedFixture(); - const id = await fixture.start(); - await fixture.load(id, "original"); - const held = gate(); - onTestFinished(held.release); - fixture.controls.load = held.wait; + const { fixture, id, held } = await scriptLoadFixture(); fixture.controls.unloadFailures = 1; const controller = new AbortController(); const cancelled = fixture.load(id, "cancelled-native", controller.signal); @@ -413,17 +419,7 @@ it("retains a cancelled script whose unload fails for an explicit MCP retry", as }), }), ); - const scriptId = fixture.manager.status(id)?.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: id, - script_id: scriptId, - }, - }); - expect(retry.isError).not.toBe(true); + await retryCancelledScriptUnload(fixture, id); expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]); expect(fixture.manager.status(id)?.scripts).toHaveLength(2); expect(fixture.controls.detachCalls).toBe(0); @@ -432,12 +428,7 @@ it("retains a cancelled script whose unload fails for an explicit MCP retry", as it.each([0, 1])( "settles cancelled native-load cleanup before a queued resume (unload failures: %i)", async (unloadFailures) => { - const fixture = await connectedFixture(); - const id = await fixture.start(); - await fixture.load(id, "original"); - const held = gate(); - onTestFinished(held.release); - fixture.controls.load = held.wait; + const { fixture, id, held } = await scriptLoadFixture(); fixture.controls.unloadFailures = unloadFailures; const controller = new AbortController(); const loading = fixture.load(id, "cancelled-native", controller.signal); @@ -481,17 +472,7 @@ it.each([0, 1])( expect(resumed.isError).toBe(true); expect(fixture.manager.status(id)?.state).toBe("paused"); expect((await resume()).isError).toBe(true); - const scriptId = fixture.manager.status(id)?.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: id, - script_id: scriptId, - }, - }); - expect(retry.isError).not.toBe(true); + await retryCancelledScriptUnload(fixture, id); expect((await resume()).isError).not.toBe(true); expect(fixture.scriptsAtResume).toEqual([["original"]]); }, @@ -500,12 +481,7 @@ it.each([0, 1])( it.each([0, 1, 2, 3, 4, 8])( "keeps MCP load Evidence consistent with queued resume across settlement timing %i", async (microtasks) => { - const fixture = await connectedFixture(); - const id = await fixture.start(); - await fixture.load(id, "original"); - const held = gate(); - onTestFinished(held.release); - fixture.controls.load = held.wait; + const { fixture, id, held } = await scriptLoadFixture(); const controller = new AbortController(); fixture.controls.afterLoad = () => { let remaining = microtasks; From 5792141ad3e6e26b36114347611271aa99c749c9 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 13:46:10 +0530 Subject: [PATCH 04/12] fix(frida): preserve operation admission before session close --- .../frida/FridaInstrumentationManager.test.ts | 22 +++++++++++ src/frida/FridaInstrumentationManager.ts | 18 ++++++++- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 39 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 7a527a8075b..d66280f6651 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -1023,6 +1023,28 @@ it("serializes script loading against concurrent close and unloads the loaded sc expect(bindings.device.session.isDetached()).toBe(true); }); +it("drains startup when the provider loader initiates shutdown", async () => { + const bindings = new FakeDeviceManager(); + let closing: Promise | undefined; + const manager = new FridaInstrumentationManager({ + deviceManagerLoader: async () => { + closing = manager.closeAll(); + return bindings; + }, + }); + const started = await manager.startSession({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + }); + if (!started.ok) throw started.error; + expect(closing).toBeDefined(); + await closing; + expect(bindings.device.session.isDetached()).toBe(true); + expect(bindings.device.resumed).toEqual([512]); + expect(manager.status(started.value.sessionId)).toBeUndefined(); +}); + it("names the unknown session or script ID it rejects", async () => { const { manager, started } = await startLocalAttachedSession(); if (!started.ok) throw started.error; diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 7600ee87a56..db0f0ca8a9e 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -841,14 +841,28 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ), ), ); - const pending = Promise.resolve().then(() => - signal?.aborted ? err(cancelledOperation(name, signal)) : operation(), + let start = () => {}; + const pending = new Promise>( + (resolve, reject) => { + start = () => { + try { + resolve( + signal?.aborted + ? err(cancelledOperation(name, signal)) + : operation(), + ); + } catch (cause: unknown) { + reject(cause); + } + }; + }, ); this.#operations.add(pending); const release = () => { this.#operations.delete(pending); }; void pending.then(release, release); + start(); return pending; } diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index cfec25c5c59..52ec7a5d53e 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -250,6 +250,45 @@ const retryCancelledScriptUnload = async ( expect(retry.isError).not.toBe(true); }; +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) => { From 443b404caed1391f5348e4b5a21cc586b49bbb8c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 14:57:26 +0530 Subject: [PATCH 05/12] test(apple): bound cold archive fixture compilation --- .../filesystem/interfaceBuilderBundle.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 4446c80dcb4..576bd4935c4 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -405,12 +405,16 @@ describe("keyed archive hierarchy coverage", () => { const resources = join(bundle, "Contents", "Resources"); await mkdir(resources, { recursive: true }); const archive = join(resources, "Foundation.nib"); - await compile("/usr/bin/xcrun", [ - "swift", - "tests/conformance/native/keyed-archive-hierarchy.swift", - archive, - "40", - ]); + await compile( + "/usr/bin/xcrun", + [ + "swift", + "tests/conformance/native/keyed-archive-hierarchy.swift", + archive, + "40", + ], + { timeout: 90_000 }, + ); const analysis = await analyzeInterfaceBuilderBundle({ bundlePath: bundle, @@ -431,6 +435,7 @@ describe("keyed archive hierarchy coverage", () => { ), ).toHaveLength(41); }, + 120_000, ); }); From 1c537624238469e98cbe227df76de4494fe20fca Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 15:07:46 +0530 Subject: [PATCH 06/12] fix(frida): retain cancelled script creation evidence --- src/frida/FridaInstrumentationManager.ts | 4 +- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 156 ++++++++++++++----- 2 files changed, 114 insertions(+), 46 deletions(-) diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index db0f0ca8a9e..b93cffae5d4 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -425,9 +425,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { }; managed.scripts.set(scriptId, entry); nativeScript.message.connect(onMessage); - if (signal?.aborted) - throw cancelledOperation("load_frida_script", signal); - await nativeScript.load(); + if (!signal?.aborted) await nativeScript.load(); const observation: FridaScriptObservation = { scriptId, sourceKind: source.sourceKind, diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 52ec7a5d53e..2f185312576 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -11,6 +12,7 @@ import type { import { expect, it, onTestFinished } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -236,8 +238,8 @@ const pendingShutdown = async (server: { close(): Promise }) => { const retryCancelledScriptUnload = async ( fixture: Awaited>, sessionId: string, + scriptId = fixture.manager.status(sessionId)?.scripts[1]?.scriptId, ) => { - const 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({ @@ -250,6 +252,51 @@ const retryCancelledScriptUnload = async ( expect(retry.isError).not.toBe(true); }; +const retainedScriptObservation = async ( + fixture: Awaited>, + 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) => { @@ -357,20 +404,19 @@ it.each(["create", "load"] as const)( expect(fixture.controls.detachCalls).toBe(0); expect(fixture.controls.resumes).toBe(0); expect(fixture.manager.status(id)?.scripts).toHaveLength(2); - if (stage === "load") { - const records = fixture.session.exportEvidenceBundle().records; - expect(records).toContainEqual( - expect.objectContaining({ - operation: "load_frida_script", - normalized_result: expect.objectContaining({ - messages: [ - expect.objectContaining({ payload: "cancelled-native" }), - ], - script_cleanup: "released", - }), - }), - ); - } + 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, + }); }, ); @@ -435,34 +481,58 @@ it("drains remote discovery before shutdown and retains failed connection cleanu expect(fixture.controls.remoteRemovals).toBe(3); }); -it("retains a cancelled script whose unload fails for an explicit MCP retry", async () => { - const { fixture, id, held } = await scriptLoadFixture(); - 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); - expect(fixture.session.exportEvidenceBundle().records).toContainEqual( - expect.objectContaining({ - operation: "load_frida_script", - normalized_result: expect.objectContaining({ - script_cleanup: "incomplete_or_unknown", - cleanup_error: expect.stringContaining("transient unload failure"), - }), - }), - ); - await retryCancelledScriptUnload(fixture, id); - expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]); - expect(fixture.manager.status(id)?.scripts).toHaveLength(2); - expect(fixture.controls.detachCalls).toBe(0); -}); +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)", From e4394384a97c5d7cd0d560b546786965a9c283b5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 13 Oct 2026 15:13:32 +0530 Subject: [PATCH 07/12] test(release): scope preflight schema to selected jobs --- .../filesystem/releaseCheckpoint.test.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/boundary/filesystem/releaseCheckpoint.test.ts b/tests/boundary/filesystem/releaseCheckpoint.test.ts index e3d425810da..3fb4692c590 100644 --- a/tests/boundary/filesystem/releaseCheckpoint.test.ts +++ b/tests/boundary/filesystem/releaseCheckpoint.test.ts @@ -642,24 +642,25 @@ it.skipIf(process.platform === "win32").each([ { job: "release-please", valid: false }, { job: "merged-release", valid: true }, { job: "merged-release", valid: false }, -])( +] as const)( "the actual $job preflight admits the next action only for valid metadata ($valid)", async ({ job, valid }) => { const f = await fixture(); const sourceSha = await candidate(f, valid ? "6.0.0" : "5.1.0"); + const preflightJobSchema = z.object({ + steps: z.array( + z.object({ + name: z.string().optional(), + run: z.string().optional(), + }), + ), + }); const workflow = z .object({ - jobs: z.record( - z.string(), - z.object({ - steps: z.array( - z.object({ - name: z.string().optional(), - run: z.string().optional(), - }), - ), - }), - ), + jobs: z.object({ + "release-please": preflightJobSchema, + "merged-release": preflightJobSchema, + }), }) .parse( parse( @@ -672,7 +673,7 @@ it.skipIf(process.platform === "win32").each([ const command = z .string() .parse( - workflow.jobs[job]?.steps.find( + workflow.jobs[job].steps.find( (step) => step.name === (job === "merged-release" From 6a7b4c63ea36d83d30cf10a39a59fa3b1a01aff3 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:52:29 +0000 Subject: [PATCH 08/12] fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 --- src/process/ProviderDeadline.test.ts | 17 +++++++++++++++++ src/process/ProviderDeadline.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index b43ce665424..0a944815037 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -33,3 +33,20 @@ it("aborts a bounded wait when its real monotonic deadline expires", async () => deadline.dispose(); } }); + +it("reports no remaining time once its timer fires before the monotonic deadline", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const now = vi.spyOn(performance, "now").mockReturnValue(1_000); + const deadline = new ProviderStartupDeadline(40); + try { + // Timers may fire while the monotonic clock is still just short of the deadline. + now.mockReturnValue(1_039.6); + vi.advanceTimersByTime(40); + expect(deadline.signal.aborted).toBe(true); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 33cb3a8d00b..a6f10779afe 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -85,6 +85,9 @@ export class ProviderStartupDeadline { /** Milliseconds still available to the next startup phase. */ remainingMs(): number { + // Node timers can fire a fraction of a millisecond before the monotonic + // deadline, so an elapsed timer must not report time left to spend. + if (this.#interruption === "timeout") return 0; return Math.max(0, Math.ceil(this.#deadlineAt - performance.now())); } From 6267beecaf888861391c0d3df3edee0d8e7b174d Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:31 +0800 Subject: [PATCH 09/12] fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commit 688defca0f4a3de04eb199f7754f323c68896c3c) --- ...uctionVerification.functionSources.test.ts | 117 +++++++++++++++++- src/domain/reconstructionVerification.ts | 46 +++++-- .../mcp/comparisonUnknownsMcp.test.ts | 3 + 3 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 5dc8db58970..e23d37d7458 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -53,6 +53,7 @@ const source = ( digit: string, observation: Partial = {}, hasSubject = true, + hasCoverage = true, ): Evidence => createEvidence( hasSubject @@ -69,7 +70,9 @@ const source = ( result: dossier, confidence: "derived", authority: "shipped-artifact", - relationshipCoverage: { status: "complete" }, + ...(hasCoverage + ? { relationshipCoverage: { status: "complete" as const } } + : {}), ...observation, }, ); @@ -197,6 +200,118 @@ describe("function reconstruction source parameters", () => { }); }); +describe("historical calls-only reconstruction changes", () => { + it.each(["missing", "unknown", "partial", "unresolved"] as const)( + "keeps historical calls-only changes unknown with %s call coverage", + (coverage) => { + const changedDossier = functionDossierSchema.parse({ + ...functionDossierSchema.parse(dossier), + callees: [ + { + address: "0x2000", + name: "puts", + classification: null, + body: { available: false, reason: "External function." }, + }, + ], + }); + const complete = source("8", { + result: jsonValueSchema.parse(changedDossier), + }); + const incomplete = source( + "9", + { + result: jsonValueSchema.parse({ + ...changedDossier, + ...(coverage === "unresolved" + ? { + unresolved_calls: [ + { + address: "0x1004", + reason: "Computed target unresolved.", + }, + ], + } + : {}), + }), + ...(coverage === "missing" + ? {} + : { + relationshipCoverage: + coverage === "unknown" + ? { status: "unknown", reason: "Call graph unavailable." } + : coverage === "partial" + ? { + status: "partial", + limitations: ["Additional edges omitted."], + } + : { status: "complete" }, + }), + }, + true, + coverage !== "missing", + ); + const historicalResult = compareFunctions(left, complete); + expect(historicalResult).toMatchObject({ + status: "changed", + summary: { changed: 1 }, + }); + expect(historicalResult.changes).toMatchObject([ + { dimension: "calls", status: "changed" }, + ]); + const completeComparison = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: complete.evidence_id, + }, + {}, + [left, complete], + {}, + historicalResult, + ); + expect( + admittedVerification(completeComparison, [left, complete], "overall")() + .status, + ).toBe("fail"); + const evidence = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: incomplete.evidence_id, + }, + {}, + [left, incomplete], + {}, + historicalResult, + ); + const before = JSON.stringify(evidence); + for (const dimension of ["calls", "overall"] as const) { + expect( + admittedVerification(evidence, [left, incomplete], dimension)(), + ).toMatchObject({ + status: "unknown", + summary: { total: 1, passed: 0, failed: 0, unknown: 1 }, + claims: { + items: [ + expect.objectContaining({ + status: "unknown", + observed_status: "changed", + limitations: expect.arrayContaining([ + expect.stringContaining(`Source ${incomplete.evidence_id}:`), + ]), + }), + ], + }, + }); + } + expect( + admittedVerification(evidence, [left, incomplete], "pseudocode")() + .status, + ).toBe("pass"); + expect(JSON.stringify(evidence)).toBe(before); + }, + ); +}); + describe("function reconstruction call coverage", () => { it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => { const incomplete = source("7", { diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index dd300a0a271..6f693a15897 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -144,10 +144,15 @@ const evaluateClaim = ( return source; }); const functionSourceFacts = readValidatedSourceFacts(claim, sources); - const observed = observedStatus(claim, comparison); + const observation = comparisonObservation(claim, comparison); + const observed = observation.status; const limitations = [ ...authorityLimitations(claim, sources, sides), - ...sourceCallCoverageLimitations(claim, functionSourceFacts, observed), + ...sourceCallCoverageLimitations( + claim, + functionSourceFacts, + observation.hasIndependentFunctionChange === true, + ), ]; const relatedUnknowns = reconstructionClaimUnknowns( heads, @@ -235,21 +240,40 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { throw new TypeError("Comparison Evidence identity or authority disagrees"); }; -const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { +const comparisonObservation = ( + claim: Claim, + evidence: Evidence, +): { + readonly status: ObservedStatus; + readonly hasIndependentFunctionChange?: boolean; +} => { if (claim.kind === "behavioral") { const result = processCaptureComparisonSchema.parse( evidence.normalized_result, ); validateProcessStatus(result); - return claim.dimension === "overall" - ? result.status - : result[claim.dimension]; + return { + status: + claim.dimension === "overall" ? result.status : result[claim.dimension], + }; } if (claim.kind === "structural-function") { const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); - if (claim.dimension === "overall") return result.status; + if (claim.dimension === "overall") + return { + status: result.status, + // Historical overall changes may come solely from incomplete call sets. + hasIndependentFunctionChange: + result.function_match.status === "mismatched" || + (result.function_match.status === "matched" && + result.dimensions.some( + (dimension) => + dimension.dimension !== "calls" && + dimension.status === "changed", + )), + }; const dimension = result.dimensions.find( (item) => item.dimension === claim.dimension, ); @@ -263,7 +287,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Function dimension cites Evidence outside comparison closure", ); - return dimension.status; + return { status: dimension.status }; } const result = artifactComparisonResultSchema.parse( evidence.normalized_result, @@ -276,7 +300,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Artifact change cites Evidence outside comparison closure", ); - return result.status; + return { status: result.status }; }; const validateProcessStatus = ( @@ -340,12 +364,12 @@ const authorityLimitations = ( const sourceCallCoverageLimitations = ( claim: Claim, functionSources: readonly FunctionSourceFacts[], - observed: ObservedStatus, + hasIndependentFunctionChange: boolean, ): string[] => { if ( claim.kind !== "structural-function" || (claim.dimension !== "calls" && - !(claim.dimension === "overall" && observed !== "changed")) + !(claim.dimension === "overall" && !hasIndependentFunctionChange)) ) return []; return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) => diff --git a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts index bf950f83b49..f872d2f1542 100644 --- a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts +++ b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts @@ -49,6 +49,9 @@ const observeAgain = ( result, confidence: source.confidence, authority: source.authority, + ...(source.relationship_coverage === null + ? {} + : { relationshipCoverage: source.relationship_coverage }), }, ); From abc535da9e268ae10a9eb1e6da3b52aaa46e374a Mon Sep 17 00:00:00 2001 From: REA Maintenance <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:09:46 +0800 Subject: [PATCH 10/12] fix(ci): isolate Linux Hopper process fixture inventory (cherry picked from commit 8cac51e12184d56a9ba80a4427b3e6082ddac1ec) --- scripts/lib/clean-linux-hopper-runtime.d.mts | 1 + scripts/lib/clean-linux-hopper-runtime.mjs | 7 ++--- .../cleanLinuxHopperRuntime.test.ts | 26 ++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/lib/clean-linux-hopper-runtime.d.mts b/scripts/lib/clean-linux-hopper-runtime.d.mts index dd1010aa1ec..d2177fad470 100644 --- a/scripts/lib/clean-linux-hopper-runtime.d.mts +++ b/scripts/lib/clean-linux-hopper-runtime.d.mts @@ -1,3 +1,4 @@ export function assertCleanLinuxHopperRuntime( launcherPaths?: readonly string[], + procRoot?: string, ): Promise; diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs index 27a306fce16..fbe15e0d3a4 100644 --- a/scripts/lib/clean-linux-hopper-runtime.mjs +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -16,6 +16,7 @@ export const assertCleanLinuxHopperRuntime = async ( ? [] : [process.env.HOPPER_LAUNCHER_PATH]), ], + procRoot = "/proc", ) => { const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]); if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout)) @@ -32,17 +33,17 @@ export const assertCleanLinuxHopperRuntime = async ( // /proc exposes the executed file even when argv[0] names a removed symlink. // Compare file identity, not a display path or a process name. let inspectionError; - for (const entry of await readdir("/proc", { withFileTypes: true })) { + for (const entry of await readdir(procRoot, { withFileTypes: true })) { if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; let executable; try { - executable = await stat(`/proc/${entry.name}/exe`, { bigint: true }); + executable = await stat(`${procRoot}/${entry.name}/exe`, { bigint: true }); } catch (error) { if (error?.code === "ENOENT" || error?.code === "ESRCH") continue; // Other users' executables may be unreadable. The setup lane launches // providers as the caller; unreadable caller-owned processes fail closed. if (error?.code === "EACCES") { - const owner = await stat(`/proc/${entry.name}`).catch((cause) => { + const owner = await stat(`${procRoot}/${entry.name}`).catch((cause) => { if (cause?.code === "ENOENT") return undefined; throw cause; }); diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index f6b9fc677b2..8b7be17da6e 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -1,6 +1,13 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { chmod, copyFile, readFile, symlink, unlink } from "node:fs/promises"; +import { + chmod, + copyFile, + mkdir, + readFile, + symlink, + unlink, +} from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -24,6 +31,8 @@ it const directory = await createTestTempDirectory("rea-clean-runtime-test-"); const launcher = join(directory, "Hopper"); const alternate = join(directory, "Hopper-alternate"); + const procRoot = join(directory, "proc"); + await mkdir(procRoot); const alternateMode = mode === "alternate launcher"; if (alternateMode) { await copyFile("/usr/bin/sleep", launcher); @@ -32,7 +41,10 @@ it } // A rejection must come from the fixture we are about to launch. An // existing Xvfb would otherwise mask a broken executable-identity check. - await assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []); + await assertCleanLinuxHopperRuntime( + alternateMode ? [launcher] : [], + procRoot, + ); const command = alternateMode ? alternate : process.execPath; const arguments_ = alternateMode ? ["30"] : ["-e", childCode]; const parentCode = ` @@ -59,6 +71,11 @@ it const { stdout } = await parent; const { pid } = JSON.parse(stdout) as { pid: number }; orphanPid = pid; + if (alternateMode) { + const processEntry = join(procRoot, String(pid)); + await mkdir(processEntry); + await symlink(`/proc/${String(pid)}/exe`, join(processEntry, "exe")); + } const { stdout: coordinates } = await execFileAsync("ps", [ "-p", String(pid), @@ -92,7 +109,10 @@ it await unlink(alternate); } await expect( - assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []), + assertCleanLinuxHopperRuntime( + alternateMode ? [launcher] : [], + procRoot, + ), ).rejects.toThrow("Clean Linux runner retained a Hopper demo process"); // Observation must not terminate the fixture, even though it resembles Xvfb. const { stdout: alive } = await execFileAsync("ps", [ From 41aa69170564f4f97713de126e79306c33742efb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:37 +0800 Subject: [PATCH 11/12] fix(android): make DEX descriptor pattern portable (cherry picked from commit 1335bcddc569a1cfb34b6efedcb6f2e3aaaa3357) --- src/domain/android/androidAnalysis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index c1ec76ba83f..caa3bf5ee03 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -102,7 +102,7 @@ const textCoverage = z.strictObject({ export const dexDescriptorSchema = z .string() .regex( - /^\((?:\[*(?:[ZBSCIJFD]|L[^;()]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;()]+;))$/u, + /^\((?:\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;\x28\x29]+;))$/u, ); const method = z.strictObject({ From 2c5912ce1ee70565edc0efd9b74c8c2e45015d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:31:27 +0800 Subject: [PATCH 12/12] fix: repair the static-check failures on the default branch (#2895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: repair the static-check failures on the default branch The default branch fails its own required `Static checks` job three ways, so every pull request reports that job as failed regardless of its own change. - `npm run format:check` reports `src/domain/functionComparison.test.ts` and `src/ida/IdaOperationRunner.ts`. - `npm run lint` reports a duplicate `import { join } from "node:path"` in `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17). - `npm run typecheck` reports `format: "devin"` missing from the client format union in `src/application/SupportedClients.ts`, and an unchecked index in `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is inferred as `string[][]`. Apply the pinned formatter, drop the duplicate import, list the `"devin"` format that its own definition already uses, and mark the pair array `as const`. Validation: `npm run check:fast` and `npm run format:check` pass. * fix(setup): keep Devin on the JSON configuration dialect --------- Co-authored-by: 吴佳浩 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commit df3d96da801976fdbe37a726c2f4f84af8ffbf90) --- src/domain/functionComparison.test.ts | 4 +++- src/ida/IdaOperationRunner.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 7170f098b2c..16f8282c90c 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -175,7 +175,9 @@ describe("function comparison normalized identity", () => { it("retains unresolved call details alongside missing facet coverage", () => { const value: Record = { ...dossier("return 0;", "0x1000"), - unresolved_calls: [{ address: "0x1010", reason: "Unknown indirect target" }], + unresolved_calls: [ + { address: "0x1010", reason: "Unknown indirect target" }, + ], }; delete value.facet_availability; const legacy = observe("a", functionDossierSchema.parse(value), "ida"); diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 647b06e52d7..576dac1e712 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -503,7 +503,8 @@ export class IdaOperationRunner { items: [], availability: { available: false, - reason: "Upstream xrefs do not establish direct caller relationships.", + reason: + "Upstream xrefs do not establish direct caller relationships.", }, coverage: { status: "unknown",