diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md index c93c76465c..718af30c24 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 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/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 7a527a8075..d66280f665 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/application/frida/FridaInstrumentationPort.ts b/src/application/frida/FridaInstrumentationPort.ts index 39c51eb268..39fa30936b 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,14 +113,20 @@ export interface FridaInstrumentationPort { startSession( input: StartFridaSessionInput, ): Promise>; + /** Own cancellation and new-script cleanup before returning a final load outcome. */ 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.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index c1251f9a52..dc25bc62b9 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 c359850d66..3cba20d416 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, @@ -181,8 +182,17 @@ 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 && loaded.error instanceof FridaScriptLoadCancelledError) + return err(cancelledScriptEvidence(sessionId, loaded.error)); return loaded.ok ? ok( fridaEvidence("load_frida_script", scriptOutput(loaded.value), { @@ -192,12 +202,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,14 +253,19 @@ export class FridaInstrumentationService { const loaded = await this.#provider.loadScript( started.value.sessionId, 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); @@ -267,6 +282,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( @@ -462,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 22b38c4420..b93cffae5d 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -22,9 +22,11 @@ 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, + AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; @@ -58,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 { @@ -114,6 +116,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 +131,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 +187,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 +252,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 +349,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 +400,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ); } + if (signal?.aborted) + return err(cancelledOperation("load_frida_script", signal)); const sourceSha256 = createHash("sha256") .update(scriptSource) .digest("hex"); @@ -387,19 +421,36 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { name: scriptName, script: nativeScript, onMessage, - released: false, + state: "active", }; managed.scripts.set(scriptId, entry); nativeScript.message.connect(onMessage); - await nativeScript.load(); - return ok({ + if (!signal?.aborted) await nativeScript.load(); + const observation: FridaScriptObservation = { scriptId, sourceKind: source.sourceKind, sourcePath: source.sourceKind === "file" ? source.path : null, sourceSha256, messages: managed.messagesByScript.get(scriptId)?.toArray() ?? [], 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 = @@ -418,9 +469,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, ); } @@ -438,6 +501,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"; @@ -452,9 +535,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, ); } @@ -487,13 +580,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); } } @@ -629,7 +723,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) => @@ -720,6 +824,46 @@ 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.", + ), + ), + ); + 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; + } + async #withSessionLock( sessionId: string, operation: () => Promise, @@ -1217,6 +1361,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), @@ -1234,6 +1390,12 @@ 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 }); + interface CapturedMessage { readonly observation: JsonValue; readonly scriptId: string; diff --git a/src/server/registerFridaTools.ts b/src/server/registerFridaTools.ts index 5125f105ce..fb2e06bb4f 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 0000000000..2f18531257 --- /dev/null +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -0,0 +1,643 @@ +import { createHash } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { + Device, + DeviceManager, + FridaMessage, + Script, + Session, +} from "frida"; +import { expect, it, onTestFinished } 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"; + +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 scriptsAtResume: string[][] = []; + const controls = { + attach: async () => {}, + create: async () => {}, + load: async () => {}, + afterLoad: () => {}, + 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); + controls.afterLoad(); + }, + async unload() { + unloaded.push(source); + if (controls.unloadFailures > 0) { + controls.unloadFailures -= 1; + throw new Error("transient unload failure"); + } + destroyed = true; + }, + }; + scripts.push(script); + return script; + }, + }; + const device: Device = { + id: "local", + name: "Local fixture", + type: "local", + async enumerateProcesses() { + await controls.discover(); + return [{ pid: 512, name: "fixture" }]; + }, + async getProcessByPid() { + return { pid: 512, name: "fixture" }; + }, + async spawn() { + return 512; + }, + async resume() { + scriptsAtResume.push( + created.filter((_, index) => scripts[index]?.isDestroyed === false), + ); + controls.resumes += 1; + }, + async attach() { + await controls.attach(); + return nativeSession; + }, + }; + const deviceManager: DeviceManager = { + async enumerateDevices() { + return [device]; + }, + async getDeviceById() { + return device; + }, + async addRemoteDevice() { + return device; + }, + async removeRemoteDevice() { + controls.remoteRemovals += 1; + if (controls.removalFailures > 0) { + controls.removalFailures -= 1; + throw new Error("transient remote removal failure"); + } + }, + }; + return { + controls, + created, + loaded, + unloaded, + scripts, + scriptsAtResume, + deviceManager, + }; +}; + +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 }; +}; + +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, + scriptId = fixture.manager.status(sessionId)?.scripts[1]?.scriptId, +) => { + if (scriptId === undefined) + throw new Error("Cancelled script was not retained"); + const retry = await fixture.client.callTool({ + name: "unload_frida_script", + arguments: { + session_id: sessionId, + script_id: scriptId, + }, + }); + expect(retry.isError).not.toBe(true); +}; + +const retainedScriptObservation = async ( + fixture: Awaited>, + sessionId: string, + source: string, +) => { + const response = await fixture.client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + response.structuredContent, + ).result, + ); + const sourceSha256 = createHash("sha256").update(source).digest("hex"); + const records = bundle.records.filter((record) => { + const result = record.normalized_result; + return ( + record.operation === "load_frida_script" && + result !== null && + typeof result === "object" && + !Array.isArray(result) && + result.source_sha256 === sourceSha256 + ); + }); + expect(records).toHaveLength(1); + expect(records[0]?.parameters).toEqual({ session_id: sessionId }); + const observation = records[0]?.normalized_result; + if ( + observation === undefined || + observation === null || + typeof observation !== "object" || + Array.isArray(observation) + ) + throw new Error("Missing retained script observation"); + expect(observation).toMatchObject({ + script_id: expect.any(String), + source_kind: "inline", + source_path: null, + source_sha256: sourceSha256, + messages_truncated: false, + }); + return observation; +}; + +it.each(["load", "resume", "unload"] as const)( + "finishes an earlier MCP %s request before closing its Frida session", + async (operation) => { + const fixture = await connectedFixture(); + const id = await fixture.start(); + const original = await fixture.load(id, "original"); + const scriptId = toolContract("load_frida_script").outputSchema.parse( + original.structuredContent, + ).normalized_result.script_id; + const pending = fixture.client.callTool({ + name: { + load: "load_frida_script", + resume: "resume_frida_session", + unload: "unload_frida_script", + }[operation], + arguments: { + session_id: id, + ...(operation === "load" + ? { source_kind: "inline", source: "last-script" } + : {}), + ...(operation === "unload" ? { script_id: scriptId } : {}), + }, + }); + const closing = fixture.client.callTool({ + name: "close_frida_session", + arguments: { session_id: id }, + }); + expect((await pending).isError).not.toBe(true); + expect((await closing).isError).not.toBe(true); + expect(fixture.loaded).toEqual( + operation === "load" ? ["original", "last-script"] : ["original"], + ); + expect(fixture.unloaded).toEqual(fixture.loaded); + expect(fixture.controls.detachCalls).toBe(1); + expect(fixture.controls.resumes).toBe(1); + expect(fixture.manager.status(id)).toBeUndefined(); + }, +); + +it.each(["load", "resume", "unload"] as const)( + "does not dispatch a cancelled queued Frida %s through MCP", + async (operation) => { + const { fixture, id, original, held } = await scriptLoadFixture(); + const scriptId = toolContract("load_frida_script").outputSchema.parse( + original.structuredContent, + ).normalized_result.script_id; + const first = fixture.load(id, "blocking"); + await held.entered; + const controller = new AbortController(); + const cancelled = + operation === "load" + ? fixture.load(id, "cancelled", controller.signal) + : fixture.client.callTool( + { + name: + operation === "resume" + ? "resume_frida_session" + : "unload_frida_script", + arguments: { + session_id: id, + ...(operation === "unload" ? { script_id: scriptId } : {}), + }, + }, + { signal: controller.signal }, + ); + const rejected = expect(cancelled).rejects.toThrow( + "cancel queued operation", + ); + await fixture.client.ping(); + controller.abort(new Error("cancel queued operation")); + await fixture.client.ping(); + await rejected; + held.release(); + await first; + await fixture.load(id, "barrier"); + expect(fixture.created).toEqual(["original", "blocking", "barrier"]); + expect(fixture.unloaded).toEqual([]); + expect(fixture.controls.resumes).toBe(0); + expect(fixture.manager.status(id)?.scripts).toHaveLength(3); + }, +); + +it.each(["create", "load"] as const)( + "releases only the new script when MCP cancellation arrives during native %s", + async (stage) => { + const { fixture, id, held } = await scriptLoadFixture(stage); + const controller = new AbortController(); + const cancelled = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(cancelled).rejects.toThrow( + "cancel native operation", + ); + await held.entered; + controller.abort(new Error("cancel native operation")); + await fixture.client.ping(); + await rejected; + held.release(); + await fixture.load(id, "barrier"); + await fixture.client.ping(); + expect(fixture.unloaded).toEqual(["cancelled-native"]); + expect(fixture.loaded).toEqual( + stage === "create" + ? ["original", "barrier"] + : ["original", "cancelled-native", "barrier"], + ); + expect(fixture.controls.detachCalls).toBe(0); + expect(fixture.controls.resumes).toBe(0); + expect(fixture.manager.status(id)?.scripts).toHaveLength(2); + const observation = await retainedScriptObservation( + fixture, + id, + "cancelled-native", + ); + expect(observation).toMatchObject({ + messages: + stage === "create" + ? [] + : [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: "released", + cleanup_error: null, + }); + }, +); + +it("awaits an admitted attach on server shutdown and retries failed native cleanup", async () => { + const fixture = await connectedFixture(); + const held = gate(); + onTestFinished(held.release); + fixture.controls.attach = held.wait; + fixture.controls.detachFailures = 1; + const starting = fixture.client + .callTool({ + name: "start_frida_session", + arguments: { + mode: "attach", + pid: 512, + device_id: "local", + }, + }) + .catch(() => undefined); + await held.entered; + const { closing } = await pendingShutdown(fixture.server); + expect(fixture.controls.detachCalls).toBe(0); + held.release(); + expect(await closing).toBeInstanceOf(AggregateError); + await starting; + expect(fixture.controls.detachCalls).toBe(1); + await fixture.server.close(); + expect(fixture.controls.detachCalls).toBe(2); + await fixture.server.close(); + expect(fixture.controls.detachCalls).toBe(2); + const afterClose = await fixture.manager.startSession({ + mode: "attach", + pid: 512, + }); + expect(afterClose).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +it("drains remote discovery before shutdown and retains failed connection cleanup", async () => { + const fixture = await connectedFixture(); + const held = gate(); + onTestFinished(held.release); + fixture.controls.discover = held.wait; + fixture.controls.removalFailures = 2; + const discovering = fixture.client + .callTool({ + name: "list_frida_processes", + arguments: { + remote: { address: "127.0.0.1:27042" }, + }, + }) + .catch(() => undefined); + await held.entered; + const { closing } = await pendingShutdown(fixture.server); + held.release(); + expect(await closing).toBeInstanceOf(AggregateError); + await discovering; + expect(fixture.controls.remoteRemovals).toBe(2); + await fixture.server.close(); + expect(fixture.controls.remoteRemovals).toBe(3); +}); + +it.each(["create", "load"] as const)( + "retains the identity of a cancelled native %s for an explicit MCP unload retry", + async (stage) => { + const { fixture, id, held } = await scriptLoadFixture(stage); + fixture.controls.unloadFailures = 1; + const controller = new AbortController(); + const cancelled = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(cancelled).rejects.toThrow( + "cancel retained script", + ); + await held.entered; + controller.abort(new Error("cancel retained script")); + await fixture.client.ping(); + await rejected; + held.release(); + await fixture.load(id, "barrier"); + await fixture.client.ping(); + expect(fixture.manager.status(id)?.scripts).toHaveLength(3); + const observation = await retainedScriptObservation( + fixture, + id, + "cancelled-native", + ); + expect(observation).toMatchObject({ + messages: + stage === "create" + ? [] + : [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: "incomplete_or_unknown", + cleanup_error: expect.stringContaining("transient unload failure"), + }); + const scriptId = observation.script_id; + if (typeof scriptId !== "string") + throw new Error("Missing cancelled script identity"); + expect(fixture.manager.status(id)?.scripts[1]?.scriptId).toBe(scriptId); + const resuming = await fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + expect(resuming.isError).toBe(true); + expect(fixture.controls.resumes).toBe(0); + await retryCancelledScriptUnload(fixture, id, scriptId); + expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]); + expect(fixture.loaded).toEqual( + stage === "create" + ? ["original", "barrier"] + : ["original", "cancelled-native", "barrier"], + ); + expect(fixture.manager.status(id)?.scripts).toHaveLength(2); + expect(fixture.controls.detachCalls).toBe(0); + }, +); + +it.each([0, 1])( + "settles cancelled native-load cleanup before a queued resume (unload failures: %i)", + async (unloadFailures) => { + const { fixture, id, held } = await scriptLoadFixture(); + fixture.controls.unloadFailures = unloadFailures; + const controller = new AbortController(); + const loading = fixture.load(id, "cancelled-native", controller.signal); + const rejected = expect(loading).rejects.toThrow( + "cancel before queued resume", + ); + await held.entered; + const resume = () => + fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + const resuming = resume(); + await fixture.client.ping(); + controller.abort(new Error("cancel before queued resume")); + await fixture.client.ping(); + await rejected; + held.release(); + const resumed = await resuming; + await fixture.client.ping(); + expect(fixture.unloaded).toEqual(["cancelled-native"]); + expect(fixture.scriptsAtResume).toEqual( + unloadFailures === 0 ? [["original"]] : [], + ); + expect(fixture.controls.detachCalls).toBe(0); + expect(fixture.session.exportEvidenceBundle().records).toContainEqual( + expect.objectContaining({ + operation: "load_frida_script", + normalized_result: expect.objectContaining({ + messages: [expect.objectContaining({ payload: "cancelled-native" })], + script_cleanup: + unloadFailures === 0 ? "released" : "incomplete_or_unknown", + }), + }), + ); + if (unloadFailures === 0) { + expect(resumed.isError).not.toBe(true); + expect(fixture.manager.status(id)?.scripts).toHaveLength(1); + return; + } + expect(resumed.isError).toBe(true); + expect(fixture.manager.status(id)?.state).toBe("paused"); + expect((await resume()).isError).toBe(true); + await retryCancelledScriptUnload(fixture, id); + expect((await resume()).isError).not.toBe(true); + expect(fixture.scriptsAtResume).toEqual([["original"]]); + }, +); + +it.each([0, 1, 2, 3, 4, 8])( + "keeps MCP load Evidence consistent with queued resume across settlement timing %i", + async (microtasks) => { + const { fixture, id, held } = await scriptLoadFixture(); + const controller = new AbortController(); + fixture.controls.afterLoad = () => { + let remaining = microtasks; + const abort = () => { + if (remaining-- > 0) queueMicrotask(abort); + else controller.abort(new Error("cancel around load completion")); + }; + abort(); + }; + const loading = fixture.load(id, "settling-native", controller.signal); + const observed = loading.catch(() => undefined); + await held.entered; + const resuming = fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + await fixture.client.ping(); + held.release(); + await observed; + expect((await resuming).isError).not.toBe(true); + fixture.controls.afterLoad = () => {}; + await fixture.load(id, "barrier"); + await fixture.client.ping(); + const records = fixture.session + .exportEvidenceBundle() + .records.filter( + (record) => + record.operation === "load_frida_script" && + JSON.stringify(record.normalized_result).includes("settling-native"), + ); + expect(records).toHaveLength(1); + const recorded = records[0]?.normalized_result; + if ( + recorded === null || + typeof recorded !== "object" || + Array.isArray(recorded) + ) + throw new Error("Missing script load Evidence"); + if (recorded.script_cleanup === "released") { + expect(fixture.scriptsAtResume).toEqual([["original"]]); + expect(fixture.unloaded).toEqual(["settling-native"]); + } else { + expect(recorded.script_cleanup).toBeUndefined(); + expect(fixture.scriptsAtResume).toEqual([ + ["original", "settling-native"], + ]); + expect(fixture.unloaded).toEqual([]); + } + }, +);