mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## 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>
183 lines
11 KiB
TypeScript
183 lines
11 KiB
TypeScript
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([]);
|
|
});
|
|
});
|