Files
PaperClipAI/tests/e2e/smoke-lab-routine.mts
DottaandPaperclip 3db2e6bdd2 feat(mcp) [split 8/8]: add e2e coverage and operator docs (#9563)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 8/8 and focuses on end-to-end coverage,
operator docs, evals, and release notes
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: The complete stack needs discoverable browser scenarios,
operator guidance, threat modeling, eval coverage, and a parity proof
before merge.
- Proposed solution: Adds MCP user-story and Smoke Lab e2e suites,
docs/evals/release notes, the skill update, and the root e2e driver
script registration.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is `pap10341-split/07-ui-apps-activation`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: QA for flag audit and e2e/browser acceptance;
Greptile on every PR.

## What Changed

- Adds MCP user-story and Smoke Lab e2e suites, docs/evals/release
notes, the skill update, and the root e2e driver script registration.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck`
- `node --check scripts/e2e-mcp-user-stories.mjs`
- `pnpm exec playwright test --config tests/e2e/playwright.config.ts
--list` — 43 tests discovered
- `git diff pap10341-split/08-e2e-docs
6b40e3876d9297105d4ec306e47e46d351c86172` — empty (0 bytes)

## Risks

- Browser suites depend on runtime services and environment setup; this
PR validates discovery locally while QA owns full flag-on/flag-off
execution.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-14 15:48:57 -05:00

426 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Smoke Lab recurring-routine wrapper (PAP-13351 / plan §D5).
*
* This is what the daily Smoke Lab routine's woken QA agent runs. It wraps the
* S5 agent-driven browser runner (`smoke-lab-browser-runner.mts`) with the three
* behaviours the plan asks for:
*
* 1. budget-bounded — the browser run is killed after SMOKE_BUDGET_MS.
* 2. file-on-failure — a real product failure files a control-plane issue
* (failing step + screenshot) assigned to the owning coder
* and links it back to the routine issue.
* 3. amber/skipped — if the local_trusted instance or the flag is unavailable
* (or the runner itself crashes on an environment problem),
* record an amber/skipped outcome instead of failing
* silently or filing a bogus product bug.
*
* It never forks the P1-P7 step list: the actual lifecycle lives in the S5
* runner, which imports the S4 catalog.
*
* Run (as the woken routine agent, after booting a throwaway local_trusted
* instance per doc/connections/SMOKE-LAB-BROWSER-RUNNER.md §0):
* node --experimental-strip-types tests/e2e/smoke-lab-routine.mts
*
* Target under test (the throwaway instance):
* SMOKE_BASE=http://127.0.0.1:3251 local_trusted, non-prod instance
* SMOKE_ONLY=P1,P3 budget-bounding path subset (optional)
* SMOKE_SHOT_DIR=<dir> screenshot output (default under TMPDIR)
* SMOKE_BUDGET_MS=600000 hard timeout for the browser run
*
* Control plane where failure issues + amber notes are recorded (the REAL
* Paperclip company — provided in every routine run's env):
* PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_RUN_ID
* ROUTINE_ISSUE_ID=<uuid> issue to record against (default PAPERCLIP_TASK_ID)
* SMOKE_OWNER_DEFAULT / SMOKE_OWNER_UI / SMOKE_OWNER_CTO owner overrides
* SMOKE_DRY_RUN=1 log the control-plane writes, don't perform them
*/
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { pathToFileURL } from "node:url";
const SMOKE_BASE = (process.env.SMOKE_BASE ?? "http://127.0.0.1:3251").replace(/\/$/, "");
const SHOT_DIR = process.env.SMOKE_SHOT_DIR ?? path.join(os.tmpdir(), `pap13351-routine-${Date.now()}`);
const BUDGET_MS = Number(process.env.SMOKE_BUDGET_MS ?? 600_000);
const ONLY = process.env.SMOKE_ONLY ?? "";
const DRY = process.env.SMOKE_DRY_RUN === "1";
const CP_BASE = (() => {
const b = (process.env.PAPERCLIP_API_URL ?? "").replace(/\/$/, "");
return b.replace(/\/api$/, "");
})();
const CP_KEY = process.env.PAPERCLIP_API_KEY ?? "";
const CP_COMPANY = process.env.PAPERCLIP_COMPANY_ID ?? "";
const CP_RUN = process.env.PAPERCLIP_RUN_ID ?? "";
const ROUTINE_ISSUE_ID = process.env.ROUTINE_ISSUE_ID ?? process.env.PAPERCLIP_TASK_ID ?? "";
// Owning coder per plan (§5): S1/S4 governance+catalog = CodexCoder; S2 UI = ClaudeCoder;
// escalation fallback = CTO. Steps recorded by the runner are governance/API behaviours,
// so the default owner is the S1/S4 coder; the UI owner is available for matrix-render defects.
const OWNER_DEFAULT = process.env.SMOKE_OWNER_DEFAULT ?? "eab6f1c7-5950-410e-8eed-7d074426165d"; // CodexCoder
const OWNER_UI = process.env.SMOKE_OWNER_UI ?? "3108ef8e-5ed0-41d9-b561-6b41c41b8545"; // ClaudeCoder
const OWNER_CTO = process.env.SMOKE_OWNER_CTO ?? "66b3c071-6cb8-4424-b833-9d9b6318de0b"; // CTO
const RUNNER = path.join(import.meta.dirname, "smoke-lab-browser-runner.mts");
type Json = Record<string, any>;
export function isForcedFailureSelfTest(failed: string[], forcedFailDetail = process.env.SMOKE_FORCE_FAIL): boolean {
if (!forcedFailDetail || failed.length !== 1) return false;
return /^[A-Z0-9]+\/forced-failure:\s*SMOKE_FORCE_FAIL:/.test(failed[0] ?? "");
}
export function findDuplicateFailureIssue(issues: Json[], title: string, routineIssueId: string): Json | null {
return (
issues.find(
(issue) =>
issue?.title === title &&
issue?.parentId === routineIssueId &&
issue?.status !== "cancelled",
) ?? null
);
}
function log(...a: any[]) {
console.log("[routine]", ...a);
}
async function cp(method: string, apiPath: string, body?: Json): Promise<any> {
if (DRY) {
log(`DRY ${method} ${apiPath}`, body ? JSON.stringify(body).slice(0, 200) : "");
return {};
}
const res = await fetch(`${CP_BASE}${apiPath}`, {
method,
headers: {
"content-type": "application/json",
authorization: `Bearer ${CP_KEY}`,
"x-paperclip-run-id": CP_RUN,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`CP ${method} ${apiPath} -> ${res.status}: ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : {};
}
async function cpComment(issueId: string, bodyText: string) {
if (!issueId) return;
try {
await cp("POST", `/api/issues/${issueId}/comments`, { body: bodyText });
} catch (e: any) {
log("WARN comment failed:", e?.message ?? e);
}
}
function issueLink(issue: Json): string {
const identifier = issue.identifier ?? issue.id ?? "issue";
const prefix = String(identifier).split("-")[0] || "PAP";
return `[${identifier}](/${prefix}/issues/${identifier})`;
}
async function findExistingFailureIssue(title: string): Promise<Json | null> {
if (!CP_COMPANY || !ROUTINE_ISSUE_ID) return null;
try {
const found = await cp("GET", `/api/companies/${CP_COMPANY}/issues?q=${encodeURIComponent(title)}`);
const issues = Array.isArray(found) ? found : found.issues ?? [];
return findDuplicateFailureIssue(issues, title, ROUTINE_ISSUE_ID);
} catch (e: any) {
log("WARN duplicate failure lookup failed:", e?.message ?? e);
return null;
}
}
// Upload a screenshot as a company asset and return its served URL. Assets are
// company-scoped, so the run token can upload one even when the target issue is
// assigned to another agent (attachments to an assigned-away issue 403 on the
// run-token authorization boundary). Embed the URL in the issue body instead.
async function cpUploadAsset(file: string): Promise<string | null> {
if (DRY) {
log(`DRY upload asset ${file}`);
return `${CP_BASE}/api/assets/dry-run/content`;
}
try {
const buf = await fs.readFile(file);
const form = new FormData();
form.append("file", new Blob([buf], { type: "image/png" }), path.basename(file));
form.append("namespace", "smoke-lab");
const res = await fetch(`${CP_BASE}/api/companies/${CP_COMPANY}/assets/images`, {
method: "POST",
headers: { authorization: `Bearer ${CP_KEY}`, "x-paperclip-run-id": CP_RUN },
body: form as any,
});
if (!res.ok) {
log("WARN asset upload failed:", res.status, (await res.text()).slice(0, 200));
return null;
}
const j = await res.json();
return j.contentPath ? `${CP_BASE}${j.contentPath}` : null;
} catch (e: any) {
log("WARN asset upload error:", e?.message ?? e);
return null;
}
}
async function cpAttach(issueId: string, file: string): Promise<boolean> {
if (DRY) {
log(`DRY attach ${file} -> ${issueId}`);
return true;
}
try {
const buf = await fs.readFile(file);
const form = new FormData();
form.append("file", new Blob([buf], { type: "image/png" }), path.basename(file));
const res = await fetch(`${CP_BASE}/api/companies/${CP_COMPANY}/issues/${issueId}/attachments`, {
method: "POST",
headers: { authorization: `Bearer ${CP_KEY}`, "x-paperclip-run-id": CP_RUN },
body: form as any,
});
if (!res.ok) {
log("WARN attach failed:", res.status, (await res.text()).slice(0, 200));
return false;
}
return true;
} catch (e: any) {
log("WARN attach error:", e?.message ?? e);
return false;
}
}
// ---- precondition: is a private (non-public) instance reachable + can we enable the flag? ----
async function precondition(): Promise<{ ok: true } | { ok: false; reason: string }> {
let health: Json;
try {
const res = await fetch(`${SMOKE_BASE}/api/health`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return { ok: false, reason: `health ${res.status}` };
health = await res.json();
} catch (e: any) {
return { ok: false, reason: `instance unreachable at ${SMOKE_BASE} (${e?.message ?? e})` };
}
// The Smoke Lab gate only blocks public exposure — any private instance works,
// including an `authenticated` dev server behind Tailscale + login. Match that
// here so the routine runs on the everyday dev box, not just a throwaway
// `local_trusted` instance.
if (health.deploymentExposure === "public") {
return { ok: false, reason: `exposure=public (need private/loopback)` };
}
// Enable the flag (idempotent). On a `local_trusted` box board access is implicit
// with the Origin header; on an `authenticated` instance this PATCH needs a board
// session, so if it 403s the run degrades to amber/skipped rather than failing.
try {
const res = await fetch(`${SMOKE_BASE}/api/instance/settings/experimental`, {
method: "PATCH",
headers: { "content-type": "application/json", origin: SMOKE_BASE },
body: JSON.stringify({ enableSmokeLab: true }),
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return { ok: false, reason: `cannot enable smoke-lab flag (${res.status})` };
} catch (e: any) {
return { ok: false, reason: `flag enable failed (${e?.message ?? e})` };
}
return { ok: true };
}
function runBrowserSmoke(): Promise<{ code: number | null; timedOut: boolean }> {
return new Promise((resolve) => {
const child = spawn(
process.execPath,
["--experimental-strip-types", RUNNER],
{
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
SMOKE_BASE,
SMOKE_SHOT_DIR: SHOT_DIR,
SMOKE_ONLY: ONLY,
SMOKE_TRIGGER: "routine",
},
},
);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, BUDGET_MS);
child.on("exit", (code) => {
clearTimeout(timer);
resolve({ code, timedOut });
});
});
}
async function readResult(): Promise<Json | null> {
try {
return JSON.parse(await fs.readFile(path.join(SHOT_DIR, "result.json"), "utf8"));
} catch {
return null;
}
}
function ownerForFailure(failed: string[]): { id: string; name: string } {
// The runner's failing steps are governance/API behaviours (connect, catalog,
// policy, gateway, audit) → S1/S4 coder. UI matrix-render defects surface in the
// tab screenshot, not step failures, so the default is the API owner.
const anyUi = failed.some((f) => /matrix|dashboard|render|tab/i.test(f));
return anyUi ? { id: OWNER_UI, name: "ClaudeCoder (S2 UI)" } : { id: OWNER_DEFAULT, name: "CodexCoder (S1/S4)" };
}
async function recordAmber(reason: string) {
log(`AMBER/SKIPPED: ${reason}`);
await fs.mkdir(SHOT_DIR, { recursive: true }).catch(() => {});
await fs
.writeFile(path.join(SHOT_DIR, "result.json"), JSON.stringify({ outcome: "skipped", reason, at: new Date().toISOString() }, null, 2))
.catch(() => {});
const body = [
`### Smoke Lab routine — amber / skipped`,
``,
`The daily Smoke Lab smoke did **not** run this cycle, and is recorded as **skipped** (not a failure).`,
``,
`- **Target:** \`${SMOKE_BASE}\``,
`- **Reason:** ${reason}`,
``,
`This is expected when no private instance is reachable or the flag can't be enabled for the smoke (see \`doc/connections/SMOKE-LAB-BROWSER-RUNNER.md\` §0). No product issue was filed — an amber/skipped run is not a defect.`,
].join("\n");
await cpComment(ROUTINE_ISSUE_ID, body);
}
async function recordPass(result: Json) {
const body = [
`### Smoke Lab routine — ✅ passed`,
``,
`- **Target:** \`${SMOKE_BASE}\``,
`- **Company (throwaway):** \`${result.companyId}\` · **run:** \`${result.runId}\``,
`- **Steps:** ${result.steps} across ${Object.keys(result.byPath ?? {}).length} path(s) — ${JSON.stringify(result.byPath ?? {})}`,
`- **Failures:** 0`,
``,
`Recorded to the Smoke Lab results API on the throwaway instance (matrix + dashboard card). Tab + dashboard screenshots attached below.`,
].join("\n");
await cpComment(ROUTINE_ISSUE_ID, body);
for (const f of ["zz-smoke-lab-tab.png", "zz-dashboard-card.png"]) {
await cpAttach(ROUTINE_ISSUE_ID, path.join(SHOT_DIR, f));
}
}
async function recordForcedFailureDuplicate(result: Json, existing: Json, failed: string[]) {
const body = [
`### Smoke Lab routine — forced-failure drill already recorded`,
``,
`- **Target:** \`${SMOKE_BASE}\``,
`- **Throwaway company:** \`${result.companyId}\` · **run:** \`${result.runId}\``,
`- **Synthetic step:** ${failed.map((f) => "`" + f + "`").join(", ")}`,
`- **Existing evidence issue:** ${issueLink(existing)}`,
``,
`No new product-failure issue was filed. The existing induced-failure issue already proves the D5 file-on-failure path; repeated \`SMOKE_FORCE_FAIL\` runs should not keep waking the owning coder.`,
].join("\n");
await cpComment(ROUTINE_ISSUE_ID, body);
}
async function recordFailure(result: Json): Promise<"filed" | "suppressed_duplicate"> {
const failed: string[] = result.failed ?? [];
const owner = ownerForFailure(failed);
const first = failed[0] ?? "unknown step";
const [pathStep] = first.split(":");
const [pathId, step] = (pathStep ?? "").split("/");
const title = `Smoke Lab failure: ${pathId ?? "path"} / ${step ?? "step"}`;
if (isForcedFailureSelfTest(failed)) {
const existing = await findExistingFailureIssue(title);
if (existing) {
await recordForcedFailureDuplicate(result, existing, failed);
return "suppressed_duplicate";
}
}
const shotFile = path.join(SHOT_DIR, `${(pathId ?? "").toLowerCase()}-${step ?? ""}-failed.png`);
const shotUrl = await cpUploadAsset(shotFile);
const bodyLines = [
`## Smoke Lab smoke failed — ${pathId ?? "path"} / ${step ?? "step"}`,
``,
`Auto-filed by the daily Smoke Lab routine (PAP-13351 / plan §D5). A real product failure was observed while driving the P1–P7 lifecycle in a real browser against a \`local_trusted\` instance.`,
``,
`### Failing step(s)`,
"```",
...failed.map((f) => `- ${f}`),
"```",
``,
...(shotUrl ? [`### Screenshot of the failing step`, `![failing step](${shotUrl})`, ``] : []),
`### Repro`,
`1. Boot a throwaway \`local_trusted\` instance (see \`doc/connections/SMOKE-LAB-BROWSER-RUNNER.md\` §0).`,
`2. \`SMOKE_BASE=<url> SMOKE_ONLY=${pathId ?? ""} node --experimental-strip-types tests/e2e/smoke-lab-browser-runner.mts\``,
`3. Observe the failure above; the failing step's screenshot is attached.`,
``,
`### Context`,
`- **Throwaway company:** \`${result.companyId}\` · **run:** \`${result.runId}\` · **status:** \`${result.status}\``,
`- **Suggested owner:** ${owner.name}. If this is the wrong surface, reassign — a governance/API step failure is S1/S4; a matrix/dashboard render defect is S2.`,
`- **Parent / linked:** routine issue \`${ROUTINE_ISSUE_ID}\`.`,
];
let issueId = "";
try {
const created = await cp("POST", `/api/companies/${CP_COMPANY}/issues`, {
title,
description: bodyLines.join("\n"),
priority: "high",
assigneeAgentId: owner.id,
parentId: ROUTINE_ISSUE_ID || undefined,
});
issueId = created.id ?? created.issue?.id ?? "";
log(`filed failure issue ${issueId} (assignee ${owner.name}; screenshot ${shotUrl ? "embedded" : "unavailable"})`);
} catch (e: any) {
log("ERROR filing failure issue:", e?.message ?? e);
}
// Link back on the routine issue.
await cpComment(
ROUTINE_ISSUE_ID,
[
`### Smoke Lab routine — ❌ failed`,
``,
`- **Target:** \`${SMOKE_BASE}\``,
`- **Failing step(s):** ${failed.map((f) => "`" + f + "`").join(", ")}`,
`- **Filed:** ${issueId ? `issue \`${issueId}\` assigned to ${owner.name}` : "(issue filing failed — see logs)"}`,
].join("\n"),
);
return "filed";
}
async function main() {
await fs.mkdir(SHOT_DIR, { recursive: true });
log(`base=${SMOKE_BASE} only=${ONLY || "(all)"} budgetMs=${BUDGET_MS} shots=${SHOT_DIR} dry=${DRY}`);
const pre = await precondition();
if (!pre.ok) {
await recordAmber(pre.reason);
process.exit(0);
}
const { code, timedOut } = await runBrowserSmoke();
const result = await readResult();
if (timedOut) {
await recordAmber(`browser smoke exceeded budget of ${BUDGET_MS}ms and was killed`);
process.exit(0);
}
// exit 0 = clean pass; exit 2 = recorded step failure(s); anything else = runner crashed.
if (code === 0 && result && !(result.failed?.length)) {
await recordPass(result);
process.exit(0);
}
if (code === 2 && result?.failed?.length) {
const outcome = await recordFailure(result);
process.exit(outcome === "suppressed_duplicate" ? 0 : 2);
}
// Runner crashed before recording a clean pass/fail → environment/runner problem, not a
// product defect. Amber, don't file a bogus bug (matches the S5 runbook triage rule).
await recordAmber(`runner exited ${code} without a recorded pass/fail (environment/runner issue, not a product defect)`);
process.exit(0);
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(async (e) => {
await recordAmber(`routine wrapper crashed: ${e?.message ?? e}`);
process.exit(0);
});
}