Files
PaperClipAI/scripts/runner-api-eval-worker.ts
T
DottaandPaperclip e095b84dab feat(connections): connect services from native task feeds (#13058)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents use connections to reach external services.
> - A fresh native task can have no service tools installed.
> - The agent needs a way to discover services and ask the responsible
person for access.
> - This pull request brings the existing connection-intent flow into
native task execution.
> - The person can connect from the task, and the agent can continue
with updated tools.

## Linked Issues or Issue Description

**Subsystem affected**

Native runner tool authority, connection intents, task interactions, and
shared connection setup.

**Problem or motivation**

A task that needs an unconnected service cannot finish its work. Leaving
the task to configure access also loses context. A resolved request must
survive a restart and resume the correct agent once.

**Proposed solution**

Expose connection discovery and access requests as server-owned native
tools. Render a durable task card and use the shared setup dialog.
Persist outcome delivery and start a fresh provider session after access
is ready.

**Alternatives considered**

Sending the person to the Connections page adds navigation and does not
solve continuation. Polling for authorization consumes runs and can
create duplicate requests.

**Roadmap alignment**

This extends the existing connection-intent runtime and setup
experience. It reuses the shared access model and the native runner.

Related: #12345, #12347. The service-slug fix in #12906 is related but
separate. Companion evaluation PR:
https://github.com/paperclipai/paperclip-evals/pull/21.

## What Changed

- Expose `connections_search` and `connection_request` with server-bound
company, task, agent, and responsible user. Preserve the legacy entry
points.
- Discover catalog services and authorized custom connections. Check
installation, identity, health, and executable permissions before
reporting ready.
- Keep pending cards through ordinary messages. Reuse requests and
retire stale ownership. Put Connect at the right of Not now.
- Reuse the shared setup flow in a task dialog. Keep access additive and
default to the requesting agent. Recover from cancelled or blocked OAuth
windows with a new-tab fallback.
- Persist outcome delivery with an idempotent wake key. Resume in a
fresh session and recheck ownership before dispatch.
- Add native browser fixtures, offline Storybook states, server
contracts, and evaluation fixtures. Update guidance and documentation.

## Verification

- `pnpm build`: passed after replaying the change on current master.
- `pnpm -r typecheck`: passed.
- `pnpm check:token-gates`: passed.
- `pnpm --filter @paperclipai/ui build-storybook`: passed.
- New continuation-policy regression cases: 16 passed.
- Docker-backed PostgreSQL regressions passed for requester-only OAuth
access, assignment-only expiry, terminal expiry, and credential-free
setup metadata.
- Shared setup and task-card UI tests: 121 passed, including configured
MCP reconnect URL recovery and preserving user edits across refetch.
- Storybook browser checks: all 119 passed on the latest reconnect fix.
- `pnpm test:run`: 4,734 tests passed in the first server group, but
embedded PostgreSQL startup failures and resulting cleanup errors
prevented a complete local pass. All Linux CI lanes passed on the latest
reviewed commit. One external-object route test returned an unexplained
500 on the first run; it passed twice locally and the failed shard
passed on retry without code changes.
- Earlier feature-checkout evidence: three deterministic native browser
journeys passed, including restart delivery and an actual fixture tool
result. Legacy scripted coverage also passed. All 59 added stories were
inspected in light and dark themes.
- Live Notion testing recorded successful provider reads. The manual
test used a local-trusted instance. It does not prove
authenticated/cloud deployment or every provider journey.
- Native browser rerun reached the embedded PostgreSQL startup limit
before bootstrap, so the latest checkout’s full native browser journey
remains unverified. Both OAuth page/task regression cases passed against
isolated Docker-backed PostgreSQL 17. They verify no premature task
access, requester-only completion, additive retries, and reconnect
preservation.
- Applied both new migrations twice to isolated PostgreSQL 17. Foreign
keys remained intact, duplicate active delivery keys were rejected, and
failed delivery records did not block retries.

Reviewer path: start a fresh test drive, enable the native runner, use
an agent that can perform work directly, and ask it to summarize a
Notion page. Connect from the card, then verify the resumed provider
call and source-linked answer. The default test-drive CEO is instructed
to delegate, so it can introduce an unrelated hiring step.

## Risks

- Two additive migrations create durable deliveries and a partial unique
wake index. They are idempotent. The wake index can require a
maintenance window on large tables because migrations run in a
transaction.
- OAuth and continuation cross asynchronous boundaries. Tests cover
ownership changes, retries, additive access, and restart delivery; live
provider behavior still varies.
- The latest requester-scope fix has not yet been exercised through live
OAuth. GitHub, API-key, authenticated-user, and all recovery journeys
are not claimed as verified.

## Model Used

OpenAI GPT-6-based Codex assisted with implementation, tests, and review
using tools and code execution. The runtime does not expose the exact
model version, context window, or reasoning setting. Live evaluation
used `gpt-5.6-luna`; manual native testing used `gpt-5.6-sol`.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used and disclosed unavailable runtime
details
- [x] I have checked ROADMAP.md and confirmed this extends existing
connection 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 feature issue
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
- [ ] I have run all required 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 P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-08 15:55:26 -05:00

222 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** JSONL worker for the companion paperclip-evals API suite. Never selects cases or retries. */
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, realpathSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { homedir } from "node:os";
import { runnerApiCatalog } from "../server/src/services/native-runtime/runner-api-catalog.js";
import { startRunnerApiTestServer } from "../server/src/__tests__/helpers/runner-api-server.js";
import { registerRunnerPrpAuthority } from "../server/src/realtime/runner-prp-ws.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../packages/paperclip-runner/src/live/runnerd-codex-transport.js";
import { createSkilllessCodexThreadConfig } from "../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.js";
import { AttemptJournal } from "../packages/paperclip-runner/src/evals/attempt-journal.js";
import { estimateModelCostNanodollars } from "../packages/paperclip-runner/src/evals/model-pricing.js";
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "../packages/shared/src/connection-intent-guidance.js";
if (process.argv.includes("--catalog")) {
process.stdout.write(JSON.stringify(runnerApiCatalog()) + "\n");
process.exit(0);
}
if (!process.argv.includes("--jsonl")) throw new Error("Use --catalog or --jsonl; there is no default campaign");
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID() + randomUUID();
const output = (value: unknown) => process.stdout.write("RUNNER_API_EVAL " + JSON.stringify(value) + "\n");
const OPENROUTER_MODELS = new Set(["openrouter/anthropic/claude-sonnet-5", "openrouter/deepseek/deepseek-v4-flash-0731", "openrouter/google/gemini-3.8-flash"]);
// The controller selects and injects one provider credential. The worker never
// reads ambient home credential files or desktop keychains.
function openRouterEnvironment() {
const key = process.env.OPENROUTER_API_KEY;
if (!key || /\s/.test(key)) throw new Error("Controller must inject OPENROUTER_API_KEY");
return { PATH: process.env.PATH, OPENROUTER_API_KEY: key };
}
const server = await startRunnerApiTestServer();
const fileDigests = new Map<string, Promise<string | null>>();
function fileDigest(path: string): Promise<string | null> {
let digest = fileDigests.get(path);
if (!digest) {
digest = (async () => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
})().catch(() => null);
fileDigests.set(path, digest);
}
return digest;
}
const runtimeBuild = {
runnerBinarySha256: await fileDigest(defaultCapabilityRunnerdBinary()),
workerSourceSha256: await fileDigest(resolve("scripts/runner-api-eval-worker.ts")),
lockfileSha256: await fileDigest(resolve("pnpm-lock.yaml")),
};
output({ ready: true });
const record = (value: unknown): Record<string, any> => value && typeof value === "object" ? value as Record<string, any> : {};
try {
for await (const line of createInterface({ input: process.stdin })) {
const request = JSON.parse(line);
if (request.shutdown) break;
const directory = resolve(request.outputDirectory);
await mkdir(directory, { recursive: true, mode: 0o700 });
const journal = new AttemptJournal(join(directory, "journal.jsonl"));
const startedAt = new Date().toISOString();
journal.append({ kind: "attempt_started", attemptId: request.attemptId, model: request.model, startedAt });
const started = performance.now();
const isOpenRouter = OPENROUTER_MODELS.has(request.model);
const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
let providerVersion: string | null = null;
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, connectionScenario: request.connectionScenario });
const initialState = await fixture.snapshot();
const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
const calls: Record<string, any>[] = [], notifications: unknown[] = [];
const observedToolCallIds = new Set<string>();
const definitions = await fixture.authority.definitions();
const prompt = expand(request.prompt ?? "");
let error: string | null = null, evidence: unknown = null, thread: Record<string, any> = {}, usage: Record<string, any> | null = null;
let usageUpdates = 0, lastUsageFingerprint = "", terminalSeen = false, providerTurnStarted = false;
let diagnosticTail: string | null = null;
let bundle: ReturnType<typeof createRunnerdCodexTransport> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const invoke = async (tool: string, args: unknown, callId: string) => {
observedToolCallIds.add(callId);
if (observedToolCallIds.size > 12) throw new Error("Attempt tool-call ceiling reached");
const call: Record<string, any> = { tool, arguments: args, callId, startedMs: performance.now() - started };
calls.push(call);
journal.append({ kind: "tool_dispatch", ...call });
try { call.result = await fixture.authority.execute({ tool, arguments: args, callId }); }
catch (caught) { call.error = caught instanceof Error ? caught.message : String(caught); call.status = record(caught).status; }
call.durationMs = performance.now() - started - call.startedMs;
journal.append({ kind: "tool_result", ...call });
return call;
};
try {
if (request.calls) {
for (const [i, call] of request.calls.entries()) await invoke(call.tool, expand(call.arguments), call.callId ?? `direct-${i}`);
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, accountingProvenance: "Provider-free direct authority contract; no provider or runnerd dispatched" };
} else {
if (request.model === "claude-sonnet-5" && (process.platform !== "linux" || process.arch !== "x64")) throw new Error("Qualified ACPX Claude requires Linux x64; no provider turn was dispatched");
if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
if (isOpenRouter) {
providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim();
if (providerVersion !== "1.18.29") throw new Error("OpenCode profile requires version 1.18.29");
}
const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => {
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!token) throw new Error("Controller must inject CLAUDE_CODE_OAUTH_TOKEN");
return { PATH: process.env.PATH, CLAUDE_CODE_OAUTH_TOKEN: token };
})() : undefined;
bundle = createRunnerdCodexTransport({
provider, acpxAgent: "claude", acpxPermissionMode: "approve-reads",
environment: { ...providerEnvironment, PAPERCLIP_PROVIDER_TRACE_PATH: join(directory, "provider-trace.jsonl"), PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(32 * 1024 * 1024) },
codexCommand: request.model === "gpt-5.6-luna" ? realpathSync(execFileSync("which", ["codex"], { encoding: "utf8" }).trim()) : undefined,
sourceCodexHome: process.env.CODEX_HOME ?? join(homedir(), ".codex"),
runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: join(server.root, `runner-${request.attemptId}`),
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: { runnerInstanceId: `runner-${fixture.runId}`, environmentLeaseId: `lease-${fixture.runId}`, runId: fixture.runId, normalizedSessionId: `session-${fixture.runId}`, turnId: `turn-${fixture.runId}`, itemId: `item-${fixture.runId}` },
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
});
const active = bundle;
timer = setTimeout(() => { error = "Attempt exceeded 120 seconds"; void active.transport.close(error); }, 120_000);
bundle.transport.setServerRequestHandler(async (request) => {
const params = record(request.params);
const result = await invoke(String(params.tool), params.arguments, String(params.callId));
return { success: !result.error, contentItems: [{ type: "inputText", text: JSON.stringify(result.error ? { ok: false, error: result.error, status: result.status } : { ok: true, result: result.result }) }] };
});
await bundle.transport.request("initialize", {});
thread = await bundle.transport.request("thread/start", {
cwd: fixture.workspace, model: request.model,
completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE,
dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
});
if (request.preflight) {
terminalSeen = true;
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, preflight: true };
} else {
journal.append({ kind: "provider_turn_dispatch", thread, at: new Date().toISOString() });
providerTurnStarted = true;
await bundle.transport.request("turn/start", { input: [{ type: "text", text: prompt }] });
for await (const notification of bundle.transport.notifications()) {
const retained = { ...notification, observedAt: new Date().toISOString() };
journal.append({ kind: "notification", ...retained });
notifications.push(retained);
const item = record(record(notification.params).item);
if (["dynamicToolCall", "tool_call", "tool_result"].includes(item.type) && typeof item.id === "string") {
observedToolCallIds.add(item.id);
if (observedToolCallIds.size > 12) { error = "Attempt tool-call ceiling reached, including provider-rejected calls"; break; }
}
if (notification.method === "thread/tokenUsage/updated") {
const params = record(notification.params);
const tokenUsage = record(params.tokenUsage);
const cumulative = record(tokenUsage.total ?? tokenUsage.totalTokenUsage ?? tokenUsage.total_token_usage ?? tokenUsage);
if (request.model === "claude-sonnet-5" && params.runDeltaAvailable !== true) continue;
const total = request.model === "claude-sonnet-5" ? record(tokenUsage.runDelta) : cumulative;
const cacheWriteTokens = total.cacheWriteTokens ?? 0;
const inputTokens = (total.inputTokens ?? total.input_tokens) + (request.model === "claude-sonnet-5" ? (total.cacheReadTokens ?? 0) : isOpenRouter ? (total.cacheReadTokens ?? total.cachedInputTokens ?? 0) : 0);
const outputTokens = total.outputTokens ?? total.output_tokens;
const cachedInputTokens = total.cacheReadTokens ?? total.cachedInputTokens ?? total.cache_read_input_tokens ?? 0;
if ([inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens].every(value => Number.isSafeInteger(value) && value >= 0)) {
const fingerprint = `${inputTokens}:${outputTokens}:${cachedInputTokens}`;
if (fingerprint !== lastUsageFingerprint) { usageUpdates++; lastUsageFingerprint = fingerprint; }
usage = { inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens, providerRequests: (total.requests ?? total.providerRequests ?? 0) > 0 ? total.requests ?? total.providerRequests : null, providerUsageUpdateCount: usageUpdates, providerRequestCountSource: (total.requests ?? total.providerRequests ?? 0) > 0 ? "provider" : "not reported; providerUsageUpdateCount is a proxy", providerReportedCostNanodollars: cumulative.providerCostUsd > 0 ? Math.round(cumulative.providerCostUsd * 1e9) : null, ...estimateModelCostNanodollars(request.model, { inputTokens, outputTokens, cachedInputTokens }) };
// A conservative one-hour cache-write ceiling (2× standard input)
// covers either cache duration when the runtime omits its TTL.
if (request.model === "claude-sonnet-5" || request.model === "openrouter/anthropic/claude-sonnet-5") {
usage.estimatedCostNanodollars += cacheWriteTokens * 4_000;
usage.cacheWriteUsdPerMillionTokens = 4;
usage.cacheWritePricing = "Conservative one-hour cache-write rate; TTL not exposed";
}
if (Math.max(usage.estimatedCostNanodollars, usage.providerReportedCostNanodollars ?? 0) >= 500_000_000) { error = "Attempt cost ceiling reached"; break; }
}
}
if (notification.method === "turn/completed") {
terminalSeen = true;
const params = record(notification.params);
const terminal = record(params.turn);
if ((params.status ?? terminal.status) === "failed") error = String(record(params.error ?? terminal.error).message ?? "Provider turn failed");
break;
}
}
}
evidence = bundle.evidence();
if (!request.preflight && (!usage || usage.inputTokens + usage.outputTokens === 0)) {
usage = null;
error ??= "Missing provider accounting; block further paid attempts";
}
}
} catch (caught) { error ??= caught instanceof Error ? caught.message : String(caught); }
finally {
if (timer) clearTimeout(timer);
if (bundle) {
evidence = bundle.evidence();
diagnosticTail = await readFile(join(server.root, `runner-${request.attemptId}`, "diagnostics", "runnerd.stderr.log"), "utf8").then(text => text.slice(-16000).replace(/sk-(?:ant-|or-v1-)[A-Za-z0-9_-]+/g, "[redacted]").replace(/Bearer\s+[^\s"']+/gi, "Bearer [redacted]").replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted JWT]")).catch(() => null);
}
await bundle?.transport.close();
}
if (!providerTurnStarted && !request.calls) {
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, accountingProvenance: "No provider turn/start was dispatched" };
}
const artifact = {
schema: "paperclip-runner/eval-session-artifact/v1", attemptId: request.attemptId,
requestedModel: request.calls ? "provider-free" : request.model ?? "provider-free", provider: request.calls ? "none" : provider, driver: request.calls ? "direct-authority-contract" : "real-server-api-tools",
evidenceMode: request.calls ? "provider-free-contract" : "live-provider",
providerSessionId: record(thread.thread).id ?? null, effectiveModel: record(thread.thread).model ?? null,
providerVersion: request.calls ? null : request.model === "gpt-5.6-luna" ? execFileSync("codex", ["--version"], { encoding: "utf8" }).trim() : providerVersion ?? record(evidence).providerVersion ?? null,
runtimeVersions: { node: process.versions.node, acpx: record(evidence).providerVersion, agentServer: record(evidence).agentServerVersion, agentRuntime: record(evidence).agentRuntimeVersion },
runtimeBuild,
timing: { startedAt, finishedAt: new Date().toISOString(), durationMs: performance.now() - started },
usage, accountingComplete: Boolean(request.calls) || !providerTurnStarted || terminalSeen, providerTurnStarted, observedProviderToolCalls: observedToolCallIds.size, diagnosticTail, error, calls, notifications, evidence, thread, prompt, arm: request.arm ?? "treatment",
fixture: substitutions, initialState, state: await fixture.snapshot(),
toolSchemaBytes: Buffer.byteLength(JSON.stringify(definitions)), definitions,
};
// The controller supplies a unique retained attempt directory; never overwrite evidence.
journal.append({ kind: "attempt_finished", error, usage, terminalSeen, at: new Date().toISOString() });
journal.close();
await writeFile(join(directory, "artifact.json"), JSON.stringify(artifact, null, 2), { flag: "wx", mode: 0o600 });
output({ attemptId: request.attemptId, artifactPath: join(directory, "artifact.json"), error, usage });
}
} finally { await server.close(); }