mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
test: retain onboarding Connect failure evidence (#17529)
## Thinking Path > - Paperclip manages agents and their work through normal company controls. > - Product E2E journeys check these controls through the browser and public API. > - Cloud onboarding uses one Connect action to validate the environment and hire the first agent. > - A failed Connect action previously left only a general journey error in the report. > - This pull request retains a closed diagnostic receipt for that action. > - The report can show the observed failure boundary without retaining private response text. ## Linked Issues or Issue Description **What existing behavior does this improve?** Cloud onboarding Product E2E failure evidence and report detail. **Current behavior** A validation or hire failure can reach the journey as a general operation failure. The report does not retain which Connect response failed. **Proposed behavior** Observe only the same-origin, same-company validation and hire POST requests made during one Connect action. Retain a closed receipt with the checkpoint, response state, bounded HTTP status, and allowlisted adapter, outcome, check code, level, and server code. The Get started control and saved-agent readback remain required. **Reason and benefit** The next failure can be attributed to the exact observed browser/API boundary. This change does not claim the cause of a historical failure. **Breaking changes** The new receipt is optional. Historical results remain valid. Explicit validation and hire rejections now stop the journey with a precise closed error code. This changes only E2E source; it does not change product behavior or qualification gates. ## What Changed - Add a bounded Connect observer with exact request matching and listener cleanup. - Retain known server codes from `details.code` or `code` without response messages, hints, headers, request bodies, provider text, or credentials. - Validate the receipt as a closed schema and render its allowlisted fields in existing report detail. - Include the new helper in the journey definition hash. - Add route, privacy, malformed-body, deadline, late-response, delayed-failure, success, and historical compatibility tests. Document the evidence boundary. ## Verification - Focused Product E2E tests passed: 3 files, 58 tests on the review-fix head. - E2E typecheck, workspace typecheck, and full build passed on the preceding implementation head. The review fix changes only report labels, checkpoint timing, and the matching tests. - The local full `pnpm test:run` command stopped progressing at the general-server start header. Its owned session was interrupted. This is not recorded as a pass. - All 58 remote checks completed on the final review-fix head: 55 passed, one neutral, and two expected skips. Both Greptile and Paperclip reviews are 5/5 with no unresolved review threads. - No live campaign was run with this patch. The observed historical failure is preserved. ## Risks - Unknown or missing responses remain explicit diagnostic states. They do not establish a product root cause. - Response body reads stop after five seconds or the action deadline and accept at most 64 KiB of JSON. Checks are capped at 32 retained entries. - New server or check codes appear as `unrecognized` until the closed allowlist is updated. - The production launch classifier, native gateway qualification, and Get started requirement are unchanged. ## Model Used OpenAI Codex (GPT-6 family), with code execution and tool use. The host does not expose the exact model ID or context window to this run; these values are unavailable. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2a9d527c68
commit
970127a022
11 files changed
+414
-3
No files matched your search
@@ -237,6 +237,11 @@ The explicit [deployment-targeted Product journeys](PRODUCT-JOURNEYS.md) reuse
|
||||
browser actions across managed self-hosted and Cloud targets. They include a
|
||||
model-free task-properties story, fresh Cloud onboarding, credential-free
|
||||
previews, private evidence, and prerequisite reuse; `--all` is unchanged.
|
||||
Cloud onboarding may retain a versioned `onboardingConnect` receipt for the
|
||||
Connect action: its last checkpoint, validation/hire response state and HTTP
|
||||
status, known adapters, and allowlisted outcome/check/server codes. The case
|
||||
report renders these fields explicitly; older results without the receipt remain
|
||||
valid. This diagnostic does not change the journey grade or qualify a live run.
|
||||
|
||||
## Live provider connection journeys
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ their expected four-field result, exact base64 download bytes, checksums, size,
|
||||
and attachment/run IDs. Extra fields and arbitrary agent output are rejected.
|
||||
It contains no credentials, browser state, request headers, or provider text.
|
||||
|
||||
The optional `onboardingConnect` diagnostic is also a closed, versioned receipt.
|
||||
It retains only a known checkpoint, response states, HTTP statuses, known adapter
|
||||
names, and allowlisted outcome/check/server codes (at most 32 checks per response).
|
||||
Unknown codes become `unrecognized`; raw bodies, messages, URLs, headers, and
|
||||
browser errors are excluded. Reports validate the receipt before rendering its
|
||||
explicit fields, and historical results may omit it.
|
||||
|
||||
## Attended connection authentication
|
||||
|
||||
See [PROVIDER-CONNECTIONS.md](PROVIDER-CONNECTIONS.md) for the explicit-only
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RunnerE2EResult } from "./types.js";
|
||||
import { isBlockedConnectionResult } from "./connection-evidence.js";
|
||||
import { validateOnboardingConnectEvidence } from "./product-journeys/onboarding-connect.js";
|
||||
const html = (value: unknown) =>
|
||||
String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
@@ -8,6 +9,28 @@ const html = (value: unknown) =>
|
||||
c
|
||||
]!,
|
||||
);
|
||||
|
||||
function renderOnboardingConnect(result: RunnerE2EResult): string {
|
||||
const receipt = result.productJourney?.onboardingConnect;
|
||||
if (receipt === undefined) return "";
|
||||
try {
|
||||
validateOnboardingConnectEvidence(receipt);
|
||||
} catch {
|
||||
return "<p>Onboarding Connect diagnostics unavailable: invalid receipt.</p>";
|
||||
}
|
||||
return `<details class="case-context"><summary>Onboarding Connect diagnostics</summary>
|
||||
<p>Last checkpoint: <code>${html(receipt.checkpoint)}</code></p>
|
||||
${([["validation", "Native validation"], ["hire", "Agent hire"]] as const).map(([key, label]) => {
|
||||
const response = receipt[key];
|
||||
return `<h4>${label}</h4><dl>${[
|
||||
["Response state", response.state], ["HTTP status", response.httpStatus],
|
||||
["Test endpoint adapter", response.requestedAdapter], ["Returned adapter", response.adapter],
|
||||
["Validation outcome", response.outcome], ["Server code", response.serverCode],
|
||||
].map(([name, value]) => `<div><dt>${name}</dt><dd>${html(value ?? "Unavailable")}</dd></div>`).join("")}</dl>
|
||||
${response.checks.length ? `<p>Validation checks:</p><ul>${response.checks.map(check => `<li><code>${html(check.code)}</code> · ${html(check.level)}</li>`).join("")}</ul>` : ""}
|
||||
${response.truncatedChecks ? "<p>Only the first 32 validation checks were retained.</p>" : ""}`;
|
||||
}).join("")}</details>`;
|
||||
}
|
||||
/** An interrupted first-task journey is not evidence of failed downstream behavior. */
|
||||
export function isIncompleteFirstTaskResult(
|
||||
result: RunnerE2EResult,
|
||||
@@ -69,6 +92,7 @@ export function renderCaseOutcome(
|
||||
${result.productJourney.deployment?.effectiveFeatures ? `<p>Effective features:</p><dl>${Object.entries(result.productJourney.deployment.effectiveFeatures).map(([key, value]) => `<div><dt>${html(key)}</dt><dd>${html(value)}</dd></div>`).join("")}</dl>` : ""}</details>` : ""}
|
||||
${result.productJourney?.operations ? `<p>Eval operations: ${html(result.productJourney.operations.campaignId)} · ${result.productJourney.operations.requestCount} requests · model cost $${html(result.productJourney.operations.modelCostUsd ?? "unknown")} (setup $${html(result.productJourney.operations.setupCostUsd ?? "unknown")}, counted once). Feedback: ${html(result.productJourney.operations.feedback.collection)} · ${result.productJourney.operations.feedback.complaints} complaints · ${result.productJourney.operations.feedback.suggestions} suggestions.</p>` : ""}
|
||||
${result.productJourney?.operations?.traceIds.length ? `<details><summary>Trace correlation IDs</summary><ul>${result.productJourney.operations.traceIds.map(id => `<li><code>${html(id)}</code></li>`).join("")}</ul></details>` : ""}
|
||||
${renderOnboardingConnect(result)}
|
||||
${result.providerConnection ? `<p>Target: ${html(result.providerConnection.target.origin)} · ${html(result.providerConnection.target.commit ?? "revision unavailable")} · ${result.providerConnection.assisted ? "Assisted login" : "Unassisted"} · ${html(result.providerConnection.authFreshness)} browser · ${html(result.providerConnection.entry)} / ${html(result.providerConnection.method)} · stopped at ${html(result.providerConnection.phase)}</p>` : ""}
|
||||
${failures.length ? `<p>Failed checks:</p><ul>${failures.map((c) => `<li><strong>${html(c.id)}</strong> — ${html(c.detail)}</li>`).join("")}</ul>` : checks.length ? `<p>No behavioral matcher failed.${notReached.length ? " The journey is incomplete; unexercised checks are not passes." : failed ? " The overall failure came from a separate run, cleanup, or evidence check." : ""}</p>` : ""}
|
||||
${notReached.length ? `<p>Not reached:</p><ul>${notReached.map((c) => `<li><strong>${html(c.id)}</strong> — ${html(c.notReached)}</li>`).join("")}</ul>` : ""}
|
||||
|
||||
@@ -86,6 +86,8 @@ function fixture(entry: Entry, options: { intentFailure?: boolean; qualification
|
||||
},
|
||||
});
|
||||
const page = {
|
||||
on() { return this; },
|
||||
off() { return this; },
|
||||
getByLabel(label: string) { return locator(() => controls().filter(control => control.name === label && control.role === "textbox")); },
|
||||
getByRole(role: string, { name: label }: { name: string | RegExp }) {
|
||||
return locator(() => controls().filter(control => control.role === role && match(control.name, label)));
|
||||
@@ -93,6 +95,7 @@ function fixture(entry: Entry, options: { intentFailure?: boolean; qualification
|
||||
locator(selector: string) { expect(selector).toBe("summary"); return locator(() => controls().filter(control => control.role === "summary")); },
|
||||
};
|
||||
const context = { page, company: { id: "company" }, identity: { kind: "cloud" }, startingState: "recipe", api: {
|
||||
origin: "https://tenant.example.test",
|
||||
async get(route: string) {
|
||||
if (route.endsWith("/ai-connections")) return { connections: saved ? [connection] : [] };
|
||||
if (route.endsWith("/agents")) return hired ? [{ id: "agent", name, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: gateway.model } }] : [];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { createConnectionProof } from "../connection-evidence.js";
|
||||
import { onboardingConnect } from "./onboarding-connect.js";
|
||||
import { onboardingFirstTask } from "./onboarding-first-task.js";
|
||||
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
|
||||
|
||||
@@ -69,11 +70,12 @@ export async function cloudOnboarding(input: CloudOnboardingInput): Promise<Jour
|
||||
await page.getByRole("option", { name: /^Paperclip Runner/ }).click();
|
||||
await input.qualify(context, connection.id);
|
||||
// Connect performs the production probe and agent creation. Never press Retry.
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Get started", exact: true }).waitFor({ timeout: 180_000 });
|
||||
const connect = await onboardingConnect({ page, origin: api.origin, companyId: company.id, deadlineAt: input.deadlineAt,
|
||||
retain: value => { evidence.onboardingConnect = value; } });
|
||||
const agents = await api.get<Row[]>(`/api/companies/${company.id}/agents`);
|
||||
const agent = agents.find(a => a.name === name);
|
||||
if (!agent || agents.length !== 1 || agent.adapterType !== "paperclip_runner" || agent.adapterConfig?.provider !== "codex" || agent.adapterConfig?.model !== input.gateway.model) throw new JourneyError("failed", "onboarding_agent_configuration_mismatch");
|
||||
connect.checkpoint = "agent-verification";
|
||||
await input.own({ kind: "agent", name, id: agent.id });
|
||||
evidence.checks.push({ id: "ui-connection-and-native-agent", passed: true });
|
||||
await input.own({ kind: "issue", name: "Paperclip onboarding" });
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { RunnerE2EResult } from "../types.js";
|
||||
import type { JourneyContext, JourneyEvidence, TargetSession } from "./types.js";
|
||||
import { createConnectionProof } from "../connection-evidence.js";
|
||||
import { retainOnboardingArtifact } from "./artifact-evidence.js";
|
||||
import type { OnboardingConnectEvidence } from "./onboarding-connect.js";
|
||||
vi.mock("@playwright/test", () => ({
|
||||
expect: Object.assign(
|
||||
(locator: { textContent(): Promise<string> }) => ({ async toContainText(value: string) { expect(await locator.textContent()).toContain(value); } }),
|
||||
@@ -29,7 +30,60 @@ function result(): RunnerE2EResult {
|
||||
return { schema: "paperclip.runner-e2e.result/v2", suiteId: "product-journeys", suiteDefinitionHash: "test", executionId: "product-journeys.local-qa.local.task-properties", profileId: "local-qa", environmentId: "local", caseId: "task-properties", provider: null, model: null, runtimeMode: "none", status: "passed", cleanup: "passed", attempt: 1, startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", durationMs: 1000,
|
||||
productJourney: { outcome: "passed", startingState: "recipe", phases: [{phase: "journey", outcome: "passed"}], checks: [{id:"assignment-persisted", passed:true},{id:"no-agent-work",passed:true}], privacy: "private", resourceDisposition: "released", deployment: { kind: "self-hosted", targetId: "local-qa", origin: "http://127.0.0.1:3100", commit: "a".repeat(40), cloudRelease: null, runtimeImage: null, configurationHash: "test" } } };
|
||||
}
|
||||
function connectReceipt(): OnboardingConnectEvidence {
|
||||
return {
|
||||
version: 1, checkpoint: "hire",
|
||||
validation: { state: "json", httpStatus: 200, requestedAdapter: "paperclip_runner", adapter: "paperclip_runner", outcome: "fail", serverCode: null, checks: [{ code: "adapter_auth_missing", level: "error" }], truncatedChecks: false },
|
||||
hire: { state: "json", httpStatus: 422, requestedAdapter: null, adapter: null, outcome: null, serverCode: "agent_runner_unavailable", checks: [], truncatedChecks: false },
|
||||
};
|
||||
}
|
||||
describe("deployment journey contracts", () => {
|
||||
it("accepts historical results without Connect diagnostics and optional closed receipts", () => {
|
||||
const retained = result();
|
||||
retained.caseId = "cloud-onboarding";
|
||||
retained.status = "failed";
|
||||
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
||||
expect(renderCaseOutcome(retained, true, [])).not.toContain("Onboarding Connect diagnostics");
|
||||
retained.productJourney!.onboardingConnect = connectReceipt();
|
||||
expect(() => validateRetainedRunnerResult(JSON.parse(JSON.stringify(retained)))).not.toThrow();
|
||||
retained.productJourney!.onboardingConnect.validation = { state: "pending", httpStatus: 200, requestedAdapter: "codex_local", adapter: null, outcome: null, serverCode: null, checks: [], truncatedChecks: false };
|
||||
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
||||
});
|
||||
it.each([
|
||||
["request headers", () => ({ ...connectReceipt(), requestHeaders: { authorization: "must-not-retain" } })],
|
||||
["response bodies", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, body: "must-not-retain" } })],
|
||||
["check messages", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "adapter_auth_missing", level: "error", message: "must-not-retain" }] } })],
|
||||
["unknown server codes", () => ({ ...connectReceipt(), hire: { ...connectReceipt().hire, serverCode: "raw-provider-error" } })],
|
||||
["unknown check codes", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "raw-provider-error", level: "error" }] } })],
|
||||
["unknown adapters", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, adapter: "raw-provider-name" } })],
|
||||
["unknown requested adapters", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, requestedAdapter: "raw-provider-name" } })],
|
||||
["unknown outcomes", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, outcome: "raw-provider-message" } })],
|
||||
["unknown response states", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, state: "raw-provider-message" } })],
|
||||
["unknown check levels", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: [{ code: "adapter_auth_missing", level: "fatal" }] } })],
|
||||
["oversized check arrays", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, checks: Array.from({ length: 33 }, () => ({ code: "adapter_auth_missing", level: "error" })) } })],
|
||||
["invalid HTTP statuses", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, httpStatus: 600 } })],
|
||||
["non-boolean truncation", () => ({ ...connectReceipt(), validation: { ...connectReceipt().validation, truncatedChecks: "true" } })],
|
||||
["missing response fields", () => ({ ...connectReceipt(), hire: { state: "missing" } })],
|
||||
["unsupported receipt versions", () => ({ ...connectReceipt(), version: 2 })],
|
||||
["unknown checkpoints", () => ({ ...connectReceipt(), checkpoint: "raw-browser-text" })],
|
||||
] as const)("rejects Connect receipts containing %s", (_label, makeReceipt) => {
|
||||
const retained = result();
|
||||
retained.productJourney!.onboardingConnect = makeReceipt() as OnboardingConnectEvidence;
|
||||
expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingConnect");
|
||||
});
|
||||
it("renders only validated Connect fields and marks missing or truncated evidence", () => {
|
||||
const retained = result();
|
||||
retained.productJourney!.onboardingConnect = connectReceipt();
|
||||
const output = renderCaseOutcome(retained, true, []);
|
||||
for (const text of ["Onboarding Connect diagnostics", "Last checkpoint", "Native validation", "Agent hire", "HTTP status", "Test endpoint adapter", "Returned adapter", "200", "422", "paperclip_runner", "adapter_auth_missing", "agent_runner_unavailable", "error", "Unavailable"]) expect(output).toContain(text);
|
||||
retained.productJourney!.onboardingConnect.validation.truncatedChecks = true;
|
||||
expect(renderCaseOutcome(retained, true, [])).toContain("Only the first 32 validation checks were retained");
|
||||
Object.assign(retained.productJourney!.onboardingConnect.validation, { body: "<script>must-not-render</script>" });
|
||||
const invalid = renderCaseOutcome(retained, false, []);
|
||||
expect(invalid).toContain("Onboarding Connect diagnostics unavailable: invalid receipt");
|
||||
expect(invalid).not.toContain("must-not-render");
|
||||
expect(invalid).not.toContain("adapter_auth_missing");
|
||||
});
|
||||
it("validates optional historical artifact evidence without trusting extra fields or a different run", () => {
|
||||
const retained = result();
|
||||
retained.caseId = "cloud-onboarding";
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
|
||||
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
|
||||
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
|
||||
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
|
||||
for (const name of ["catalog.ts", "task-properties.ts", "cloud-onboarding.ts", "onboarding-first-task.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
|
||||
for (const name of ["catalog.ts", "task-properties.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
|
||||
const suiteDefinitionHash = definitionHash.digest("hex");
|
||||
const expected = config.journeys.map(id => `product-journeys.${config.id}.${id === "cloud-onboarding" ? config.modelProfileId : "browser"}.${config.executionEnvironment}.${id}`);
|
||||
let reportPersisted = false;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { onboardingConnect, validateOnboardingConnectEvidence, type OnboardingConnectEvidence } from "./onboarding-connect.js";
|
||||
|
||||
const origin = "https://tenant.example.test", companyId = "company-1";
|
||||
const validation = `/api/companies/${companyId}/adapters/paperclip_runner/test-environment`;
|
||||
const hire = `/api/companies/${companyId}/agent-hires`;
|
||||
const secret = "PRIVATE_SENTINEL_do_not_retain";
|
||||
function fixture(options: { ready?: boolean; durationMs?: number; click?: () => Promise<void> | void; wait?: () => Promise<void> } = {}) {
|
||||
const events = new EventEmitter();
|
||||
let retained!: OnboardingConnectEvidence;
|
||||
const page = Object.assign(events, { getByRole(_role: string, { name }: { name: string }) {
|
||||
return { click: vi.fn(async () => { expect(name).toBe("Connect"); await options.click?.(); }),
|
||||
waitFor: vi.fn(({ timeout }: { timeout: number }) => {
|
||||
expect(name).toBe("Get started");
|
||||
if (options.wait) return options.wait();
|
||||
return options.ready ? Promise.resolve() : new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(Object.assign(new Error(secret), { name: "TimeoutError" })), timeout);
|
||||
});
|
||||
}) };
|
||||
} });
|
||||
const request = (path = validation, method = "POST", requestOrigin = origin) => ({ url: () => requestOrigin + path, method: () => method });
|
||||
const respond = (body: unknown, status = 200, path = validation, method = "POST", requestOrigin = origin) => {
|
||||
const req = request(path, method, requestOrigin);
|
||||
const response = { request: () => req, status: () => status, body: () => Promise.resolve(Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body))) };
|
||||
events.emit("request", req); events.emit("response", response);
|
||||
return response;
|
||||
};
|
||||
const run = () => onboardingConnect({ page: page as any, origin, companyId, deadlineAt: Date.now() + (options.durationMs ?? 100), retain: value => { retained = value; } });
|
||||
return { page, events, request, respond, run, receipt: () => retained };
|
||||
}
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
describe("one onboarding Connect diagnostic boundary", () => {
|
||||
it("does not press Connect after the action deadline already expired", async () => {
|
||||
const click = vi.fn();
|
||||
const f = fixture({ durationMs: 0, click });
|
||||
await expect(f.run()).rejects.toMatchObject({ code: "onboarding_connect_timeout" });
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
expect(f.receipt().checkpoint).toBe("connect");
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a delayed failure body even after Get started becomes visible", async () => {
|
||||
let resolveBody!: (bytes: Buffer) => void;
|
||||
const body = new Promise<Buffer>(resolve => { resolveBody = resolve; });
|
||||
let reviewSeen!: () => void;
|
||||
const review = new Promise<void>(resolve => { reviewSeen = resolve; });
|
||||
const f = fixture({ wait: async () => { reviewSeen(); }, click: () => {
|
||||
const req = f.request(); f.events.emit("request", req);
|
||||
f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
|
||||
} });
|
||||
const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
|
||||
await review;
|
||||
for (let i = 0; i < 10 && f.events.listenerCount("response"); i++) await Promise.resolve();
|
||||
expect(f.events.listenerCount("response")).toBe(0);
|
||||
resolveBody(Buffer.from(JSON.stringify({ status: "fail", checks: [] })));
|
||||
await result;
|
||||
expect(f.receipt().validation.outcome).toBe("fail");
|
||||
expect(f.receipt().checkpoint).toBe("validation");
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["details", "top-level"])("retains a known %s server rejection without its free text", async location => {
|
||||
const f = fixture({ click: () => { f.respond({ error: secret, ...(location === "details" ? { details: { code: "agent_runner_config_incompatible", fields: [secret] } } : { code: "agent_runner_unavailable" }) }, 422); } });
|
||||
await expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
|
||||
expect(f.receipt()).toMatchObject({ checkpoint: "validation", validation: { state: "json", httpStatus: 422, serverCode: location === "details" ? "agent_runner_config_incompatible" : "agent_runner_unavailable" }, hire: { state: "missing" } });
|
||||
expect(JSON.stringify(f.receipt())).not.toContain(secret);
|
||||
validateOnboardingConnectEvidence(f.receipt());
|
||||
expect(f.events.listenerCount("response")).toBe(0);
|
||||
});
|
||||
|
||||
it.each([{ status: "fail", checks: [] }, { status: "pass", checks: [{ code: "adapter_auth_missing", level: "error" }] }])("fails on a 200 validation rejection: %j", async body => {
|
||||
const f = fixture({ click: () => { f.respond(body); } });
|
||||
await expect(f.run()).rejects.toMatchObject({ code: "onboarding_native_validation_failed" });
|
||||
expect(f.receipt().validation.httpStatus).toBe(200);
|
||||
});
|
||||
|
||||
it("retains the hire boundary rejection and does not wait for Get started", async () => {
|
||||
const f = fixture({ click: () => { f.respond({ details: { code: "agent_runner_platform_probe_failed" }, error: secret }, 503, hire); } });
|
||||
await expect(f.run()).rejects.toMatchObject({ code: "onboarding_agent_hire_failed" });
|
||||
expect(f.receipt()).toMatchObject({ checkpoint: "hire", hire: { httpStatus: 503, serverCode: "agent_runner_platform_probe_failed" } });
|
||||
});
|
||||
|
||||
it("projects only enums from malicious nested fields, including unknown codes and bounded checks", async () => {
|
||||
const f = fixture({ ready: true, click: () => { f.respond({ adapterType: secret, status: secret, code: { credential: secret }, details: { code: secret },
|
||||
message: secret, hint: secret, headers: { authorization: secret }, checks: Array.from({ length: 40 }, () => ({ code: secret, level: secret, message: secret, nested: { apiKey: secret } })) }); } });
|
||||
const result = await f.run();
|
||||
expect(result.validation).toMatchObject({ adapter: "unrecognized", outcome: "unrecognized", serverCode: "unrecognized", truncatedChecks: true });
|
||||
expect(result.validation.checks).toHaveLength(32);
|
||||
expect(result.validation.checks[0]).toEqual({ code: "unrecognized", level: "unrecognized" });
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
validateOnboardingConnectEvidence(result);
|
||||
expect(() => validateOnboardingConnectEvidence({ ...result, validation: { ...result.validation, requestedAdapter: ["codex_local"] } })).toThrow();
|
||||
});
|
||||
|
||||
it("ignores wrong origin, company, method, query, and responses without an in-action request", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture({ click: () => {
|
||||
f.respond({}, 500, validation, "POST", "https://foreign.example.test");
|
||||
f.respond({}, 500, validation.replace(companyId, "company-2"));
|
||||
f.respond({}, 500, validation, "GET");
|
||||
f.respond({}, 500, validation + "?private=" + secret);
|
||||
f.events.emit("response", { request: () => f.request(), status: () => 500 });
|
||||
} });
|
||||
const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
|
||||
await vi.advanceTimersByTimeAsync(101); await result;
|
||||
expect(f.receipt()).toMatchObject({ checkpoint: "connect", validation: { state: "missing" }, hire: { state: "missing" } });
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([Buffer.from("{not-json"), Buffer.alloc(65_537, "x")])("keeps malformed or oversized bodies unknown and still requires Get started", async bytes => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture({ click: () => { f.respond(bytes); } });
|
||||
const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
|
||||
await vi.advanceTimersByTimeAsync(101); await result;
|
||||
expect(f.receipt().validation.state).toBe(bytes.byteLength > 65_536 ? "oversized" : "unreadable");
|
||||
expect(f.receipt().checkpoint).toBe("validation");
|
||||
});
|
||||
|
||||
it("records missing responses and ignores late responses after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture({ click: () => { f.events.emit("request", f.request()); } });
|
||||
const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
|
||||
await vi.advanceTimersByTimeAsync(101); await result;
|
||||
const sealed = JSON.stringify(f.receipt());
|
||||
f.respond({ status: "pass", adapterType: "paperclip_runner", checks: [] });
|
||||
expect(JSON.stringify(f.receipt())).toBe(sealed);
|
||||
expect(f.receipt().validation.state).toBe("missing");
|
||||
});
|
||||
|
||||
it("bounds stalled JSON reads and cannot mutate retained evidence after return or reject unhandled", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectBody!: (reason: Error) => void;
|
||||
const body = new Promise<Buffer>((_resolve, reject) => { rejectBody = reject; });
|
||||
const f = fixture({ ready: true, durationMs: 10_000, click: () => {
|
||||
const req = f.request(); f.events.emit("request", req);
|
||||
f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
|
||||
} });
|
||||
const result = f.run();
|
||||
await vi.advanceTimersByTimeAsync(5001);
|
||||
expect((await result).validation.state).toBe("unreadable");
|
||||
const sealed = JSON.stringify(f.receipt());
|
||||
rejectBody(new Error(secret)); await Promise.resolve();
|
||||
expect(JSON.stringify(f.receipt())).toBe(sealed);
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves the Get started timeout when a previously received body arrives later", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveBody!: (bytes: Buffer) => void;
|
||||
const body = new Promise<Buffer>(resolve => { resolveBody = resolve; });
|
||||
const f = fixture();
|
||||
const result = expect(f.run()).rejects.toMatchObject({ code: "onboarding_get_started_timeout" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
const req = f.request(); f.events.emit("request", req);
|
||||
f.events.emit("response", { request: () => req, status: () => 200, body: () => body });
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
resolveBody(Buffer.from(JSON.stringify({ status: "fail" })));
|
||||
await vi.advanceTimersByTimeAsync(1); await result;
|
||||
expect(f.receipt().checkpoint).toBe("validation");
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a browser error at its own boundary without retaining the browser text", async () => {
|
||||
const f = fixture({ click: () => { throw new Error(secret); } });
|
||||
await expect(f.run()).rejects.toMatchObject({ code: "onboarding_connect_failed" });
|
||||
expect(f.receipt().checkpoint).toBe("connect");
|
||||
expect(JSON.stringify(f.receipt())).not.toContain(secret);
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("observes both successful responses and requires the visible review step", async () => {
|
||||
const f = fixture({ ready: true, click: () => {
|
||||
f.respond({ adapterType: "paperclip_runner", status: "pass", checks: [{ code: "codex_hello_probe_passed", level: "info", message: secret }] }, 200, validation.replace("paperclip_runner", "codex_local"));
|
||||
f.respond({ agent: { id: secret }, approval: null }, 201, hire);
|
||||
} });
|
||||
expect(await f.run()).toMatchObject({ checkpoint: "review", validation: { requestedAdapter: "codex_local", adapter: "paperclip_runner", outcome: "pass", checks: [{ code: "codex_hello_probe_passed", level: "info" }] }, hire: { state: "json", httpStatus: 201 } });
|
||||
expect(JSON.stringify(f.receipt())).not.toContain(secret);
|
||||
expect(f.events.eventNames()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Page, Response, Request } from "@playwright/test";
|
||||
import { JourneyError } from "./types.js";
|
||||
|
||||
export const onboardingServerCodes = ["agent_runner_config_incompatible", "agent_runner_unavailable", "agent_runner_platform_probe_failed", "managed_sandbox_unavailable", "environment_company_mismatch"] as const;
|
||||
export const onboardingCheckCodes = ["adapter_auth_missing", "codex_hello_probe_failed", "codex_hello_probe_passed", "codex_hello_probe_timeout", "codex_hello_probe_auth_required", "environment_lease_acquire_failed", "environment_workspace_realize_failed", "environment_target_failed", "environment_target_unsupported", "environment_not_found", "environment_target_unavailable", "managed_sandbox_unavailable", "environment_env_binding_missing", "environment_env_resolution_failed", "ai_connection_api_key_rejected", "ai_connection_verification_failed", "sandbox_test_identity"] as const;
|
||||
const checkpoints = ["connect", "validation", "hire", "review", "agent-verification"] as const;
|
||||
const states = ["missing", "pending", "json", "unreadable", "oversized"] as const;
|
||||
const requestedAdapters = ["codex_local", "paperclip_runner"] as const;
|
||||
const adapters = ["codex_local", "paperclip_runner", "unrecognized"] as const;
|
||||
const outcomes = ["pass", "warn", "fail", "unrecognized"] as const;
|
||||
const levels = ["info", "warn", "error", "unrecognized"] as const;
|
||||
type Known<T extends readonly string[]> = T[number] | "unrecognized";
|
||||
export interface OnboardingResponseEvidence {
|
||||
state: typeof states[number]; httpStatus: number | null;
|
||||
requestedAdapter: "codex_local" | "paperclip_runner" | null;
|
||||
adapter: typeof adapters[number] | null; outcome: typeof outcomes[number] | null;
|
||||
serverCode: Known<typeof onboardingServerCodes> | null;
|
||||
checks: Array<{ code: Known<typeof onboardingCheckCodes>; level: typeof levels[number] }>;
|
||||
truncatedChecks: boolean;
|
||||
}
|
||||
export interface OnboardingConnectEvidence {
|
||||
version: 1; checkpoint: typeof checkpoints[number]; validation: OnboardingResponseEvidence; hire: OnboardingResponseEvidence;
|
||||
}
|
||||
const blank = (): OnboardingResponseEvidence => ({ state: "missing", httpStatus: null, requestedAdapter: null, adapter: null, outcome: null, serverCode: null, checks: [], truncatedChecks: false });
|
||||
const record = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === "object" && !Array.isArray(v));
|
||||
const member = <T extends readonly string[]>(values: T, v: unknown): v is T[number] => typeof v === "string" && values.includes(v);
|
||||
const closed = (v: unknown, keys: string[]) => record(v) && Object.keys(v).length === keys.length && keys.every(key => Object.hasOwn(v, key));
|
||||
export function validateOnboardingConnectEvidence(value: unknown): void {
|
||||
if (!closed(value, ["version", "checkpoint", "validation", "hire"]) || !record(value) || value.version !== 1 || !member(checkpoints, value.checkpoint)) throw new Error("invalid_onboarding_connect_evidence");
|
||||
for (const response of [value.validation, value.hire]) {
|
||||
if (!closed(response, ["state", "httpStatus", "requestedAdapter", "adapter", "outcome", "serverCode", "checks", "truncatedChecks"]) || !record(response) ||
|
||||
!member(states, response.state) || !(response.httpStatus === null || Number.isSafeInteger(response.httpStatus) && Number(response.httpStatus) >= 100 && Number(response.httpStatus) <= 599) ||
|
||||
!(response.requestedAdapter === null || member(requestedAdapters, response.requestedAdapter)) ||
|
||||
!(response.adapter === null || member(adapters, response.adapter)) || !(response.outcome === null || member(outcomes, response.outcome)) ||
|
||||
!(response.serverCode === null || response.serverCode === "unrecognized" || member(onboardingServerCodes, response.serverCode)) ||
|
||||
typeof response.truncatedChecks !== "boolean" || !Array.isArray(response.checks) || response.checks.length > 32 ||
|
||||
response.checks.some(check => !closed(check, ["code", "level"]) || !record(check) || !(check.code === "unrecognized" || member(onboardingCheckCodes, check.code)) || !member(levels, check.level))) throw new Error("invalid_onboarding_connect_evidence");
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe only responses to this Connect action; never retain bodies, text, headers or browser errors. */
|
||||
export async function onboardingConnect(input: { page: Page; origin: string; companyId: string; deadlineAt: number; retain(value: OnboardingConnectEvidence): void }): Promise<OnboardingConnectEvidence> {
|
||||
const { page } = input, receipt: OnboardingConnectEvidence = { version: 1, checkpoint: "connect", validation: blank(), hire: blank() };
|
||||
input.retain(receipt);
|
||||
let active = true, failure: JourneyError | undefined;
|
||||
let signalFailure!: (error: JourneyError) => void;
|
||||
const failed = new Promise<JourneyError>(resolve => { signalFailure = resolve; });
|
||||
const requests = new Set<Request>(), pending = new Set<Promise<void>>();
|
||||
const deadlineAt = Math.min(input.deadlineAt, Date.now() + 180_000);
|
||||
const remaining = () => Math.max(1, deadlineAt - Date.now());
|
||||
const advance = (checkpoint: OnboardingConnectEvidence["checkpoint"]) => {
|
||||
if (checkpoints.indexOf(checkpoint) > checkpoints.indexOf(receipt.checkpoint)) receipt.checkpoint = checkpoint;
|
||||
};
|
||||
const reject = (code: string) => { failure ??= new JourneyError("failed", code); signalFailure(failure); };
|
||||
const route = (request: Request): { kind: "validation" | "hire"; adapter: "codex_local" | "paperclip_runner" | null } | null => {
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "POST" || url.origin !== input.origin || url.search || url.hash) return null;
|
||||
const prefix = `/api/companies/${encodeURIComponent(input.companyId)}`;
|
||||
if (url.pathname === `${prefix}/agent-hires`) return { kind: "hire", adapter: null };
|
||||
for (const adapter of ["codex_local", "paperclip_runner"] as const) if (url.pathname === `${prefix}/adapters/${adapter}/test-environment`) return { kind: "validation", adapter };
|
||||
} catch { /* An unrelated or malformed URL is not this action's evidence. */ }
|
||||
return null;
|
||||
};
|
||||
const onRequest = (request: Request) => { if (active && route(request)) requests.add(request); };
|
||||
const onResponse = (response: Response) => {
|
||||
const request = response.request(), matched = route(request);
|
||||
if (!active || !matched || !requests.delete(request)) return;
|
||||
const observed = receipt[matched.kind];
|
||||
// One Connect action can report each boundary once. Later traffic cannot replace its failure.
|
||||
if (observed.state !== "missing") return;
|
||||
advance(matched.kind); observed.state = "pending";
|
||||
const status = response.status();
|
||||
observed.httpStatus = Number.isSafeInteger(status) && status >= 100 && status <= 599 ? status : null;
|
||||
observed.requestedAdapter = matched.adapter;
|
||||
if (response.status() >= 400) reject(matched.kind === "validation" ? "onboarding_native_validation_failed" : "onboarding_agent_hire_failed");
|
||||
const classify = async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const bytes = await Promise.race([response.body(), new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), Math.min(5000, remaining())); })]);
|
||||
if (!active) return;
|
||||
if (!bytes) { observed.state = "unreadable"; return; }
|
||||
if (bytes.byteLength > 65_536) { observed.state = "oversized"; return; }
|
||||
let body: unknown; try { body = JSON.parse(bytes.toString("utf8")); } catch { observed.state = "unreadable"; return; }
|
||||
if (!record(body)) { observed.state = "unreadable"; return; }
|
||||
observed.state = "json";
|
||||
const detailCode = record(body.details) ? body.details.code : undefined;
|
||||
const code = member(onboardingServerCodes, detailCode) ? detailCode : member(onboardingServerCodes, body.code) ? body.code : detailCode ?? body.code;
|
||||
if (code !== undefined) observed.serverCode = member(onboardingServerCodes, code) ? code : "unrecognized";
|
||||
if (matched.kind === "validation") {
|
||||
observed.adapter = member(adapters, body.adapterType) ? body.adapterType : "unrecognized";
|
||||
observed.outcome = member(outcomes, body.status) ? body.status : "unrecognized";
|
||||
if (Array.isArray(body.checks)) {
|
||||
observed.truncatedChecks = body.checks.length > 32;
|
||||
observed.checks = body.checks.slice(0, 32).map(check => ({ code: record(check) && member(onboardingCheckCodes, check.code) ? check.code : "unrecognized", level: record(check) && member(levels, check.level) ? check.level : "unrecognized" }));
|
||||
if (body.status === "fail" || body.checks.some(check => record(check) && check.code === "adapter_auth_missing")) reject("onboarding_native_validation_failed");
|
||||
} else if (body.status === "fail") reject("onboarding_native_validation_failed");
|
||||
}
|
||||
} catch { if (active) observed.state = "unreadable"; }
|
||||
finally { clearTimeout(timer); }
|
||||
};
|
||||
const work = classify(); pending.add(work); void work.then(() => pending.delete(work), () => pending.delete(work));
|
||||
};
|
||||
page.on("request", onRequest); page.on("response", onResponse);
|
||||
try {
|
||||
if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_connect_timeout");
|
||||
const clicked = page.getByRole("button", { name: "Connect", exact: true }).click({ timeout: remaining() })
|
||||
.then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_connect_timeout" : "onboarding_connect_failed"));
|
||||
const clickResult = await Promise.race([failed, clicked]);
|
||||
if (clickResult) throw clickResult;
|
||||
if (failure) throw failure;
|
||||
const result = await Promise.race([failed, page.getByRole("button", { name: "Get started", exact: true }).waitFor({ timeout: remaining() })
|
||||
.then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_get_started_timeout" : "onboarding_get_started_failed"))]);
|
||||
const boundaryFailure = failure;
|
||||
// Seal the observed boundary before awaiting body reads. A response arriving
|
||||
// after Get started or its timeout cannot change this action's outcome.
|
||||
page.off("request", onRequest); page.off("response", onResponse);
|
||||
if (pending.size) await Promise.all([...pending]);
|
||||
if (boundaryFailure) throw boundaryFailure;
|
||||
if (result) throw result;
|
||||
if (failure) throw failure;
|
||||
if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_get_started_timeout");
|
||||
advance("review");
|
||||
return receipt;
|
||||
} finally {
|
||||
// Settle only already observed responses within their bounded read; detach before later traffic.
|
||||
page.off("request", onRequest); page.off("response", onResponse);
|
||||
if (pending.size) await Promise.all([...pending]);
|
||||
active = false; requests.clear();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface JourneyEvidence {
|
||||
attemptId?: string;
|
||||
operations?: JourneyOperations;
|
||||
artifact?: OnboardingArtifactEvidence;
|
||||
onboardingConnect?: import("./onboarding-connect.js").OnboardingConnectEvidence;
|
||||
}
|
||||
export interface OnboardingArtifactEvidence {
|
||||
schema: "paperclip.product-journey.artifact/v1";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { githubJourneyCheckpoints, gradeGitHubReviewJourney } from "./github-rev
|
||||
import { STARTUP_REUSE_LAYERS, STARTUP_REUSE_REASONS } from "../../packages/adapter-utils/src/startup-reuse.js";
|
||||
import type { RunnerE2ECampaignAccounting, RunnerE2EResult } from "./types.js";
|
||||
import { DEPENDABLE_CELLS } from "./dependable-qualification.js";
|
||||
import { validateOnboardingConnectEvidence } from "./product-journeys/onboarding-connect.js";
|
||||
import { validateOnboardingArtifact } from "./product-journeys/artifact-evidence.js";
|
||||
|
||||
type Rule = (value: unknown, at: string) => void;
|
||||
@@ -344,6 +345,7 @@ const fields = {
|
||||
productJourney: optional(shape({
|
||||
targetId: optional(string),
|
||||
campaignId: optional(string), attemptId: optional(string),
|
||||
onboardingConnect: optional((value, at) => { try { validateOnboardingConnectEvidence(value); } catch { invalid(at); } }),
|
||||
artifact: optional((value, at) => { try { validateOnboardingArtifact(value); } catch { invalid(at); } }),
|
||||
operations: optional(closedShape({ campaignId: string, attemptId: string, modelCostUsd: nullable(number), setupCostUsd: nullable(number), requestCount: integer,
|
||||
feedback: closedShape({ collection: oneOf("complete", "partial", "unavailable"), complaints: integer, suggestions: integer }), traceIds: array(string) })),
|
||||
|
||||
Reference in new issue
Block a user