mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* feat: add Frida instrumentation support * fix: resolve Frida CI checks * test: cover Frida contract validation branches * docs: link Frida instrumentation guide * fix(frida): handle cancellation cleanup safely * fix(frida): address PR review findings * fix(frida): preserve resume and message observations * fix(ci): use canonical registry for rolldown * fix(frida): declare foreign code effects * fix(frida): retain failed startup resources for cleanup retries Keep failed startup session and spawn handles owned until detach, resume, and remote lease cleanup complete. Serialize retries and release each remote reference once. Await CLI provider cleanup while preserving the primary failure and any completed observations. Add regressions for retained startup resources, concurrent cleanup, shared remote leases, and CLI cleanup outcomes. Validation: 41 focused tests pass on the PR head and its latest-main integration; serial static and docs checks pass. Real Linux Frida 17.23.2 workflows and the native spawn/controlled-failure cleanup probe pass. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --------- Co-authored-by: N0zoM1z0 <1705004398@qq.com>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
|
import type { Logger } from "pino";
|
|
|
|
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
|
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
|
|
import type { FridaInstrumentationPort } from "../application/frida/FridaInstrumentationPort.js";
|
|
import type { FridaRemoteConnection } from "../application/frida/FridaInstrumentationPort.js";
|
|
import { toolContract } from "../contracts/toolContracts.js";
|
|
import { evidenceSchema } from "../domain/evidence.js";
|
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import { logToolExecution } from "./toolLogging.js";
|
|
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
|
import type { ToolResultDelivery } from "./toolResult.js";
|
|
|
|
/** Register Frida device discovery and persistent instrumentation operations. */
|
|
export const registerFridaTools = (
|
|
server: McpServer,
|
|
provider: FridaInstrumentationPort,
|
|
logger: Logger,
|
|
delivery: ToolResultDelivery,
|
|
recordEvidence?: EvidenceWriter["recordEvidence"],
|
|
): void => {
|
|
const service = new FridaInstrumentationService(provider);
|
|
const listDevices = toolContract("list_frida_devices");
|
|
const listProcesses = toolContract("list_frida_processes");
|
|
const start = toolContract("start_frida_session");
|
|
const load = toolContract("load_frida_script");
|
|
const resume = toolContract("resume_frida_session");
|
|
const unload = toolContract("unload_frida_script");
|
|
const status = toolContract("frida_session_status");
|
|
const close = toolContract("close_frida_session");
|
|
const instrument = toolContract("instrument_with_frida");
|
|
|
|
server.registerTool(
|
|
listDevices.name,
|
|
toolRegistrationOptions(listDevices),
|
|
async (input) => {
|
|
const result = await logToolExecution(logger, listDevices.name, () =>
|
|
service.listDevices(remoteConnection(input.remote)),
|
|
);
|
|
return result.ok
|
|
? delivery.toCallToolResult(
|
|
{
|
|
ok: true,
|
|
value: jsonValueSchema.parse({
|
|
devices: result.value.devices.map((device) => ({
|
|
device_id: device.deviceId,
|
|
name: device.name,
|
|
type: device.type,
|
|
})),
|
|
cleanup_error: result.value.cleanupError,
|
|
}),
|
|
},
|
|
listDevices,
|
|
)
|
|
: delivery.toCallToolResult(result, listDevices);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
listProcesses.name,
|
|
toolRegistrationOptions(listProcesses),
|
|
async (input) => {
|
|
const remote = remoteConnection(input.remote);
|
|
const result = await logToolExecution(logger, listProcesses.name, () =>
|
|
service.listProcesses({
|
|
...(input.device_id === undefined
|
|
? {}
|
|
: { deviceId: input.device_id }),
|
|
...(remote === undefined ? {} : { remote }),
|
|
}),
|
|
);
|
|
return result.ok
|
|
? delivery.toCallToolResult(
|
|
{
|
|
ok: true,
|
|
value: jsonValueSchema.parse({
|
|
device_id: result.value.deviceId,
|
|
processes: result.value.processes,
|
|
cleanup_error: result.value.cleanupError,
|
|
}),
|
|
},
|
|
listProcesses,
|
|
)
|
|
: delivery.toCallToolResult(result, listProcesses);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
start.name,
|
|
toolRegistrationOptions(start),
|
|
async (input, context: ServerContext) => {
|
|
const remote = remoteConnection(input.remote);
|
|
const selected = {
|
|
...(input.device_id === undefined ? {} : { deviceId: input.device_id }),
|
|
...(remote === undefined ? {} : { remote }),
|
|
};
|
|
const result = await logToolExecution(logger, start.name, () =>
|
|
input.mode === "attach" && input.pid !== undefined
|
|
? service.startSession(
|
|
{
|
|
...selected,
|
|
mode: input.mode,
|
|
pid: input.pid,
|
|
},
|
|
context.mcpReq.signal,
|
|
)
|
|
: input.mode === "spawn" && input.program !== undefined
|
|
? service.startSession(
|
|
{
|
|
...selected,
|
|
mode: input.mode,
|
|
program: input.program,
|
|
...(input.argv === undefined ? {} : { argv: input.argv }),
|
|
},
|
|
context.mcpReq.signal,
|
|
)
|
|
: Promise.resolve({
|
|
ok: false as const,
|
|
error: new AnalysisInputError(start.name),
|
|
}),
|
|
);
|
|
if (!result.ok) {
|
|
recordPartialEvidence(result.error, recordEvidence);
|
|
return delivery.toCallToolResult(result, start);
|
|
}
|
|
return delivery.toCallToolResult(
|
|
{
|
|
ok: true,
|
|
value: jsonValueSchema.parse(sessionOutput(result.value)),
|
|
},
|
|
start,
|
|
);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
load.name,
|
|
toolRegistrationOptions(load),
|
|
async (input) => {
|
|
const result = await logToolExecution(logger, load.name, () =>
|
|
input.source_kind === "inline" && input.source !== undefined
|
|
? service.loadScript(input.session_id, {
|
|
sourceKind: "inline",
|
|
source: input.source,
|
|
})
|
|
: input.source_kind === "file" && input.path !== undefined
|
|
? service.loadScript(input.session_id, {
|
|
sourceKind: "file",
|
|
path: input.path,
|
|
})
|
|
: Promise.resolve({
|
|
ok: false as const,
|
|
error: new AnalysisInputError(load.name),
|
|
}),
|
|
);
|
|
if (!result.ok) return delivery.toCallToolResult(result, load);
|
|
return delivery.toEvidenceToolResult(
|
|
result.value,
|
|
load,
|
|
recordEvidence?.(result.value),
|
|
);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
resume.name,
|
|
toolRegistrationOptions(resume),
|
|
async (input) => {
|
|
const result = await logToolExecution(logger, resume.name, () =>
|
|
service.resumeSession(input.session_id),
|
|
);
|
|
return result.ok
|
|
? delivery.toCallToolResult(
|
|
{ ok: true, value: { state: "running" } },
|
|
resume,
|
|
)
|
|
: delivery.toCallToolResult(result, resume);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
unload.name,
|
|
toolRegistrationOptions(unload),
|
|
async (input) => {
|
|
const result = await logToolExecution(logger, unload.name, () =>
|
|
service.unloadScript(input.session_id, input.script_id),
|
|
);
|
|
return result.ok
|
|
? delivery.toCallToolResult(
|
|
{ ok: true, value: { unloaded: true } },
|
|
unload,
|
|
)
|
|
: delivery.toCallToolResult(result, unload);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
status.name,
|
|
toolRegistrationOptions(status),
|
|
async (input) => {
|
|
const evidence = service.statusEvidence(input.session_id);
|
|
if (evidence === undefined)
|
|
return delivery.toCallToolResult(
|
|
{
|
|
ok: false as const,
|
|
error: new AnalysisInputError(status.name),
|
|
},
|
|
status,
|
|
);
|
|
return delivery.toEvidenceToolResult(
|
|
evidence,
|
|
status,
|
|
recordEvidence?.(evidence),
|
|
);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
close.name,
|
|
toolRegistrationOptions(close),
|
|
async (input) => {
|
|
const result = await logToolExecution(logger, close.name, () =>
|
|
service.closeSession(input.session_id),
|
|
);
|
|
return result.ok
|
|
? delivery.toCallToolResult(
|
|
{ ok: true, value: { target_state: "unknown" } },
|
|
close,
|
|
)
|
|
: delivery.toCallToolResult(result, close);
|
|
},
|
|
);
|
|
server.registerTool(
|
|
instrument.name,
|
|
toolRegistrationOptions(instrument),
|
|
async (input, context: ServerContext) => {
|
|
const remote = remoteConnection(input.remote);
|
|
const selected = {
|
|
...(input.device_id === undefined ? {} : { deviceId: input.device_id }),
|
|
...(remote === undefined ? {} : { remote }),
|
|
};
|
|
const source =
|
|
input.source_kind === "inline" && input.source !== undefined
|
|
? { sourceKind: "inline" as const, source: input.source }
|
|
: input.source_kind === "file" && input.path !== undefined
|
|
? { sourceKind: "file" as const, path: input.path }
|
|
: undefined;
|
|
if (source === undefined)
|
|
return delivery.toCallToolResult(
|
|
{ ok: false, error: new AnalysisInputError(instrument.name) },
|
|
instrument,
|
|
);
|
|
const operation =
|
|
input.mode === "attach" && input.pid !== undefined
|
|
? service.instrument(
|
|
{
|
|
...selected,
|
|
mode: "attach",
|
|
pid: input.pid,
|
|
source,
|
|
durationMs: input.duration_ms,
|
|
},
|
|
context.mcpReq.signal,
|
|
)
|
|
: input.mode === "spawn" && input.program !== undefined
|
|
? service.instrument(
|
|
{
|
|
...selected,
|
|
mode: "spawn",
|
|
program: input.program,
|
|
...(input.argv === undefined ? {} : { argv: input.argv }),
|
|
source,
|
|
durationMs: input.duration_ms,
|
|
},
|
|
context.mcpReq.signal,
|
|
)
|
|
: Promise.resolve({
|
|
ok: false as const,
|
|
error: new AnalysisInputError(instrument.name),
|
|
});
|
|
const result = await logToolExecution(
|
|
logger,
|
|
instrument.name,
|
|
() => operation,
|
|
);
|
|
if (!result.ok) {
|
|
recordPartialEvidence(result.error, recordEvidence);
|
|
return delivery.toCallToolResult(result, instrument);
|
|
}
|
|
return delivery.toEvidenceToolResult(
|
|
result.value.evidence,
|
|
instrument,
|
|
recordEvidence?.(result.value.evidence),
|
|
);
|
|
},
|
|
);
|
|
};
|
|
|
|
const recordPartialEvidence = (
|
|
error: { readonly partialObservation?: unknown },
|
|
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
|
): void => {
|
|
if (error.partialObservation === undefined) return;
|
|
const parsed = evidenceSchema.safeParse(error.partialObservation);
|
|
if (parsed.success) recordEvidence?.(parsed.data);
|
|
};
|
|
|
|
const remoteConnection = (
|
|
input:
|
|
| {
|
|
readonly address: string;
|
|
readonly token?: string | undefined;
|
|
readonly certificate?: string | undefined;
|
|
readonly origin?: string | undefined;
|
|
readonly keepalive_interval?: number | undefined;
|
|
}
|
|
| undefined,
|
|
): FridaRemoteConnection | undefined =>
|
|
input === undefined
|
|
? undefined
|
|
: {
|
|
address: input.address,
|
|
...(typeof input.token === "string" ? { token: input.token } : {}),
|
|
...(typeof input.certificate === "string"
|
|
? { certificate: input.certificate }
|
|
: {}),
|
|
...(typeof input.origin === "string" ? { origin: input.origin } : {}),
|
|
...(typeof input.keepalive_interval === "number"
|
|
? { keepaliveInterval: input.keepalive_interval }
|
|
: {}),
|
|
};
|
|
|
|
const sessionOutput = (session: {
|
|
readonly sessionId: string;
|
|
readonly deviceId: string;
|
|
readonly target: string;
|
|
readonly pid: number;
|
|
readonly mode: "attach" | "spawn";
|
|
readonly state: "paused" | "running" | "detached";
|
|
}) => ({
|
|
session_id: session.sessionId,
|
|
device_id: session.deviceId,
|
|
target: session.target,
|
|
pid: session.pid,
|
|
mode: session.mode,
|
|
state: session.state,
|
|
});
|