mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Reuse connector rows, access controls, and agent setup components. Add persistent subscription, API key, and custom gateway choices with matching dropdowns and provider logos. Include grouped review stories and an opt-in browser campaign for local or staging targets with private credential and evidence handling. Co-Authored-By: Paperclip <noreply@paperclip.ing>
94 lines
6.2 KiB
TypeScript
94 lines
6.2 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) {
|
|
if (!route.startsWith("/api/") || new URL(route, this.origin).origin !== this.origin) throw new ConnectionFailure("invalid_api_route");
|
|
try {
|
|
const response = await this.request.fetch(`${this.origin}${route}`, { method, ...(data === undefined ? {} : { data }), maxRedirects: 0, timeout: 30_000 });
|
|
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): Promise<T> { return (await this.response(route, "POST", data)).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) {
|
|
if (config.target.mode === "attach") return { origin: targetOrigin(config.target.baseURL), stop: async () => {} };
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-connection-e2e-"));
|
|
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"),
|
|
});
|
|
const 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 stop = async () => { await stopOwnedServer(child); await rm(root, { recursive: true, force: true }); };
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
try {
|
|
const deadline = Date.now() + 180_000;
|
|
while (Date.now() < deadline) {
|
|
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: AbortSignal.timeout(1500) }); if (response.ok) return { origin, stop }; } catch { /* 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) {
|
|
let health: any;
|
|
try {
|
|
const response = await fetch(`${targetOrigin(origin)}/api/health`, { redirect: "error", signal: AbortSignal.timeout(15_000) });
|
|
if (!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) };
|
|
}
|