Merge current master and decision admission fixes from work-folders base

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/work-folders-base-refresh: (25 commits)
  fix: retain saved messages while decisions are pending
  fix(server): stop reporting expected managed-cloud transients to Sentry (#13323)
  fix: promote review tasks when continuations start (#13318)
  fix: require explicit native completion reviews (#13314)
  fix(onboarding): make chief-of-staff hiring reliable (#13317)
  fix: fence native startup against cancellation (#13316)
  chore(skills): allow verified PR merges (#13313)
  feat: add experimental persistent agent chat (#13284)
  chore(lockfile): refresh pnpm-lock.yaml (#13279)
  fix(ui): improve mobile task spacing (#13304)
  fix(connections): repair and simplify Google Workspace setup (#13289)
  fix(ui): stabilize steered chat activity presentation (#13246)
  fix(ui): make feed cards fully clickable (#13294)
  ci: activate shared PR dependency cache restores (#13302)
  test: remove cold executable reads from runner integrity deadlines (#13301)
  ci: reuse dependency caches without per-PR uploads (#13300)
  fix(ui): hide profile feedback flag on Cloud (#13292)
  feat(ui): refine dashboard cards, charts, and recent lists (#13269)
  fix: protect starting runs during overlapping deployments (#13285)
  fix: reliably interrupt and resume legacy message queues (#13275)
  ...

# Conflicts:
#	packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
This commit is contained in:
Dotta committed 2026-09-12 13:31:38 -05:00
commit bb80a847c4
385 files changed
+114493 -3058

No files matched your search

+18
View File
@@ -154,3 +154,21 @@ pnpm test:e2e:runner -- --list
Then run the narrowest paid cell that exercises the fixture. A full matrix is a
manual or scheduled campaign, not a PR requirement.
## Persistent chat fixtures
`chat-cases.ts` defines the six-case `agent-chat` suite; `chat-flow.ts` drives the
production composer, plan revision/approval controls, questions, reset command,
and project cards. Keep its 24 local cells intentional. `expectedRunCount`
counts provider turns, including cancelled and handed-off task runs, but excludes
synthetic `/new` runs. Assertions must inspect all company runs because ordinary
issue lists exclude the source conversation. `assertChatHandoff` rejects missing
projects/plans, chat children, wrong assignees, and execution before plan commit.
Retained `api-state.json`, `chat-handoff.json`, and plan-revision evidence
include persisted comments, session generations, run context and logs, project
workspaces, task documents, and ordering. They pass through the normal sanitizer.
Screenshots are allowlisted to the exact disposable agent chat. Cleanup cancels
all active runs in the isolated company, including handed-off work; usage from
failed and cancelled runs must not disappear from campaign totals.
+56 -3
View File
@@ -72,7 +72,7 @@ pnpm test:e2e:runner -- --suite daytona-warm-continuity
pnpm test:e2e:runner -- --all
```
The catalog contains four suites. `core-compatibility` (**Core Runner
The catalog contains five suites. `core-compatibility` (**Core Runner
Compatibility**) is seven major runner profiles × local/Daytona × three
workflows: 42 cells. Its cases are:
@@ -120,7 +120,44 @@ runner instance, PID, and process-start identity. Each turn is bounded to ten
minutes, the cell to thirty minutes, and cleanup explicitly deletes the
sandbox rather than waiting for Daytona's idle timeout.
The complete catalog is 68 cells (45 local and 23 Daytona) and 120 expected
`agent-chat` (**Persistent Agent Chat**) adds six workflows on `legacy-codex`,
`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **24 local cells**.
They cover continuity across server restart, fresh context after `/new`,
Stop/reset/resume, draft/revise/approve/plan handoff, clarification with existing
project reuse, and a new project with two repository URLs. Each cell opens the
production chat surface and resolves the backing issue through the chat API.
The source conversation must settle to `in_review` / `waiting`; handed-off
execution tasks must finish with their initial Plan and output documents.
Reset runs are retained separately from the 68 expected provider turns in this
suite. Cancelled turns and execution-task runs remain included in billing and
cleanup. The production chat directive is injected normally; fixtures do not
replace it with completion instructions. Daytona is excluded.
```bash
# Run these after deterministic checks, with the required provider keys set.
pnpm test:e2e:runner -- --id agent-chat.legacy-codex.local.continuity-restart
pnpm test:e2e:runner -- --id agent-chat.legacy-claude.local.continuity-restart
pnpm test:e2e:runner -- --suite agent-chat
```
The regular browser suite has deterministic process providers in
`tests/e2e/fixtures/agent-chat.mjs`. It exercises the real queue, APIs, database,
MCP project tools, and shared task UI without provider billing. Only upstream
GitHub discovery is simulated, scoped to a fixture-only credential; repository
permissions and mutations remain real. Run it with:
```bash
pnpm --filter @paperclipai/ui build
pnpm test:e2e tests/e2e/agent-chat.spec.ts
# Against a dedicated authenticated test instance configured per that suite:
pnpm test:e2e:multiuser-authenticated --grep 'agent chats'
```
Both suites save and restore experimental settings. Browser E2E always starts a
throwaway instance; never point the authenticated suite at the running demo.
Missing provider credentials fail paid preflight and are not passing coverage.
The complete catalog is 92 cells (69 local and 23 Daytona) and 188 expected
paid agent turns. Follow-up steps remain ordered within their cell; all other
cells are independent. Narrow selectors are strongly recommended while
developing fixtures.
@@ -219,6 +256,12 @@ usage is labeled `unavailable` or `unpriced`; it is never presented as zero
cost. The CI report job stages the same portable site at
`normalized/index.html` inside the access-controlled merged report artifact.
The trusted publisher discovers display-only entries for selected execution IDs
absent from its local catalog, so branch-only suites remain visible in the
dashboard, filters, gallery, and summary image. It validates execution identity
and escapes display text without loading target-branch executable code. Unknown
suite cardinality is not treated as proof of full-suite coverage.
Permanent publication uses two explicit bundles. Both retain only normalized
result PNG files with the explicit `public-runner-fixture` publication marker,
including marked `failure.png` captures, so every campaign dashboard has its
@@ -237,6 +280,10 @@ retains allowlisted inert per-attempt evidence (`.json`, `.log`, `.md`, and
redaction. The GitHub Pages bundle is regenerated separately with the same
declared-screenshot boundary.
Publication fails if any declared public screenshot is missing from the bundle.
The evidence packager explicitly retains `chat-plan-draft.png` and
`chat-plan-revised.png`; arbitrary chat-prefixed files remain excluded.
Both public bundles exclude video, archives, raw/unallowlisted logs, SVG or
other active content, generated Playwright/blob/HTML report trees, and
undeclared PNG files, and per-attempt XML. The root `junit.xml` remains public
@@ -272,6 +319,12 @@ artifacts. Each cell name links to its exact section in the campaign report.
The public campaign links become available after the history publisher
finishes. The artifact links remain available for 30 days.
For a development branch that adds a suite, the trusted default-branch dashboard
may not yet include that suite's interactive cards. Its published `summary.md`
and `normalized-results.json` still contain every selected cell. Use those files,
the GitHub job summary, or `html/index.html` in the merged Playwright artifact
to inspect branch-only results; an absent dashboard card is not passing coverage.
### Iterate on a published dashboard without rerunning paid tests
Download and extract the `github-pages` artifact from an existing workflow run,
@@ -369,7 +422,7 @@ Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped
ephemeral AWS RunsOn fleet selected by
`runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value uses the
proven GitHub-hosted `ubuntu-latest` target. Set `RUNNER_E2E_MAX_PARALLEL` to an
integer from 1–100 on AWS (default 100); use at least 68 to run the current
integer from 1–100 on AWS (default 100); use at least 92 to run the current
complete catalog in one wave. The fallback runner retains its 1–57 limit and
default of 32. Multi-turn steps are sequential inside their cell while
independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports
+10
View File
@@ -74,6 +74,16 @@ report, history, and Pages jobs receive none of these secrets.
Each full-stack matrix cell receives only its selected profile credential, plus
Daytona only for Daytona cells. Secret-bearing and OIDC jobs use frozen installs
without a shared dependency cache.
On disposable GitHub Linux runners with Ubuntu's unprivileged-user-namespace
restriction, the authorized default-branch workflow provisions an AppArmor profile before provider credentials are exposed. The profile is attached to the exact
lockfile-pinned Codex executable. It grants `userns` so Codex can construct its
filesystem sandbox; it does not disable the kernel restriction or Codex's
workspace policy. Setup fails before invoking a model if the noninteractive
profile load fails. Target-controlled tests only probe the existing sandbox and never invoke sudo or load host policy. This host-only profile disappears with the ephemeral runner.
See [Ubuntu's namespace restriction documentation](https://documentation.ubuntu.com/security/security-features/privilege-restriction/apparmor/).
Local developer machines are never modified by this setup. Legacy Codex fixtures
disable optional shell-environment snapshots to avoid persisting credentials;
the secret scanner retains its existing rejection rules.
The Paperclip server process also receives none; the browser posts each value
once to the encrypted company secret API and agents/environments retain only
secret references.
+16 -8
View File
@@ -41,10 +41,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
42, 14, 10, 2,
24, 42, 14, 10, 2,
]);
expect(validateRunnerCatalog()).toHaveLength(68);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(68);
expect(validateRunnerCatalog()).toHaveLength(92);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(92);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(42);
@@ -68,7 +68,7 @@ describe("runner E2E catalog", () => {
(total, execution) => total + execution.task.expectedRunCount,
0,
),
).toBe(120);
).toBe(188);
expect(
runnerTasks.find((task) => task.id === "plan-revise-accept")
?.attemptTimeoutMs,
@@ -354,7 +354,14 @@ describe("runner E2E catalog", () => {
},
executionId: execution!.id,
}),
).toMatchObject({ adapterConfig: { engine: "cli" } });
).toMatchObject({
adapterConfig: {
engine: "cli",
...(profileId === "legacy-codex"
? { extraArgs: ["-c", "features.shell_snapshot=false"] }
: {}),
},
});
}
});
@@ -497,6 +504,7 @@ describe("runner E2E selectors", () => {
"local",
]);
expect(selectRunnerExecutions(options).map((entry) => entry.id)).toEqual([
...runnerMatrix.filter(entry => entry.suite.id === "agent-chat" && ["legacy-codex", "runner-codex"].includes(entry.profile.id)).map(entry => entry.id),
"core-compatibility.legacy-codex.local.message-marker",
"core-compatibility.legacy-codex.local.plan-revise-accept",
"core-compatibility.legacy-codex.local.ask-question",
@@ -551,10 +559,10 @@ describe("runner E2E selectors", () => {
const jobs = buildMatrixJobs(
selectRunnerExecutions(parseRunnerSelectors(["--all"])),
);
expect(jobs).toHaveLength(68);
expect(jobs).toHaveLength(92);
expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(45);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(68);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(69);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(92);
expect(
jobs.find(
(job) =>
+20 -3
View File
@@ -1,3 +1,4 @@
import { chatTasks } from "./chat-cases.js";
import { createHash } from "node:crypto";
import { createAgentSchema } from "../../packages/shared/src/validators/agent.js";
import { createEnvironmentSchema } from "../../packages/shared/src/validators/environment.js";
@@ -30,6 +31,7 @@ const SELECTABLE_GROUPS = [
"warm",
"core",
"breadth",
"chat",
] as const;
const SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
@@ -68,8 +70,9 @@ function commonAgent(
"AGENTS.md": [
"You are running a paid Paperclip end-to-end acceptance fixture.",
"Follow the assigned task and its Paperclip work mode literally.",
"For standard and ask tasks, publish the requested visible answer and mark the task done.",
"For planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
"In ongoing agent chats, follow the injected production chat directive; keep the conversation available after replying. The completion and implementation instructions below apply only to ordinary execution tasks.",
"For ordinary standard and ask tasks, publish the requested visible answer and mark the task done.",
"For ordinary planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
"Invoke assigned tools only through the runtime's real tool-call channel. Never print XML, DSML, JSON, or other tool-call markup as assistant text.",
"Legacy adapters must use the public Paperclip API and the injected PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_TASK_ID, and PAPERCLIP_RUN_ID values for comments, documents, interactions, and status changes.",
...(adapterType === "paperclip_runner"
@@ -205,7 +208,13 @@ export const runnerProfiles: readonly RunnerProfileFixture[] = [
credential: "OPENAI_API_KEY",
// Keep this fixture on the classic adapter/CLI lane. ACP execution is
// covered independently by the native runner ACPX profiles below.
extraConfig: { engine: "cli" },
extraConfig: {
engine: "cli",
// Shell snapshots serialize inherited environment values into CODEX_HOME.
// These disposable runs carry short-lived API credentials; keep that
// optional optimization off rather than exempting leaked files from scans.
extraArgs: ["-c", "features.shell_snapshot=false"],
},
}),
legacyProfile({
id: "legacy-claude",
@@ -874,6 +883,14 @@ export const connectionReviewSuite: RunnerSuiteFixture = {
};
export const runnerSuites: readonly RunnerSuiteFixture[] = [
{
id: "agent-chat", label: "Persistent Agent Chat",
description: "Task-backed conversations, session resets, and project plan handoff.",
groups: ["chat"],
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 24,
definitionMetadata: { version: 1, resetRunsCountedSeparately: true },
},
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
{
id: "core-compatibility",
+36
View File
@@ -0,0 +1,36 @@
import type { RunnerTaskFixture } from "./types.js";
// Markers cross the rich-text composer and Markdown persistence boundary.
// Alphanumeric text has identical visible and stored representations.
export function chatMarker(
prefix: "CHAT" | "DRAFT" | "OLDCONTEXT",
nonce: string,
) {
return `${prefix}${nonce.replace(/[^a-zA-Z0-9]/g, "")}`;
}
export const CHAT_CASES = [
["continuity-restart", "Conversation continuity across restart", 3],
["new-session", "Fresh context within preserved history", 2],
["stop-new-resume", "Stop, reset, and resume", 3],
["plan-handoff", "Draft, revise, approve, and hand off a plan", 4],
["clarify-reuse", "Clarify and reuse an existing project", 3],
["multi-repository", "Create a project with multiple repository URLs", 2],
] as const;
export type ChatCase = (typeof CHAT_CASES)[number][0];
export const chatTasks: readonly RunnerTaskFixture[] = CHAT_CASES.map(
([id, label, expectedRunCount]) => ({
id,
label,
groups: ["chat"],
flow: "agent_chat",
workMode: "standard",
expectedRunCount, // Provider turns, including cancelled turns and handed-off work; reset runs are separate.
attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 },
expectedTerminalState: { issue: "in_review", run: "succeeded" },
buildTitle: (nonce) => `Chat acceptance ${id} ${nonce}`,
buildPrompt: (nonce) => `Let's discuss ${nonce}.`,
buildVisibleMarker: (nonce) => chatMarker("CHAT", nonce),
buildMatchers: () => [{ kind: "issue_status", expected: "in_review" }],
}),
);
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, vi } from "vitest";
import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js";
import {
assertChatHandoff,
assertChatTaskHandoff,
chatQuestionPresentation,
chatRunFailure,
chatTaskCompletionFailure,
createChatIdleFailureDetector,
collectChatRunEvidence,
readRunningChatLog,
readChatOutputDocument,
isChatClarificationReply,
assertChatExecutionOutput,
isResetRun,
type ChatIssue,
type ChatRun,
} from "./chat-flow.js";
import type { RunnerApi } from "./api.js";
import { chatMarker } from "./chat-cases.js";
import { runnerMatrix } from "./catalog.js";
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
const source: ChatIssue = {
id: "chat",
companyId: "co",
title: "Chat",
status: "in_review",
assigneeAgentId: "agent",
};
const task: ChatIssue = {
...source,
id: "work",
parentId: null,
projectId: "project",
};
const plan = {
body: "# Relevant plan",
latestRevisionId: "revision",
updatedAt: "2026-09-11T10:00:00Z",
};
const run: ChatRun = {
id: "run",
companyId: "co",
agentId: "agent",
status: "succeeded",
startedAt: "2026-09-11T10:00:01Z",
};
describe("chat acceptance contracts", () => {
it("accepts concrete information requests without requiring question punctuation", () => {
expect(isChatClarificationReply("What is the club name?")).toBe(true);
expect(
isChatClarificationReply(
"Before assigning the welcome-note work, please share:\n\n1. Club name and intended readers.\n2. Format, length, and tone.\n3. Required details, sender, and deadline.",
),
).toBe(true);
expect(
isChatClarificationReply("Tell me the intended audience and format."),
).toBe(true);
expect(
isChatClarificationReply(
"Thanks — before assigning the welcome-note drafting work, I need a compact brief covering:\n\n- Club and audience: club name and intended readers.\n- Purpose: welcome or next steps.\n- Required content: dates, links, and contacts.\n- Voice: tone and sender.\n- Delivery constraints: format, length, and deadline.\n- Examples or policies: existing notes and approval requirements.",
),
).toBe(true);
expect(isChatClarificationReply("I'll need your details about the audience and format.")).toBe(true);
expect(isChatClarificationReply("We need some information about the club and intended readers.")).toBe(true);
expect(isChatClarificationReply("I needed a compact brief before I assigned the work.")).toBe(false);
expect(isChatClarificationReply("I need a compact brief:")).toBe(false);
expect(isChatClarificationReply("I need information.")).toBe(false);
expect(isChatClarificationReply("I need to create the task and write the note.")).toBe(false);
expect(isChatClarificationReply("Please share:")).toBe(false);
expect(isChatClarificationReply("Please share.")).toBe(false);
expect(
isChatClarificationReply("Asked the user clarifying questions about their club."),
).toBe(false);
expect(
isChatClarificationReply("I created the task and started writing the welcome note."),
).toBe(false);
});
it("rejects superseded plan requirements in executed output, independently of plan history", () => {
expect(() => assertChatExecutionOutput("Welcome CHAT123.", "CHAT123", "DRAFT123")).not.toThrow();
expect(() => assertChatExecutionOutput("Welcome DRAFT123 and CHAT123.", "CHAT123", "DRAFT123")).toThrow();
expect(() => assertChatExecutionOutput("Welcome DRAFT123.", "CHAT123", "DRAFT123")).toThrow();
});
it("keeps chat markers literal across rich-text and Markdown boundaries", () => {
for (const prefix of ["CHAT", "DRAFT", "OLDCONTEXT"] as const) {
expect(chatMarker(prefix, "abc123-1")).toBe(`${prefix}abc1231`);
expect(chatMarker(prefix, "abc_123-1")).toMatch(/^[a-zA-Z0-9]+$/);
}
expect(chatMarker("OLDCONTEXT", "one-1")).not.toBe(
chatMarker("CHAT", "one-1"),
);
expect(chatMarker("CHAT", "one-1")).not.toBe(chatMarker("CHAT", "two-1"));
});
it("has exactly six workflows on the four chosen local profiles", () => {
const matrix = runnerMatrix.filter(
(cell) => cell.suite.id === "agent-chat",
);
expect(matrix).toHaveLength(24);
expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual(
new Set([
"legacy-codex",
"legacy-claude",
"runner-codex",
"runner-acpx-claude",
]),
);
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(6);
expect(
matrix.every(
(cell) =>
cell.environment.id === "local" &&
cell.task.expectedTerminalState.issue === "in_review",
),
).toBe(true);
});
it("rejects missing plan, chat children, wrong assignments, and execution before the plan", () => {
expect(() => assertChatHandoff(task, plan, [run], source)).not.toThrow();
for (const invalid of [
{ ...task, parentId: "chat" },
{ ...task, projectId: null },
{ ...task, assigneeAgentId: "other" },
])
expect(() => assertChatHandoff(invalid, plan, [run], source)).toThrow();
expect(() =>
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
).toThrow();
expect(() =>
assertChatHandoff(
task,
{ ...plan, updatedAt: "2026-09-11T10:00:02Z" },
[run],
source,
),
).toThrow();
expect(() => assertChatHandoff(task, plan, [], source)).toThrow();
});
it("requires a plan for plan handoff, while direct requests need only normal task assignment", () => {
expect(() => assertChatTaskHandoff(task, [run], source)).not.toThrow();
expect(() =>
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
).toThrow();
expect(() =>
assertChatTaskHandoff({ ...task, projectId: null }, [run], source),
).toThrow();
});
it.each(["project-description", "welcome-note", "output"])("finds committed %s output without accepting a copied plan or a claim", async (key) => {
const output = {
...plan,
id: "description-doc",
issueId: "work",
key,
body: "A completed description with CHAT123.",
createdByAgentId: "agent",
};
const get = vi.fn(async (path: string) => {
if (path === "/api/issues/work/documents")
return [{ key: "plan" }, { key }];
if (path === `/api/issues/work/documents/${key}`)
return output;
throw new Error(`Unexpected document read: ${path}`);
});
const api = { get } as Pick<RunnerApi, "get">;
await expect(readChatOutputDocument(api, "work", "CHAT123")).resolves.toBe(
output,
);
await expect(readChatOutputDocument(api, "work", "WRONG123")).rejects.toThrow(
"no non-plan output document",
);
get.mockImplementation(async () => [{ key: "plan" }]);
await expect(readChatOutputDocument(api, "work", "CHAT123")).rejects.toThrow(
"document keys: plan",
);
});
it("uses durable free-text labels, multi-selection, and the supplied submit label", () => {
const payload: AskUserQuestionsPayload = {
version: 1,
submitLabel: "Send brief",
questions: [
{
id: "audience",
prompt: "Who is it for?",
selectionMode: "multi",
required: true,
options: [
{ id: "members", label: "New members" },
{
id: "custom",
label: "Another audience or occasion",
freeText: true,
},
],
},
],
};
const presentation = chatQuestionPresentation(payload);
expect(presentation.submitLabel).toBe("Send brief");
expect(presentation.questions[0]).toMatchObject({
answerMode: "multi_select",
customAnswer: { enabled: true, label: "Another audience or occasion" },
});
const nativePayload: AskUserQuestionsPayload = {
...payload,
questionSet: {
schema: "paperclip.question_set.v1",
submitLabel: "Continue",
questions: [
{
id: "audience",
prompt: "Who is it for?",
required: true,
answerMode: "text",
},
],
},
};
expect(chatQuestionPresentation(nativePayload)).toBe(
nativePayload.questionSet,
);
});
it("retains reset events without requesting a provider log, and does not hide missing real logs", async () => {
const get = vi.fn().mockResolvedValue([{ type: "session_reset" }]);
const reset = { ...run, resultJson: { conversationReset: true } };
await expect(collectChatRunEvidence({ get }, reset)).resolves.toEqual({
runId: run.id,
log: null,
events: [{ type: "session_reset" }],
});
expect(get.mock.calls).toEqual([
[`/api/heartbeat-runs/${run.id}/events?limit=1000`],
]);
get.mockRejectedValue(new Error("Run log not found"));
await expect(collectChatRunEvidence({ get }, run)).rejects.toThrow(
"Run log not found",
);
});
it("waits for a newly running provider's log file without swallowing server failures", async () => {
const get = vi.fn().mockResolvedValue({ status: () => 404 });
const api = { request: { get } } as unknown as Pick<RunnerApi, "request">;
await expect(readRunningChatLog(api, "starting")).resolves.toBeUndefined();
get.mockResolvedValue({
status: () => 200,
ok: () => true,
json: async () => ({ content: "streamed reply" }),
});
await expect(readRunningChatLog(api, "running")).resolves.toBe(
"streamed reply",
);
get.mockResolvedValue({ status: () => 500, ok: () => false });
await expect(readRunningChatLog(api, "broken")).rejects.toThrow(
"log returned 500",
);
});
it("fails terminal execution errors without preempting active retries or recovery", () => {
const failed = {
...run,
status: "failed",
error: "provider rejected request",
};
expect(chatTaskCompletionFailure(task, [failed])).toContain(
"provider rejected request",
);
expect(
chatTaskCompletionFailure(task, [failed, { ...run, status: "queued" }]),
).toBeUndefined();
expect(
chatTaskCompletionFailure({ ...task, scheduledRetry: { id: "retry" } }, [
failed,
]),
).toBeUndefined();
expect(
chatTaskCompletionFailure(
{ ...task, activeRecoveryAction: { id: "recovery" } },
[failed],
),
).toBeUndefined();
});
it("fails stable contradictory idle states promptly without paid retries or transient false alarms", () => {
const detect = createChatIdleFailureDetector(3);
const settled = {
resolved: true,
status: "blocked",
conversationState: "waiting",
providerRunCount: 3,
activeRuns: [] as string[],
};
expect(detect(settled)).toBeUndefined();
expect(detect({ ...settled, activeRuns: ["running"] })).toBeUndefined();
expect(detect(settled)).toBeUndefined();
const failure = detect(settled);
expect(failure).toContain("chat_idle_state_invariant");
expect(classifyFailure(failure)).toBe("candidate_failure");
expect(shouldRetryFailure(classifyFailure(failure))).toBe(false);
expect(detect({ ...settled, status: "in_review" })).toBeUndefined();
expect(detect(settled)).toBeUndefined();
expect(detect({ ...settled, providerRunCount: 2 })).toBeUndefined();
expect(detect({ ...settled, status: "in_progress" })).toBeUndefined();
expect(detect(settled)).toBeUndefined();
});
it("fails promptly on terminal provider failures while permitting only expected cancellations", () => {
expect(chatRunFailure([run])).toBeUndefined();
expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined();
expect(
chatRunFailure([
{
...run,
status: "failed",
errorCode: "permission_denied",
error: "sandbox unavailable",
},
]),
).toContain("run run failed (permission_denied): sandbox unavailable");
expect(chatRunFailure([{ ...run, status: "cancelled" }])).toContain(
"cancelled",
);
expect(
chatRunFailure([{ ...run, status: "cancelled" }], true),
).toBeUndefined();
});
it("separates reset control runs from provider runs without treating failures as resets", () => {
expect(isResetRun(run)).toBe(false);
expect(isResetRun({ ...run, status: "failed" })).toBe(false);
expect(
isResetRun({ ...run, contextSnapshot: { conversationReset: true } }),
).toBe(true);
});
it("only publishes screenshots of the exact disposable chat", () => {
const target = {
issuePrefix: "E2E",
issueId: "chat",
issueIdentifier: null,
chatAgentId: "fixture-agent",
};
expect(
isPublicRunnerScreenshotRoute(
"http://127.0.0.1:3199/E2E/chats/fixture-agent",
target,
),
).toBe(true);
expect(
isPublicRunnerScreenshotRoute(
"http://127.0.0.1:3199/E2E/chats/another-agent",
target,
),
).toBe(false);
expect(
isPublicRunnerScreenshotRoute(
"https://example.com/E2E/chats/fixture-agent",
target,
),
).toBe(false);
});
});
+837
View File
@@ -0,0 +1,837 @@
import { expect, type Page } from "@playwright/test";
import { pollUntil, type RunnerApi } from "./api.js";
import type {
AskUserQuestionsPayload,
PaperclipQuestionSetPayload,
} from "../../packages/shared/src/types/issue.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
import { chatMarker } from "./chat-cases.js";
// Public API observations only: this driver never fabricates provider results or writes DB state.
export interface ChatIssue {
id: string;
companyId: string;
title: string;
status: string;
identifier?: string;
conversationState?: string;
conversationSessionGeneration?: number;
conversationBoundaryCommentId?: string;
parentId?: string | null;
projectId?: string | null;
assigneeAgentId?: string | null;
scheduledRetry?: unknown;
activeRecoveryAction?: unknown;
}
export interface ChatRun {
id: string;
companyId: string;
agentId: string;
status: string;
error?: string | null;
errorCode?: string | null;
runtimeMode?: string;
contextSnapshot?: Record<string, unknown>;
resultJson?: Record<string, unknown>;
sessionIdBefore?: string | null;
sessionIdAfter?: string | null;
startedAt?: string;
}
type Comment = {
id: string;
body: string;
authorAgentId?: string;
createdByRunId?: string;
conversationSessionGeneration?: number;
};
type Plan = { body: string; latestRevisionId: string; updatedAt: string };
type ChatOutputDocument = Plan & { id: string; issueId: string; key: string };
/** Clarification may request information imperatively rather than end in a question mark. */
export function isChatClarificationReply(body: string): boolean {
if (body.includes("?")) return true;
const request = body.match(
/\b(?:please\s+(?:share|provide|clarify|confirm)|tell me|let me know)\b([\s\S]*)/i,
) ?? body.match(
/\b(?:I|we)(?:'ll|\s+will)?\s+need\s+(?:(?:a|some|the|your|more|following|compact|short|few|additional)\s+){0,4}(?:brief|details|information|context|clarification)\b([\s\S]*)/i,
);
return Boolean(request && /[\p{L}\p{N}]/u.test(request[1]));
}
export function assertChatExecutionOutput(
body: string,
marker: string,
supersededMarker?: string,
): void {
expect(body).toContain(marker);
if (supersededMarker) expect(body).not.toContain(supersededMarker);
}
/** A requested output document may have a descriptive key; a copied plan is not output. */
export async function readChatOutputDocument(
api: Pick<RunnerApi, "get">,
issueId: string,
marker: string,
): Promise<ChatOutputDocument> {
const summaries = await api.get<Array<{ key: string }>>(
`/api/issues/${issueId}/documents`,
);
const documents = await Promise.all(
summaries
.filter((document) => document.key !== "plan")
.map((document) =>
api.get<ChatOutputDocument>(
`/api/issues/${issueId}/documents/${encodeURIComponent(document.key)}`,
),
),
);
const output = documents.find(
(document) => document.issueId === issueId && document.body.includes(marker),
);
if (!output)
throw new Error(
`Execution task ${issueId} has no non-plan output document containing ${marker}; document keys: ${summaries.map((document) => document.key).join(", ") || "none"}`,
);
expect(output.id).toBeTruthy();
expect(output.latestRevisionId).toBeTruthy();
return output;
}
export const isResetRun = (run: ChatRun) =>
run.contextSnapshot?.conversationReset === true ||
run.resultJson?.conversationReset === true;
export function chatRunFailure(
runs: ChatRun[],
allowCancelled = false,
): string | undefined {
const failed = runs.find(
(run) =>
["failed", "timed_out"].includes(run.status) ||
(!allowCancelled && run.status === "cancelled"),
);
return failed
? `run ${failed.id} ${failed.status}${failed.errorCode ? ` (${failed.errorCode})` : ""}${failed.error ? `: ${failed.error}` : ""}`
: undefined;
}
export function chatTaskCompletionFailure(
task: ChatIssue,
runs: ChatRun[],
): string | undefined {
if (
task.scheduledRetry ||
task.activeRecoveryAction ||
runs.some((run) => ["queued", "running"].includes(run.status))
)
return undefined;
return chatRunFailure(runs);
}
/** Require two settled observations so an in-flight finalization is not a failure. */
export function createChatIdleFailureDetector(minimumProviderRuns: number) {
let priorInconsistentState: string | undefined;
return (state: {
resolved: boolean;
status?: string;
conversationState?: string;
providerRunCount: number;
activeRuns: string[];
}): string | undefined => {
const inconsistentState =
state.resolved &&
state.providerRunCount >= minimumProviderRuns &&
state.activeRuns.length === 0 &&
state.conversationState === "waiting" &&
["blocked", "done", "cancelled"].includes(state.status ?? "")
? `${state.status}:${state.providerRunCount}`
: undefined;
const stable =
inconsistentState !== undefined &&
inconsistentState === priorInconsistentState;
priorInconsistentState = inconsistentState;
return stable
? `chat_idle_state_invariant: conversation is ${state.status} while waiting after ${state.providerRunCount} settled provider runs`
: undefined;
};
}
/** Match the shared question form's durable/native presentation, including custom labels. */
export function chatQuestionPresentation(
payload: AskUserQuestionsPayload,
): PaperclipQuestionSetPayload {
if (payload.questionSet) return payload.questionSet;
return {
schema: "paperclip.question_set.v1",
...(payload.submitLabel ? { submitLabel: payload.submitLabel } : {}),
questions: payload.questions.map((question) => {
const freeText = question.options.find((option) => option.freeText);
return {
id: question.id,
prompt: question.prompt,
required: question.required === true,
answerMode:
question.selectionMode === "multi" ? "multi_select" : "single_select",
...(freeText
? { customAnswer: { enabled: true as const, label: freeText.label } }
: {}),
};
}),
};
}
export function assertChatTaskHandoff(
task: ChatIssue,
runs: ChatRun[],
source: ChatIssue,
) {
expect(task.parentId).toBeNull();
expect(task.projectId).toBeTruthy();
expect(task.assigneeAgentId).toBe(source.assigneeAgentId);
expect(runs.length).toBeGreaterThan(0);
}
/** A running row can precede creation of its log file. Only that expected 404 is retryable. */
export async function readRunningChatLog(
api: Pick<RunnerApi, "request">,
runId: string,
): Promise<string | undefined> {
const response = await api.request.get(
`/api/heartbeat-runs/${runId}/log?limitBytes=65536`,
);
if (response.status() === 404) return undefined;
if (!response.ok())
throw new Error(`Run ${runId} log returned ${response.status()}`);
return ((await response.json()) as { content?: string }).content;
}
/** Synthetic reset runs have durable events but never start a provider log. */
export async function collectChatRunEvidence(
api: Pick<RunnerApi, "get">,
run: ChatRun,
) {
return {
runId: run.id,
log: isResetRun(run)
? null
: await api.get(`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`),
events: await api.get(`/api/heartbeat-runs/${run.id}/events?limit=1000`),
};
}
export function assertChatHandoff(
task: ChatIssue,
plan: Plan,
runs: ChatRun[],
source: ChatIssue,
) {
assertChatTaskHandoff(task, runs, source);
expect(plan.body.trim()).not.toBe("");
for (const run of runs) {
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
Date.parse(run.startedAt!),
);
}
}
export async function sendChatMessage(page: Page, message: string) {
const composer = page.getByTestId("task-chat-composer-input").last();
await composer
.locator('[contenteditable="true"], textarea')
.first()
.fill(message);
await page.getByTestId("task-chat-composer-send").last().click();
}
export async function runChatFlow(input: {
page: Page;
api: RunnerApi;
fixtures: LiveFixtureValues;
execution: MatrixExecution;
nonce: string;
restart: () => Promise<void>;
observe: (issue: ChatIssue, runs: ChatRun[]) => void;
capture: (id: string, label: string, file: string) => Promise<void>;
evidence: (name: string, data: unknown) => Promise<void>;
}) {
const { page, api, fixtures: f, execution, nonce } = input;
const chatPath = `/api/companies/${f.company.id}/chats/${f.agent.id}`;
const route = `/${f.company.issuePrefix}/chats/${f.agent.id}`;
const marker = execution.task.buildVisibleMarker(nonce);
const draftMarker = chatMarker("DRAFT", nonce);
const caseId = execution.task.id;
let issue: ChatIssue;
let runs: ChatRun[] = [];
const settings = await api.get<Record<string, unknown>>(
"/api/instance/settings/experimental",
);
const allRuns = async () => {
const rows = await api.get<ChatRun[]>(
`/api/companies/${f.company.id}/heartbeat-runs?limit=100`,
);
return Promise.all(
rows.map((row) => api.get<ChatRun>(`/api/heartbeat-runs/${row.id}`)),
);
};
const tasks = () =>
api.get<ChatIssue[]>(`/api/companies/${f.company.id}/issues`);
const comments = async () =>
(
await api.get<Array<Comment & { createdAt: string }>>(
`/api/issues/${issue.id}/comments?order=asc`,
)
).sort(
(a, b) =>
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
);
const idle = async (minimumProviderRuns: number) => {
const inconsistentIdle = createChatIdleFailureDetector(minimumProviderRuns);
await pollUntil({
label: "chat turn settles to waiting",
deadlineAt: Date.now() + 240_000,
intervalMs: 1000,
load: async () => {
const resolved = await api.get<ChatIssue | null>(chatPath);
if (resolved) issue = resolved;
runs = await allRuns();
if (resolved) input.observe(resolved, runs);
return {
resolved: Boolean(resolved),
status: resolved?.status,
conversationState: resolved?.conversationState,
providerRunCount: runs.filter((run) => !isResetRun(run)).length,
activeRuns: runs
.filter((run) => ["queued", "running"].includes(run.status))
.map((run) => run.id),
failure: chatRunFailure(runs, caseId === "stop-new-resume"),
};
},
reject: (state) => state.failure ?? inconsistentIdle(state),
accept: (state) =>
!state.failure &&
state.resolved &&
state.providerRunCount >= minimumProviderRuns &&
state.activeRuns.length === 0 &&
state.status === "in_review" &&
state.conversationState === "waiting",
});
};
const turn = async (text: string, count: number) => {
await sendChatMessage(page, text);
await idle(count);
};
const noTasks = async () => expect(await tasks()).toHaveLength(0);
try {
await api.patch("/api/instance/settings/experimental", {
enableAgentChat: true,
enableClassicTaskInterface: false,
});
expect(await api.get(chatPath)).toBeNull();
// Cold Vite startup can keep unrelated assets loading after the chat is
// interactive. The composer assertion below verifies actual UI readiness.
await page.goto(route, { waitUntil: "domcontentloaded", timeout: 60_000 });
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
expect(await api.get(chatPath)).toBeNull();
expect(await allRuns()).toHaveLength(0);
if (
["continuity-restart", "new-session", "stop-new-resume"].includes(caseId)
) {
const secret = chatMarker("OLDCONTEXT", nonce);
await turn(
`For this conversation only, remember the phrase ${secret}. Just acknowledge briefly; no project or task is needed.`,
1,
);
const initialId = issue!.id;
const before = runs.filter((run) => !isResetRun(run))[0]!;
await noTasks();
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
if (caseId === "continuity-restart") {
await turn(
"What phrase did I just ask you to remember? Reply with the phrase only.",
2,
);
expect(
(await comments()).filter((c) => c.authorAgentId).at(-1)?.body,
).toContain(secret);
const count = runs.length;
await input.restart();
// Re-enter the canonical route after the server replaces its browser
// transport; reloading the stale document can target a detached page.
await page.goto(route, { waitUntil: "domcontentloaded", timeout: 60_000 });
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
await idle(2);
expect(runs).toHaveLength(count);
await turn(
`We are done discussing it. Reply with ${marker} only; no further work.`,
3,
);
} else {
let cancelledId: string | undefined;
if (caseId === "stop-new-resume") {
await sendChatMessage(
page,
"Explain the history of gardening at length here, in 100 numbered paragraphs. This is discussion only; do not create work.",
);
await expect
.poll(
async () => {
runs = await allRuns();
const active = runs.find(
(run) => run.status === "running" && run.id !== before.id,
);
if (!active) return false;
const events = await api.get<Array<Record<string, unknown>>>(
`/api/heartbeat-runs/${active.id}/events?limit=1000`,
);
const log = await readRunningChatLog(api, active.id);
if (!(events.length || log?.length)) return false;
cancelledId = active.id;
return true;
},
{ timeout: 120_000 },
)
.toBe(true);
await page.getByTestId("task-chat-composer-stop").click();
await expect
.poll(
async () =>
(await api.get<ChatRun>(`/api/heartbeat-runs/${cancelledId}`))
.status,
)
.toBe("cancelled");
}
const oldComments = await comments();
await sendChatMessage(page, "/new");
await expect
.poll(
async () =>
(await api.get<ChatIssue>(chatPath))
.conversationSessionGeneration,
{ timeout: 30_000 },
)
.toBe(1);
await expect
.poll(async () => (await allRuns()).some(isResetRun))
.toBe(true);
await turn(
`Without reading older history or files, if you have a remembered phrase in your current context return it; otherwise reply exactly ${marker}. Do not look it up.`,
caseId === "new-session" ? 2 : 3,
);
const fresh = runs
.filter((run) => !isResetRun(run) && run.status === "succeeded")
.sort((a, b) => Date.parse(a.startedAt!) - Date.parse(b.startedAt!))
.at(-1)!;
expect(fresh.contextSnapshot?.conversationSessionGeneration).toBe(1);
expect(fresh.sessionIdBefore).toBeFalsy();
expect(
String(fresh.contextSnapshot?.paperclipTaskMarkdown ?? ""),
).not.toContain(secret);
if (before.sessionIdAfter && fresh.sessionIdAfter)
expect(fresh.sessionIdAfter).not.toBe(before.sessionIdAfter);
const replies = (await comments()).filter(
(c) => c.createdByRunId === fresh.id && c.authorAgentId,
);
expect(replies.map((c) => c.body).join("\n")).toContain(marker);
expect(replies.map((c) => c.body).join("\n")).not.toContain(secret);
const reset = runs.filter(isResetRun);
expect(reset).toHaveLength(1);
expect(
(await comments()).filter(
(c) => c.authorAgentId && c.createdByRunId === reset[0]!.id,
),
).toHaveLength(0);
expect(
(await comments()).filter((c) =>
oldComments.some((old) => old.id === c.id),
),
).toHaveLength(oldComments.length);
if (cancelledId)
expect(
(await comments()).filter((c) => c.createdByRunId === cancelledId),
).toEqual(
oldComments.filter((c) => c.createdByRunId === cancelledId),
);
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
await expect(
page.getByText("New session", { exact: true }),
).toHaveCount(1);
}
expect(issue!.id).toBe(initialId);
await noTasks();
} else {
let existingProject: { id: string; name: string } | undefined;
let acceptedPlan: Plan | undefined;
let repositoryCatalogBefore:
{ repositories: Array<{ id: string; url: string }> } | undefined;
const expectedRepositoryUrls = [
"https://github.com/octocat/Hello-World",
"https://github.com/octocat/Spoon-Knife",
];
if (caseId === "clarify-reuse") {
existingProject = await api.post(
`/api/companies/${f.company.id}/projects`,
{
name: `Garden ${nonce}`,
description: "Garden club welcome notes and event announcements.",
},
);
await turn(
"I need a welcome note for our club. Help me clarify what information you need before assigning the work.",
1,
);
await noTasks();
const questions = await api.get<
Array<{
status: string;
kind: string;
payload: AskUserQuestionsPayload;
}>
>(`/api/issues/${issue!.id}/interactions`);
const pendingInteraction = questions.find(
(row) =>
row.status === "pending" && row.kind === "ask_user_questions",
);
const questionSet = pendingInteraction
? chatQuestionPresentation(pendingInteraction.payload)
: undefined;
const pendingQuestions = questionSet?.questions;
expect(
Boolean(pendingQuestions?.length) ||
(await comments()).some(
(c) => c.authorAgentId && isChatClarificationReply(c.body),
),
).toBe(true);
const clarification = `It is the garden club; use the existing Garden ${nonce} project. Make one assigned task for yourself to write a two-sentence welcome note. Include ${marker} in that note, save it as a Paperclip document attached to that execution task, and finish that execution task. Please get it started now.`;
if (pendingQuestions?.length) {
for (const [index, question] of pendingQuestions.entries()) {
const textInput = page
.getByTestId("question-text-answer-composer")
.last();
if (await textInput.isVisible()) {
await textInput
.locator('[contenteditable="true"],textarea')
.first()
.fill(clarification);
} else {
await page
.getByRole(
question.answerMode === "multi_select" ? "checkbox" : "radio",
{
name: question.customAnswer?.label ?? "Other",
exact: true,
},
)
.last()
.click();
await page
.getByTestId("question-other-answer-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(clarification);
}
await page
.getByRole("button", {
name:
index === pendingQuestions.length - 1
? (questionSet?.submitLabel ?? "Submit answers")
: "Next",
exact: true,
})
.last()
.click();
}
await idle(3);
} else await turn(clarification, 3);
} else if (caseId === "plan-handoff") {
await page.getByTestId("task-chat-composer-mode").click();
await page
.getByTestId("task-chat-composer-mode-menu")
.getByText("Plan mode", { exact: true })
.click();
await turn(
`Let's plan a two-sentence garden club welcome note. The finished welcome note itself must contain the exact phrase ${draftMarker}. Write a plan in the plan panel that includes this requirement, and present it for approval. When I approve the final revision, create a suitable repository-free project and an assigned task for yourself, copy the plan into that task, and have it save the note as a Paperclip document attached to that execution task and finish. Do not create the project or task before approval.`,
1,
);
const draft = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(draft.body).toContain(draftMarker);
await noTasks();
await input.capture(
"chat-plan-draft",
"Draft plan in the conversation",
"chat-plan-draft.png",
);
const initialInteractions = await api.get<
Array<{
status: string;
kind: string;
payload?: {
target?: { revisionId?: string };
rejectLabel?: string;
};
}>
>(`/api/issues/${issue!.id}/interactions`);
const initialApproval = initialInteractions.find(
(row) =>
row.status === "pending" &&
row.kind === "request_confirmation" &&
row.payload?.target?.revisionId === draft.latestRevisionId,
);
expect(
initialApproval,
"draft has a revision-bound approval",
).toBeTruthy();
const reviseButton = page
.getByRole("button", {
name: initialApproval!.payload?.rejectLabel ?? "Reject",
exact: true,
})
.last();
await reviseButton.click();
await page
.getByTestId("plan-revision-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(
`Revise the plan: the finished welcome note itself must contain the exact phrase ${marker} instead of ${draftMarker}. Include that requirement in the revised plan. The execution task should save that welcome note as a Paperclip document attached to that task. Present this revised plan for approval; wait for that approval before handing it off as agreed.`,
);
await reviseButton.click();
await idle(2);
const revised = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(revised.body).toContain(marker);
// A revision-history section may quote the superseded requirement.
// The executed output below must use only the accepted requirement.
expect(revised.latestRevisionId).not.toBe(draft.latestRevisionId);
acceptedPlan = revised;
await noTasks();
const interactions = await api.get<
Array<{
id: string;
status: string;
kind: string;
payload?: {
target?: { revisionId?: string };
acceptLabel?: string;
};
}>
>(`/api/issues/${issue!.id}/interactions`);
const approval = interactions.find(
(row) =>
row.status === "pending" &&
row.kind === "request_confirmation" &&
row.payload?.target?.revisionId === revised.latestRevisionId,
);
expect(approval, "approval targets the revised plan").toBeTruthy();
await input.capture(
"chat-plan-revised",
"Revised plan before handoff",
"chat-plan-revised.png",
);
await page
.getByRole("button", {
name: approval!.payload?.acceptLabel ?? "Approve",
exact: true,
})
.last()
.click();
await idle(4);
await input.evidence("chat-plan-revisions.json", {
draft,
revised,
approval,
source: await api.get(`/api/issues/${issue!.id}/documents/plan`),
});
} else {
repositoryCatalogBefore = await api.get(
`/api/companies/${f.company.id}/project-repositories`,
);
expect(
repositoryCatalogBefore!.repositories.filter((repository) =>
expectedRepositoryUrls.includes(repository.url),
),
).toHaveLength(0);
await turn(
`Create a project called Repository Discussion ${nonce} for work spanning https://github.com/octocat/Hello-World and https://github.com/octocat/Spoon-Knife. These existing public repositories are not in our catalog; register both URLs. Then make one assigned task for yourself to write a two-sentence description of the intended project as a Paperclip document attached to that execution task, containing ${marker}, and complete that task. No code changes or remote repository creation are needed.`,
2,
);
}
const children = await tasks();
expect(children).toHaveLength(1);
const child = children[0]!;
await pollUntil({
label: `execution task ${child.id} completes`,
deadlineAt: Date.now() + 240_000,
intervalMs: 1000,
load: async () => ({
task: await api.get<ChatIssue>(`/api/issues/${child.id}`),
runs: await api.get<ChatRun[]>(`/api/issues/${child.id}/runs`),
}),
accept: (state) => state.task.status === "done",
reject: (state) => chatTaskCompletionFailure(state.task, state.runs),
});
runs = await allRuns();
input.observe(issue!, runs);
const plan =
caseId === "plan-handoff"
? await api.get<Plan>(`/api/issues/${child.id}/documents/plan`)
: null;
const taskRuns = runs.filter(
(run) => run.contextSnapshot?.issueId === child.id,
);
if (plan) {
assertChatHandoff(child, plan, taskRuns, issue!);
expect(plan.body).toContain(marker);
const sourcePlan = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(sourcePlan.body).toBe(acceptedPlan!.body);
expect(sourcePlan.latestRevisionId).toBe(
acceptedPlan!.latestRevisionId,
);
} else assertChatTaskHandoff(child, taskRuns, issue!);
const output = await readChatOutputDocument(api, child.id, marker);
assertChatExecutionOutput(
output.body,
marker,
caseId === "plan-handoff" ? draftMarker : undefined,
);
await input.evidence("chat-execution-output.json", {
taskId: child.id,
document: output,
revisions: await api.get(
`/api/issues/${child.id}/documents/${encodeURIComponent(output.key)}/revisions`,
),
executionRunIds: taskRuns.map((run) => run.id),
});
expect(
(await comments())
.filter((c) => c.authorAgentId)
.map((c) => c.body)
.join("\n"),
).toMatch(new RegExp(`${child.id}|${child.identifier}`));
const projects = await api.get<
Array<{
id: string;
name: string;
workspaces: Array<{ id: string; name: string; repoUrl?: string }>;
}>
>(`/api/companies/${f.company.id}/projects`);
expect(projects).toHaveLength(1);
if (existingProject) {
expect(child.projectId).toBe(existingProject.id);
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(0);
} else {
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(1);
if (caseId === "multi-repository") {
expect(projects[0]!.workspaces.map((w) => w.repoUrl).sort()).toEqual(
[...expectedRepositoryUrls].sort(),
);
// URL registration is persisted as project workspaces; the discovery
// catalog continues to reflect authorized GitHub connections only.
expect(
projects[0]!.workspaces.every((workspace) => Boolean(workspace.id)),
).toBe(true);
expect(
new Set(projects[0]!.workspaces.map((workspace) => workspace.id))
.size,
).toBe(2);
const persistedProject = await api.get<{
workspaces: Array<{ id: string; repoUrl?: string }>;
}>(`/api/projects/${projects[0]!.id}`);
expect(
persistedProject.workspaces.map(({ id, repoUrl }) => ({
id,
repoUrl,
})),
).toEqual(
projects[0]!.workspaces.map(({ id, repoUrl }) => ({ id, repoUrl })),
);
await input.evidence("chat-repository-registration.json", {
catalogBefore: repositoryCatalogBefore,
projectId: projects[0]!.id,
registeredWorkspaces: persistedProject.workspaces,
});
const projectCard = page.getByRole("article", {
name: /Project created:/,
});
// Repository labels may be customized; verify the actual destinations.
for (const repositoryUrl of expectedRepositoryUrls) {
await expect(
projectCard.locator(`a[href="${repositoryUrl}"]`),
).toBeVisible();
}
} else
expect(projects[0]!.workspaces.filter((w) => w.repoUrl)).toHaveLength(
0,
);
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(1);
}
await input.evidence("chat-handoff.json", {
source: issue!,
task: child,
plan,
output,
projects,
});
}
await idle(execution.task.expectedRunCount);
expect(runs.filter((run) => !isResetRun(run))).toHaveLength(
execution.task.expectedRunCount,
);
for (const run of runs.filter((run) => !isResetRun(run))) {
expect(run.runtimeMode).toBe(execution.profile.expectedRuntimeMode);
expect(run.status).toBe(
caseId === "stop-new-resume" && run.status === "cancelled"
? "cancelled"
: "succeeded",
);
}
await input.evidence("api-state.json", {
issue: issue!,
runs,
runGroups: {
resets: runs.filter(isResetRun).map((run) => run.id),
cancelled: runs
.filter((run) => run.status === "cancelled")
.map((run) => run.id),
conversation: runs
.filter(
(run) =>
!isResetRun(run) && run.contextSnapshot?.issueId === issue!.id,
)
.map((run) => run.id),
handoff: runs
.filter((run) => run.contextSnapshot?.issueId !== issue!.id)
.map((run) => run.id),
},
comments: await comments(),
activity: await api.get(`/api/issues/${issue!.id}/activity`),
runEvidence: await Promise.all(
runs.map((run) => collectChatRunEvidence(api, run)),
),
});
await input.capture(
"final-state",
"Chat waiting after its verified workflow",
"final-state.png",
);
return { issue: issue!, runs };
} finally {
await api.patch("/api/instance/settings/experimental", {
enableAgentChat: settings.enableAgentChat,
enableClassicTaskInterface: settings.enableClassicTaskInterface,
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = path.resolve(import.meta.dirname, "../..");
describe("Codex CI sandbox trust boundary", () => {
it("keeps privileged policy changes out of target-controlled tests", async () => {
const source = await readFile(
path.join(root, "tests/runner-e2e/codex-ci-sandbox.ts"),
"utf8",
);
expect(source).not.toMatch(
/execFileSync\(["']sudo|apparmor_parser|flags=\(unconfined\)/,
);
expect(source).toMatch(/"sandbox",\s*"--permission-profile",\s*"paperclip-e2e-probe"/);
expect(source).toContain(
"permissions.paperclip-e2e-probe.network.enabled=false",
);
expect(source).toContain(
'permissions.paperclip-e2e-probe.filesystem={":root"="read"}',
);
expect(source).toContain("Codex sandbox preflight failed");
expect(source).not.toContain("...process.env");
});
it("provisions the exact executable in trusted CI before provider credentials", async () => {
const workflow = await readFile(
path.join(root, ".github/workflows/runner-full-stack-e2e.yml"),
"utf8",
);
const setup = workflow.indexOf(
"- name: Provision Codex sandbox on the disposable trusted runner",
);
const paid = workflow.indexOf("- name: Run paid cell");
expect(setup).toBeGreaterThan(
workflow.indexOf(
"- name: Reauthorize paid execution before provider access",
),
);
expect(paid).toBeGreaterThan(setup);
const step = workflow.slice(setup, paid);
expect(step).toContain('binary.startsWith(root + "/node_modules/.pnpm/")');
expect(step).toContain("binary.endsWith(suffix)");
expect(step).toContain('"-n", "apparmor_parser", "-r", profilePath');
expect(step).toContain('flag:"wx"');
expect(step).not.toContain("secrets.");
expect(step).not.toContain("node scripts/");
expect(step).not.toContain("sysctl -w");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { execFileSync } from "node:child_process";
import { mkdir, realpath } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
/** Probe the existing Linux sandbox before spending provider credentials.
* Host policy is provisioned by the trusted workflow, never by target test code.
*/
export async function prepareCodexCiSandbox(
repositoryRoot: string,
temporaryRoot: string,
) {
if (process.platform !== "linux" || process.env.GITHUB_ACTIONS !== "true")
return;
const runnerRequire = createRequire(
path.join(repositoryRoot, "packages/paperclip-runner/package.json"),
);
const acpRequire = createRequire(
runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"),
);
const codexRequire = createRequire(
acpRequire.resolve("@openai/codex/package.json"),
);
const arch =
process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
if (!arch) throw new Error("Unsupported Codex CI architecture");
const platformPackage = codexRequire.resolve(
`@openai/codex-linux-${arch}/package.json`,
);
const triple =
arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
const binary = await realpath(
path.join(path.dirname(platformPackage), "vendor", triple, "bin", "codex"),
);
const probeHome = path.join(temporaryRoot, "codex-sandbox-probe");
await mkdir(probeHome, { mode: 0o700 });
try {
execFileSync(
binary,
[
"sandbox",
"--permission-profile",
"paperclip-e2e-probe",
"-c",
'permissions.paperclip-e2e-probe.filesystem={":root"="read"}',
"-c",
"permissions.paperclip-e2e-probe.network.enabled=false",
"-C",
temporaryRoot,
"--",
"/bin/true",
],
{
cwd: temporaryRoot,
env: { PATH: process.env.PATH, CODEX_HOME: probeHome },
timeout: 15_000,
stdio: "pipe",
},
);
} catch (error) {
throw new Error(
"Codex sandbox preflight failed. The trusted CI runner must provision its user-namespace policy before paid tests run.",
{ cause: error },
);
}
}
+22 -13
View File
@@ -1,5 +1,8 @@
import {
discoverReportCatalog,
type ReportExecution,
} from "./report-catalog.js";
import type {
MatrixExecution,
RunnerE2ECampaign,
RunnerE2EHistoryIndex,
RunnerE2EResult,
@@ -22,7 +25,7 @@ export interface RunnerDashboardInput {
title: string;
generatedAt: string;
expected: readonly string[];
catalog: readonly MatrixExecution[];
catalog: readonly ReportExecution[];
entries: readonly RunnerDashboardEntry[];
campaign?: RunnerE2ECampaign;
history?: RunnerE2EHistoryIndex;
@@ -135,7 +138,7 @@ function resolveScreenshots(
}
function renderCase(
execution: MatrixExecution,
execution: ReportExecution,
expected: ReadonlySet<string>,
entryById: ReadonlyMap<string, RunnerDashboardEntry>,
) {
@@ -151,7 +154,11 @@ function renderCase(
const label = state.replace("-", " ");
const detail =
entry?.errors.join("; ") ||
(entry?.valid ? "All invariants passed" : "Not selected");
(entry?.valid
? "All invariants passed"
: selected
? "No result artifact was uploaded"
: "Not selected");
const screenshots = resolveScreenshots(entry);
const billing = entry ? summarizeExecutionBilling(entry.result) : null;
const matcherResults = entry?.result.matcherResults ?? [];
@@ -508,7 +515,7 @@ function renderHistory(history: RunnerE2EHistoryIndex | undefined) {
}
function renderSuiteMatrix(input: {
suiteCatalog: readonly MatrixExecution[];
suiteCatalog: readonly ReportExecution[];
expected: ReadonlySet<string>;
entryById: ReadonlyMap<string, RunnerDashboardEntry>;
summary?: RunnerE2ESuiteSummary;
@@ -580,6 +587,11 @@ function renderSuiteMatrix(input: {
}
export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
const catalog = discoverReportCatalog({
catalog: input.catalog,
expected: input.expected,
results: input.entries.map((entry) => entry.result),
});
const expected = new Set(input.expected);
const entryById = new Map(
input.entries.map((entry) => [entry.result.executionId, entry]),
@@ -602,13 +614,13 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
);
const suites = [
...new Map(
input.catalog.map((execution) => [execution.suite.id, execution.suite]),
catalog.map((execution) => [execution.suite.id, execution.suite]),
).values(),
];
const suiteSections = suites
.map((suite) =>
renderSuiteMatrix({
suiteCatalog: input.catalog.filter(
suiteCatalog: catalog.filter(
(execution) => execution.suite.id === suite.id,
),
expected,
@@ -625,15 +637,12 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
);
const filterProfiles = [
...new Map(
input.catalog.map((execution) => [
execution.profile.id,
execution.profile,
]),
catalog.map((execution) => [execution.profile.id, execution.profile]),
).values(),
];
const filterEnvironments = [
...new Map(
input.catalog.map((execution) => [
catalog.map((execution) => [
execution.environment.id,
execution.environment,
]),
@@ -1034,7 +1043,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
</section>
${suiteSections}
${historySection}
<footer><span>Generated ${html(input.generatedAt)}</span><span>${input.catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published</span></footer>
<footer><span>Generated ${html(input.generatedAt)}</span><span>${catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published</span></footer>
</main>
<dialog class="gallery-dialog" data-gallery-dialog aria-labelledby="gallery-title">
<div class="gallery-shell">
+6 -8
View File
@@ -38,10 +38,7 @@ describe("runner E2E Daytona image contract", () => {
"COPY packages/paperclip-runner ./packages/paperclip-runner",
);
expect(dockerfile).toContain(
"COPY packages/paperclip-eval-kernel/src ./packages/paperclip-eval-kernel/src",
);
expect(dockerfile).toContain(
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
"COPY packages ./packages",
);
expect(dockerfile).toContain(
"/opt/paperclip-runner/provider-pack/provider-pack.json",
@@ -137,20 +134,21 @@ describe("runner E2E Daytona image contract", () => {
workflow.indexOf(`--format '{{json .Image}}'`),
);
const providerInstall = dockerfile.indexOf(
"RUN pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
"pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
);
const runnerSourceCopy = dockerfile.indexOf(
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
"COPY packages ./packages",
);
const providerRevisionArg = dockerfile.indexOf(
"ARG PAPERCLIP_RUNNER_SOURCE_REVISION",
);
const cliInstall = dockerfile.indexOf("RUN npm install -g");
const cliInstall = dockerfile.indexOf("npm install -g");
const finalMetadataArgs = dockerfile.lastIndexOf(
"ARG PAPERCLIP_RUNNER_CONTENT_ID",
);
expect(providerInstall).toBeGreaterThan(0);
expect(providerInstall).toBeLessThan(runnerSourceCopy);
expect(runnerSourceCopy).toBeGreaterThan(0);
expect(runnerSourceCopy).toBeLessThan(providerInstall);
expect(providerInstall).toBeLessThan(providerRevisionArg);
expect(cliInstall).toBeGreaterThan(0);
expect(cliInstall).toBeLessThan(finalMetadataArgs);
+2
View File
@@ -38,6 +38,8 @@ const ALLOWED_ROOT_FILES = new Set([
"result.json",
"final-state.png",
"failure.png",
"chat-plan-draft.png",
"chat-plan-revised.png",
"server.log",
"playwright.log",
"junit.xml",
+14
View File
@@ -4,6 +4,14 @@ const TRANSIENT =
/(?:\b429\b|\b5\d\d\b|rate.?limit|ECONN(?:RESET|REFUSED)|socket hang up|network (?:error|interruption|timeout)|service unavailable|(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness).*(?:temporar|timed? out|timeout|closed|failed|unavailable|interrupt|reset|refused|create|start|connect)|(?:timed? out|timeout).*(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness))/i;
const PERMANENT =
/(?:missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i;
// These controller checkpoint failures are reproducible protocol defects, even
// when their outer error is described as a provider transport failure.
const NON_RETRYABLE_SESSION_CLOSE =
/(?:native_session_close_unrecoverable|runner did not durably suspend before checkpoint)/i;
// Recovery cannot repair a provider session-open rejection that explicitly
// declares itself non-retryable. Keep generic transport/start failures transient.
const NON_RETRYABLE_ACPX_SESSION_OPEN =
/native_session_recovery_failed[\s\S]*ACPX sidecar command session\.open was rejected\s*\([^)]*\bretryable\s*=\s*false\b/i;
const CANDIDATE =
/(?:matcher|expected.*observed|marker|issue status|run status|runtime mode|wrong output|missing output)/i;
@@ -20,6 +28,12 @@ export function classifyFailure(error: unknown): FailureClass {
: "cleanup_failure";
}
if (PERMANENT.test(message)) return "permanent_infrastructure";
if (
/chat_idle_state_invariant/.test(message) ||
NON_RETRYABLE_SESSION_CLOSE.test(message) ||
NON_RETRYABLE_ACPX_SESSION_OPEN.test(message)
)
return "candidate_failure";
if (TRANSIENT.test(message)) return "transient_infrastructure";
if (CANDIDATE.test(message)) return "candidate_failure";
return "candidate_failure";
+9 -3
View File
@@ -1,3 +1,4 @@
import { validateRetainedRunnerResult } from "./result-validation.js";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import path from "node:path";
@@ -313,10 +314,14 @@ export async function createBundleManifest(
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(campaignId)) {
throw new Error("Campaign ID is unsafe for immutable object storage");
}
const retainedPaths = await relativeFiles(root, root, allowPublicSummary, publicScreenshots);
const retained = new Set(retainedPaths);
const missingScreenshots = [...publicScreenshots].filter((relative) => !retained.has(relative));
if (missingScreenshots.length > 0) {
throw new Error(`Declared public screenshots are missing from the historical bundle: ${missingScreenshots.sort().join(", ")}`);
}
const files = await Promise.all(
(await relativeFiles(root, root, allowPublicSummary, publicScreenshots))
.sort()
.map(async (relative) => {
retainedPaths.sort().map(async (relative) => {
const absolute = path.join(root, ...relative.split("/"));
if (
relative === "public-images/campaign-summary.png" ||
@@ -513,6 +518,7 @@ async function main() {
const campaign = JSON.parse(
await readFile(path.join(reportRoot, "normalized-results.json"), "utf8"),
) as RunnerE2ECampaign;
for (const result of campaign.results ?? []) validateRetainedRunnerResult(result);
if (campaign.schema !== "paperclip.runner-e2e.campaign/v2") {
throw new Error("Historical publishing requires a v2 normalized campaign");
}
+20 -7
View File
@@ -195,8 +195,8 @@ describe("runner E2E campaign history", () => {
expect(index).toContain("Runner E2E campaigns");
expect(index).toContain("complete-green");
expect(index).toContain("complete-red");
expect(index).toContain("68/68 passed");
expect(index).toContain("67/68 passed");
expect(index).toContain("92/92 passed");
expect(index).toContain("91/92 passed");
expect(index).toContain("Open report&nbsp;→");
expect(index).toContain(
"campaigns/complete-red/public-images/campaign-summary.png",
@@ -501,9 +501,25 @@ describe("historical publication security", () => {
).rejects.toThrow("does not match its raster file type");
});
it("refuses a public bundle when any declared screenshot is absent", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-missing-screenshot-"));
temporaryDirectories.push(root);
const directory = path.join(root, "evidence", "agent-chat.runner-codex.local.plan-handoff", "attempt-1");
await mkdir(directory, { recursive: true });
const base = "evidence/agent-chat.runner-codex.local.plan-handoff/attempt-1";
const declared = new Set([`${base}/final-state.png`, `${base}/chat-plan-draft.png`]);
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
await writeFile(path.join(directory, "final-state.png"), png);
await expect(createBundleManifest(root, "campaign-1", false, declared))
.rejects.toThrow(`Declared public screenshots are missing from the historical bundle: ${base}/chat-plan-draft.png`);
await writeFile(path.join(directory, "chat-plan-draft.png"), png);
const manifest = await createBundleManifest(root, "campaign-1", false, declared);
expect(new Set(manifest.files.map((file) => file.path))).toEqual(declared);
});
it("requires trusted-fixture opt-in and rejects unsafe screenshot paths", () => {
const execution = runnerMatrix[0]!;
const campaign = buildRunnerCampaign({
expect(() => buildRunnerCampaign({
campaignId: "unsafe-screenshot",
generatedAt: "2026-08-28T00:01:00.000Z",
expected: [execution.id],
@@ -520,10 +536,7 @@ describe("historical publication security", () => {
],
},
],
});
expect(() => publicScreenshotPaths(campaign)).toThrow(
"Cannot publish unsafe screenshot path",
);
})).toThrow("Invalid retained runner result field: result.screenshots[0].file");
const failedCampaign = buildRunnerCampaign({
campaignId: "failed-screenshot",
+45 -5
View File
@@ -1,3 +1,9 @@
import { validateRetainedRunnerResult } from "./result-validation.js";
import {
discoverReportCatalog,
parseReportExecutionId,
type ReportExecution,
} from "./report-catalog.js";
import { runnerMatrix, runnerSuites } from "./catalog.js";
import {
aggregateCampaignBilling,
@@ -20,11 +26,17 @@ export function canonicalExecutionId(id: string) {
}
export function upgradeRunnerResult(result: RunnerE2EResult): RunnerE2EResult {
validateRetainedRunnerResult(result);
const executionId = canonicalExecutionId(result.executionId);
const execution = runnerMatrix.find(
(candidate) => candidate.id === executionId,
);
if (!execution) return result;
if (!execution)
return {
...result,
executionId,
suiteId: result.suiteId ?? parseReportExecutionId(executionId).suiteId,
};
return {
...result,
executionId,
@@ -49,25 +61,39 @@ export function buildRunnerCampaign(input: {
expected: readonly string[];
results: readonly RunnerE2EResult[];
eventName?: string | null;
catalog?: readonly ReportExecution[];
}): RunnerE2ECampaign {
const expected = input.expected.map(canonicalExecutionId);
const results = input.results.map((result) => ({
...upgradeRunnerResult(result),
billing: result.billing ?? summarizeExecutionBilling(result),
}));
const knownCatalog = input.catalog ?? runnerMatrix;
const catalog = discoverReportCatalog({
catalog: knownCatalog,
expected,
results,
});
const discoveredSuites = [
...new Map(
catalog.map((execution) => [execution.suite.id, execution.suite]),
).values(),
];
const resultSource = results.find((result) => result.source)?.source;
const source = {
...resolveRunnerE2ESource(resultSource),
eventName: input.eventName ?? process.env.GITHUB_EVENT_NAME ?? null,
};
const suites = runnerSuites
const suites = discoveredSuites
.map((suite) => {
const suiteExpected = expected.filter((id) =>
id.startsWith(`${suite.id}.`),
);
if (suiteExpected.length === 0) return null;
const suiteResults = results.filter(
(result) => result.suiteId === suite.id,
(result) =>
expected.includes(result.executionId) &&
result.executionId.startsWith(`${suite.id}.`),
);
const passed = suiteResults.filter(
(result) => result.status === "passed" && result.cleanup === "passed",
@@ -79,7 +105,7 @@ export function buildRunnerCampaign(input: {
suiteId: suite.id,
suiteDefinitionHash:
suiteResults[0]?.suiteDefinitionHash ??
runnerMatrix.find((execution) => execution.suite.id === suite.id)!
catalog.find((execution) => execution.suite.id === suite.id)!
.suiteDefinitionHash,
expected: suite.expectedMatrixSize,
selected: suiteExpected.length,
@@ -93,7 +119,21 @@ export function buildRunnerCampaign(input: {
cleanupPassed: suiteResults.every(
(result) => result.cleanup === "passed",
),
complete: suiteExpected.length === suite.expectedMatrixSize,
complete:
suiteExpected.length === suite.expectedMatrixSize &&
knownCatalog.filter((execution) => execution.suite.id === suite.id)
.length === suiteExpected.length &&
knownCatalog
.filter((execution) => execution.suite.id === suite.id)
.every(
(execution) =>
suiteExpected.includes(execution.id) &&
suiteResults.every(
(result) =>
result.suiteDefinitionHash ===
execution.suiteDefinitionHash,
),
),
durationMs: suiteResults.reduce(
(total, result) => total + result.durationMs,
0,
+7
View File
@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { prepareCodexCiSandbox } from "./codex-ci-sandbox.js";
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { createRequire } from "node:module";
@@ -32,6 +33,7 @@ import {
assertSecretFree,
findSecretLeakInDirectory,
isEphemeralCodexRuntimeAuthFile,
isEphemeralPostgresPidFile,
normalizedSecrets,
sanitizeJson,
} from "./redaction.js";
@@ -645,6 +647,9 @@ async function runAttempt(input: {
temporaryRoot,
process.env.PATH,
);
if (execution.environment.id === "local" && execution.profile.id === "runner-codex") {
await prepareCodexCiSandbox(repositoryRoot, temporaryRoot);
}
const agentJwtSecret = secret(48);
const decisionSigningSecret = secret(48);
const toolActionSigningSecret = secret(48);
@@ -785,6 +790,8 @@ async function runAttempt(input: {
const leak = await findSecretLeakInDirectory(directory, credentials, {
includeShapes: false,
ignoreFile: (file) => expectedEphemeralCredentials.has(file),
allowDisappearedFile: (file) =>
label === "Paperclip home" && isEphemeralPostgresPidFile(paperclipHome, file),
});
if (!leak) break;
const isManagedCodexRuntimeAuth =
+5 -1
View File
@@ -1,6 +1,9 @@
import path from "node:path";
import { defineConfig } from "@playwright/test";
import { runnerE2EWebServerCommand } from "./web-server-command.js";
import {
runnerE2EWebServerCommand,
runnerE2EWebServerGracefulShutdown,
} from "./web-server-command.js";
function required(name: string) {
const value = process.env[name]?.trim();
@@ -69,6 +72,7 @@ export default defineConfig({
// blob reports. The wrapper inherits the test process and strips provider
// keys before spawning the real Paperclip process.
command: runnerE2EWebServerCommand(repositoryRoot),
gracefulShutdown: runnerE2EWebServerGracefulShutdown,
url: `${baseURL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
+25
View File
@@ -90,3 +90,28 @@ export async function reserveRunnerE2EServerPort(
`Failed to reserve a conflict-free Paperclip/Vite port pair after ${maxAttempts} attempts`,
);
}
/** Hold an OS-assigned database port until the isolated server is ready to spawn.
* Using ephemeral allocation avoids concurrent cells scanning the same default
* Postgres fallback range. The child cannot inherit this socket, so an unrelated
* process can still claim it in the short close-to-spawn interval.
*/
export async function reserveRunnerE2EDatabasePort(
serverPort: number,
options: { maxAttempts?: number; openPort?: OpenLoopbackPort } = {},
): Promise<LoopbackPortReservation> {
const openPort = options.openPort ?? openLoopbackPort;
const maxAttempts = options.maxAttempts ?? 32;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const reservation = await openPort(0);
if (
!runnerE2EServerPortConflictsWithDatabase(serverPort, reservation.port)
) {
return reservation;
}
await reservation.close();
}
throw new Error(
`Failed to reserve an isolated database port after ${maxAttempts} attempts`,
);
}
+10 -7
View File
@@ -1,3 +1,4 @@
import { discoverReportCatalog } from "./report-catalog.js";
import path from "node:path";
import { mkdir } from "node:fs/promises";
import { chromium } from "@playwright/test";
@@ -24,8 +25,13 @@ function durationLabel(durationMs: number) {
}
export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
const catalog = discoverReportCatalog({
catalog: runnerMatrix,
expected: campaign.expected,
results: campaign.results,
});
const catalogById = new Map(
runnerMatrix.map((execution) => [execution.id, execution]),
catalog.map((execution) => [execution.id, execution]),
);
const expectedIds = [
...new Set(
@@ -53,10 +59,7 @@ export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
);
const suites = [
...new Map(
runnerMatrix.map((execution) => [
execution.suite.id,
execution.suite.label,
]),
catalog.map((execution) => [execution.suite.id, execution.suite.label]),
),
].map(([suiteId, label]) => {
const selectedIds = expectedIds.filter(
@@ -111,12 +114,12 @@ export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
<p class="eyebrow">Runner full-stack E2E</p>
<h1>Campaign summary</h1>
<section class="metrics">
<div class="metric"><strong>${passed}/${expectedIds.length}</strong><span>Known executions passed</span></div>
<div class="metric"><strong>${passed}/${expectedIds.length}</strong><span>Selected executions passed</span></div>
<div class="metric"><strong>${expectedIds.length - passed}</strong><span>Failed or incomplete</span></div>
<div class="metric"><strong>${html(durationLabel(durationMs))}</strong><span>Total test time</span></div>
</section>
<section class="suites">${rows}</section>
<footer>Generated from fixed catalog labels and sanitized numeric/status fields. Provider output is never rendered here.</footer>
<footer>Generated from trusted catalog labels, validated execution identities, and sanitized numeric/status fields. Provider output is never rendered here.</footer>
</main>
</body>
</html>`;
+22 -8
View File
@@ -152,12 +152,18 @@ export function assertSecretFree(
if (leak) throw new Error(`Secret leak in ${label}: ${leak}`);
}
export function isEphemeralPostgresPidFile(paperclipHome: string, file: string): boolean {
const relative = path.relative(paperclipHome, file).split(path.sep).join("/");
return /^instances\/[^/]+\/db\/postmaster\.pid$/.test(relative);
}
export async function findSecretLeakInDirectory(
root: string,
secrets: readonly string[],
options: {
includeShapes?: boolean;
ignoreFile?: (file: string) => boolean;
allowDisappearedFile?: (file: string) => boolean;
} = {},
): Promise<{ file: string; reason: string } | null> {
const overlap = Math.max(
@@ -179,14 +185,22 @@ export async function findSecretLeakInDirectory(
} else if (entry.isFile()) {
if (options.ignoreFile?.(file)) continue;
let carry = Buffer.alloc(0);
for await (const chunk of createReadStream(file)) {
const data = Buffer.concat([
carry,
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
const reason = findSecretLeak(data, secrets, options);
if (reason) return { file, reason };
carry = data.subarray(Math.max(0, data.length - overlap));
try {
for await (const chunk of createReadStream(file)) {
const data = Buffer.concat([
carry,
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
const reason = findSecretLeak(data, secrets, options);
if (reason) return { file, reason };
carry = data.subarray(Math.max(0, data.length - overlap));
}
} catch (error) {
// PostgreSQL removes its PID file on shutdown, possibly after readdir.
// Existing contents are still scanned; only the caller's exact
// transient paths may disappear. Evidence and other I/O errors fail.
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT" ||
!options.allowDisappearedFile?.(file)) throw error;
}
}
}
+317
View File
@@ -0,0 +1,317 @@
import { renderPublicCampaignSummary } from "./public-summary-image.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runnerMatrix } from "./catalog.js";
import { discoverReportCatalog } from "./report-catalog.js";
import { buildRunnerCampaign } from "./history.js";
import { renderRunnerE2EDashboard } from "./dashboard.js";
import { regenerateRunnerDashboard } from "./dashboard-regenerate.js";
import type { RunnerE2EResult } from "./types.js";
const temporary: string[] = [];
afterEach(async () => {
await Promise.all(
temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
const id = "branch-only-chat.branch-provider.local.conversation";
const missingId = "branch-only-chat.branch-provider.local.reset";
function result(): RunnerE2EResult {
return {
schema: "paperclip.runner-e2e.result/v2",
executionId: id,
suiteId: "branch-only-chat",
suiteDefinitionHash: "branch-definition",
profileId: "branch-provider",
environmentId: "local",
caseId: "conversation",
provider: '<img src=x onerror="alert(1)">',
model: "branch-model",
runtimeMode: "native",
attempt: 1,
status: "passed",
cleanup: "passed",
durationMs: 1000,
startedAt: "2026-09-11T21:00:00.000Z",
finishedAt: "2026-09-11T21:00:01.000Z",
screenshots: [
{
id: "final-state",
label: '<script>alert("label")</script>',
file: "final-state.png",
publication: "public-runner-fixture",
},
],
};
}
function campaign(results: RunnerE2EResult[] = [result()]) {
return buildRunnerCampaign({
campaignId: "branch-campaign",
generatedAt: "2026-09-11T21:01:00.000Z",
expected: [id, missingId],
results,
});
}
describe("trusted report catalog discovery", () => {
it("aggregates unknown suites and renders their cases, missing results, and screenshot gallery", () => {
const summary = campaign();
expect(summary.suites).toEqual([
expect.objectContaining({
suiteId: "branch-only-chat",
selected: 2,
executed: 1,
passed: 1,
failed: 1,
complete: false,
}),
]);
const page = renderRunnerE2EDashboard({
title: "Report",
generatedAt: summary.generatedAt,
expected: summary.expected,
catalog: [],
campaign: summary,
entries: [
{
result: result(),
valid: true,
errors: [],
evidenceBaseHref: `evidence/${id}/attempt-1`,
evidenceFiles: ["final-state.png"],
},
],
});
expect(page).toContain('id="suite-branch-only-chat"');
expect(page).toContain('class="suite-summary"');
expect(page).toContain(`data-execution-id="${id}"`);
expect(page).toContain(`data-execution-id="${missingId}"`);
expect(page).toContain('class="case case-missing"');
expect(page).toContain("No result artifact was uploaded");
expect(page).toContain(
`data-gallery-href="evidence/${id}/attempt-1/final-state.png"`,
);
expect(page).toContain("View gallery · 1");
expect(page).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
expect(page).not.toContain("<img src=x");
expect(page).not.toContain('<script>alert("label")</script>');
});
it("renders selected branch suites in the public summary image without provider content", () => {
const page = renderPublicCampaignSummary(campaign());
expect(page).toContain("<strong>1/2</strong>");
expect(page).toContain("<span>branch-only-chat</span>");
expect(page).not.toContain("<strong>0/0</strong>");
expect(page).not.toContain("onerror");
expect(page).not.toContain("branch-model");
});
it("retains ordinary catalog metadata and marks no-results branch selections failed", () => {
const catalog = discoverReportCatalog({
catalog: runnerMatrix,
expected: [runnerMatrix[0]!.id],
results: [],
});
expect(catalog).toEqual(runnerMatrix);
expect(catalog[0]).toBe(runnerMatrix[0]);
expect(campaign([])).toMatchObject({
selected: 2,
executed: 0,
passed: 0,
failed: 2,
suites: [
expect.objectContaining({ suiteId: "branch-only-chat", failed: 2 }),
],
});
});
it("uses known profile metadata in a new suite and rejects forged identities", () => {
const known = runnerMatrix[0]!;
const knownProfileId = `branch-only-chat.${known.profile.id}.${known.environment.id}.conversation`;
expect(
discoverReportCatalog({
catalog: runnerMatrix,
expected: [knownProfileId],
results: [],
}).at(-1)?.profile,
).toBe(known.profile);
expect(() =>
discoverReportCatalog({
catalog: [],
expected: [id],
results: [{ ...result(), suiteId: "forged-suite" }],
}),
).toThrow("Result identity does not match");
expect(() =>
discoverReportCatalog({
catalog: [],
expected: ["../../private"],
results: [],
}),
).toThrow("Invalid report execution identity");
expect(() =>
discoverReportCatalog({ catalog: [], expected: [id, id], results: [] }),
).toThrow("must be unique");
});
it("regenerates branch-only cards and gallery from persisted JSON using trusted code", async () => {
const bundle = await mkdtemp(
path.join(os.tmpdir(), "branch-report-regenerate-"),
);
temporary.push(bundle);
const evidence = path.join(bundle, "evidence", id, "attempt-1");
await mkdir(evidence, { recursive: true });
await writeFile(path.join(evidence, "final-state.png"), "fixture");
await writeFile(
path.join(bundle, "normalized-results.json"),
JSON.stringify({ ...campaign(), suites: [] }),
);
await regenerateRunnerDashboard({ bundle, historyFile: null });
const page = await readFile(path.join(bundle, "index.html"), "utf8");
expect(page).toContain(`data-execution-id="${id}"`);
expect(page).toContain(`data-execution-id="${missingId}"`);
expect(page).toContain(
`data-gallery-href="evidence/${id}/attempt-1/final-state.png"`,
);
const normalized = JSON.parse(
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
);
expect(normalized.suites).toEqual([
expect.objectContaining({
suiteId: "branch-only-chat",
selected: 2,
passed: 1,
failed: 1,
}),
]);
});
it("generates an explicit failed result with the correct unknown identity when no artifact exists", async () => {
const root = await mkdtemp(
path.join(os.tmpdir(), "branch-report-missing-"),
);
temporary.push(root);
const out = path.join(root, "merged");
const repo = path.resolve(import.meta.dirname, "../..");
await promisify(execFile)(
process.execPath,
[
path.join(repo, "cli/node_modules/tsx/dist/cli.mjs"),
path.join(repo, "tests/runner-e2e/report.ts"),
],
{
cwd: repo,
env: {
...process.env,
PAPERCLIP_RUNNER_E2E_REPORT_ROOT: root,
PAPERCLIP_RUNNER_E2E_REPORT_OUT: out,
PAPERCLIP_RUNNER_E2E_EXPECTED_IDS: JSON.stringify([missingId]),
},
},
).catch((error: { code?: number }) => {
if (error.code !== 1) throw error;
});
const normalized = JSON.parse(
await readFile(path.join(out, "normalized-results.json"), "utf8"),
);
expect(normalized.results).toEqual([
expect.objectContaining({
executionId: missingId,
suiteId: "branch-only-chat",
profileId: "branch-provider",
caseId: "reset",
attempt: 0,
status: "failed",
}),
]);
expect(normalized.suites).toEqual([
expect.objectContaining({
suiteId: "branch-only-chat",
failed: 1,
executed: 0,
}),
]);
expect(await readFile(path.join(out, "index.html"), "utf8")).toContain(
`data-execution-id="${missingId}"`,
);
});
it.each([
"1/../../../escape",
"<img src=x onerror=alert(1)>",
-1,
1.5,
Infinity,
])("rejects malformed attempts before report staging: %s", (attempt) => {
expect(() =>
campaign([{ ...result(), attempt } as RunnerE2EResult]),
).toThrow("result.attempt");
});
it("validates nested timing and billing fields before rendering", () => {
const malicious = {
...result(),
turnTimings: [{ turn: "<script>alert(1)</script>" }],
} as unknown as RunnerE2EResult;
expect(() => campaign([malicious])).toThrow("result.turnTimings[0].turn");
const measured = campaign().results[0]!;
expect(() =>
campaign([
{
...measured,
billing: {
...measured.billing!,
llm: {
...measured.billing!.llm,
runCount: "<img>" as unknown as number,
},
},
},
]),
).toThrow("result.billing.llm.runCount");
});
it("does not certify a same-sized replacement matrix or changed suite definition", () => {
const known = runnerMatrix.filter(
(entry) => entry.suite.id === runnerMatrix[0]!.suite.id,
);
const receipts = known.map((entry) => ({
...result(),
executionId: entry.id,
suiteId: entry.suite.id,
suiteDefinitionHash: entry.suiteDefinitionHash,
profileId: entry.profile.id,
environmentId: entry.environment.id,
caseId: entry.task.id,
}));
const build = (results: RunnerE2EResult[]) =>
buildRunnerCampaign({
campaignId: "matrix-proof",
generatedAt: result().finishedAt,
expected: results.map((item) => item.executionId),
results,
});
expect(build(receipts).suites[0]!.complete).toBe(true);
const replacement = receipts.map((item, index) =>
index
? item
: {
...item,
caseId: "replacement",
executionId: item.executionId.replace(/[^.]+$/, "replacement"),
},
);
expect(build(replacement).suites[0]!.complete).toBe(false);
expect(
build(
receipts.map((item) => ({
...item,
suiteDefinitionHash: "different-definition",
})),
).suites[0]!.complete,
).toBe(false);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { validateRetainedRunnerResult } from "./result-validation.js";
import { createHash } from "node:crypto";
import type { MatrixExecution, RunnerE2EResult } from "./types.js";
/** Display-only metadata: never load or reconstruct executable branch fixtures. */
export interface ReportExecution {
id: string;
suiteDefinitionHash: string;
suite: Pick<
MatrixExecution["suite"],
"id" | "label" | "description" | "expectedMatrixSize"
>;
profile: Pick<
MatrixExecution["profile"],
"id" | "label" | "generation" | "provider" | "model" | "expectedRuntimeMode"
>;
environment: Pick<
MatrixExecution["environment"],
"id" | "label" | "provider" | "expectedExecutionTarget"
>;
task: Pick<MatrixExecution["task"], "id" | "label">;
}
export function parseReportExecutionId(id: string) {
// These identities also become evidence paths. Reject path traversal and any
// syntax other than the four catalog identity segments, even in imported JSON.
if (typeof id !== "string")
throw new Error("Report execution identity must be a string");
const match =
/^([a-z0-9][a-z0-9_-]*)\.([a-z0-9][a-z0-9_-]*)\.(local|daytona)\.([a-z0-9][a-z0-9_-]*)$/.exec(
id,
);
if (!match || id.length > 512)
throw new Error(
`Invalid report execution identity: ${String(id).slice(0, 512)}`,
);
return {
suiteId: match[1]!,
profileId: match[2]!,
environmentId: match[3]! as "local" | "daytona",
caseId: match[4]!,
};
}
export function validateReportResultIdentity(result: RunnerE2EResult) {
validateRetainedRunnerResult(result);
const identity = parseReportExecutionId(result.executionId);
if (
(result.suiteId !== undefined && result.suiteId !== identity.suiteId) ||
result.profileId !== identity.profileId ||
result.environmentId !== identity.environmentId ||
result.caseId !== identity.caseId
) {
throw new Error(
`Result identity does not match execution ID: ${result.executionId}`,
);
}
return identity;
}
export function discoverReportCatalog(input: {
catalog: readonly ReportExecution[];
expected: readonly string[];
results: readonly RunnerE2EResult[];
}): ReportExecution[] {
const expected = new Set(input.expected);
if (expected.size !== input.expected.length)
throw new Error("Report expected identities must be unique");
const identities = new Map(
input.expected.map((id) => [id, parseReportExecutionId(id)]),
);
const results = new Map<string, RunnerE2EResult>();
for (const result of input.results) {
validateReportResultIdentity(result);
if (expected.has(result.executionId))
results.set(result.executionId, result);
}
const catalog = [...input.catalog];
const knownIds = new Set(catalog.map((execution) => execution.id));
const knownSuites = new Map(
catalog.map((execution) => [execution.suite.id, execution.suite]),
);
for (const [id, identity] of identities) {
if (knownIds.has(id)) continue;
const result = results.get(id);
const suiteIds = input.expected
.filter(
(candidate) => identities.get(candidate)?.suiteId === identity.suiteId,
)
.sort();
const suite = knownSuites.get(identity.suiteId) ?? {
id: identity.suiteId,
label: identity.suiteId,
description:
"Suite discovered from retained campaign identities; full suite size is not known to this publisher.",
expectedMatrixSize: suiteIds.length,
};
const knownProfile = input.catalog.find(
(execution) => execution.profile.id === identity.profileId,
)?.profile;
const knownEnvironment = input.catalog.find(
(execution) => execution.environment.id === identity.environmentId,
)?.environment;
const runtimeMode = result?.runtimeMode === "legacy" ? "legacy" : "native";
catalog.push({
id,
suite,
suiteDefinitionHash:
result?.suiteDefinitionHash ??
`selection-${createHash("sha256").update(JSON.stringify(suiteIds)).digest("hex")}`,
profile: knownProfile ?? {
id: identity.profileId,
label: identity.profileId,
generation: runtimeMode,
expectedRuntimeMode: runtimeMode,
provider:
typeof result?.provider === "string" ? result.provider : "unknown",
model: typeof result?.model === "string" ? result.model : "unknown",
},
environment: knownEnvironment ?? {
id: identity.environmentId,
label: identity.environmentId,
provider: identity.environmentId,
expectedExecutionTarget: {
kind: identity.environmentId === "local" ? "local" : "remote",
},
},
task: { id: identity.caseId, label: identity.caseId },
});
}
return catalog;
}
+10 -2
View File
@@ -1,3 +1,4 @@
import { discoverReportCatalog } from "./report-catalog.js";
import path from "node:path";
import {
copyFile,
@@ -188,6 +189,13 @@ async function main() {
]);
}
const reportCatalog = discoverReportCatalog({
catalog: runnerMatrix,
expected,
results: [...candidates.values()].flatMap((entries) =>
entries.map((entry) => entry.result),
),
});
const selected: AggregatedResult[] = [];
for (const executionId of expected) {
const attempts = (candidates.get(executionId) ?? []).sort((left, right) => {
@@ -205,9 +213,9 @@ async function main() {
});
if (attempts.length === 0) {
const now = new Date().toISOString();
const execution = runnerMatrix.find(
const execution = reportCatalog.find(
(candidate) => candidate.id === executionId,
);
)!;
const missing: RunnerE2EResult = {
schema: "paperclip.runner-e2e.result/v2",
executionId,
+241
View File
@@ -0,0 +1,241 @@
import type { RunnerE2EResult } from "./types.js";
type Rule = (value: unknown, at: string) => void;
const invalid = (at: string): never => {
throw new Error(`Invalid retained runner result field: ${at}`);
};
const string: Rule = (value, at) => {
if (typeof value !== "string") invalid(at);
};
const number: Rule = (value, at) => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
invalid(at);
};
const integer: Rule = (value, at) => {
number(value, at);
if (!Number.isSafeInteger(value)) invalid(at);
};
const boolean: Rule = (value, at) => {
if (typeof value !== "boolean") invalid(at);
};
const object: Rule = (value, at) => {
if (!value || typeof value !== "object" || Array.isArray(value)) invalid(at);
};
const optional =
(rule: Rule): Rule =>
(value, at) => {
if (value !== undefined) rule(value, at);
};
const nullable =
(rule: Rule): Rule =>
(value, at) => {
if (value !== null) rule(value, at);
};
const oneOf =
(...values: string[]): Rule =>
(value, at) => {
if (typeof value !== "string" || !values.includes(value)) invalid(at);
};
const array =
(rule: Rule): Rule =>
(value, at) => {
if (!Array.isArray(value)) invalid(at);
(value as unknown[]).forEach((item, index) =>
rule(item, `${at}[${index}]`),
);
};
const shape =
(fields: Record<string, Rule>): Rule =>
(value, at) => {
object(value, at);
for (const [key, rule] of Object.entries(fields))
rule((value as Record<string, unknown>)[key], `${at}.${key}`);
};
const date: Rule = (value, at) => {
string(value, at);
if (!Number.isFinite(Date.parse(value as string))) invalid(at);
};
const url: Rule = (value, at) => {
string(value, at);
try {
const parsed = new URL(value as string);
if (
!["https:", "http:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password
)
invalid(at);
} catch {
invalid(at);
}
};
const relativeFile: Rule = (value, at) => {
string(value, at);
if (
!/^[A-Za-z0-9_.\-/]+$/.test(value as string) ||
(value as string).startsWith("/") ||
(value as string)
.split("/")
.some((part) => !part || part === "." || part === "..")
)
invalid(at);
};
const runtime = shape({
provider: oneOf("local", "daytona"),
agentRunDurationMs: number,
leaseDurationMs: nullable(number),
leaseCount: integer,
cpuCores: optional(number),
memoryGiB: optional(number),
diskGiB: optional(number),
estimatedListCostUsd: optional(number),
costStatus: oneOf("estimated", "unavailable", "not_metered"),
costSource: oneOf(
"daytona_public_list_price",
"provider_cost_unavailable",
"local_not_metered",
),
pricingAsOf: optional(string),
pricingUrl: optional(url),
});
const llm = shape({
runCount: integer,
runsWithTokenUsage: integer,
runsWithReportedCost: integer,
inputTokens: number,
outputTokens: number,
cachedInputTokens: number,
totalTokens: number,
reportedCostUsd: number,
costStatus: oneOf("reported", "partial", "unpriced", "unavailable"),
});
const billing = shape({
llm,
runtime,
reportedCostUsd: number,
estimatedRuntimeCostUsd: number,
observedAndEstimatedCostUsd: number,
complete: boolean,
});
const matcher: Rule = (value, at) => {
object(value, at);
const item = value as Record<string, unknown>;
string(item.kind, `${at}.kind`);
const kinds: Record<string, Rule> = {
message_exact: shape({ expected: string }),
message_contains: shape({ expected: string }),
message_occurrences: shape({ expected: string, count: integer }),
message_regex: shape({ pattern: string, flags: optional(string) }),
message_ordered: shape({ expected: array(string) }),
issue_status: shape({ expected: string }),
run_status: shape({ expected: string }),
runtime_mode: shape({ expected: oneOf("legacy", "native") }),
environment: shape({ expected: oneOf("local", "daytona") }),
file_exists: shape({ path: string }),
file_exact: shape({ path: string, expected: string }),
file_contains: shape({ path: string, expected: string }),
artifact_exists: shape({ name: string, mimeType: optional(string) }),
json_path: shape({ path: string }),
json_schema: shape({ schema: object }),
};
const rule = kinds[item.kind as string];
if (!rule) invalid(`${at}.kind`);
rule(value, at);
};
const fields = {
schema: oneOf(
"paperclip.runner-e2e.result/v1",
"paperclip.runner-e2e.result/v2",
),
executionId: string,
suiteId: optional(string),
suiteDefinitionHash: optional(string),
source: optional(
shape({
sha: nullable(string),
ref: nullable(string),
workflowRunUrl: nullable(url),
}),
),
rankingSnapshot: optional(
shape({
snapshotId: string,
capturedAt: date,
sourceUrl: url,
rank: integer,
canonicalModelId: string,
}),
),
attempt: integer,
status: oneOf("passed", "failed"),
failureClass: optional(
oneOf(
"candidate_failure",
"provider_variance",
"transient_infrastructure",
"permanent_infrastructure",
"secret_leak",
"cleanup_failure",
),
),
error: optional(string),
profileId: string,
environmentId: oneOf("local", "daytona"),
caseId: string,
provider: string,
model: string,
runtimeMode: oneOf("legacy", "native"),
issueId: optional(string),
issueIdentifier: optional(nullable(string)),
runIds: optional(array(string)),
turnTimings: optional(
array(
shape({
turn: integer,
submittedAt: date,
runStartedAt: nullable(date),
runFinishedAt: nullable(date),
schedulerLatencyMs: nullable(number),
runDurationMs: nullable(number),
responseLatencyMs: nullable(number),
runId: string,
leaseAcquisitionOutcome: oneOf(
"created",
"resumed",
"replacement",
"unknown",
),
}),
),
),
startedAt: date,
finishedAt: date,
durationMs: number,
// Provider usage is intentionally opaque. Billing reads only finite numbers;
// the dashboard shows opaque diagnostics through escaped JSON.
usage: optional(nullable(object)),
runtimeUsage: optional(runtime),
billing: optional(billing),
matcherResults: optional(
array(shape({ matcher, passed: boolean, detail: string })),
),
screenshots: optional(
array(
shape({
id: string,
label: string,
file: relativeFile,
publication: optional(oneOf("public-runner-fixture")),
}),
),
),
cleanup: oneOf("not_started", "passed", "failed"),
} satisfies Record<keyof RunnerE2EResult, Rule>;
const result = shape(fields);
/** Validate every typed result field before upgrading, path construction or HTML rendering. */
export function validateRetainedRunnerResult(
value: unknown,
): asserts value is RunnerE2EResult {
result(value, "result");
}
+44 -9
View File
@@ -1,3 +1,4 @@
import { runChatFlow } from "./chat-flow.js";
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -628,6 +629,7 @@ for (const execution of executions) {
const isReviewedFixtureScreenshotRoute = () =>
isPublicRunnerScreenshotRoute(page.url(), {
chatAgentId: execution.task.flow === "agent_chat" ? fixtures?.agent.id : undefined,
issuePrefix: fixtures?.company.issuePrefix,
issueId: issue?.id,
issueIdentifier: issue?.identifier,
@@ -670,10 +672,10 @@ for (const execution of executions) {
};
const cancelActiveRunsForCleanup = async () => {
if (!issue) return;
const cleanupIssueId = issue.id;
if (!issue && !(execution.task.flow === "agent_chat" && fixtures)) return;
const cleanupIssueId = issue?.id;
const runs = await api.get<RunRecord[]>(
`/api/issues/${cleanupIssueId}/runs`,
execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`,
);
const activeRunIds = [
...new Set(
@@ -694,7 +696,7 @@ for (const execution of executions) {
await pollUntil({
label: `cleanup cancellation for issue ${cleanupIssueId}`,
deadlineAt: Date.now() + 45_000,
load: () => api.get<RunRecord[]>(`/api/issues/${cleanupIssueId}/runs`),
load: () => api.get<RunRecord[]>(execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`),
accept: (currentRuns) =>
currentRuns
.filter((run) => activeIds.has(run.id))
@@ -704,7 +706,16 @@ for (const execution of executions) {
};
const captureFailureApiState = async () => {
if (!fixtures || !issue) return;
if (!fixtures) return;
if (execution.task.flow === "agent_chat" && !issue) {
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
// Settings are already restored on failure, so chat resolution may be
// gated. Direct task access still permits evidence and usage capture.
const sourceId = selectedRuns.map(run => record(run.contextSnapshot).issueId).find(id => typeof id === "string");
if (typeof sourceId === "string") issue = await api.get<IssueRecord>(`/api/issues/${sourceId}`);
}
if (!issue) return;
const capture = async <T>(operation: () => Promise<T>) =>
operation().catch((error) => ({
evidenceCaptureError:
@@ -715,7 +726,9 @@ for (const execution of executions) {
capture(() => api.get<IssueRecord>(`/api/issues/${issue!.id}`)),
capture(() =>
api.get<RunRecord[]>(
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
execution.task.flow === "agent_chat"
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
),
),
capture(() =>
@@ -730,7 +743,7 @@ for (const execution of executions) {
),
]);
const taskRuns = Array.isArray(listedRuns)
? matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
? execution.task.flow === "agent_chat" ? listedRuns : matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
: [];
const detailedRuns = await Promise.all(
taskRuns.map((candidate) =>
@@ -801,7 +814,9 @@ for (const execution of executions) {
// Each isolated campaign selects its generation explicitly; the app's
// default can change without changing which runtime this cell exercises.
const enableNativeRunner = execution.profile.generation === "native";
await api.patch("/api/instance/settings/experimental", {
const experimental = await api.patch<{
enableNativeRunner: boolean;
}>("/api/instance/settings/experimental", {
enableNativeRunner,
...(execution.task.flow === "warm_three_turn"
? { enableIsolatedWorkspaces: true }
@@ -811,6 +826,7 @@ for (const execution of executions) {
? { enableRunnerPreviewIngress: true }
: {}),
});
expect(experimental.enableNativeRunner).toBe(enableNativeRunner);
const configuredExperimental = await api.get<{
enableNativeRunner: boolean;
@@ -850,6 +866,17 @@ for (const execution of executions) {
secrets,
);
if (execution.task.flow === "agent_chat") {
const chat = await runChatFlow({
page, api, fixtures, execution, nonce,
restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; },
capture: captureScreenshot,
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
});
issue = chat.issue; selectedRuns = chat.runs;
matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }];
} else {
const issuePrefix = fixtures.company.issuePrefix;
if (!issuePrefix)
throw new Error(
@@ -915,7 +942,9 @@ for (const execution of executions) {
const [currentIssue, runs, comments, interactions] = await Promise.all([
api.get<IssueRecord>(`/api/issues/${issue!.id}`),
api.get<RunRecord[]>(
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
execution.task.flow === "agent_chat"
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
),
api.get<CommentRecord[]>(
`/api/issues/${issue!.id}/comments?order=asc`,
@@ -2381,6 +2410,7 @@ for (const execution of executions) {
`Runtime invariant failure: ${invariantFailures.join("; ")}`,
);
}
}
} catch (error) {
primaryError = error;
try {
@@ -2443,6 +2473,11 @@ for (const execution of executions) {
});
try {
await cancelActiveRunsForCleanup();
if (execution.task.flow === "agent_chat") {
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
await writeSanitizedJson(snapshotsDir, "chat-final-run-ledger.json", selectedRuns, secrets);
}
await fixtures.teardown();
cleanup = "passed";
} catch (error) {
+4 -1
View File
@@ -1,6 +1,7 @@
export const PUBLIC_RUNNER_SCREENSHOT_MARKER = "public-runner-fixture" as const;
export interface PublicRunnerScreenshotTarget {
chatAgentId?: string;
issuePrefix: string | null | undefined;
issueId: string | null | undefined;
issueIdentifier: string | null | undefined;
@@ -15,10 +16,12 @@ export function isPublicRunnerScreenshotRoute(
const candidate = new URL(url);
const issueReference = target.issueIdentifier ?? target.issueId;
const expectedPath = `/${encodeURIComponent(target.issuePrefix)}/issues/${encodeURIComponent(issueReference)}`;
const chatPath = target.chatAgentId ? `/${encodeURIComponent(target.issuePrefix)}/chats/${encodeURIComponent(target.chatAgentId)}` : null;
return (
candidate.protocol === "http:" &&
candidate.hostname === "127.0.0.1" &&
(candidate.pathname === expectedPath ||
(candidate.pathname === chatPath ||
candidate.pathname === expectedPath ||
candidate.pathname === `${expectedPath}/`)
);
} catch {
+92
View File
@@ -0,0 +1,92 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
import {
reserveRunnerE2EDatabasePort,
type LoopbackPortReservation,
} from "./ports.js";
import { prepareRunnerE2EServerConfig } from "./server-config.js";
const roots: string[] = [];
const reservations: LoopbackPortReservation[] = [];
afterEach(async () => {
await Promise.all(
reservations.splice(0).map((reservation) => reservation.close()),
);
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("isolated paid E2E database ports", () => {
it("keeps parallel database reservations distinct and held until server spawn", async () => {
reservations.push(
...(await Promise.all(
Array.from({ length: 8 }, () => reserveRunnerE2EDatabasePort(3100)),
)),
);
expect(new Set(reservations.map(({ port }) => port)).size).toBe(8);
const contender = createServer();
const error = await new Promise<NodeJS.ErrnoException>((resolve) => {
contender.once("error", resolve);
contender.listen(reservations[0].port, "127.0.0.1");
});
expect(error.code).toBe("EADDRINUSE");
});
it("rejects the HTTP/HMR ports and retains only the selected reservation", async () => {
const closed: number[] = [];
const ports = [3100, 13100, 41000];
const selected = await reserveRunnerE2EDatabasePort(3100, {
openPort: async () => {
const port = ports.shift()!;
return {
port,
close: async () => {
closed.push(port);
},
};
},
});
expect(selected.port).toBe(41000);
expect(closed).toEqual([3100, 13100]);
await selected.close();
expect(closed).toEqual([3100, 13100, 41000]);
});
it("writes a valid private config and preserves its database on restart", async () => {
const temporaryRoot = await mkdtemp(
path.join(os.tmpdir(), "runner-e2e-config-test-"),
);
roots.push(temporaryRoot);
const configPath = path.join(
temporaryRoot,
"paperclip-home",
"instances",
"fixture",
"config.json",
);
const input = { temporaryRoot, configPath, serverPort: 3100 };
const reservation = await prepareRunnerE2EServerConfig(input);
expect(reservation).not.toBeNull();
reservations.push(reservation!);
const encoded = await readFile(configPath, "utf8");
const config = paperclipConfigSchema.parse(JSON.parse(encoded));
expect(config.database.embeddedPostgresPort).toBe(reservation!.port);
expect(config.database.mode).toBe("embedded-postgres");
for (const directory of [
config.database.embeddedPostgresDataDir,
config.database.backup.dir,
config.logging.logDir,
config.storage.localDisk.baseDir,
config.secrets.localEncrypted.keyFilePath,
])
expect(directory.startsWith(`${temporaryRoot}/`)).toBe(true);
expect(config.secrets.strictMode).toBe(true);
expect(await prepareRunnerE2EServerConfig(input)).toBeNull();
expect(await readFile(configPath, "utf8")).toBe(encoded);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
import { reserveRunnerE2EDatabasePort } from "./ports.js";
/** Seed only the disposable fixture. Onboarding preserves this validated config
* and still creates the normal secrets and database. Restarts keep the same DB.
*/
export async function prepareRunnerE2EServerConfig(input: {
temporaryRoot: string;
configPath: string;
serverPort: number;
}) {
try {
await readFile(input.configPath, "utf8");
return null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const databaseReservation = await reserveRunnerE2EDatabasePort(
input.serverPort,
);
try {
const instanceRoot = path.dirname(input.configPath);
const config = paperclipConfigSchema.parse({
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "onboard",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
embeddedPostgresPort: databaseReservation.port,
backup: {
enabled: false,
dir: path.join(input.temporaryRoot, "backups"),
},
},
logging: { mode: "file", logDir: path.join(instanceRoot, "logs") },
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: input.serverPort,
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: path.join(input.temporaryRoot, "storage") },
},
secrets: {
provider: "local_encrypted",
strictMode: true,
localEncrypted: {
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
},
},
});
await mkdir(instanceRoot, { recursive: true, mode: 0o700 });
await writeFile(input.configPath, `${JSON.stringify(config, null, 2)}\n`, {
flag: "wx",
mode: 0o600,
});
return databaseReservation;
} catch (error) {
await databaseReservation.close();
throw error;
}
}
+8
View File
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { prepareRunnerE2EServerConfig } from "./server-config.js";
import {
assertIsolatedServerEnvironment,
buildPaperclipServerEnvironment,
@@ -347,6 +348,13 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
}
async function supervise() {
const databaseReservation = await prepareRunnerE2EServerConfig({
temporaryRoot,
configPath,
serverPort: Number(port),
});
// Postgres needs the socket itself; release immediately before child spawn.
await databaseReservation?.close();
startServer();
let lastRestartRequestId: string | null = null;
while (!shutdownRequested()) {
+116
View File
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, unlinkSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -24,6 +25,7 @@ import {
findSecretLeakInJsonValues,
findSecretLeakInDirectory,
isEphemeralCodexRuntimeAuthFile,
isEphemeralPostgresPidFile,
redactText,
sanitizeJson,
} from "./redaction.js";
@@ -683,6 +685,35 @@ describe("runner E2E run observations", () => {
});
describe("runner E2E failure policy", () => {
it.each([
"native_session_close_unrecoverable: provider transport failed",
"Provider connection closed: runner did not durably suspend before checkpoint",
"native_session_close_unrecoverable: provider transport timed out; runner did not durably suspend before checkpoint",
])("does not retry controller session-close defects: %s", (message) => {
const failureClass = classifyFailure(new Error(message));
expect(failureClass).toBe("candidate_failure");
expect(shouldRetryFailure(failureClass)).toBe(false);
});
it.each([
'native_session_recovery_failed: Error: PRP command run.attach failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=unclassified)"},"status":"failed"}',
"native_session_recovery_failed: provider transport failed\nACPX sidecar command session.open was rejected (retryable = false, classification=session_not_found)",
])("does not retry explicit non-retryable ACPX recovery rejection: %s", (message) => {
const failureClass = classifyFailure(new Error(message));
expect(failureClass).toBe("candidate_failure");
expect(shouldRetryFailure(failureClass)).toBe(false);
});
it.each([
'native_session_recovery_failed: PRP run.attach failed: {"message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=true, classification=network)","status":"failed"}',
'native_session_recovery_failed: PRP run.attach failed: {"message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected","status":"failed"}',
"native_session_recovery_failed: failed to start ACPX provider: ECONNRESET",
])("retains transient ACPX recovery retries: %s", (message) => {
const failureClass = classifyFailure(new Error(message));
expect(failureClass).toBe("transient_infrastructure");
expect(shouldRetryFailure(failureClass)).toBe(true);
});
it("retries only transient infrastructure failures", () => {
expect(
classifyFailure(new Error("Daytona preview connection timed out")),
@@ -930,6 +961,74 @@ describe("runner E2E evidence redaction", () => {
).resolves.toMatchObject({ reason: "exact secret value" });
});
it("tolerates only the embedded PostgreSQL PID disappearing during shutdown and keeps scanning", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-race-"));
cleanupDirectories.push(root);
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
const persistedFile = path.join(path.dirname(pidFile), "z-persisted.bin");
await mkdir(path.dirname(pidFile), { recursive: true });
await writeFile(pidFile, "1234\n");
await writeFile(persistedFile, secret);
await expect(findSecretLeakInDirectory(root, [secret], {
ignoreFile: (file) => {
if (file === pidFile) unlinkSync(file); // Removed after directory enumeration.
return false;
},
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
})).resolves.toEqual({ file: persistedFile, reason: "exact secret value" });
expect(isEphemeralPostgresPidFile(root, path.join(root, "workspace", "postmaster.pid"))).toBe(false);
expect(isEphemeralPostgresPidFile(root, path.join(root, "instances", "test", "db", "records.bin"))).toBe(false);
});
it("still detects secrets in an existing PostgreSQL PID file", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-secret-"));
cleanupDirectories.push(root);
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
await mkdir(path.dirname(pidFile), { recursive: true });
await writeFile(pidFile, secret);
await expect(findSecretLeakInDirectory(root, [secret], {
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
})).resolves.toEqual({ file: pidFile, reason: "exact secret value" });
});
it("fails when required persisted state disappears during scanning", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-required-file-race-"));
cleanupDirectories.push(root);
const requiredFile = path.join(root, "records.json");
await writeFile(requiredFile, "{}");
await expect(findSecretLeakInDirectory(root, [secret], {
ignoreFile: (file) => { unlinkSync(file); return false; },
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
})).rejects.toMatchObject({ code: "ENOENT" });
});
it("does not suppress other I/O failures for the ephemeral PID path", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-io-"));
cleanupDirectories.push(root);
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
await mkdir(path.dirname(pidFile), { recursive: true });
await writeFile(pidFile, "1234\n");
await expect(findSecretLeakInDirectory(root, [secret], {
ignoreFile: (file) => { unlinkSync(file); mkdirSync(file); return false; },
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
})).rejects.toMatchObject({ code: "EISDIR" });
});
it("still reports missing mandatory pass evidence", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-required-evidence-"));
cleanupDirectories.push(root);
const privateDir = path.join(root, "private");
await mkdir(privateDir);
const packaged = await packageEvidence({
privateDir,
uploadDir: path.join(root, "upload"),
secrets: [secret],
expectPassScreenshot: true,
});
expect(packaged.missing).toContain("result.json");
expect(packaged.missing).toContain("final-state.png");
});
it("can ignore fake key shapes while scanning persisted package state", async () => {
const root = await mkdtemp(
path.join(os.tmpdir(), "runner-e2e-secret-shape-test-"),
@@ -1038,6 +1137,23 @@ describe("runner E2E evidence redaction", () => {
).rejects.toThrow();
});
it("retains the two reviewed chat plan captures without admitting arbitrary chat PNGs", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-chat-captures-"));
cleanupDirectories.push(root);
const privateDir = path.join(root, "private");
const uploadDir = path.join(root, "upload");
await mkdir(privateDir, { recursive: true });
for (const file of ["chat-plan-draft.png", "chat-plan-revised.png", "chat-secret.png", "chat-plan-extra.png"]) {
await writeFile(path.join(privateDir, file), "fixture raster");
}
const packaged = await packageEvidence({ privateDir, uploadDir, secrets: [secret], expectPassScreenshot: false });
expect(packaged.files.sort()).toEqual(["chat-plan-draft.png", "chat-plan-revised.png", "evidence-manifest.json"]);
expect(packaged.leaks).toEqual([]);
for (const file of packaged.files.filter((file) => file.endsWith(".png"))) {
expect(await readFile(path.join(uploadDir, file), "utf8")).toBe("fixture raster");
}
});
it("keeps raster evidence private to CI and rejects active SVG content", async () => {
const root = await mkdtemp(
path.join(os.tmpdir(), "runner-e2e-visual-evidence-test-"),
+2 -1
View File
@@ -10,6 +10,7 @@ export type RunnerGeneration = "legacy" | "native";
export type RunnerEnvironmentId = "local" | "daytona";
export type RunnerTaskWorkMode = "standard" | "planning" | "ask";
export type RunnerTaskFlow =
| "agent_chat"
| "governed_tool_review"
| "single_turn"
| "plan_revision_acceptance"
@@ -122,7 +123,7 @@ export interface RunnerTaskFixture {
expectedRunCount: number;
attemptTimeoutMs: Readonly<Record<RunnerEnvironmentId, number>>;
expectedTerminalState: {
issue: "done";
issue: "done" | "in_review";
run: "succeeded";
};
buildTitle(nonce: string): string;
+7
View File
@@ -1,5 +1,12 @@
import path from "node:path";
// Let the restart-aware supervisor reap Paperclip and finish its log streams.
// Its stop path allows 30 seconds for SIGTERM and 5 seconds for SIGKILL.
export const runnerE2EWebServerGracefulShutdown = {
signal: "SIGTERM" as const,
timeout: 45_000,
};
function shellQuote(value: string) {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
@@ -0,0 +1,117 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
import { reserveRunnerE2EDatabasePort } from "./ports.js";
import { runnerE2EWebServerGracefulShutdown } from "./web-server-command.js";
const require = createRequire(import.meta.url);
it("lets Playwright reap a restarted server through the production bounded shutdown policy", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-playwright-shutdown-"));
const reservation = await reserveRunnerE2EDatabasePort(3100);
const port = reservation.port;
const supervisorPath = path.join(root, "supervisor.cjs");
const workerPath = path.join(root, "worker.cjs");
const stoppedPath = path.join(root, "stopped.json");
const pidsPath = path.join(root, "pids.json");
const configPath = path.join(root, "playwright.config.cjs");
const testModule = require.resolve("@playwright/test");
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
let child: ReturnType<typeof spawn> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
let reservationReleased = false;
try {
await writeFile(workerPath, `
const http = require('node:http');
const server = http.createServer((req, res) => {
res.end(process.env.REVISION);
if (req.url === '/restart') process.send('restart');
});
process.on('SIGTERM', () => server.close(() => process.exit(0)));
server.listen(${port}, '127.0.0.1');
`);
await writeFile(supervisorPath, `
const { spawn } = require('node:child_process');
const fs = require('node:fs');
let stopping = false;
let revision = 0;
let child;
const pids = [];
function start() {
child = spawn(process.execPath, [${JSON.stringify(workerPath)}], {
env: { ...process.env, REVISION: String(++revision) },
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
});
pids.push(child.pid);
fs.writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify(pids));
child.once('message', () => child.kill('SIGTERM'));
child.once('exit', () => {
if (!stopping) return start();
fs.writeFileSync(${JSON.stringify(stoppedPath)}, JSON.stringify({ revision, pids }));
});
}
process.on('SIGTERM', () => { stopping = true; child.kill('SIGTERM'); });
start();
`);
await writeFile(configPath, `module.exports = {
testDir: ${JSON.stringify(root)}, testMatch: 'shutdown.spec.cjs', workers: 1,
reporter: 'line', timeout: 10000,
webServer: {
command: ${JSON.stringify(`"${process.execPath}" "${supervisorPath}"`)},
url: 'http://127.0.0.1:${port}', timeout: 10000,
gracefulShutdown: ${JSON.stringify(runnerE2EWebServerGracefulShutdown)},
},
};`);
await writeFile(path.join(root, "shutdown.spec.cjs"), `
const { test, expect } = require(${JSON.stringify(testModule)});
test('restarts before teardown', async ({ request }) => {
await request.get('http://127.0.0.1:${port}/restart');
await expect.poll(async () => {
try { return await (await request.get('http://127.0.0.1:${port}')).text(); }
catch { return ''; }
}).toBe('2');
});
`);
await reservation.close();
reservationReleased = true;
child = spawn(process.execPath, [cli, "test", "--config", configPath], {
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
env: { ...process.env, CI: "1" },
});
let output = "";
child.stdout?.on("data", (chunk) => { output += chunk; });
child.stderr?.on("data", (chunk) => { output += chunk; });
const exit = await Promise.race([
new Promise<number | null>((resolve, reject) => {
child!.once("error", reject);
child!.once("exit", resolve);
}),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Playwright cleanup stalled: ${output}`)), 20_000);
}),
]);
expect(exit, output).toBe(0);
const stopped = JSON.parse(await readFile(stoppedPath, "utf8"));
expect(stopped.revision).toBe(2);
expect(stopped.pids).toHaveLength(2);
for (const pid of stopped.pids) {
expect(() => process.kill(pid, 0)).toThrow();
}
await expect(fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) })).rejects.toThrow();
} finally {
if (timer) clearTimeout(timer);
if (child?.pid && child.exitCode === null && child.signalCode === null) {
try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
}
const pids: number[] = JSON.parse(await readFile(pidsPath, "utf8").catch(() => "[]"));
for (const pid of pids) {
try { process.kill(pid, "SIGKILL"); } catch { /* Already reaped. */ }
}
if (!reservationReleased) await reservation.close();
await rm(root, { recursive: true, force: true });
}
}, 25_000);
+7 -3
View File
@@ -46,7 +46,9 @@ describe("public repository paid workflow security", () => {
.split(/\n(?= {6}- )/u)
.filter((step) => step.includes("uses: pnpm/action-setup@"));
expect(pnpmSetupSteps, workflowName).toHaveLength(7);
expect(pnpmSetupSteps, workflowName).toHaveLength(
workflowName === "pr-trusted.yml" ? 8 : 7,
);
for (const step of pnpmSetupSteps) {
expect(step, workflowName).toContain('NPM_CONFIG_AUDIT: "false"');
expect(step, workflowName).toContain('NPM_CONFIG_FUND: "false"');
@@ -75,7 +77,7 @@ describe("public repository paid workflow security", () => {
},
{
name: "pr-trusted.yml",
expectedCachedSetupNodeSteps: 7,
expectedCachedSetupNodeSteps: 8,
},
];
@@ -89,7 +91,9 @@ describe("public repository paid workflow security", () => {
step.includes("uses: pnpm/action-setup@") ? [index] : [],
);
expect(pnpmSetupStepIndexes, name).toHaveLength(7);
expect(pnpmSetupStepIndexes, name).toHaveLength(
name === "pr-trusted.yml" ? 8 : 7,
);
for (const pnpmSetupStepIndex of pnpmSetupStepIndexes) {
const pnpmSetupStep = steps[pnpmSetupStepIndex]!;
const nodeBootstrapStep = steps[pnpmSetupStepIndex - 1]!;