mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## Thinking Path > - Paperclip manages agents and the work they perform. > - Product E2E checks verify real user paths through those agents. > - A recorded GitHub review-bot walkthrough had no repeatable registered case. > - App setup, automatic triggering, publication, and task completion need separate evidence. > - This pull request adds an explicit attended journey to the existing catalog and report tools. > - Reusable setup profiles and independent assertions preserve the same outcome across compatible targets. ## Linked Issues or Issue Description Refs: #17262. This case remains an explicit attended attached-target flow. It does not replace the shared deployment-target or paid-operations framework. **What existing behavior does this improve?** The Product E2E catalog can now select and retain evidence for GitHub review-bot setup and its first automatic review. **Current behavior** Recorded walkthroughs cannot be rerun as a fixed registered case. A published review alone can hide a failed run or unfinished task. **Proposed behavior** Create and install an owned App, save automatic new-PR settings, create a defective PR, and require correlated review delivery, an inline finding, a consistent check, a successful run, and a Done task. Retain failures and confirm owned-resource cleanup. **Reason and benefit** The case makes a useful external integration reproducible without treating setup, unit checks, or report rendering as proof of a completed live journey. ## What Changed - Add two explicit attended cells for an existing configured agent in local or Daytona execution. - Declare hosting, account, connection, and credential prerequisites through private setup bindings. - Add fixed browser controls, ownership checks, independent grading, and cleanup. - Package declared screenshots through the existing sanitizer, manifest, validator, and dashboard. - Reject operations-managed execution until budgeted routing is qualified. - Document hosted QA credential setup and evidence limits. ## Verification - Focused catalog, oracle, report, connection, and ownership checks: 127 tests passed. - Inherited-environment preflight regression failed against the previous commit and passed after the fix. Actual-run environment verification remains required. - Harness TypeScript check and full workspace typecheck passed. Full build passed. The local full-test attempt was interrupted after repeated embedded PostgreSQL startup failures; its exit code of zero does not establish a pass; Exact-head Linux CI passed the full test, typecheck, build, Runner, serialized, browser, and canary gates on `abbddeade848edefc8de75f11cc9008b2cd52764`. - A real hosted attempt published a rated review and inline finding. It failed when its native session timed out before the task reached Done. Cleanup passed. Full live qualification remains open. - Credential scan found no known credential values in the authored files or retained report corpus. ## Risks - The full case creates external test resources and uses the configured provider. It requires an explicit selection and current task authority. It is excluded from default and CI matrices. - Time and run limits are not a hard dollar cap. Missing dollar cost stays unknown. Fixture setup does not weaken budget policy. - Fresh personal OAuth, unattended authentication, runtime skill-read proof, and cross-host qualification are separate claims. - The selected live fixture needs environment/lifecycle recovery before it can adopt a longer provider deadline. ## Model Used OpenAI GPT-6 family through Codex, with reasoning, code execution, and browser tools. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used, including the available 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 described the issue in-PR following the enhancement template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket ID - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open findings - [x] I will address all Greptile and reviewer comments before merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
108 lines
7.1 KiB
TypeScript
108 lines
7.1 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { APIRequestContext } from "@playwright/test";
|
|
import { reserveRunnerE2EServerPort } from "./ports.js";
|
|
import { resolvePaperclipRunnerBinaryForHarness } from "./harness-env.js";
|
|
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
|
|
import { ConnectionBlock, ConnectionFailure, targetOrigin, type ConnectionConfig } from "./connection-config.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
|
|
export const connectionDelay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
|
|
/** No raw response bodies, callback URLs, headers, or Playwright diagnostics escape. */
|
|
export class ConnectionApi {
|
|
constructor(readonly request: APIRequestContext, readonly origin: string) {}
|
|
async response(route: string, method = "GET", data?: unknown, options: { timeoutMs?: number } = {}) {
|
|
const target = new URL(route, this.origin);
|
|
if (!route.startsWith("/api/") || target.origin !== this.origin || !target.pathname.startsWith("/api/")) throw new ConnectionFailure("invalid_api_route");
|
|
const timeout = options.timeoutMs ?? 30_000;
|
|
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new ConnectionFailure("invalid_api_timeout");
|
|
try {
|
|
const response = await this.request.fetch(`${this.origin}${route}`, { method, headers: { Origin: this.origin }, ...(data === undefined ? {} : { data }), maxRedirects: 0, timeout });
|
|
if (!response.ok()) throw new ConnectionFailure(`api_status_${response.status()}`);
|
|
return response;
|
|
} catch (error) {
|
|
if (error instanceof Error && /^api_status_\d+$/.test(error.message)) throw error;
|
|
throw new ConnectionFailure("api_request_failed");
|
|
}
|
|
}
|
|
async get<T = any>(route: string): Promise<T> { return (await this.response(route)).json(); }
|
|
async post<T = any>(route: string, data?: unknown, options?: { timeoutMs?: number }): Promise<T> { return (await this.response(route, "POST", data, options)).json(); }
|
|
async patch<T = any>(route: string, data: unknown): Promise<T> { return (await this.response(route, "PATCH", data)).json(); }
|
|
async delete(route: string) { await this.response(route, "DELETE"); }
|
|
async bytes(route: string) { return (await this.response(route)).body(); }
|
|
}
|
|
|
|
async function stopOwnedServer(child: ChildProcess) {
|
|
if (!child.pid) return;
|
|
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
const kill = (signal: NodeJS.Signals) => {
|
|
try { process.kill(process.platform === "win32" ? child.pid! : -child.pid!, signal); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
};
|
|
kill("SIGTERM");
|
|
const deadline = Date.now() + 45_000;
|
|
while (!exited() && Date.now() < deadline) await connectionDelay(100);
|
|
// The group is exclusively the server this function spawned, never an attached target.
|
|
kill("SIGKILL");
|
|
}
|
|
|
|
export async function startConnectionTarget(config: ConnectionConfig, executions: readonly MatrixExecution[], repositoryRoot: string, signal?: AbortSignal) {
|
|
const assertActive = () => { if (signal?.aborted) throw new ConnectionBlock("blocked_target", "test_interrupted"); };
|
|
assertActive();
|
|
if (config.target.mode === "attach") return { origin: targetOrigin(config.target.baseURL), stop: async () => {} };
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-connection-e2e-"));
|
|
let child: ChildProcess | undefined;
|
|
const stop = async () => { if (child) await stopOwnedServer(child); await rm(root, { recursive: true, force: true }); };
|
|
try {
|
|
assertActive();
|
|
const home = path.join(root, "paperclip-home");
|
|
const instance = `connection-${randomBytes(6).toString("hex")}`;
|
|
const port = await reserveRunnerE2EServerPort();
|
|
await mkdir(home, { mode: 0o700 });
|
|
// Only bootstrap essentials are inherited. Selected provider keys enter via UI.
|
|
const env: NodeJS.ProcessEnv = Object.fromEntries(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "NODE_EXTRA_CA_CERTS"].flatMap(key => process.env[key] ? [[key, process.env[key]!]] : []));
|
|
Object.assign(env, {
|
|
PAPERCLIP_HOME: home, PAPERCLIP_INSTANCE_ID: instance, PAPERCLIP_CONFIG: path.join(home, "instances", instance, "config.json"),
|
|
PAPERCLIP_RUNNER_E2E_TEMP_ROOT: root, PAPERCLIP_RUNNER_E2E_PORT: String(port), PAPERCLIP_RUNNER_E2E_SERVER_LOG: path.join(root, "server.log"),
|
|
PAPERCLIP_RUNNER_BINARY: resolvePaperclipRunnerBinaryForHarness(executions, repositoryRoot),
|
|
PAPERCLIP_VITE_CACHE_DIR: path.join(root, "vite-cache"), PAPERCLIP_ANNOUNCEMENTS_ENABLED: "false",
|
|
PAPERCLIP_AGENT_JWT_SECRET: randomBytes(48).toString("hex"), PAPERCLIP_DECISION_SIGNING_SECRET: randomBytes(48).toString("hex"),
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: randomBytes(48).toString("hex"), BETTER_AUTH_SECRET: randomBytes(48).toString("hex"),
|
|
});
|
|
assertActive();
|
|
child = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(repositoryRoot, path.join(repositoryRoot, "tests/runner-e2e/server.ts")), { cwd: repositoryRoot, env, detached: process.platform !== "win32", stdio: "ignore" });
|
|
let failed = false;
|
|
child.once("error", () => { failed = true; });
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
const deadline = Date.now() + 180_000;
|
|
while (Date.now() < deadline) {
|
|
assertActive();
|
|
if (failed || child.exitCode !== null) throw new ConnectionBlock("blocked_target", "managed_server_start_failed");
|
|
try {
|
|
const response = await fetch(`${origin}/api/health`, { redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(1500)]) : AbortSignal.timeout(1500) });
|
|
if (response.ok) { assertActive(); return { origin, stop }; }
|
|
} catch { assertActive(); /* Bounded startup. */ }
|
|
await connectionDelay(500);
|
|
}
|
|
throw new ConnectionBlock("blocked_target", "managed_server_start_timeout");
|
|
} catch (error) { await stop(); throw error; }
|
|
}
|
|
|
|
export async function verifyConnectionTarget(origin: string, config: ConnectionConfig, request?: APIRequestContext) {
|
|
let health: any;
|
|
try {
|
|
const response = request
|
|
? await request.get(`${targetOrigin(origin)}/api/health`, { maxRedirects: 0, timeout: 15_000 })
|
|
: await fetch(`${targetOrigin(origin)}/api/health`, { redirect: "error", signal: AbortSignal.timeout(15_000) });
|
|
if (!(typeof response.ok === "function" ? response.ok() : response.ok)) throw new ConnectionFailure();
|
|
health = await response.json();
|
|
} catch { throw new ConnectionBlock("blocked_target", "target_health_unavailable_or_redirected"); }
|
|
if (typeof health.commit !== "string" || !/^[a-f0-9]{40}$/.test(health.commit)) throw new ConnectionBlock("blocked_target", "target_revision_unavailable");
|
|
if (config.target.mode === "attach" && (health.commit !== config.target.expectedCommit || health.deploymentMode !== config.target.deploymentMode)) throw new ConnectionBlock("blocked_target", "target_identity_mismatch");
|
|
return { commit: health.commit as string, deploymentMode: String(health.deploymentMode) };
|
|
}
|