mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
feat(runner): add experimental two-way OpenAI Dot provider
This commit is contained in:
1 parent
cd4739aa9c
commit
bc1220336d
74 files changed
+8429
-230
No files matched your search
@@ -0,0 +1,20 @@
|
||||
// Local operator companion. Never print or send the control credential remotely.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [file, action, requestId] = process.argv.slice(2);
|
||||
assert(file && ["status", "approve", "queue", "stop"].includes(action),
|
||||
"Usage: node server/scripts/dot-runner-live-control.mjs <control.json> status|approve <requestId>|queue|stop");
|
||||
const config = JSON.parse(await readFile(file, "utf8"));
|
||||
const origin = new URL(config.controlOrigin);
|
||||
assert(origin.protocol === "http:" && origin.hostname === "127.0.0.1" && origin.pathname === "/" && !origin.username && !origin.password);
|
||||
assert(typeof config.token === "string" && /^[A-Za-z0-9_-]{43}$/.test(config.token));
|
||||
if (action === "approve") assert(requestId, "Inspect status and supply the exact pending request ID to approve");
|
||||
const response = await fetch(new URL(`/${action}`, origin), {
|
||||
method: action === "status" ? "GET" : "POST",
|
||||
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
|
||||
...(action === "approve" ? { body: JSON.stringify({ requestId }) } : {}),
|
||||
redirect: "error", signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
console.log(JSON.stringify(await response.json(), null, 2));
|
||||
if (!response.ok) process.exitCode = 1;
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Disposable real-Dot transport lab. Not production scheduling or admission.
|
||||
* Only the MCP listener may be tunneled. Control stays on a separate loopback
|
||||
* port with a random bearer credential stored in a mode-0600 local file.
|
||||
* No existing Paperclip database, user, API key, or company is loaded. */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import { z } from "zod";
|
||||
import { activityLog, authUsers, companies, companyMemberships, createDb, issues, mcpEventSubscriptions, mcpEventDeliveries, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { DotHarnessDriver } from "../../packages/paperclip-runner/src/drivers/dot/dot-harness-driver.js";
|
||||
import { HarnessDriverBackend } from "../../packages/paperclip-runner/src/backends/harness-driver-backend.js";
|
||||
import { publicMcpIngressRoutes } from "../src/routes/public-mcp.js";
|
||||
import { createPublicMcpOAuth } from "../src/services/public-mcp/oauth.js";
|
||||
import { createPublicMcpEvents } from "../src/services/public-mcp/events.js";
|
||||
import { createPublicMcpExecutor, McpApiError, type ApiDispatch } from "../src/services/public-mcp/capabilities.js";
|
||||
import { createDotRunnerMcpBridge } from "../src/services/public-mcp/dot-runner.js";
|
||||
import { instanceSettingsService } from "../src/services/instance-settings.js";
|
||||
|
||||
const origin = new URL(process.env.DOT_LAB_ORIGIN ?? "").origin;
|
||||
assert(origin.startsWith("https://"), "DOT_LAB_ORIGIN must be the public HTTPS tunnel origin");
|
||||
const port = z.coerce.number().int().min(1024).max(65534).parse(process.env.DOT_LAB_PORT ?? 43127);
|
||||
const requestIdSchema = z.string().regex(/^pcmcp_request_[A-Za-z0-9_-]{43}$/);
|
||||
const scratch = await mkdtemp(join(tmpdir(), "paperclip-dot-live-"));
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(scratch, "master.key");
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_CLOUD_API_ORIGIN;
|
||||
const controlToken = randomBytes(32).toString("base64url");
|
||||
const app = express(); app.use(express.json({ limit: "256kb" }));
|
||||
const control = express(); control.use(express.json({ limit: "16kb" }));
|
||||
const http = createServer(app), admin = createServer(control);
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let service: ReturnType<typeof createPublicMcpEvents> | undefined;
|
||||
let unregister: (() => void) | undefined;
|
||||
let active = true;
|
||||
async function listen(server: Server, listenPort: number) { server.listen(listenPort, "127.0.0.1"); await once(server, "listening"); }
|
||||
async function close(server: Server) { server.closeAllConnections(); if (server.listening) await new Promise<void>(resolve => server.close(() => resolve())); }
|
||||
const escape = (value: string) => value.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-dot-live-");
|
||||
const db = createDb(database.connectionString);
|
||||
const oauth = createPublicMcpOAuth(db, { origin, resource: origin + "/mcp/paperclip" });
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true });
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Dot lab operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Disposable Dot integration lab", issuePrefix: "DOT" }).returning(); assert(company);
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [task] = await db.insert(issues).values({ companyId: company.id, title: "Dot live test inbox", status: "todo" }).returning(); assert(task);
|
||||
const actor = { type: "board" as const, source: "session" as const, userId };
|
||||
const pending = new Map<string, Awaited<ReturnType<typeof oauth.describeRequest>>>();
|
||||
const redirects = new Map<string, string>();
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
const reports: string[] = [];
|
||||
const transcript: unknown[] = [];
|
||||
const record = (kind: string, fields: Record<string, unknown> = {}) => {
|
||||
const entry = { at: new Date().toISOString(), kind, ...fields }; records.push(entry);
|
||||
if (records.length > 300) records.shift();
|
||||
console.log(JSON.stringify(entry));
|
||||
};
|
||||
let native: Awaited<ReturnType<HarnessDriverBackend["openSession"]>> | undefined;
|
||||
let grantId: string | undefined;
|
||||
let queued = false;
|
||||
let stopLab!: () => void;
|
||||
const stopped = new Promise<void>(resolve => { stopLab = resolve; });
|
||||
const api: ApiDispatch = async (principal, method, path) => {
|
||||
if (principal.grant.companyId !== company.id || method !== "GET" || path !== `/issues/${task.id}`) throw new McpApiError(403);
|
||||
return task;
|
||||
};
|
||||
const bridge = createDotRunnerMcpBridge();
|
||||
// Real remote callback transport: retain production HTTPS, DNS/IP pinning,
|
||||
// verification challenge, encryption, signatures and retry policy unchanged.
|
||||
service = createPublicMcpEvents(db, oauth, api, { enableDotPrototype: true });
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer");
|
||||
if (req.path === "/mcp/paperclip" && req.method === "POST") {
|
||||
const method = String(req.body?.method ?? "").slice(0, 100);
|
||||
const tool = method === "tools/call" ? String(req.body?.params?.name ?? "").slice(0, 100) : undefined;
|
||||
res.on("finish", () => record("mcp", { method, ...(tool ? { tool } : {}), status: res.statusCode }));
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get("/", (_req, res) => res.type("text").send("Disposable Paperclip Dot lab. Authenticated MCP endpoint: /mcp/paperclip. No production data."));
|
||||
app.get("/mcp-connect/:id", async (req, res) => {
|
||||
const id = requestIdSchema.parse(req.params.id);
|
||||
const redirect = redirects.get(id);
|
||||
if (redirect) { res.redirect(303, redirect); return; }
|
||||
const request = await oauth.describeRequest(id, actor, null);
|
||||
if (pending.size >= 20 && !pending.has(id)) { res.sendStatus(429); return; }
|
||||
pending.set(id, request);
|
||||
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
|
||||
res.type("html").send(`<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>Paperclip Dot lab connection</title><h1>Connect the disposable Dot lab</h1><p>Waiting for the local lab operator to approve this request.</p><p>Client: ${escape(request.clientName)}. Return destination: ${escape(request.redirectOrigin)}.</p><p>This lab contains one test task and no existing Paperclip data. It expires when the lab closes.</p><p>Request: ${escape(id)}</p>`);
|
||||
});
|
||||
app.use(publicMcpIngressRoutes(oauth, createPublicMcpExecutor(db, oauth, api), service, bridge));
|
||||
const errors: express.ErrorRequestHandler = (_error, _req, res, _next) => { res.status(400).json({ error: "Lab request unavailable" }); };
|
||||
app.use(errors);
|
||||
control.use((req, res, next) => {
|
||||
const supplied = Buffer.from(req.headers.authorization ?? "");
|
||||
const expected = Buffer.from(`Bearer ${controlToken}`);
|
||||
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) { res.sendStatus(401); return; }
|
||||
next();
|
||||
});
|
||||
control.get("/status", async (_req, res) => res.json({ companyId: company.id, taskId: task.id, connected: !!native, queued,
|
||||
pending: [...pending.values()], reports, transcript, records,
|
||||
subscriptions: await db.select({ id: mcpEventSubscriptions.id, name: mcpEventSubscriptions.name, stoppedAt: mcpEventSubscriptions.stoppedAt }).from(mcpEventSubscriptions),
|
||||
deliveries: await db.select({ id: mcpEventDeliveries.id, outcome: mcpEventDeliveries.outcome, attempts: mcpEventDeliveries.attempts }).from(mcpEventDeliveries),
|
||||
}));
|
||||
// Explicit local operator approval, never a public auto-consent route.
|
||||
control.post("/approve", async (req, res) => {
|
||||
assert(!grantId, "This lab permits only one connection");
|
||||
const requestId = requestIdSchema.parse(req.body.requestId);
|
||||
assert(pending.has(requestId), "Visit the public consent page first");
|
||||
const consent = await oauth.consent(requestId, actor, { decision: "approve", companyId: company.id, allowWrites: true });
|
||||
const grants = await oauth.listConnections(userId); assert.equal(grants.length, 1);
|
||||
grantId = grants[0]!.id;
|
||||
const identity = { companyId: company.id, agentId: randomUUID(), issueId: task.id, runId: randomUUID(), sessionId: randomUUID() };
|
||||
const driver = new DotHarnessDriver({ identity, principal: { companyId: company.id, grantId }, expiresAt: Date.now() + 2 * 3600_000,
|
||||
assertAuthority: async () => { assert(active); await oauth.authorizeGrant(grantId!); },
|
||||
tools: [{ name: "save_report", description: "Save a short harmless report in this disposable lab only", inputSchema: { type: "object", properties: { body: { type: "string", maxLength: 2000 } }, required: ["body"], additionalProperties: false } }],
|
||||
executeTool: async ({ name, arguments: args }) => { assert.equal(name, "save_report"); const { body } = z.object({ body: z.string().min(1).max(2000) }).strict().parse(args); reports.push(body); record("report_saved", { body }); return { saved: true, reportNumber: reports.length }; },
|
||||
publish: async assignment => { await db.insert(activityLog).values({ companyId: company.id, actorType: "agent", actorId: identity.agentId, action: "dot.work_available", entityType: "issue", entityId: task.id, details: { ...assignment } }); record("assignment_published", { ...assignment }); },
|
||||
});
|
||||
unregister = bridge.register(driver, grantId);
|
||||
native = await new HarnessDriverBackend(driver).openSession({ identity });
|
||||
void (async () => { for await (const event of native!.events()) { transcript.push(event); record("runner_event", { event }); } })().catch(() => record("runner_stream_error"));
|
||||
redirects.set(requestId, consent.redirectUrl); pending.delete(requestId);
|
||||
record("connection_approved"); res.json({ connected: true, companyId: company.id, taskId: task.id });
|
||||
});
|
||||
control.post("/queue", async (_req, res) => {
|
||||
assert(native && !queued, "Connect first; this lab permits one task");
|
||||
const subscriptions = await db.select().from(mcpEventSubscriptions);
|
||||
assert(subscriptions.some(s => s.grantId === grantId && s.name === "paperclip.dot.work_available" && !s.stoppedAt && s.expiresAt > new Date()), "Dot must establish its event subscription first");
|
||||
queued = true;
|
||||
await native.startTurn({ message: { role: "user", text: "Live integration test: compute 17 + 25. Call the projected save_report tool with body 'Dot received the Paperclip event. 17 + 25 = 42.' Then finish with schema paperclip.run_result.v1, reportedWorkDisposition 'done', summary stating the saved result, completionClaim {contractRevision:'dot-live-v1',objectiveSatisfied:true,criteria:[],remainingWork:[]}, and empty arrays for evidence, verification, attentionRequests, artifacts. Do not use any other apps, private data, external messages, or additional tasks." } });
|
||||
await service!.tick(); res.json({ queued: true });
|
||||
});
|
||||
control.post("/stop", (_req, res) => { res.json({ stopping: true }); stopLab(); });
|
||||
control.use(errors);
|
||||
await listen(http, port); await listen(admin, port + 1); service.start();
|
||||
await writeFile(join(scratch, "control.json"), JSON.stringify({ origin, controlOrigin: `http://127.0.0.1:${port + 1}`, token: controlToken, companyId: company.id, taskId: task.id }), { mode: 0o600 });
|
||||
console.log(`LIVE LAB READY ${origin}/mcp/paperclip`);
|
||||
console.log(`Local control file: ${join(scratch, "control.json")}`);
|
||||
console.log(`Standing instruction: Subscribe to paperclip.dot.work_available for companyId ${company.id}, taskId ${task.id}. When an event arrives, check paperclip_dot_inbox; read, accept, execute and finish its assignment. Do not poll. Wait for the event before taking work.`);
|
||||
process.once("SIGINT", stopLab); process.once("SIGTERM", stopLab);
|
||||
const timer = setTimeout(stopLab, 2 * 3600_000); timer.unref();
|
||||
await stopped; clearTimeout(timer);
|
||||
if (grantId) await oauth.revokeConnection(grantId, userId);
|
||||
} finally {
|
||||
active = false; unregister?.(); await service?.stop(); await close(http); await close(admin);
|
||||
await database?.cleanup(); await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user