Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
87 changes: 86 additions & 1 deletion packages/core/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export type PointerResolution =
| { status: "claiming"; raw: string; claim?: ActiveEvidenceClaim }
| {
status: "active";
raw: string;
pointer: ActiveEvidencePointer;
manifest: RunManifest;
}
Expand Down Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -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<RunManifest | undefined> {
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`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
EVIDENCE_MAX_BYTES,
EVIDENCE_MAX_LINE_BYTES,
EVIDENCE_RETENTION_KEEP,
finalizeActiveEvidenceRun,
isEvidenceRun,
isEvidenceTruncated,
isTruncationRecord,
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -438,9 +439,18 @@ export async function updateSession(
export async function destroySessionRecord(
id: string,
env: EnvLike = process.env,
evidenceStatus: "completed" | "failed" = "completed",
): Promise<void> {
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 });
}
27 changes: 27 additions & 0 deletions packages/core/test/evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
67 changes: 67 additions & 0 deletions packages/core/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading