mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Runner support suite (`pnpm test:e2e:runner:unit`) tests the code behind paid Runner qualification: workflow security policy, evidence oracles, cost receipts and controller recovery. > - PR CI does not run this suite. Only a manual `docker-runner-check.yml` run does. > - So deliberate changes on master broke its tests, and nobody saw it. There were 12–13 failures on Nov 3 and 16 on Nov 6. > - This pull request finds the master change behind each failure and fixes the side that is wrong. No security check becomes weaker. > - The benefit is that the suite passes on master again (with #19527), so it can become a required check. ## Linked Issues or Issue Description Refs #18516, #18625, #18983, #18325, #19265, #18994, #17009, #19195. Companion PR: #19527 (the one workflow fix). **What happened?** At master `543aa4d331`, `pnpm test:e2e:runner:unit` had 16 failures in 8 files. At `98ebeefbc9` I saw 15 in 7 files. The two embedded-Postgres files passed in every run I made, and the 256MiB packaging test timed out once. **Expected behavior** The suite passes on master. Each test still checks the rule it was written for. **Steps to reproduce** Run `pnpm install`, then `pnpm test:e2e:runner:unit` on master. ## What Changed Each failure, its cause, and the fix: - **`workflow-security.test.ts`: chat catalog workflows hold provider keys and use `runner-e2e-private-paid`.** This is a deliberate policy. #18516 admitted guarded, pinned-source chat TestOps cells, with special checks. #18625 removed the pilot workflows and their checks. It did not move the checks to the full-catalog workflows from #18542 and #18647, which use the same trust model. The fix adds `chat-catalog-testops.yml` and `chat-catalog-testops-case.yml` to the provider-key allowlist. It also brings back the #18516 checks, adapted to the caller plus reusable-workflow layout: - The cell is `workflow_call` only. Its single caller is the master-only dispatcher. - The trusted guard runs before the pinned candidate checkout. `verify-selection` runs before each provider key. - The paid job never sees `ALLURE_TOKEN`. The report job runs in `runner-e2e-private-history` and has no candidate checkout. The upload job has no private environment and no secret except `ALLURE_TOKEN`. - Provider keys appear only in the two caller jobs. Those jobs need `authorize, select, build` and pass the guard's `target_sha`. - I mutation-tested this: an `ALLURE_TOKEN` in the paid job fails, and a second caller workflow fails. - **`workflow-security.test.ts`: `ref: ${{ github.sha }}` 6 vs 4, `persist-credentials: false` 12 vs 10.** #18983 added two trusted TestOps jobs (`testops_selection`, `publish_testops`). Both check out reviewed code next to the TestOps token. This is the safe direction. The counts are updated. A new check makes sure both jobs check out only `github.sha`, do not install the candidate lockfile, and use no secret except `ALLURE_TOKEN`. - **`pi-file-evidence.test.ts` (4 tests): "one observed provider validation execution".** #19265 deliberately changed the ACPX tool normalizer to keep the latest concrete title. Pi's shell call now goes from `bash` to the exact command. The ACPX sidecar uses this normalizer, so the oracle would also reject real Pi runs. The oracle now accepts one change only, from `bash` to the exact validation command, with no change back. It also counts every completed `execute` call, whatever its name. This is stricter than before: a refined title cannot hide a second shell command. New mutations cover a regressed name, a different command, and an extra call that has its own command name. The retained 44-row real stream still passes. - **`pi-controls-evidence.test.ts`: Pi profile 37.** #18325 deliberately reset Pi to profile 37. The pin and the `pi-native` suite hash are updated. I checked that the old hash comes back exactly when the version is set to 36. So the version is the only change. - **`controller-outage.test.ts`: 1 continuity cell, not 2.** #18994 deliberately limited this lane to providers with managed warm sessions. The test now expects exactly `runner-codex`. - **`operating-receipts.test.ts` (3 tests): "Operating overhead allowance needs review".** This is a time bomb from #17009. The fixture allowance was valid from Oct 1 until Nov 1, 2026. `admitAttempt` reads the real clock. The allowance window is limited to 31 days, so a longer fixture is not possible. These tests now pin only `Date` to the fixture time. Timers and the filesystem stay real. I ran the full suite with the clock moved to Dec 15, 2026, and found no other date-bound failures. - **New file `private-trust-workflow-security.test.ts`.** The private archive trust test, the chat catalog checks and the full-stack TestOps job checks moved here from `workflow-security.test.ts`. That file would have crossed 1,000 lines. It is now 970 lines. The checks did not change. - **`remote-native-bootstrap.test.ts`: cannot load `@paperclipai/plugin-sdk`.** Since #19195, server code under test imports the built plugin SDK. `run-unit.mjs` now runs `scripts/ensure-plugin-build-deps.mjs` first, as `test:run:general` already does. This step is cached. - **`private-framework-report.test.ts`: 256MiB packaging test timed out.** It takes about 10s alone and more than 30s with the whole suite running in parallel. It now has a 120s budget. - **`controller-database.test.ts`, `controller-recovery-observer.integration.test.ts`: no change.** Embedded Postgres started and both files passed in all my runs. The earlier failures look like local resource problems. ## Verification - Combined check (this branch plus #19527, on master `5d25117ffa`): `pnpm test:e2e:runner:unit` — Vitest 317 files passed (6,815 tests, 3 skipped), node:test 373 passed, 0 failed. It took 63s on a 10-core Mac. - `pnpm test:e2e:runner:typecheck` — passed. - This branch alone fails only the two pnpm-setup tests that #19527 fixes. - Focused files: `pi-file-evidence` 75 passed, `pi-controls-evidence` 72 passed, `operating-receipts` 12 passed, `workflow-security` 14 of 16 passed (the other 2 pass with #19527), `private-trust-workflow-security` 3 passed, `remote-native-bootstrap` 71 passed. ## Risks - Low risk. Only test files, one evidence oracle under `tests/runner-e2e/`, and the suite launcher change. No product code changes. - The Pi oracle change affects how paid Pi file qualification is graded. It accepts the old and new title shapes, and it is stricter about extra shell calls. A live Pi qualification run would confirm it on real Rust-normalized events. I did not run one, because it is paid. - The chat catalog policy is the same as #18516. Under that design, the pinned candidate source runs in `runner-e2e-private-paid` with `id-token: write`. The guards above limit who can start it and which source runs. This PR does not change that trust model. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), by Anthropic, in Claude Code with tool use (shell, file edits, git, GitHub CLI). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
19 KiB
TypeScript
233 lines
19 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { Page } from "@playwright/test";
|
|
import type { RunnerApi } from "./api.js";
|
|
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
|
|
|
|
type Row = Record<string, any>;
|
|
const rec = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
|
const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
|
const requireEvidence = (value: unknown, reason: string) => { if (!value) throw new Error(`Pi file evidence: ${reason}`); };
|
|
export function piFileContract(nonce: string) {
|
|
if (!/^[A-Za-z0-9-]{1,100}$/u.test(nonce)) throw new Error("Invalid file fixture nonce");
|
|
const filename = `extended-${nonce}.txt`, before = `ready-${nonce}\n`, after = `verified-${nonce}\n`;
|
|
const validationMarker = `PI-VALIDATED-${nonce}`;
|
|
const validationCommand = `node -e 'if(require("node:fs").readFileSync(${JSON.stringify(filename)},"utf8")!==${JSON.stringify(after)})throw Error("mismatch");console.log(${JSON.stringify(validationMarker)})'`;
|
|
if ([...validationCommand].length > 240) throw new Error("Validation command exceeds native Rust tool-name bound");
|
|
return { filename, before, after, sha256: sha(after), byteSize: Buffer.byteLength(after), validationMarker, validationCommand };
|
|
}
|
|
export function piFilePrompt(nonce: string) {
|
|
const c = piFileContract(nonce);
|
|
return [
|
|
`The fixture has already created ${c.filename} in your execution workspace with exactly ${JSON.stringify(c.before)}. Read it, then use your native edit tool once to replace those bytes with exactly ${JSON.stringify(c.after)}. Do not substitute write, bash, or an API for the edit.`,
|
|
`Run this exact native bash command exactly once, without an added shell prefix or suffix:\n\`\`\`bash\n${c.validationCommand}\n\`\`\`\nIt independently reads the file, fails on incorrect bytes, and prints the validation marker only after success. This is the only bash call allowed for this task; do not run any other shell commands.`,
|
|
`Deliver the edited text file as a downloadable attachment using the assigned register_deliverable semantic tool, with filename and workspace-relative contentRef ${c.filename}, contentType text/plain, byteSize ${c.byteSize}, sha256 ${c.sha256}, title ${c.filename}, and a fresh idempotencyKey. Use the supplied byteSize and sha256 for the exact validated post-edit bytes; do not run extra commands to obtain metadata. Wait for the real registration receipt. Do not upload a different file or claim publication from a disk path.`,
|
|
`After successful validation and registration, call paperclip_finish with reportedWorkDisposition done, summary EXTENDED-FILE-${nonce}, the current completion contract revision and satisfied objective criterion, no remaining work, the actual validation command/result, and the registered artifact reference.`,
|
|
`Wait for paperclip_finish to succeed, then emit exactly EXTENDED-FILE-${nonce} as your final response. Do not create unrelated files or work.`,
|
|
].join("\n");
|
|
}
|
|
export async function seedPiFile(workspace: string, nonce: string) {
|
|
const c = piFileContract(nonce);
|
|
await writeFile(join(workspace, c.filename), c.before, { flag: "wx" });
|
|
const bytes = await readFile(join(workspace, c.filename));
|
|
requireEvidence(bytes.equals(Buffer.from(c.before)), "seed bytes changed before provider startup");
|
|
return { schema: "paperclip.e2e.pi-file-seed.v1", filename: c.filename, beforeSha256: sha(bytes), byteSize: bytes.length };
|
|
}
|
|
|
|
export function gradePiCopyback(run: Row, lease: Row, companyId: string, environmentId: string) {
|
|
const context = rec(run.contextSnapshot), environment = rec(context.paperclipEnvironment);
|
|
const reference = rec(rec(run.runnerProfileJson).nativeWorkspaceSync);
|
|
const binding = rec(rec(rec(run.runnerProfileJson).nativeExecutionInput).binding);
|
|
requireEvidence(run.companyId === companyId && reference.schema === "paperclip.native-workspace-sync/v2"
|
|
&& reference.state === "finalized" && [reference.baselineSha256, reference.descriptorSha256, reference.finalHostSha256]
|
|
.every(value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value)), "finalized production copy-back hashes required");
|
|
requireEvidence(typeof reference.workspaceId === "string" && reference.workspaceId.length > 0
|
|
&& reference.workspaceId === context.executionWorkspaceId
|
|
&& (binding.executionWorkspaceId === undefined || binding.executionWorkspaceId === reference.workspaceId)
|
|
&& environment.id === environmentId && environment.driver === "sandbox" && environment.leaseId === reference.leaseId,
|
|
"copy-back workspace and environment must belong to the actual run");
|
|
requireEvidence(lease.id === reference.leaseId && lease.companyId === companyId && lease.environmentId === environmentId
|
|
&& lease.heartbeatRunId === run.id && lease.provider === "daytona" && typeof reference.providerLeaseId === "string"
|
|
&& reference.providerLeaseId.length > 0 && lease.providerLeaseId === reference.providerLeaseId
|
|
&& typeof reference.remoteCwd === "string" && reference.remoteCwd.startsWith("/")
|
|
&& reference.remoteCwd === rec(lease.metadata).remoteCwd, "copy-back must bind the exact run and provider lease");
|
|
return { kind: "product_copyback", reference, lease: { id: lease.id, companyId, environmentId, heartbeatRunId: run.id,
|
|
provider: lease.provider, providerLeaseId: lease.providerLeaseId, remoteCwd: reference.remoteCwd },
|
|
guestObservation: false, hostSnapshotRecomputed: false };
|
|
}
|
|
|
|
/** Only the common public projection is authority here. Native raw arguments,
|
|
* full diff blocks and typed command exit codes are deliberately not invented.
|
|
* The native structured output receipt is distinct from that nullable typed field. */
|
|
export function gradePiFileEvidence(input: {
|
|
nonce: string; companyId: string; issueId: string; agentId: string; run: Row;
|
|
environment: "local" | "daytona"; environmentId: string; lease?: Row;
|
|
events: unknown[]; seed: unknown; workspaceBytes: Buffer; attachments: unknown[];
|
|
attachmentId: string; downloadedBytes: Buffer; activity: unknown[];
|
|
}) {
|
|
const c = piFileContract(input.nonce), run = input.run, seed = rec(input.seed);
|
|
const workspaceProvenance = input.environment === "daytona"
|
|
? gradePiCopyback(run, rec(input.lease), input.companyId, input.environmentId)
|
|
: { kind: "independent_local_workspace" };
|
|
requireEvidence(run.id && run.companyId === input.companyId && run.agentId === input.agentId
|
|
&& run.runtimeMode === "native" && run.status === "succeeded", "actual native run identity");
|
|
requireEvidence(seed.schema === "paperclip.e2e.pi-file-seed.v1" && seed.filename === c.filename
|
|
&& seed.beforeSha256 === sha(c.before) && seed.byteSize === Buffer.byteLength(c.before), "independent pre-edit seed");
|
|
requireEvidence(input.workspaceBytes.equals(Buffer.from(c.after)), "independent final workspace bytes");
|
|
requireEvidence(input.events.length > 0 && input.events.length <= 20_000, "bounded run events");
|
|
const rows = input.events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined);
|
|
const seen = new Set<string>(), seqs = new Set<number>(), sourceSeqs = new Map<string, number>();
|
|
const canonical = rows.map(row => {
|
|
const e = rec(rec(row.payload).prpEvent);
|
|
requireEvidence(row.companyId === input.companyId && row.runId === run.id && e.runId === run.id
|
|
&& isValidNativePrpEnvelope(e, row.protocolSchemaVersion) && row.eventType === e.eventType
|
|
&& Number.isSafeInteger(row.seq) && row.seq > 0 && !seqs.has(row.seq)
|
|
&& typeof e.sourceInstanceId === "string" && e.sourceInstanceId.length > 0
|
|
&& Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > (sourceSeqs.get(e.sourceInstanceId) ?? 0)
|
|
&& row.sourceInstanceId === e.sourceInstanceId && row.sourceSeq === e.sourceSeq && row.sourceEventId === e.sourceEventId
|
|
&& e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !seen.has(e.sourceEventId), "foreign, duplicated, or reordered durable evidence");
|
|
seqs.add(row.seq); seen.add(e.sourceEventId); sourceSeqs.set(e.sourceInstanceId, e.sourceSeq);
|
|
return { row, e, p: rec(e.payload) };
|
|
});
|
|
const tools = canonical.filter(x => x.e.eventType.startsWith("tool.execution.") && x.p.transport === "builtin");
|
|
const edits = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "edit");
|
|
requireEvidence(edits.length === 1, "one native edit required; final-only writes cannot qualify");
|
|
const edit = edits[0]!;
|
|
requireEvidence(edit.p.name === "edit" && edit.p.target === c.filename, "exact native edit and relative target required");
|
|
// `names[0]` is the opening name. Later names may only refine it once, to
|
|
// the final name (the runner keeps the latest concrete ACP title, #19265).
|
|
function lifecycle(end: typeof edit, names: readonly unknown[] = [end.p.name]) {
|
|
const matches = tools.filter(x => x.p.executionId === end.p.executionId);
|
|
const starts = matches.filter(x => x.e.eventType === "tool.execution.started");
|
|
requireEvidence(typeof end.p.executionId === "string" && end.p.executionId.length > 0
|
|
&& end.p.schema === "paperclip.tool.execution.v1" && end.p.status === "completed"
|
|
&& end.e.sourceKind === "runner" && end.e.sourceInstanceId === run.runnerInstanceId
|
|
&& end.e.normalizedSessionId === run.nativeSessionId && typeof end.e.turnId === "string" && end.e.turnId.length > 0
|
|
&& typeof end.e.normalizedSessionId === "string" && end.e.normalizedSessionId.length > 0
|
|
&& starts.length === 1 && starts[0]!.row.seq < end.row.seq
|
|
&& matches.filter(x => x.e.eventType === "tool.execution.completed").length === 1
|
|
&& matches.every(x => x.p.schema === end.p.schema && names.includes(x.p.name) && x.p.operation === end.p.operation
|
|
&& x.row.seq >= starts[0]!.row.seq && x.row.seq <= end.row.seq
|
|
&& x.e.sourceKind === "runner" && x.e.turnId === end.e.turnId
|
|
&& x.e.normalizedSessionId === end.e.normalizedSessionId && x.e.sourceInstanceId === end.e.sourceInstanceId
|
|
&& (x === end || (x.p.status === "running"
|
|
&& ["tool.execution.started", "tool.execution.progressed"].includes(x.e.eventType)))), "incomplete or unsuccessful native tool lifecycle");
|
|
// Native arguments arrive incrementally. An unresolved target is allowed
|
|
// only before the first exact target; conflicting or regressed targets fail.
|
|
let resolved = false;
|
|
for (const x of matches) {
|
|
requireEvidence(x.p.target === end.p.target || (!resolved && x.p.target === null), "conflicting native tool target");
|
|
if (x.p.target !== null) resolved = true;
|
|
}
|
|
let refined = false;
|
|
for (const x of matches) {
|
|
requireEvidence(!refined || x.p.name === end.p.name && x.p.name !== names[0], "conflicting or regressed native tool name");
|
|
if (x.p.name !== names[0]) refined = true;
|
|
}
|
|
return { start: starts[0]!, matches };
|
|
}
|
|
lifecycle(edit);
|
|
// Count every completed shell execution, whatever its name, so a refined
|
|
// title cannot hide a second command from the one-bash-call rule.
|
|
const validations = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "execute");
|
|
requireEvidence(validations.length === 1, "one observed provider validation execution");
|
|
const validationNames = ["bash", c.validationCommand] as const;
|
|
requireEvidence(validationNames.includes(validations[0]!.p.name as never), "exact native validation command required");
|
|
const validation = validations[0]!, { start, matches } = lifecycle(validation, validationNames);
|
|
requireEvidence(start.row.seq > edit.row.seq && validation.e.turnId === edit.e.turnId
|
|
&& validation.e.normalizedSessionId === edit.e.normalizedSessionId
|
|
&& validation.e.sourceInstanceId === edit.e.sourceInstanceId && validation.p.outputTruncated === false,
|
|
"validation must follow the edit in the same native turn");
|
|
// The lifecycle opens as bash. The resolved command is retained as
|
|
// progress, and may also become the name once the runner sees it.
|
|
const commandProgress = `${c.validationCommand} (in_progress): ${c.validationCommand}`;
|
|
const allowedProgress = new Set([`bash (pending): [terminal] ${validation.p.executionId}`,
|
|
`${c.validationCommand} (pending): ${c.validationCommand}`, commandProgress]);
|
|
requireEvidence(matches.some(x => x !== validation && x.p.progress === commandProgress)
|
|
&& matches.every(x => x.p.progress === null || allowedProgress.has(x.p.progress)),
|
|
"exact correlated command progress before completion required");
|
|
requireEvidence(typeof validation.p.output === "string" && Buffer.byteLength(validation.p.output) <= 16_384,
|
|
"bounded native validation receipt required");
|
|
let output: Row = {};
|
|
try { output = rec(JSON.parse(validation.p.output)); } catch { /* Rejected below; plain marker text is insufficient. */ }
|
|
const structured = rec(output.structuredContent), expectedOutput = `${c.validationMarker}\n`;
|
|
requireEvidence(structured.exit_code === 0 && structured.truncated === false && structured.output === expectedOutput
|
|
&& Array.isArray(output.content) && output.content.length === 1
|
|
&& rec(output.content[0]).type === "text" && rec(output.content[0]).text === expectedOutput
|
|
&& (validation.p.exitCode === null || validation.p.exitCode === 0), "successful native structured validation receipt required");
|
|
const attachments = input.attachments.map(rec).filter(a => a.id === input.attachmentId);
|
|
requireEvidence(attachments.length === 1, "one public attachment");
|
|
const a = attachments[0]!;
|
|
requireEvidence(a.companyId === input.companyId && a.issueId === input.issueId && a.originatingRunId === run.id
|
|
&& a.createdByAgentId === input.agentId && a.originalFilename === c.filename && a.contentType === "text/plain"
|
|
&& a.byteSize === c.byteSize && a.sha256 === c.sha256, "attachment bytes and immutable run attribution");
|
|
requireEvidence(input.downloadedBytes.equals(Buffer.from(c.after)), "actual public download bytes");
|
|
const receipts = Object.values(rec(rec(run.resultJson).semanticToolReceipts)).map(rec)
|
|
.filter(r => r.operationId === "register_deliverable" && rec(r.input).filename === c.filename);
|
|
requireEvidence(receipts.length === 1, "one authenticated semantic registration");
|
|
const receipt = receipts[0]!, args = rec(receipt.input), result = rec(receipt.result);
|
|
requireEvidence(args.contentRef === c.filename && args.contentType === "text/plain" && args.byteSize === c.byteSize
|
|
&& args.sha256 === c.sha256 && result.disposition === "applied" && result.commandId === `deliverable-prepared:${a.id}`
|
|
&& Array.isArray(result.entityRefs) && result.entityRefs[0] === a.id, "registered artifact receipt must match downloaded attachment");
|
|
const publications = input.activity.map(rec).filter(row => row.action === "issue.attachment_added"
|
|
&& rec(row.details).attachmentId === a.id);
|
|
requireEvidence(publications.length === 1 && publications[0]!.companyId === input.companyId
|
|
&& publications[0]!.runId === run.id && publications[0]!.actorId === input.agentId
|
|
&& publications[0]!.entityId === input.issueId && rec(publications[0]!.details).source === "paperclip_runner_protocol",
|
|
"publication activity must belong to this run and task");
|
|
return {
|
|
schema: "paperclip.e2e.pi-file-evidence.v1", passed: true, workspaceProvenance,
|
|
runId: run.id, turnId: edit.e.turnId, nativeEditExecutionId: edit.p.executionId,
|
|
validationExecutionId: validation.p.executionId, attachmentId: a.id,
|
|
verification: { kind: "independent_exact_bytes", beforeSha256: sha(c.before), afterSha256: c.sha256,
|
|
downloadedSha256: sha(input.downloadedBytes), byteSize: c.byteSize, providerExecutionStatus: validation.p.status,
|
|
command: c.validationCommand, providerToolName: validation.p.name, commandEvidence: "correlated_native_progress",
|
|
exitEvidence: "native_structured_output_receipt", nativeStructuredExitCode: structured.exit_code,
|
|
typedExitCode: validation.p.exitCode, nativeFileTarget: edit.p.target,
|
|
nativeFileAttribution: "workspace_relative_display_target" },
|
|
diff: { source: "independent_seed_and_workspace_bytes", path: c.filename,
|
|
text: `--- ${c.filename}\n+++ ${c.filename}\n@@ -1 +1 @@\n-${c.before.trimEnd()}\n+${c.after.trimEnd()}\n` },
|
|
limits: ["Exact command progress and the native structured output receipt are checked; raw arguments and nullable typed exitCode are not attested by those distinct fields.",
|
|
"Diff is computed from independently checked workspace bytes; it does not qualify native diff presentation or private invocation metadata."],
|
|
};
|
|
}
|
|
|
|
export async function collectPiFileEvidence(input: {
|
|
page: Page; api: RunnerApi; nonce: string; companyId: string; issueId: string; agentId: string;
|
|
run: Row; events: unknown[]; seed: unknown; workspace: string;
|
|
environment: "local" | "daytona"; environmentId: string;
|
|
evidence(data: unknown): Promise<void>;
|
|
}) {
|
|
const c = piFileContract(input.nonce);
|
|
let run = input.run, lease: Row | undefined;
|
|
if (input.environment === "daytona") {
|
|
run = await input.api.get<Row>(`/api/heartbeat-runs/${input.run.id}`);
|
|
requireEvidence(run.id === input.run.id, "public copy-back run readback changed identity");
|
|
const leaseId = rec(rec(run.runnerProfileJson).nativeWorkspaceSync).leaseId;
|
|
requireEvidence(typeof leaseId === "string" && /^[A-Za-z0-9_-]{1,128}$/u.test(leaseId), "copy-back lease reference missing");
|
|
lease = await input.api.get<Row>(`/api/environment-leases/${leaseId}`);
|
|
}
|
|
const attachments = await input.api.get<Row[]>(`/api/issues/${input.issueId}/attachments`);
|
|
const selected = attachments.filter(a => a.originalFilename === c.filename);
|
|
requireEvidence(selected.length === 1 && /^[a-f0-9-]{36}$/u.test(selected[0]!.id), "exact published file selection");
|
|
requireEvidence(selected[0]!.byteSize === c.byteSize && selected[0]!.sha256 === c.sha256
|
|
&& selected[0]!.contentType === "text/plain", "bounded attachment metadata before download");
|
|
const attachmentId = selected[0]!.id;
|
|
const contentPath = `/api/attachments/${attachmentId}/content`;
|
|
// Exercise the real task surface and the authenticated public download route.
|
|
await input.page.locator(`a[href*="${contentPath}"]`).first().waitFor({ state: "visible", timeout: 15_000 });
|
|
const response = await input.api.request.get(contentPath);
|
|
requireEvidence(response.ok(), "public attachment download failed");
|
|
const downloadedBytes = await response.body();
|
|
requireEvidence(downloadedBytes.length <= 4096, "bounded text attachment");
|
|
const workspaceBytes = await readFile(join(input.workspace, c.filename));
|
|
const activity = await input.api.get<Row[]>(`/api/issues/${input.issueId}/activity`);
|
|
await input.evidence({ schema: "paperclip.e2e.pi-file-observation.v1", attachments, attachmentId, activity,
|
|
workspaceBytes: workspaceBytes.toString("base64"), downloadedBytes: downloadedBytes.toString("base64"),
|
|
encoding: "base64", expectedSha256: c.sha256, copybackReference: rec(run.runnerProfileJson).nativeWorkspaceSync,
|
|
copybackLease: lease && { id: lease.id, companyId: lease.companyId, environmentId: lease.environmentId,
|
|
heartbeatRunId: lease.heartbeatRunId, provider: lease.provider, providerLeaseId: lease.providerLeaseId, remoteCwd: rec(lease.metadata).remoteCwd } });
|
|
return gradePiFileEvidence({ ...input, run, lease, attachments, attachmentId, downloadedBytes, workspaceBytes, activity });
|
|
}
|