diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index cb6cf99..349d820 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -72,10 +72,10 @@ then reset this file. (length + allowlisted input type only), `sanitizeActionTarget` (per-field allowlist; unknown fields dropped), and `sanitizeNetworkFailure` (method/origin-path/status/resource type/timing/sanitized error only — - never headers, bodies, or query). No runtime consumers yet beyond the - strengthened redaction. -- Added a dormant computer-use evidence storage foundation to - `@pickforge/picklab-core` (no producers wired yet). New `evidence.ts` provides + never headers, bodies, or query). MCP computer-use producers now consume + these sanitizers before appending actions. +- Added the computer-use evidence storage foundation to + `@pickforge/picklab-core`. New `evidence.ts` provides a session-scoped active-run pointer claimed with an atomic `wx` protocol. The winner stamps a claim carrying its verifiable owner identity (PID + `/proc` start ticks) at creation time, then creates the run and atomically @@ -111,8 +111,7 @@ then reset this file. `actionLog`, and `evidenceTruncated` fields; `createRun({ evidence: true })` stamps them and seeds the empty journal, so plain screenshot runs are unaffected. Config resolution gained `evidence.enabled` (product - configuration, default true) via `isEvidenceEnabled`. No MCP or DevTools relay - instrumentation is included. + configuration, default true) via `isEvidenceEnabled`. - Added backward-compatible evidence rendering for recorded runs: CLI and MCP artifact reports now include a deterministically ordered, defensively redacted action timeline, while legacy runs remain artifact inventories. Evidence runs @@ -123,8 +122,15 @@ then reset this file. reads bind no-follow descriptors to the validated file identity and re-check the pathname after reading; journal reads/appends also use no-follow opens. Report publication uses an exclusive temporary file plus atomic rename so - planted symlinks are never followed. No action producers - or DevTools/MCP instrumentation are included in this slice. + planted symlinks are never followed. +- MCP desktop, Android, and session tools now append sanitized success/failure + actions to one active evidence run per session. Typed values persist only as + length plus allowlisted input type; metadata actions never auto-capture + screenshots. Explicit screenshot tools associate confined PNG paths with the + active journal, while sessions without an active evidence run preserve the + existing one-shot screenshot run. Session destroy finalizes and releases the + run, and dead-session reaping finalizes it as failed. Evidence failures are + redacted, stderr-only, and never change the underlying tool result. - Hosted CI now installs `x11vnc` alongside the other desktop test dependencies, and the desktop-linux integration suite asserts `x11vnc` is present when `CI=true` so VNC tests fail loudly instead of silently @@ -275,6 +281,13 @@ then reset this file. screenshot embedding, action/report resource MIME and listings, and rejection of symlinked journals, reports, screenshots, run directories, planted report publication targets, and parent-directory swaps between validation and open. +- MCP evidence instrumentation: `bun run typecheck` and `bun run build` pass; + the full and coverage suites pass 75 files / 928 passed / 2 skipped with all + global thresholds met. Coverage includes sanitized typed actions and + thrown/structured failures, confined screenshot association, + disabled/no-session behavior, evidence-write failure isolation, active-run + reuse, explicit/shared session finalization, in-flight claim coordination, + and dead-session reaping. Two independent changed-HEAD reviews are clean. ### Not tested yet diff --git a/packages/core/src/evidence.ts b/packages/core/src/evidence.ts index 3cb471a..1ff4f8c 100644 --- a/packages/core/src/evidence.ts +++ b/packages/core/src/evidence.ts @@ -138,6 +138,7 @@ export type PointerResolution = | { status: "claiming"; raw: string; claim?: ActiveEvidenceClaim } | { status: "active"; + raw: string; pointer: ActiveEvidencePointer; manifest: RunManifest; } @@ -366,7 +367,7 @@ export async function resolveActivePointer( if (!identityIsAlive(pointer.ownerPid, pointer.ownerStartTicks)) { return { status: "stale", raw, pointer }; } - return { status: "active", pointer, manifest }; + return { status: "active", raw, pointer, manifest }; } /** @@ -780,6 +781,90 @@ export async function beginEvidenceRun( ); } +/** + * Finalize and release the evidence run currently associated with a session. + * The pointer is compare-cleared only after the manifest is durable, so a + * concurrently replaced pointer is never removed. + */ +export async function finalizeActiveEvidenceRun( + projectDir: string, + sessionId: string, + status: RunStatus = "completed", +): Promise { + for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { + const resolution = await resolveActivePointer(projectDir, sessionId); + if (resolution.status === "absent") return undefined; + + if (resolution.status === "claiming") { + const owner = resolution.claim; + if ( + owner !== undefined && + identityIsAlive(owner.ownerPid, owner.ownerStartTicks) + ) { + await delay(claimBackoff(attempt)); + continue; + } + if (owner === undefined && attempt < EMPTY_CLAIM_GRACE_ATTEMPTS) { + await delay(claimBackoff(attempt)); + continue; + } + if ( + await clearActivePointer(projectDir, sessionId, { + expectRaw: resolution.raw, + }) + ) { + return undefined; + } + continue; + } + if (resolution.status === "corrupt") { + if ( + await clearActivePointer(projectDir, sessionId, { + expectRaw: resolution.raw, + }) + ) { + return undefined; + } + continue; + } + + const pointer = resolution.pointer; + if (pointer === undefined) continue; + const runDir = path.join(runsDir(projectDir), pointer.runId); + const manifest = + resolution.status === "active" + ? resolution.manifest + : await readManifest(runDir); + if (manifest === undefined || !isEvidenceRun(manifest)) { + if ( + await clearActivePointer(projectDir, sessionId, { + expectRaw: resolution.raw, + }) + ) { + return undefined; + } + continue; + } + + if (manifest.status === "running") { + manifest.evidenceTruncated = await isEvidenceTruncated(runDir); + await new RunHandle(runDir, manifest).finish(status); + } + if ( + !(await clearActivePointer(projectDir, sessionId, { + expectRaw: resolution.raw, + })) + ) { + continue; + } + await pruneFinalizedEvidenceRuns(projectDir); + return manifest; + } + throw new Error( + `Failed to finalize the active evidence run for session ${sessionId}`, + ); +} + function encodeRecord(record: EvidenceRecord): string { return `${JSON.stringify(record)}\n`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd71869..f63725e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export { EVIDENCE_MAX_BYTES, EVIDENCE_MAX_LINE_BYTES, EVIDENCE_RETENTION_KEEP, + finalizeActiveEvidenceRun, isEvidenceRun, isEvidenceTruncated, isTruncationRecord, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1bc48ab..19e914e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { finalizeActiveEvidenceRun } from "./evidence.js"; import { ensureDir, isProfileConfined, @@ -303,7 +304,7 @@ export async function reapDeadRunningSessions( continue; } try { - await destroySessionRecord(record.id, env); + await destroySessionRecord(record.id, env, "failed"); } catch { await updateSession( record.id, @@ -438,9 +439,18 @@ export async function updateSession( export async function destroySessionRecord( id: string, env: EnvLike = process.env, + evidenceStatus: "completed" | "failed" = "completed", ): Promise { if (!isValidSessionId(id)) { throw invalidSessionIdError(id); } + const record = await getSession(id, env); + if (record !== undefined) { + await finalizeActiveEvidenceRun( + record.projectDir, + record.id, + evidenceStatus, + ).catch(() => {}); + } await fs.promises.rm(sessionPath(id, env), { force: true }); } diff --git a/packages/core/test/evidence.test.ts b/packages/core/test/evidence.test.ts index 7ca7eb2..c084167 100644 --- a/packages/core/test/evidence.test.ts +++ b/packages/core/test/evidence.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { activePointerPath, appendAction, beginEvidenceRun, clearActivePointer, + finalizeActiveEvidenceRun, isEvidenceRun, isEvidenceTruncated, isTruncationRecord, @@ -185,6 +187,31 @@ describe("beginEvidenceRun and the active pointer", () => { if (resolution.status !== "active") throw new Error("expected active"); expect(resolution.pointer.runId).toBe(winner.run.runId); }); + + it("waits for an in-flight claim before finalizing the published run", async () => { + const sessionId = "desk-finalize-claim"; + const beginning = beginEvidenceRun(project, sessionId, { + _afterClaim: async () => { + await delay(75); + }, + }); + while ( + (await resolveActivePointer(project, sessionId)).status !== "claiming" + ) { + await delay(1); + } + + const finalized = finalizeActiveEvidenceRun(project, sessionId, "failed"); + const [begun, manifest] = await Promise.all([beginning, finalized]); + + expect(manifest).toMatchObject({ + runId: begun.run.runId, + status: "failed", + }); + expect(await resolveActivePointer(project, sessionId)).toEqual({ + status: "absent", + }); + }); }); describe("pointer resolution and clearing", () => { diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index 190e474..607b5aa 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -6,6 +6,12 @@ import path from "node:path"; import { once } from "node:events"; import { setTimeout as delay } from "node:timers/promises"; import { isPidAlive, readProcessIdentity, stopPid } from "../src/proc.js"; +import { + activePointerPath, + appendAction, + beginEvidenceRun, + readActions, +} from "../src/evidence.js"; import { createSession, destroySessionRecord, @@ -239,6 +245,67 @@ describe("session registry", () => { expect(await getSession(stopped.id, env)).toBeDefined(); }); + it("finalizes active evidence through shared session destruction", async () => { + const projectDir = path.join(home, "project"); + await fs.promises.mkdir(projectDir); + const session = await createSession( + { type: "desktop", projectDir, status: "running" }, + env, + ); + const { run } = await beginEvidenceRun(projectDir, session.id); + + await destroySessionRecord(session.id, env); + + expect(await getSession(session.id, env)).toBeUndefined(); + expect( + JSON.parse( + await fs.promises.readFile( + path.join(run.dir, "manifest.json"), + "utf8", + ), + ), + ).toMatchObject({ status: "completed" }); + expect(fs.existsSync(activePointerPath(projectDir, session.id))).toBe(false); + }); + + it("finalizes active evidence when reaping a dead session", async () => { + const projectDir = path.join(home, "project"); + await fs.promises.mkdir(projectDir); + const stale = await createSession( + { + type: "desktop", + projectDir, + status: "running", + desktop: { display: ":92", xvfbPid: 4_194_306 }, + }, + env, + ); + const { run } = await beginEvidenceRun(projectDir, stale.id); + await appendAction(run.dir, { + actionId: "before-reap", + source: "mcp", + tool: "desktop_click", + sessionId: stale.id, + startedAt: new Date().toISOString(), + status: "ok", + }); + + expect( + (await reapDeadRunningSessions(env)).map((record) => record.id), + ).toEqual([stale.id]); + + expect( + JSON.parse( + await fs.promises.readFile( + path.join(run.dir, "manifest.json"), + "utf8", + ), + ), + ).toMatchObject({ status: "failed", evidenceTruncated: false }); + expect(await readActions(run.dir)).toHaveLength(1); + expect(fs.existsSync(activePointerPath(projectDir, stale.id))).toBe(false); + }); + it("stops recorded helper pids before deleting dead running records", async () => { const helper = spawn( process.execPath, diff --git a/packages/mcp-server/src/evidence.ts b/packages/mcp-server/src/evidence.ts new file mode 100644 index 0000000..151b17f --- /dev/null +++ b/packages/mcp-server/src/evidence.ts @@ -0,0 +1,178 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { + appendAction, + beginEvidenceRun, + isEvidenceEnabled, + loadConfig, + sanitizeActionTarget, + sanitizeErrorText, + sanitizeTypedValue, + type EvidenceAction, + type RunHandle, + type SanitizedTypedValue, +} from "@pickforge/picklab-core"; +import type { ServerContext, ToolReport } from "./context.js"; + +export interface EvidenceOperationContext { + actionId: string; + run?: RunHandle; +} + +export interface McpEvidenceOptions { + sessionId?: string; + tool: string; + target?: Record; + typedValue?: { value: string; inputType?: string }; + artifacts?: (result: T, run: RunHandle) => readonly string[]; +} + +function evidenceStatus(error: unknown): EvidenceAction["status"] { + const name = error instanceof Error ? error.name.toLowerCase() : ""; + const message = error instanceof Error ? error.message.toLowerCase() : ""; + if (name.includes("abort") || message.includes("cancel")) return "cancelled"; + if (name.includes("timeout") || message.includes("timed out")) return "timeout"; + return "error"; +} + +function reportEvidenceFailure(tool: string, error: unknown): void { + const detail = sanitizeErrorText( + error instanceof Error ? error.message : String(error), + ); + process.stderr.write(`[picklab evidence] ${tool}: ${detail}\n`); +} + +async function evidenceRun( + ctx: ServerContext, + sessionId: string, +): Promise { + const config = await loadConfig(ctx.projectDir, ctx.env); + if (!isEvidenceEnabled(config)) return undefined; + return (await beginEvidenceRun(ctx.projectDir, sessionId, { slug: "computer-use" })) + .run; +} + +async function confinedArtifacts( + run: RunHandle, + candidates: readonly string[], +): Promise { + const realRun = await fs.promises.realpath(run.dir); + const artifacts: string[] = []; + for (const candidate of candidates) { + const absolute = path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(run.dir, candidate); + const relative = path.relative(run.dir, absolute); + if ( + relative === "" || + relative.startsWith("..") || + path.isAbsolute(relative) + ) { + continue; + } + try { + const stat = await fs.promises.lstat(absolute); + if (stat.isSymbolicLink() || !stat.isFile()) continue; + const realArtifact = await fs.promises.realpath(absolute); + if (realArtifact !== path.join(realRun, relative)) continue; + artifacts.push(relative); + } catch { + continue; + } + } + return artifacts; +} + +function sanitizedTarget( + target: Record | undefined, + typedValue: SanitizedTypedValue | undefined, +): Record | undefined { + const sanitized: Record = { + ...sanitizeActionTarget(target), + }; + if (typedValue !== undefined) Object.assign(sanitized, typedValue); + return Object.keys(sanitized).length === 0 ? undefined : sanitized; +} + +export async function withMcpEvidence( + ctx: ServerContext, + options: McpEvidenceOptions, + operation: (evidence: EvidenceOperationContext) => Promise, +): Promise { + const actionId = crypto.randomUUID(); + const startedAt = new Date(); + let run: RunHandle | undefined; + if (options.sessionId !== undefined) { + try { + run = await evidenceRun(ctx, options.sessionId); + } catch (error) { + reportEvidenceFailure(options.tool, error); + } + } + + const typedValue = + options.typedValue === undefined + ? undefined + : sanitizeTypedValue( + options.typedValue.value, + options.typedValue.inputType, + ); + const target = sanitizedTarget(options.target, typedValue); + + try { + const result = await operation({ actionId, run }); + if (run !== undefined) { + try { + const artifacts = + options.artifacts === undefined + ? [] + : await confinedArtifacts(run, options.artifacts(result, run)); + const action: EvidenceAction = { + actionId, + source: "mcp", + tool: options.tool, + startedAt: startedAt.toISOString(), + durationMs: Date.now() - startedAt.getTime(), + status: (result.errors?.length ?? 0) === 0 ? "ok" : "error", + }; + if (options.sessionId !== undefined) { + action.sessionId = options.sessionId; + } + if (target !== undefined) action.target = target; + if (artifacts.length > 0) action.artifacts = artifacts; + if ((result.errors?.length ?? 0) > 0) { + action.error = sanitizeErrorText(result.errors!.join("; ")); + } + await appendAction(run.dir, action); + } catch (error) { + reportEvidenceFailure(options.tool, error); + } + } + return result; + } catch (error) { + if (run !== undefined) { + try { + const action: EvidenceAction = { + actionId, + source: "mcp", + tool: options.tool, + startedAt: startedAt.toISOString(), + durationMs: Date.now() - startedAt.getTime(), + status: evidenceStatus(error), + error: sanitizeErrorText( + error instanceof Error ? error.message : String(error), + ), + }; + if (options.sessionId !== undefined) { + action.sessionId = options.sessionId; + } + if (target !== undefined) action.target = target; + await appendAction(run.dir, action); + } catch (appendError) { + reportEvidenceFailure(options.tool, appendError); + } + } + throw error; + } +} diff --git a/packages/mcp-server/src/tools/android.ts b/packages/mcp-server/src/tools/android.ts index ac76fcf..8b3fdfd 100644 --- a/packages/mcp-server/src/tools/android.ts +++ b/packages/mcp-server/src/tools/android.ts @@ -23,7 +23,12 @@ import { runTool, type ServerContext, } from "../context.js"; -import { createSessions, progressReporter } from "./session.js"; +import { withMcpEvidence } from "../evidence.js"; +import { + createSessions, + progressReporter, + recordCreatedSessionsEvidence, +} from "./session.js"; const targetArgs = { session: z @@ -84,15 +89,15 @@ export function registerAndroidTools( }, }, (args, extra) => - runTool(async () => ({ - data: { - sessions: await createSessions( - ctx, - { type: "android", avdName: args.avdName }, - { onProgress: progressReporter(extra), signal: extra.signal }, - ), - }, - })), + runTool(async () => { + const sessions = await createSessions( + ctx, + { type: "android", avdName: args.avdName }, + { onProgress: progressReporter(extra), signal: extra.signal }, + ); + await recordCreatedSessionsEvidence(ctx, sessions, "android_start"); + return { data: { sessions } }; + }), ); server.registerTool( @@ -110,8 +115,18 @@ export function registerAndroidTools( runTool(async () => { const target = await resolveAndroidTarget(ctx, args); const apkPath = path.resolve(ctx.projectDir, args.apkPath); - await installApk({ serial: target.serial, apkPath, env: ctx.env }); - return { data: { ...targetData(target), apkPath } }; + return withMcpEvidence( + ctx, + { + sessionId: target.sessionId, + tool: "android_install_apk", + target: { name: path.basename(apkPath) }, + }, + async () => { + await installApk({ serial: target.serial, apkPath, env: ctx.env }); + return { data: { ...targetData(target), apkPath } }; + }, + ); }), ); @@ -136,15 +151,25 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - await launchApp({ - serial: target.serial, - packageName: args.packageName, - activity: args.activity, - env: ctx.env, - }); - return { - data: { ...targetData(target), packageName: args.packageName }, - }; + return withMcpEvidence( + ctx, + { + sessionId: target.sessionId, + tool: "android_launch_app", + target: { name: args.packageName }, + }, + async () => { + await launchApp({ + serial: target.serial, + packageName: args.packageName, + activity: args.activity, + env: ctx.env, + }); + return { + data: { ...targetData(target), packageName: args.packageName }, + }; + }, + ); }), ); @@ -153,9 +178,9 @@ export function registerAndroidTools( { title: "Android screenshot", description: - "Capture the device screen as PNG. By default the image is recorded " + - "as an artifact of a new run under .picklab/runs and returned inline " + - "when small enough.", + "Capture the device screen as PNG. By default the image joins the " + + "session's active evidence run, or creates a one-shot run when evidence " + + "is disabled or no session is selected. Small images return inline.", inputSchema: { ...targetArgs, out: z @@ -173,23 +198,49 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - const destination = await resolveScreenshotTarget( + return withMcpEvidence( ctx, - args, - "android", - target.sessionId, + { + sessionId: target.sessionId, + tool: "android_screenshot", + artifacts: (result) => + typeof result.data?.path === "string" ? [result.data.path] : [], + }, + async ({ actionId, run }) => { + const destination = + run !== undefined && + args.out === undefined && + args.runSlug === undefined + ? { + outPath: path.join( + run.dir, + "screenshots", + `${actionId}.png`, + ), + } + : await resolveScreenshotTarget( + ctx, + args, + "android", + target.sessionId, + ); + const data = await captureToTarget(destination, async () => { + await screenshot({ + serial: target.serial, + outPath: destination.outPath, + env: ctx.env, + }); + }); + Object.assign(data, targetData(target)); + if (run !== undefined && destination.run === undefined) { + data.runId = run.runId; + data.runDir = run.dir; + } + const image = await imageContent(destination.outPath); + Object.assign(data, image.meta); + return { data, extraContent: image.content }; + }, ); - const data = await captureToTarget(destination, async () => { - await screenshot({ - serial: target.serial, - outPath: destination.outPath, - env: ctx.env, - }); - }); - Object.assign(data, targetData(target)); - const image = await imageContent(destination.outPath); - Object.assign(data, image.meta); - return { data, extraContent: image.content }; }), ); @@ -207,8 +258,23 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - await tap({ serial: target.serial, x: args.x, y: args.y, env: ctx.env }); - return { data: { ...targetData(target), x: args.x, y: args.y } }; + return withMcpEvidence( + ctx, + { + sessionId: target.sessionId, + tool: "android_tap", + target: { x: args.x, y: args.y }, + }, + async () => { + await tap({ + serial: target.serial, + x: args.x, + y: args.y, + env: ctx.env, + }); + return { data: { ...targetData(target), x: args.x, y: args.y } }; + }, + ); }), ); @@ -225,8 +291,22 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - await typeText({ serial: target.serial, text: args.text, env: ctx.env }); - return { data: { ...targetData(target), length: args.text.length } }; + return withMcpEvidence( + ctx, + { + sessionId: target.sessionId, + tool: "android_type", + typedValue: { value: args.text, inputType: "text" }, + }, + async () => { + await typeText({ + serial: target.serial, + text: args.text, + env: ctx.env, + }); + return { data: { ...targetData(target), length: args.text.length } }; + }, + ); }), ); @@ -240,8 +320,14 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - await back({ serial: target.serial, env: ctx.env }); - return { data: targetData(target) }; + return withMcpEvidence( + ctx, + { sessionId: target.sessionId, tool: "android_back" }, + async () => { + await back({ serial: target.serial, env: ctx.env }); + return { data: targetData(target) }; + }, + ); }), ); @@ -255,8 +341,14 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - await home({ serial: target.serial, env: ctx.env }); - return { data: targetData(target) }; + return withMcpEvidence( + ctx, + { sessionId: target.sessionId, tool: "android_home" }, + async () => { + await home({ serial: target.serial, env: ctx.env }); + return { data: targetData(target) }; + }, + ); }), ); @@ -272,10 +364,16 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - const xml = redactSecrets( - await getUiTree({ serial: target.serial, env: ctx.env }), + return withMcpEvidence( + ctx, + { sessionId: target.sessionId, tool: "android_get_ui_tree" }, + async () => { + const xml = redactSecrets( + await getUiTree({ serial: target.serial, env: ctx.env }), + ); + return { data: { ...targetData(target), xml } }; + }, ); - return { data: { ...targetData(target), xml } }; }), ); @@ -308,19 +406,29 @@ export function registerAndroidTools( (args) => runTool(async () => { const target = await resolveAndroidTarget(ctx, args); - if (args.clear === true) { - await clearLogcat({ serial: target.serial, env: ctx.env }); - return { data: { ...targetData(target), cleared: true } }; - } - const output = redactSecrets( - await logcat({ - serial: target.serial, - lines: args.lines, - filter: args.filter, - env: ctx.env, - }), + return withMcpEvidence( + ctx, + { + sessionId: target.sessionId, + tool: "android_logcat", + target: args.filter === undefined ? undefined : { name: args.filter }, + }, + async () => { + if (args.clear === true) { + await clearLogcat({ serial: target.serial, env: ctx.env }); + return { data: { ...targetData(target), cleared: true } }; + } + const output = redactSecrets( + await logcat({ + serial: target.serial, + lines: args.lines, + filter: args.filter, + env: ctx.env, + }), + ); + return { data: { ...targetData(target), output } }; + }, ); - return { data: { ...targetData(target), output } }; }), ); @@ -362,22 +470,32 @@ export function registerAndroidTools( target = undefined; } } - const result = await runAdb({ - args: args.args, - serial: target?.serial, - env: ctx.env, - timeoutMs: args.timeoutMs, - }); - const data: Record = { - ...(target === undefined ? {} : targetData(target)), - code: result.code, - stdout: redactSecrets(result.stdout), - stderr: redactSecrets(result.stderr), - }; - return { - data, - errors: result.ok ? [] : [`adb exited with code ${result.code}`], - }; + return withMcpEvidence( + ctx, + { + sessionId: target?.sessionId, + tool: "android_run_adb", + target: { name: args.args[0] }, + }, + async () => { + const result = await runAdb({ + args: args.args, + serial: target?.serial, + env: ctx.env, + timeoutMs: args.timeoutMs, + }); + const data: Record = { + ...(target === undefined ? {} : targetData(target)), + code: result.code, + stdout: redactSecrets(result.stdout), + stderr: redactSecrets(result.stderr), + }; + return { + data, + errors: result.ok ? [] : [`adb exited with code ${result.code}`], + }; + }, + ); }), ); } diff --git a/packages/mcp-server/src/tools/desktop.ts b/packages/mcp-server/src/tools/desktop.ts index 06794c6..1600187 100644 --- a/packages/mcp-server/src/tools/desktop.ts +++ b/packages/mcp-server/src/tools/desktop.ts @@ -26,6 +26,7 @@ import { runTool, type ServerContext, } from "../context.js"; +import { withMcpEvidence } from "../evidence.js"; const sessionArg = { session: z @@ -91,27 +92,37 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - const app = await launchApp({ - display, - command: args.command, - args: args.args ?? [], - env: ctx.env, - logDir: desktopSessionLogDir(id, ctx.env), - cwd: - args.cwd === undefined - ? undefined - : path.resolve(ctx.projectDir, args.cwd), - }); - const data: Record = { - sessionId: id, - display, - pid: app.pid, - logPath: app.logPath, - }; - if (args.waitWindow !== undefined) { - data.window = await waitForWindow(display, args.waitWindow); - } - return { data }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_launch", + target: { name: args.command }, + }, + async () => { + const app = await launchApp({ + display, + command: args.command, + args: args.args ?? [], + env: ctx.env, + logDir: desktopSessionLogDir(id, ctx.env), + cwd: + args.cwd === undefined + ? undefined + : path.resolve(ctx.projectDir, args.cwd), + }); + const data: Record = { + sessionId: id, + display, + pid: app.pid, + logPath: app.logPath, + }; + if (args.waitWindow !== undefined) { + data.window = await waitForWindow(display, args.waitWindow); + } + return { data }; + }, + ); }), ); @@ -120,9 +131,9 @@ export function registerDesktopTools( { title: "Desktop screenshot", description: - "Capture the desktop display as PNG. By default the image is " + - "recorded as an artifact of a new run under .picklab/runs and " + - "returned inline when small enough.", + "Capture the desktop display as PNG. By default the image joins the " + + "session's active evidence run, or creates a one-shot run when evidence " + + "is disabled or no session is selected. Small images return inline.", inputSchema: { ...sessionArg, out: z @@ -140,22 +151,48 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - const target = await resolveScreenshotTarget(ctx, args, "desktop", id); - let tool: string | undefined; - const data = await captureToTarget(target, async () => { - const result = await screenshot({ - display, - outPath: target.outPath, - env: ctx.env, - }); - tool = result.tool; - }); - data.sessionId = id; - data.display = display; - data.tool = tool; - const image = await imageContent(target.outPath); - Object.assign(data, image.meta); - return { data, extraContent: image.content }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_screenshot", + artifacts: (result) => + typeof result.data?.path === "string" ? [result.data.path] : [], + }, + async ({ actionId, run }) => { + const target = + run !== undefined && + args.out === undefined && + args.runSlug === undefined + ? { + outPath: path.join( + run.dir, + "screenshots", + `${actionId}.png`, + ), + } + : await resolveScreenshotTarget(ctx, args, "desktop", id); + let tool: string | undefined; + const data = await captureToTarget(target, async () => { + const result = await screenshot({ + display, + outPath: target.outPath, + env: ctx.env, + }); + tool = result.tool; + }); + data.sessionId = id; + data.display = display; + data.tool = tool; + if (run !== undefined && target.run === undefined) { + data.runId = run.runId; + data.runDir = run.dir; + } + const image = await imageContent(target.outPath); + Object.assign(data, image.meta); + return { data, extraContent: image.content }; + }, + ); }), ); @@ -174,16 +211,31 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await click({ display, x: args.x, y: args.y, button: args.button }); - return { - data: { + return withMcpEvidence( + ctx, + { sessionId: id, - display, - x: args.x, - y: args.y, - button: args.button ?? 1, + tool: "desktop_click", + target: { x: args.x, y: args.y }, }, - }; + async () => { + await click({ + display, + x: args.x, + y: args.y, + button: args.button, + }); + return { + data: { + sessionId: id, + display, + x: args.x, + y: args.y, + button: args.button ?? 1, + }, + }; + }, + ); }), ); @@ -203,10 +255,20 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await move({ display, x: args.x, y: args.y }); - return { - data: { sessionId: id, display, x: args.x, y: args.y }, - }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_move", + target: { x: args.x, y: args.y }, + }, + async () => { + await move({ display, x: args.x, y: args.y }); + return { + data: { sessionId: id, display, x: args.x, y: args.y }, + }; + }, + ); }), ); @@ -243,24 +305,37 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await scroll({ - display, - deltaX: args.deltaX, - deltaY: args.deltaY, - x: args.x, - y: args.y, - }); - const data: Record = { - sessionId: id, - display, - deltaX: args.deltaX, - deltaY: args.deltaY, - }; - if (args.x !== undefined && args.y !== undefined) { - data.x = args.x; - data.y = args.y; - } - return { data }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_scroll", + target: + args.x === undefined || args.y === undefined + ? undefined + : { x: args.x, y: args.y }, + }, + async () => { + await scroll({ + display, + deltaX: args.deltaX, + deltaY: args.deltaY, + x: args.x, + y: args.y, + }); + const data: Record = { + sessionId: id, + display, + deltaX: args.deltaX, + deltaY: args.deltaY, + }; + if (args.x !== undefined && args.y !== undefined) { + data.x = args.x; + data.y = args.y; + } + return { data }; + }, + ); }), ); @@ -290,26 +365,36 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await drag({ - display, - fromX: args.fromX, - fromY: args.fromY, - toX: args.toX, - toY: args.toY, - button: args.button, - durationMs: args.durationMs, - }); - return { - data: { + return withMcpEvidence( + ctx, + { sessionId: id, - display, - fromX: args.fromX, - fromY: args.fromY, - toX: args.toX, - toY: args.toY, - button: args.button ?? 1, + tool: "desktop_drag", + target: { x: args.toX, y: args.toY }, + }, + async () => { + await drag({ + display, + fromX: args.fromX, + fromY: args.fromY, + toX: args.toX, + toY: args.toY, + button: args.button, + durationMs: args.durationMs, + }); + return { + data: { + sessionId: id, + display, + fromX: args.fromX, + fromY: args.fromY, + toX: args.toX, + toY: args.toY, + button: args.button ?? 1, + }, + }; }, - }; + ); }), ); @@ -335,22 +420,32 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await doubleClick({ - display, - x: args.x, - y: args.y, - button: args.button, - intervalMs: args.intervalMs, - }); - return { - data: { + return withMcpEvidence( + ctx, + { sessionId: id, - display, - x: args.x, - y: args.y, - button: args.button ?? 1, + tool: "desktop_double_click", + target: { x: args.x, y: args.y }, }, - }; + async () => { + await doubleClick({ + display, + x: args.x, + y: args.y, + button: args.button, + intervalMs: args.intervalMs, + }); + return { + data: { + sessionId: id, + display, + x: args.x, + y: args.y, + button: args.button ?? 1, + }, + }; + }, + ); }), ); @@ -367,10 +462,20 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await typeText({ display, text: args.text }); - return { - data: { sessionId: id, display, length: args.text.length }, - }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_type", + typedValue: { value: args.text, inputType: "text" }, + }, + async () => { + await typeText({ display, text: args.text }); + return { + data: { sessionId: id, display, length: args.text.length }, + }; + }, + ); }), ); @@ -389,8 +494,18 @@ export function registerDesktopTools( (args) => runTool(async () => { const { id, display } = await resolveDesktop(ctx, args.session); - await pressKey({ display, key: args.key }); - return { data: { sessionId: id, display, key: args.key } }; + return withMcpEvidence( + ctx, + { + sessionId: id, + tool: "desktop_key", + typedValue: { value: args.key }, + }, + async () => { + await pressKey({ display, key: args.key }); + return { data: { sessionId: id, display, key: args.key } }; + }, + ); }), ); } diff --git a/packages/mcp-server/src/tools/session.ts b/packages/mcp-server/src/tools/session.ts index a530453..82fe5b0 100644 --- a/packages/mcp-server/src/tools/session.ts +++ b/packages/mcp-server/src/tools/session.ts @@ -27,6 +27,7 @@ import { getDesktopSessionStatus, } from "@pickforge/picklab-desktop-linux"; import { runTool, type ServerContext } from "../context.js"; +import { withMcpEvidence } from "../evidence.js"; interface SessionSummary extends Record { id: string; @@ -173,6 +174,24 @@ export async function createSessions( return sessions; } +export async function recordCreatedSessionsEvidence( + ctx: ServerContext, + sessions: readonly SessionSummary[], + tool: "session_create" | "android_start", +): Promise { + for (const session of sessions) { + await withMcpEvidence( + ctx, + { + sessionId: session.id, + tool, + target: { name: session.type }, + }, + async () => ({ data: {} }), + ); + } +} + export async function sessionStatusEntry( ctx: ServerContext, record: SessionRecord, @@ -305,14 +324,14 @@ export function registerSessionTools( }, }, (args, extra) => - runTool(async () => ({ - data: { - sessions: await createSessions(ctx, args, { - onProgress: progressReporter(extra), - signal: extra.signal, - }), - }, - })), + runTool(async () => { + const sessions = await createSessions(ctx, args, { + onProgress: progressReporter(extra), + signal: extra.signal, + }); + await recordCreatedSessionsEvidence(ctx, sessions, "session_create"); + return { data: { sessions } }; + }), ); server.registerTool( @@ -379,7 +398,18 @@ export function registerSessionTools( const errors: string[] = []; for (const record of records) { try { - await destroyRecord(ctx, record); + await withMcpEvidence( + ctx, + { + sessionId: record.id, + tool: "session_destroy", + target: { name: record.type }, + }, + async () => { + await destroyRecord(ctx, record); + return { data: {} }; + }, + ); destroyed.push(record.id); } catch (error) { errors.push( diff --git a/packages/mcp-server/test/android.test.ts b/packages/mcp-server/test/android.test.ts index c6e9838..cfcde9f 100644 --- a/packages/mcp-server/test/android.test.ts +++ b/packages/mcp-server/test/android.test.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + activePointerPath, + listRuns, + readActions, + saveProjectConfig, +} from "@pickforge/picklab-core"; import { adbLogLines, connectLab, @@ -66,6 +72,67 @@ describe("android tools (fake adb)", () => { ]); }); + it("records sanitized session actions without typed-value leakage", async () => { + const typedSecret = `password-${PLANTED_TOKEN}`; + await lab.client.callTool({ + name: "android_tap", + arguments: { x: 10, y: 20 }, + }); + await lab.client.callTool({ + name: "android_type", + arguments: { text: typedSecret }, + }); + await lab.client.callTool({ name: "android_back", arguments: {} }); + + const [manifest] = await listRuns(dirs.projectDir); + const records = await readActions( + path.join(dirs.projectDir, ".picklab", "runs", manifest!.runId), + ); + expect(records.map((record) => record.actionId)).toHaveLength(3); + expect( + records.map((record) => "tool" in record && record.tool), + ).toEqual(["android_tap", "android_type", "android_back"]); + expect(records[0]).toMatchObject({ + source: "mcp", + sessionId, + status: "ok", + target: { x: 10, y: 20 }, + }); + expect(records[1]).toMatchObject({ + source: "mcp", + sessionId, + status: "ok", + target: { length: typedSecret.length, inputType: "text" }, + }); + expect(JSON.stringify(records)).not.toContain(typedSecret); + expect(JSON.stringify(records)).not.toContain(PLANTED_TOKEN); + expect( + fs.readdirSync( + path.join( + dirs.projectDir, + ".picklab", + "runs", + manifest!.runId, + "screenshots", + ), + ), + ).toEqual([]); + }); + + it("does not create evidence when capture is disabled", async () => { + await saveProjectConfig(dirs.projectDir, { + evidence: { enabled: false }, + }); + const report = parseToolJson( + await lab.client.callTool({ + name: "android_tap", + arguments: { x: 1, y: 2 }, + }), + ); + expect(report.ok).toBe(true); + expect(await listRuns(dirs.projectDir)).toEqual([]); + }); + it("installs an apk resolved against the project dir", async () => { const report = parseToolJson( await lab.client.callTool({ @@ -171,7 +238,28 @@ describe("android tools (fake adb)", () => { ), ); expect(manifest.sessionId).toBe(sessionId); - expect(manifest.artifacts[0].type).toBe("screenshot"); + expect(manifest.evidenceVersion).toBe(1); + expect(manifest.artifacts).toEqual([]); + const records = await readActions( + path.join( + dirs.projectDir, + ".picklab", + "runs", + report.runId as string, + ), + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + source: "mcp", + tool: "android_screenshot", + sessionId, + status: "ok", + }); + expect( + "artifacts" in records[0]! ? records[0].artifacts : undefined, + ).toEqual([ + path.join("screenshots", path.basename(report.path as string)), + ]); }); it("uses an explicit serial without a session", async () => { @@ -246,6 +334,30 @@ describe("android_start (fake sdk)", () => { expect(session.avdName).toBe("picklab-avd"); expect(session.serial).toMatch(/^emulator-\d+$/); + const [activeManifest] = await listRuns(startDirs.projectDir); + expect(activeManifest).toMatchObject({ + sessionId: session.id, + status: "running", + evidenceVersion: 1, + }); + expect( + await readActions( + path.join( + startDirs.projectDir, + ".picklab", + "runs", + activeManifest!.runId, + ), + ), + ).toMatchObject([ + { + source: "mcp", + tool: "android_start", + sessionId: session.id, + status: "ok", + }, + ]); + const destroyed = parseToolJson( await startLab.client.callTool({ name: "session_destroy", @@ -256,6 +368,27 @@ describe("android_start (fake sdk)", () => { expect(destroyed.destroyed).toEqual([session.id]); const pid = Number(fs.readFileSync(pidFile, "utf8").trim()); expect(() => process.kill(pid, 0)).toThrow(); + const [finalizedManifest] = await listRuns(startDirs.projectDir); + expect(finalizedManifest).toMatchObject({ + runId: activeManifest!.runId, + status: "completed", + }); + expect( + await readActions( + path.join( + startDirs.projectDir, + ".picklab", + "runs", + finalizedManifest!.runId, + ), + ), + ).toMatchObject([ + { tool: "android_start", status: "ok" }, + { tool: "session_destroy", status: "ok" }, + ]); + expect( + fs.existsSync(activePointerPath(startDirs.projectDir, session.id)), + ).toBe(false); } finally { killFakeEmulator(pidFile); await startLab.close(); diff --git a/packages/mcp-server/test/desktop.test.ts b/packages/mcp-server/test/desktop.test.ts index abd033d..39b1043 100644 --- a/packages/mcp-server/test/desktop.test.ts +++ b/packages/mcp-server/test/desktop.test.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { listSessions } from "@pickforge/picklab-core"; +import { + listRuns, + listSessions, + readActions, +} from "@pickforge/picklab-core"; import { detectScreenshotTool, destroyDesktopSession, @@ -184,6 +188,36 @@ describe.skipIf(!hasDesktopStack)("desktop flow (real Xvfb)", () => { expect(destroyed.ok).toBe(true); expect(destroyed.destroyed).toEqual([session.id]); expect(await listSessions(registryEnv)).toEqual([]); + const [manifest] = await listRuns(dirs.projectDir); + expect(manifest).toMatchObject({ + sessionId: session.id, + status: "completed", + }); + const actions = await readActions( + path.join( + dirs.projectDir, + ".picklab", + "runs", + manifest!.runId, + ), + ); + expect( + actions.find( + (action) => "tool" in action && action.tool === "desktop_key", + ), + ).toMatchObject({ + target: { length: 6, inputType: "other" }, + }); + expect( + fs.existsSync( + path.join( + dirs.projectDir, + ".picklab", + "runs", + `.active-${session.id}.json`, + ), + ), + ).toBe(false); }, TEST_TIMEOUT_MS, ); diff --git a/packages/mcp-server/test/evidence.test.ts b/packages/mcp-server/test/evidence.test.ts new file mode 100644 index 0000000..1084e1a --- /dev/null +++ b/packages/mcp-server/test/evidence.test.ts @@ -0,0 +1,181 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + listRuns, + readActions, + saveProjectConfig, +} from "@pickforge/picklab-core"; +import { withMcpEvidence } from "../src/evidence.js"; +import { + makeLabDirs, + PLANTED_TOKEN, + removeLabDirs, + type LabDirs, +} from "./helpers.js"; + +let dirs: LabDirs; +const sessionId = "desk-evidence"; + +beforeEach(() => { + dirs = makeLabDirs(); +}); + +afterEach(() => { + removeLabDirs(dirs); +}); + +async function evidenceRecords() { + const [manifest] = await listRuns(dirs.projectDir); + expect(manifest).toBeDefined(); + return readActions( + path.join(dirs.projectDir, ".picklab", "runs", manifest!.runId), + ); +} + +describe("MCP evidence producer", () => { + it("records thrown failures without changing the operation error", async () => { + const original = new Error(`Authorization: Bearer ${PLANTED_TOKEN}`); + + await expect( + withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { + sessionId, + tool: "desktop_launch", + target: { name: `token=${PLANTED_TOKEN}`, ignored: PLANTED_TOKEN }, + }, + async () => { + throw original; + }, + ), + ).rejects.toBe(original); + + const records = await evidenceRecords(); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + source: "mcp", + tool: "desktop_launch", + sessionId, + status: "error", + target: { name: "token=[REDACTED]" }, + }); + expect(JSON.stringify(records)).not.toContain(PLANTED_TOKEN); + }); + + it("records structured tool errors as failed actions", async () => { + const result = await withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { sessionId, tool: "android_run_adb" }, + async () => ({ + data: { code: 1 }, + errors: [`token=${PLANTED_TOKEN}`], + }), + ); + + expect(result).toEqual({ + data: { code: 1 }, + errors: [`token=${PLANTED_TOKEN}`], + }); + const records = await evidenceRecords(); + expect(records[0]).toMatchObject({ + tool: "android_run_adb", + status: "error", + error: "token=[REDACTED]", + }); + expect(JSON.stringify(records)).not.toContain(PLANTED_TOKEN); + }); + + it.each([ + [Object.assign(new Error("cancelled"), { name: "AbortError" }), "cancelled"], + [new Error("operation timed out"), "timeout"], + ])("classifies %s failures as %s", async (failure, status) => { + await expect( + withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { sessionId, tool: "desktop_click" }, + async () => { + throw failure; + }, + ), + ).rejects.toBe(failure); + + expect((await evidenceRecords())[0]).toMatchObject({ status }); + }); + + it("associates only confined regular artifacts", async () => { + const outside = path.join(dirs.root, "outside.png"); + fs.writeFileSync(outside, "outside"); + + await withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { + sessionId, + tool: "desktop_screenshot", + artifacts: (_result, run) => [ + "screenshots/ok.png", + path.join(run.dir, "screenshots", "absolute.png"), + outside, + run.dir, + "screenshots/directory", + "screenshots/link.png", + "screenshots/missing.png", + ], + }, + async ({ run }) => { + const screenshots = path.join(run!.dir, "screenshots"); + fs.mkdirSync(path.join(screenshots, "directory"), { recursive: true }); + fs.writeFileSync(path.join(screenshots, "ok.png"), "ok"); + fs.writeFileSync(path.join(screenshots, "absolute.png"), "absolute"); + fs.symlinkSync(outside, path.join(screenshots, "link.png")); + return { data: { ok: true } }; + }, + ); + + expect((await evidenceRecords())[0]).toMatchObject({ + artifacts: [ + path.join("screenshots", "ok.png"), + path.join("screenshots", "absolute.png"), + ], + }); + }); + + it("does not create evidence without a session or when disabled", async () => { + const withoutSession = await withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { tool: "desktop_click" }, + async ({ run }) => ({ data: { run } }), + ); + expect(withoutSession.data.run).toBeUndefined(); + + await saveProjectConfig(dirs.projectDir, { evidence: { enabled: false } }); + const disabled = await withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { sessionId, tool: "desktop_click" }, + async ({ run }) => ({ data: { run } }), + ); + expect(disabled.data.run).toBeUndefined(); + expect(await listRuns(dirs.projectDir)).toEqual([]); + }); + + it("preserves successful results when evidence append fails", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const result = await withMcpEvidence( + { projectDir: dirs.projectDir, env: { PICKLAB_HOME: dirs.home } }, + { sessionId, tool: "desktop_click" }, + async ({ run }) => { + await fs.promises.rm(run!.dir, { recursive: true, force: true }); + return { data: { ok: true } }; + }, + ); + + expect(result).toEqual({ data: { ok: true } }); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining("[picklab evidence] desktop_click:"), + ); + } finally { + stderr.mockRestore(); + } + }); +});