mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages AI agents that do work for users. > - Product E2E journeys check real browser actions and saved results. > - A rich editor can store plain underscores as escaped Markdown. > - The QA reply check treated that valid saved message as a failure. > - A private target adapter also needs to cancel credential-safe HTTP requests before cleanup. > - This PR fixes reply comparison and adds abort and timeout options to the existing sensitive transport. ## Linked Issues or Issue Description Refs #18222. **What happened?** The artifact revision and Stop journeys rejected the correct single user reply because its underscore was stored as `\_`. Sensitive connection setup also lacked a way to pass its lifecycle signal to the actual HTTP request. **Expected behavior** The check accepts the editor's underscore escape and still rejects changed, duplicate, missing, or foreign replies. A cancelled sensitive request closes before setup cleanup proceeds. **Steps to reproduce** Run an artifact revision with an underscore in the reply. For the transport case, abort a pending sensitive request or let its deadline expire. ## What Changed - Compare the fixture reply after undoing only underscore escaping. Keep exact text, count, and author checks. - Let `ConnectionApi.postSensitive` accept a bounded timeout and abort signal. Check cancellation again after cookie loading and before dispatch. - Test the retained reply failure and both cancellation paths with an actual loopback HTTP server. Document the adapter contract. ## Verification - Focused Product E2E tests: 30 passed across two files. - Product E2E TypeScript check passed. After rebase, a final check first found four diagnostics from stale runner/plugin SDK declarations; rebuilding those two workspace packages cleared all four, and the unchanged E2E check passed. - The first sandbox run failed because loopback `listen` returned `EPERM`. The unchanged HTTP tests passed with loopback permission. The test now reports listener errors directly. - Both independent reviewers found a possible pre-arrival deadline-test hang. The follow-up races arrival against request settlement and adds one second of deadline headroom. All 30 affected tests passed after that fix. - Full current-head CI passed in run 40982914525. Greptile and Paperclip Review both scored 5/5 at `f0526f5fbbf13de5ea43e868eb012069ca3d8907`; all review threads are resolved. Live QA requalification follows the merged public and private fixes; no live pass is claimed here. ## Risks The text normalization applies only to plain fixture replies and only removes underscore escapes. It does not remove arbitrary Markdown. Sensitive calls retain their existing 30-second default and closed error messages. API wrappers must forward the optional signal and await the actual request. ## Model Used OpenAI Codex, GPT-6 family. The serving model ID and context window are not exposed by this session. Used reasoning, code editing, shell execution, and test inspection. ## 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>
131 lines
8.8 KiB
TypeScript
131 lines
8.8 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(); }
|
|
/** Keep fixture credentials out of Playwright request traces. */
|
|
async postSensitive<T = any>(route: string, data: unknown, options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<T> {
|
|
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");
|
|
const signal = AbortSignal.any([AbortSignal.timeout(timeout), ...(options.signal ? [options.signal] : [])]);
|
|
const { cookies } = await this.request.storageState();
|
|
const cookie = cookies.filter(c => (target.hostname === c.domain || c.domain.startsWith(".") && target.hostname.endsWith(c.domain))
|
|
&& (target.pathname === c.path || target.pathname.startsWith(c.path.endsWith("/") ? c.path : `${c.path}/`))
|
|
&& (!c.secure || target.protocol === "https:")
|
|
&& (c.expires === -1 || c.expires * 1000 > Date.now())).map(c => `${c.name}=${c.value}`).join("; ");
|
|
try {
|
|
signal.throwIfAborted();
|
|
const response = await fetch(target, { method: "POST", redirect: "error", signal,
|
|
headers: { "content-type": "application/json", Origin: this.origin, ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(data) });
|
|
if (!response.ok) { await response.body?.cancel(); throw new ConnectionFailure(`api_status_${response.status}`); }
|
|
return await response.json() as T;
|
|
} catch (error) {
|
|
if (error instanceof ConnectionFailure) throw error;
|
|
throw new ConnectionFailure("sensitive_api_request_failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
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) };
|
|
}
|