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
19 changes: 19 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ then reset this file.
unaffected. Config resolution gained `evidence.enabled` (product
configuration, default true) via `isEvidenceEnabled`. No MCP or DevTools relay
instrumentation is included.
- 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
can publish an escaped, no-script `report.html` filmstrip with a restrictive
CSP and only confined regular screenshot files. MCP resources expose sorted
action JSON and the static HTML report while rejecting legacy, malformed,
missing, symlinked, swapped, or out-of-run evidence files. Fixed-file resource
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.
- 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 @@ -256,6 +268,13 @@ then reset this file.
resisting spoofed-`runId`/fake-inflation manifests, and old-manifest list/read
compatibility, are also covered. `bun run test:coverage` passes global
thresholds.
- Evidence rendering/resources: `bun run typecheck` and `bun run build` pass;
the full and coverage suites pass 74 files / 916 passed / 2 skipped. Focused
tests cover deterministic ordering and post-sort step numbers, legacy reports,
corrupt/torn journals, planted secrets, HTML/XSS escaping and CSP, confined
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.

### Not tested yet

Expand Down
40 changes: 10 additions & 30 deletions packages/cli/src/commands/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { listRuns, runsDir, type RunManifest } from "@pickforge/picklab-core";
import {
isEvidenceRun,
listRuns,
readActions,
renderRunReport,
runsDir,
type RunManifest,
} from "@pickforge/picklab-core";
import { findOnPath } from "@pickforge/picklab-desktop-linux";
import {
resolveProjectDir,
Expand Down Expand Up @@ -88,44 +95,17 @@ export async function runArtifactsOpen(
});
}

export function renderRunReport(manifest: RunManifest, dir: string): string[] {
const lines = [
`# PickLab run ${manifest.runId}`,
"",
`- Slug: ${manifest.slug}`,
`- Status: ${manifest.status}`,
`- Created: ${manifest.createdAt}`,
];
if (manifest.sessionId !== undefined) {
lines.push(`- Session: ${manifest.sessionId}`);
}
lines.push(
`- Directory: ${dir}`,
"",
`## Artifacts (${manifest.artifacts.length})`,
"",
);
if (manifest.artifacts.length === 0) {
lines.push("(none)");
}
for (const artifact of manifest.artifacts) {
lines.push(
`- [${artifact.type}] ${artifact.name} — ${artifact.path} (${artifact.createdAt})`,
);
}
return lines;
}

export async function runArtifactsReport(
runId: string | undefined,
opts: BaseCliOptions,
): Promise<number> {
return runReported(opts, async () => {
const projectDir = resolveProjectDir(opts);
const { manifest, dir } = await findRun(projectDir, runId);
const records = isEvidenceRun(manifest) ? await readActions(dir) : [];
return {
data: { runId: manifest.runId, dir, manifest },
lines: renderRunReport(manifest, dir),
lines: renderRunReport(manifest, dir, records),
};
});
}
69 changes: 69 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,7 +1406,36 @@ describe("picklab artifacts", () => {
});
writeSyntheticRun(projectDir, "20260609-120000-synthetic", {
sessionId: "desk-12345678",
evidenceVersion: 1,
actionLog: "actions.jsonl",
});
fs.writeFileSync(
path.join(
projectDir,
".picklab",
"runs",
"20260609-120000-synthetic",
"actions.jsonl",
),
[
JSON.stringify({
actionId: "second",
source: "mcp",
tool: "desktop_type",
startedAt: "2026-06-09T12:00:04.000Z",
status: "error",
error: `Authorization: Bearer ${PLANTED_TOKEN}`,
}),
JSON.stringify({
actionId: "first",
source: "mcp",
tool: "desktop_click",
startedAt: "2026-06-09T12:00:03.000Z",
status: "ok",
}),
"",
].join("\n"),
);

const result = await runCli(
["artifacts", "report", "--project-dir", projectDir],
Expand All @@ -1419,6 +1448,11 @@ describe("picklab artifacts", () => {
expect(result.stdout).toContain(
"- [screenshot] screenshot.png — screenshots/screenshot.png",
);
expect(result.stdout.indexOf("Step 1 — mcp / desktop_click")).toBeLessThan(
result.stdout.indexOf("Step 2 — mcp / desktop_type"),
);
expect(result.stdout).toContain("[REDACTED]");
expect(result.stdout).not.toContain(PLANTED_TOKEN);

const specific = await runCli(
[
Expand All @@ -1437,6 +1471,41 @@ describe("picklab artifacts", () => {
expect(report.dir).toBe(
path.join(projectDir, ".picklab", "runs", "20260609-110000-synthetic"),
);

const legacy = await runCli(
[
"artifacts",
"report",
"20260609-110000-synthetic",
"--project-dir",
projectDir,
],
env,
);
expect(legacy.code).toBe(0);
expect(legacy.stdout).not.toContain("## Actions");
});

it("fails actionably for a corrupt evidence journal", async () => {
const env = makeEnv();
const projectDir = makeProjectDir();
const runId = "20260609-120000-corrupt";
writeSyntheticRun(projectDir, runId, {
evidenceVersion: 1,
actionLog: "actions.jsonl",
});
fs.writeFileSync(
path.join(projectDir, ".picklab", "runs", runId, "actions.jsonl"),
'{"actionId":"ok"}\nnot-json\n',
);

const result = await runCli(
["artifacts", "report", runId, "--project-dir", projectDir, "--json"],
env,
);
expect(result.code).toBe(1);
const report = parseJson(result);
expect(report.errors[0]).toContain("Corrupt evidence journal");
});

it("fails for unknown run ids", async () => {
Expand Down
Loading
Loading