mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
117 lines
12 KiB
TypeScript
117 lines
12 KiB
TypeScript
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { createServer } from "node:http";
|
|
import { createHash } from "node:crypto";
|
|
import { cp, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import { expect } from "@playwright/test";
|
|
import { createDb } from "../../../packages/db/src/index.js";
|
|
import { listenOnFetchAllowedPort } from "../../e2e/fetch-allowed-port.js";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { pollUntil } from "../api.js";
|
|
import { sendChatMessage } from "../chat-flow.js";
|
|
import { checks, project, task, type Entity } from "./internal-support.js";
|
|
import { privilegedDefinition, privilegedObservation, settlePrivilegedChat, companionPhase } from "./privileged-support.js";
|
|
const execute = promisify(execFile);
|
|
interface OwnedDev { root: string; checkout: string; home: string; instance: string; env: NodeJS.ProcessEnv; loader: string; entry: string; child?: ChildProcess; closed?: Promise<void>; config?: string }
|
|
const owned = new WeakMap<ChatCatalogContext, OwnedDev>();
|
|
const source = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const digest = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
|
export function validOwnedDevResult(s: Record<string, unknown>) {
|
|
return s.sourceCanaryUnchanged === true && s.targetCanaryMatches === true && s.actualTargetHealthy === true
|
|
&& s.targetStopped === true && s.targetCleaned === true && s.unrelatedSentinelUnchanged === true
|
|
&& s.sourceRevisionPinned === true && typeof s.targetUrl === "string" && s.sourceCliMatches === true;
|
|
}
|
|
async function command(o: OwnedDev, args: string[]) {
|
|
try { return (await execute(process.execPath, ["--import", o.loader, o.entry, ...args], { cwd: o.checkout, env: o.env, timeout: 240000, maxBuffer: 1_048_576 })).stdout; }
|
|
catch { throw new Error("Owned development-instance CLI command failed; private output withheld"); }
|
|
}
|
|
async function stop(o: OwnedDev) {
|
|
if (!o.child || !o.closed) return;
|
|
if (o.child.exitCode === null && o.child.signalCode === null) o.child.kill("SIGTERM");
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try { await Promise.race([o.closed, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("Owned CLI child did not stop; preserve its root instead of deleting live state")), 30000); })]); }
|
|
finally { if (timer) clearTimeout(timer); }
|
|
o.child = undefined;
|
|
}
|
|
export function gradeOwnedDev(value: unknown) {
|
|
const c = checks(), o = privilegedObservation(value); c.add("actual-dev-instance-evidence", !!o, "Actual supported owned checkout initialization, seeding, server readiness and cleanup are required."); if (!o) return c.result();
|
|
c.add("exact-owned-seeded-and-cleaned", validOwnedDevResult(o.state), "Only the owned checkout/instance is created and removed; copied canary rows, source state and unrelated sentinel independently verify isolation.");
|
|
c.add("operator-dev-guidance", /worktree|dev.*instance|isolated.*instance/i.test(String(o.state.guidance)) && /operator|terminal|human|checkout/i.test(String(o.state.guidance))
|
|
&& !/I (?:have |successfully )?(?:created|started|set up)/i.test(String(o.state.guidance)), "Initial chat assigns host changes to the explicit operator companion.");
|
|
const phase = o.state.devReport as { attributed?: boolean; reply?: string } | undefined;
|
|
c.add("native-reports-real-target", phase?.attributed === true && phase.reply?.includes(String(o.state.targetUrl)) === true
|
|
&& /health|ready|clean|stopp/i.test(phase.reply), "The actual follow-up reports the measured target URL and its final readiness/cleanup state.");
|
|
return c.result();
|
|
}
|
|
const dev = privilegedDefinition("cli-operations.owned-dev-instance", "privileged-owned-dev", {
|
|
async setup(ctx) {
|
|
const temp = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT, sourceConfig = process.env.PAPERCLIP_CONFIG, revision = process.env.PAPERCLIP_RUNNER_E2E_SOURCE_SHA;
|
|
if (!temp || !sourceConfig || !revision || !/^[a-f0-9]{40}$/.test(revision)) throw new Error("Owned dev companion requires a source-pinned immutable candidate and private source config");
|
|
const root = await mkdtemp(path.join(await realpath(temp), "operator-dev-"));
|
|
if (!(await realpath(sourceConfig)).startsWith(await realpath(temp) + path.sep)) throw new Error("Dev seed source config is outside this attempt");
|
|
const checkout = path.join(root, "checkout"), home = path.join(root, "instances-home");
|
|
const o: OwnedDev = { root, checkout, home, instance: `eval-${ctx.input.nonce.replace(/[^a-z0-9]/gi, "").slice(0, 32)}`,
|
|
loader: path.join(source, "cli/node_modules/tsx/dist/loader.mjs"), entry: path.join(checkout, "cli/src/index.ts"),
|
|
env: { PATH: process.env.PATH, HOME: path.join(root, "operator-home"), PAPERCLIP_HOME: home, PAPERCLIP_TELEMETRY_DISABLED: "1", PAPERCLIP_NO_BROWSER: "1", NO_COLOR: "1" } };
|
|
owned.set(ctx, o);
|
|
// Copy actual selected candidate sources. Dependency links resolve only to
|
|
// this immutable campaign candidate, never the shared mutable worktree.
|
|
const excluded = new Set(["node_modules", ".git", "dist", "target", "results", ".paperclip", "data", "__pycache__", ".env", ".codex", ".secrets"]);
|
|
await cp(source, checkout, { recursive: true, filter: file => !path.relative(source, file).split(path.sep).some(part => excluded.has(part)) });
|
|
for (const relative of ["", "cli", "server", "ui", "packages/db", "packages/shared"]) {
|
|
const modules = path.join(source, relative, "node_modules");
|
|
try { if ((await stat(modules)).isDirectory()) await symlink(await realpath(modules), path.join(checkout, relative, "node_modules")); }
|
|
catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; }
|
|
}
|
|
await mkdir(o.env.HOME!, { recursive: true });
|
|
await execute("git", ["init", "--initial-branch=owned-eval"], { cwd: checkout, env: { ...o.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, timeout: 10000 });
|
|
const sentinel = path.join(root, "unrelated-owned-sentinel.txt"); await writeFile(sentinel, ctx.input.nonce, { mode: 0o600 });
|
|
const launch = await project(ctx), canary = await task(ctx, "Owned dev seed canary", launch.id, { description: `Retain ${ctx.input.nonce}` });
|
|
return { task: canary, sourceConfigOwned: true, sourceRevision: revision, sourceRevisionPinned: true,
|
|
sourceCliMatches: digest(await readFile(o.entry)) === digest(await readFile(path.join(source, "cli/src/index.ts"))),
|
|
dependencySource: "explicit immutable candidate modules; source and compiled dependency provenance retained in campaign snapshot receipt" };
|
|
},
|
|
async act(ctx, state) {
|
|
const initial = await settlePrivilegedChat(ctx); state.guidance = initial.reply; const o = owned.get(ctx)!;
|
|
const reserve = async () => { const listener = createServer(); const port = await listenOnFetchAllowedPort(listener); await new Promise<void>((resolve, reject) => listener.close(e => e ? reject(e) : resolve())); return port; };
|
|
const serverPort = await reserve(), dbPort = await reserve();
|
|
if (serverPort === dbPort) throw new Error("Owned dev ports unexpectedly collide");
|
|
await command(o, ["worktree", "init", "--instance", o.instance, "--home", o.home, "--from-config", process.env.PAPERCLIP_CONFIG!, "--server-port", String(serverPort), "--db-port", String(dbPort), "--seed-mode", "minimal"]);
|
|
o.config = path.join(o.checkout, ".paperclip/config.json");
|
|
const actual = JSON.parse(await readFile(o.config, "utf8"));
|
|
const actualData = await realpath(actual.database.embeddedPostgresDataDir);
|
|
if (!actualData.startsWith(o.root + path.sep) || actual.database.embeddedPostgresPort !== dbPort || actual.server.port !== serverPort) throw new Error("Public worktree config escapes the exact owned target");
|
|
o.child = spawn(process.execPath, ["--import", o.loader, o.entry, "run", "--config", o.config, "--yes"], { cwd: o.checkout, env: o.env, stdio: "ignore" });
|
|
o.closed = new Promise<void>((resolve, reject) => { o.child!.once("error", reject); o.child!.once("close", () => resolve()); });
|
|
state.targetUrl = `http://127.0.0.1:${serverPort}`;
|
|
await pollUntil({ label: "Actual owned development target readiness", deadlineAt: Date.now() + 120000,
|
|
load: async () => { try { const r = await fetch(new URL("/api/health", String(state.targetUrl)), { signal: AbortSignal.timeout(3000) }); return { ok: r.ok, health: await r.json() as { status?: string } }; } catch { return { ok: false, health: {} }; } },
|
|
accept: r => r.ok && r.health.status === "ok", reject: () => o.child?.exitCode != null ? "Owned dev server exited before readiness" : undefined }); state.actualTargetHealthy = true;
|
|
const db = createDb(`postgres://paperclip:paperclip@127.0.0.1:${dbPort}/paperclip`, { maxConnections: 1, prepare: false }).$client;
|
|
const marker = state.task as Entity;
|
|
try { const rows = await db.unsafe("select id, company_id, title, description, status from issues where id = $1", [marker.id]);
|
|
state.targetCanaryMatches = rows.length === 1 && rows[0]!.company_id === marker.companyId && rows[0]!.title === marker.title && rows[0]!.description === marker.description && rows[0]!.status === marker.status;
|
|
} finally { await db.end({ timeout: 5 }); }
|
|
const sourceTask = await ctx.input.api.get<Entity>(`/api/issues/${marker.id}`);
|
|
state.sourceCanaryUnchanged = sourceTask.title === marker.title && sourceTask.description === marker.description && sourceTask.status === marker.status;
|
|
await stop(o); state.targetStopped = true;
|
|
await command(o, ["worktree:cleanup", o.instance, "--instance", o.instance, "--home", o.home]);
|
|
state.targetCleaned = !(await stat(path.join(o.home, "instances", o.instance)).then(() => true, e => { if (e.code === "ENOENT") return false; throw e; }));
|
|
state.unrelatedSentinelUnchanged = await readFile(path.join(o.root, "unrelated-owned-sentinel.txt"), "utf8") === ctx.input.nonce;
|
|
const receipt = { targetUrl: state.targetUrl, healthyWhenStarted: state.actualTargetHealthy, targetStopped: state.targetStopped, targetCleaned: state.targetCleaned,
|
|
seededCanaryMatches: state.targetCanaryMatches, sourceCanaryUnchanged: state.sourceCanaryUnchanged, unrelatedSentinelUnchanged: state.unrelatedSentinelUnchanged, sourceRevision: state.sourceRevision };
|
|
await ctx.input.evidence("chat-catalog-owned-dev-instance.json", receipt);
|
|
const message = `The operator used the supported owned development-instance commands and recorded these facts. Please report its URL and observed health/cleanup state:\n\n${JSON.stringify(receipt)}`;
|
|
await sendChatMessage(ctx.input.page, message); state.devReport = await companionPhase(ctx, initial.runs.map(r => r.id), message);
|
|
}, grade: gradeOwnedDev, async verifyResult(ctx) { await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); },
|
|
});
|
|
dev.expectedRunCount = 2; dev.expectedHumanMessageCount = 2; dev.attemptTimeoutMs = 900000;
|
|
dev.cleanup = async ctx => { const o = owned.get(ctx); if (!o) return; await stop(o); if (o.config) await command(o, ["worktree:cleanup", o.instance, "--instance", o.instance, "--home", o.home]);
|
|
const root = await realpath(process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT!), directory = await realpath(o.root);
|
|
if (!directory.startsWith(root + path.sep) || !path.basename(directory).startsWith("operator-dev-")) throw new Error("Refusing non-owned development cleanup");
|
|
await rm(directory, { recursive: true }); owned.delete(ctx); };
|
|
dev.sourceFiles = [...dev.sourceFiles, "../../cli/src/index.ts", "../../cli/src/commands/worktree.ts", "../../cli/src/commands/run.ts", "../e2e/fetch-allowed-port.ts"];
|
|
export const privilegedOwnedDevCases = [dev];
|