Update the Pi companion after core integration

Refresh the immutable provider lock fingerprint for the combined dependency graph.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 20:19:34 -05:00
commit 3df487feb8
141 files changed
+267923 -2893

No files matched your search

@@ -4,6 +4,7 @@ import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
symlink,
writeFile,
@@ -181,6 +182,7 @@ import {
nativeSessionRecoveryProjection,
nativeGovernedWaitResult,
parseRemoteExecutableCandidate,
buildRemoteCodexLauncherCommand,
mayUsePreinstalledRunnerArtifact,
nativeUsageCostUsd,
normalizeNativeUsage,
@@ -1605,6 +1607,32 @@ describe("remote provider checkpoint restores", () => {
});
describe("remote preinstalled executable discovery", () => {
it("stages a relative-path CLI shim without changing its installation or losing arguments", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-codex-shim-"));
try {
const installation = join(root, "image install's bin");
const target = join(root, "workspace", "bin", "codex");
const source = join(installation, "codex");
await mkdir(installation, { recursive: true });
await mkdir(join(root, "workspace", "bin"), { recursive: true });
const shim = '#!/bin/sh\ncat "$(dirname "$0")/version.txt"\nprintf "%s\\n" "$@"\n';
await writeFile(source, shim, { mode: 0o755 });
await writeFile(join(installation, "version.txt"), "codex-cli 0.153.4\n");
// Existing deployments may already have the old symlink. Never write
// through it into the shared installation while upgrading the launcher.
await symlink(source, target);
for (let pass = 0; pass < 2; pass++) {
execFileSync("sh", ["-c", buildRemoteCodexLauncherCommand(source, target)]);
expect(execFileSync(target, ["--version", "argument with 'quotes'"], { encoding: "utf8" }))
.toBe("codex-cli 0.153.4\n--version\nargument with 'quotes'\n");
expect(await readFile(source, "utf8")).toBe(shim);
}
expect(await readdir(join(root, "workspace", "bin"))).toEqual(["codex"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("accepts one normalized absolute executable path", () => {
expect(
parseRemoteExecutableCandidate(
@@ -3323,7 +3351,7 @@ describe("native warm session supervision", () => {
expect(close).not.toHaveBeenCalled();
});
it("reattaches a live runnerd warm session under a fresh run authority", async () => {
it.each([false, true])("verifies a live warm owner before refreshing run authority (broker: %s)", async (useBroker) => {
const stateBase = await mkdtemp(
join(tmpdir(), "paperclip-runnerd-warm-authority-"),
);
@@ -3395,8 +3423,13 @@ describe("native warm session supervision", () => {
return result;
})
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBe(firstSession);
expect(options.persistedSession).toBeUndefined();
if (useBroker) {
expect(options.existingSession).toBeUndefined();
expect(options.persistedSession?.providerSessionId).toBe("provider-runnerd-warm");
} else {
expect(options.existingSession).toBe(firstSession);
expect(options.persistedSession).toBeUndefined();
}
return result;
});
@@ -3404,6 +3437,7 @@ describe("native warm session supervision", () => {
await executePaperclipNativeSession({
db: leaseDb(first),
execution: first,
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" } : undefined,
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
runnerExecutionTarget: remoteTarget,
@@ -3445,20 +3479,26 @@ describe("native warm session supervision", () => {
await executePaperclipNativeSession({
db: continuationDb,
execution: second,
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" } : undefined,
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
runnerExecutionTarget: remoteTarget,
});
expect(firstClose).not.toHaveBeenCalled();
await vi.waitFor(
() =>
expect(firstClose).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}),
{
timeout: 1_500,
},
);
if (useBroker) {
expect(firstClose).toHaveBeenCalledOnce();
expect(firstClose).toHaveBeenCalledWith({ reason: "warm native session configuration changed" });
} else {
expect(firstClose).not.toHaveBeenCalled();
await vi.waitFor(
() =>
expect(firstClose).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}),
{
timeout: 1_500,
},
);
}
} finally {
if (previousStateDirectory === undefined) {
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
@@ -4046,6 +4086,11 @@ describe("runnerd provider runtime wiring", () => {
useRunnerd: true,
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
// Durable local runner state must settle before releasing this scope to
// another run, just like a remote runner's checkpoint.
expect(state.execute).toHaveBeenCalledWith(expect.objectContaining({
requireSessionCloseBeforeReturn: true,
}));
await expect(
executePaperclipNativeSession({
db: leaseDb(second),
@@ -6155,7 +6200,7 @@ describe("runnerd provider runtime wiring", () => {
stdout = script.includes("/opt/paperclip-runner/bin/codex")
? "/opt/paperclip-runner/bin/codex\n"
: "/usr/local/bin/codex\n";
} else if (!script.includes("ln -sfn")) {
} else if (!script.includes("ln -sfn") && !script.includes("paperclip_codex_launcher_tmp")) {
throw new Error(`unexpected command: ${command.command}`);
}
return {
@@ -263,6 +263,7 @@ function clearNativeRuntimeRequestResolutions(runId: string): void {
}
type WarmNativeSession = {
credentialRunId?: string;
session: NativeSession;
ownerToken: symbol;
configDigest: string;
@@ -1880,6 +1881,8 @@ function hasIdleWarmNativeSessionOwner(input: {
if (input.execution.session.lifecyclePolicy.mode !== "warm") return false;
const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
if (!entry || entry.busy) return false;
// A verified idle owner proves the checkpoint belongs to this session even
// when its process must later rotate to a new run-scoped broker capability.
const environmentId =
input.runnerExecutionTarget?.kind === "remote"
? (input.runnerExecutionTarget.environmentId ?? null)
@@ -2924,12 +2927,20 @@ export async function getNativeSessionSteeringState(
};
}
// Receipts live as long as this controller process. Durable identity reservations
// hold credential acquisition after a restart until a provider receipt is known.
const steeringDeliveries = new Map<string, Promise<{ turnId: string }>>();
function clearSteeringDeliveries(runId: string) {
for (const key of steeringDeliveries.keys()) if (key.startsWith(`${runId}:`)) steeringDeliveries.delete(key);
}
/** Dispatches a true same-turn steering message and resolves only after ack. */
export async function steerNativeSession(input: {
runId: string;
message: string;
correlationId: string;
timeoutMs?: number;
onAcknowledged?: () => Promise<void>;
}): Promise<{ turnId: string }> {
const active = activeNativeSessions.get(input.runId);
if (!active) {
@@ -2954,14 +2965,24 @@ export async function steerNativeSession(input: {
);
}
const deliveryKey = `${input.runId}:${input.correlationId}`;
let delivery = steeringDeliveries.get(deliveryKey);
if (!delivery) {
delivery = active.session.steer({
turnId,
message: { role: "user", text: input.message },
correlationId: input.correlationId,
}).then(() => ({ turnId }));
steeringDeliveries.set(deliveryKey, delivery);
void delivery.catch(() => { steeringDeliveries.delete(deliveryKey); });
}
// Do not await the persistence callback here: the route holds the run lock
// until acknowledgement. After a timeout this callback can acquire that lock.
if (input.onAcknowledged) void delivery.then(input.onAcknowledged).catch(() => undefined);
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
active.session.steer({
turnId,
message: { role: "user", text: input.message },
correlationId: input.correlationId,
}),
const acknowledged = await Promise.race([
delivery,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() =>
@@ -2975,7 +2996,7 @@ export async function steerNativeSession(input: {
);
}),
]);
return { turnId };
return acknowledged;
} catch (error) {
if (error instanceof NativeSessionSteeringError) throw error;
const message = error instanceof Error ? error.message : String(error);
@@ -4268,7 +4289,11 @@ async function executePaperclipNativeSessionWithinScope(
if (warmSessionId !== null && warmConfigDigest !== null) {
const entry = warmNativeSessions.get(warmSessionId);
if (entry) {
if (entry.configDigest !== warmConfigDigest) {
// Run-scoped broker capabilities must rotate with the process, while the
// settled provider checkpoint retains the conversation across runs.
const credentialRunChanged = Boolean(input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN)
&& entry.credentialRunId !== input.execution.binding.runId;
if (entry.configDigest !== warmConfigDigest || credentialRunChanged) {
if (entry.busy) throw new Error("native_session_supervisor_busy");
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
warmNativeSessions.delete(warmSessionId);
@@ -4427,9 +4452,9 @@ async function executePaperclipNativeSessionWithinScope(
warmSessionId === null
? undefined
: NATIVE_WARM_SEMANTIC_RESULT_TERMINAL_GRACE_MS,
requireSessionCloseBeforeReturn:
runnerdBackend !== null &&
input.runnerExecutionTarget?.kind === "remote",
// Every durable runner must finish its bounded suspension before
// the next run verifies and rotates the saved authority.
requireSessionCloseBeforeReturn: runnerdBackend !== null,
onCheckpoint:
warmSessionId !== null && warmConfigDigest !== null
? async (snapshot) =>
@@ -4492,6 +4517,8 @@ async function executePaperclipNativeSessionWithinScope(
existing.session = session;
} else
warmNativeSessions.set(warmSessionId, {
credentialRunId: input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN
? input.execution.binding.runId : undefined,
session,
ownerToken: warmSessionOwnerToken,
configDigest: warmConfigDigest,
@@ -4521,6 +4548,7 @@ async function executePaperclipNativeSessionWithinScope(
});
else {
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(
input.execution.binding.runId,
);
@@ -4544,6 +4572,7 @@ async function executePaperclipNativeSessionWithinScope(
endedAtMs: Date.now(),
});
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
} catch (error) {
await leaseRenewal.stop().catch(() => undefined);
@@ -4575,6 +4604,7 @@ async function executePaperclipNativeSessionWithinScope(
}
trace.activate(taskSettleScope);
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(
@@ -5009,6 +5039,22 @@ function processEnvironment(
);
}
/** Preserve package-manager shims that resolve dependencies relative to argv[0]. */
export function buildRemoteCodexLauncherCommand(sourcePath: string, targetPath: string): string {
if (sourcePath === targetPath) throw new Error("runner_remote_preinstalled_source_conflict");
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
const launcher = `#!/bin/sh\nexec ${quote(sourcePath)} "$@"\n`;
// Replace atomically: writing through an existing symlink would corrupt the
// image's shared CLI, and another run may be executing this launcher already.
return `umask 077; mkdir -p ${quote(posix.dirname(targetPath))} && ` +
`[ ! -d ${quote(targetPath)} ] && ` +
`paperclip_codex_launcher_tmp=$(mktemp ${quote(targetPath + ".tmp.XXXXXX")}) && ` +
`trap 'rm -f "$paperclip_codex_launcher_tmp"' 0 && ` +
`printf '%s' ${quote(launcher)} > "$paperclip_codex_launcher_tmp" && ` +
`chmod 700 "$paperclip_codex_launcher_tmp" && ` +
`mv -f "$paperclip_codex_launcher_tmp" ${quote(targetPath)}`;
}
export function parseRemoteExecutableCandidate(stdout: string): string | null {
const lines = stdout
.split(/\r?\n/)
@@ -6506,8 +6552,10 @@ async function createRunnerdBackendWithinSessionClaim(
command: "sh",
args: [
"-c",
`umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
targetPath === remoteCodexBinary
? buildRemoteCodexLauncherCommand(sourcePath, targetPath)
: `umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -88,10 +88,13 @@ describe("paperclip-runner real server vertical slice", () => {
apiUrl: `http://127.0.0.1:${address.port}`,
});
const stateDirectory = await mkdtemp(resolve(tmpdir(), "paperclip-runner-real-resume-"));
const expectedContextFile = resolve(stateDirectory, "expected-context.json");
await writeFile(expectedContextFile, JSON.stringify({ companyId, actorId: agentId, taskId: issueId, runId, callId: "semantic-call-1" }));
const fakeArgs = ["--state-file", resolve(stateDirectory, "fake-provider-state.json"), "--emit-tool-call", "--durable-turn-ids", "--durable-tool-ids", "--expected-canonical-task-context-file", expectedContextFile];
const bundle = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
codexArgs: [],
codexArgs: fakeArgs,
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: {
@@ -115,7 +118,7 @@ describe("paperclip-runner real server vertical slice", () => {
observedResults.push(result);
return {
success: true,
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, operationId: params.tool, callId: params.callId, value: result }) }],
};
});
@@ -158,10 +161,11 @@ describe("paperclip-runner real server vertical slice", () => {
issueId,
runId: resumedRunId,
});
await writeFile(expectedContextFile, JSON.stringify({ companyId, actorId: agentId, taskId: issueId, runId: resumedRunId, callId: "semantic-call-2" }));
const restored = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
codexArgs: [],
codexArgs: fakeArgs,
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
resumeDynamicTools: await resumedAuthority.definitions(),
@@ -189,7 +193,7 @@ describe("paperclip-runner real server vertical slice", () => {
observedResults.push(result);
return {
success: true,
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, operationId: params.tool, callId: params.callId, value: result }) }],
};
});
try {
@@ -206,7 +210,7 @@ describe("paperclip-runner real server vertical slice", () => {
run: { id: resumedRunId },
});
expect(restored.evidence().diagnostics).toContain(
"runnerd restored its durable PRP session and provider thread",
"runnerd attached the durable provider session to a fresh PRP run authority",
);
} finally {
await restored.transport.close();
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, approvals, companies, createDb, documents, heartbeatRuns, issueApprovals, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@@ -386,6 +387,8 @@ describe("PaperclipRunnerToolAuthority", () => {
},
});
const prerequisiteId = (prerequisite as { task: { id: string } }).task.id;
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
.toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.created", agentId, runId, companyId })]));
const dependent = await authority.execute({
tool: "create_task",
callId: "create-dependent",
@@ -480,6 +483,10 @@ describe("PaperclipRunnerToolAuthority", () => {
},
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
const creationEvents = (await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
.filter(event => event.action === "issue.created");
expect(creationEvents).toHaveLength(1);
const foreignCompanyId = "00000000-0000-4000-8000-000000000201";
const foreignAgentId = "00000000-0000-4000-8000-000000000202";
const foreignIssueId = "00000000-0000-4000-8000-000000000203";
@@ -597,6 +604,46 @@ describe("PaperclipRunnerToolAuthority", () => {
.toHaveLength(0);
});
it("captures delegation and approval origins before steering and preserves replay identity", async () => {
const issueId = "00000000-0000-4000-8000-000000000120";
const runId = "00000000-0000-4000-8000-000000000121";
await db.insert(issues).values({ id: issueId, companyId, title: "Identity delegation",
status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId,
status: "running", runtimeMode: "native", nativeIssueId: issueId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const origin = await initializeRunIdentity(db, {
companyId, runId, issueId, responsibleUserId: "person-a", cause: "instruction",
});
const [message] = await db.insert(issueComments).values({
companyId, issueId, body: "New instruction", authorUserId: "person-b",
}).returning();
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = { tool: "create_task", callId: "identity-child", arguments: {
idempotencyKey: "identity-child", title: "Keep the initiating identity",
responsibleUserId: "forged-user", originIdentityContextId: "forged-context",
} };
const first = await authority.execute(call) as { task: { id: string } };
const pending = await reserveSteeredIdentity(db, { companyId, runId, issueId, messageId: message.id });
await reconcileSteeredIdentity(db, pending!);
await expect(authority.execute({ ...call, callId: "identity-child-replay" })).resolves.toEqual(first);
const [child] = await db.select().from(issues).where(eq(issues.id, first.task.id));
expect(child).toMatchObject({ originRunId: runId, originIdentityContextId: origin.id,
continuationIdentityContextId: origin.id });
const second = await authority.execute({ ...call, callId: "identity-child-b", arguments: {
idempotencyKey: "identity-child-b", title: "Use the next instruction identity",
} }) as { task: { id: string } };
const [nextChild] = await db.select().from(issues).where(eq(issues.id, second.task.id));
expect(nextChild.originIdentityContextId).toBe(pending!.id);
await authority.execute({ tool: "request_human_input", callId: "identity-approval", arguments: {
idempotencyKey: "identity-approval", interactionKind: "confirmation", title: "Approve continuation",
prompt: "Continue?", continuationPolicy: "wake_assignee", payload: {},
} });
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
expect(interactions.find((row) => row.title === "Approve continuation")?.sourceIdentityContextId).toBe(pending!.id);
});
it("fails closed once the run is no longer active", async () => {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
@@ -1,4 +1,15 @@
import { createHash } from "node:crypto";
import { runnerApiToolsEnabled } from "./runner-api-rollout.js";
import { openRunnerApiWorkspaceFile } from "./runner-api-files.js";
import { basename } from "node:path";
import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { getStorageService } from "../../storage/index.js";
import type { StorageService } from "../../storage/types.js";
import { assetService } from "../assets.js";
import { workspaceFileResourceService } from "../workspace-file-resources.js";
import { badRequest, forbidden } from "../../errors.js";
import { searchRunnerApi } from "./runner-api-catalog.js";
import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
import { and, desc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -18,8 +29,10 @@ import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { captureRunIdentity } from "../run-identity.js";
const IMPLEMENTED_OPERATIONS = new Set([
"search_api", "call_api",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",
@@ -33,6 +46,11 @@ type Binding = {
runId: string;
agentId: string;
normalizedSessionId?: string;
/** Server-owned API origin and storage; never obtained from tool input. */
apiUrl?: string;
storage?: StorageService;
/** Server-owned suppression for baseline evals; true never overrides operator opt-in. */
apiToolsEnabled?: boolean;
workMode?: "standard" | "planning" | "ask";
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
@@ -75,6 +93,7 @@ export class PaperclipRunnerToolAuthority {
return CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) =>
IMPLEMENTED_OPERATIONS.has(descriptor.operationId)
&& (runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled) || !["search_api", "call_api"].includes(descriptor.operationId))
&& descriptor.allowedModes.includes(workMode)
)
.map((descriptor) => ({
@@ -86,6 +105,7 @@ export class PaperclipRunnerToolAuthority {
async execute(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled) && ["search_api", "call_api"].includes(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
const context = await this.#boundContext();
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((candidate) => candidate.operationId === call.tool);
if (!descriptor || !descriptor.allowedModes.includes(
@@ -95,6 +115,8 @@ export class PaperclipRunnerToolAuthority {
}
const input = record(call.arguments);
switch (call.tool) {
case "search_api": return searchRunnerApi(call.arguments);
case "call_api": return this.#callApi(call.callId, call.arguments);
case "get_task_context": return {
company: { id: this.binding.companyId },
actor: redactedActor(context.actor),
@@ -168,13 +190,121 @@ export class PaperclipRunnerToolAuthority {
return { approval, tasks: tasks.map((row) => row.issue) };
}
case "report_progress": return this.#reportProgress(input);
case "request_human_input": return this.#requestHumanInput(input);
case "create_task": return this.#createTask(input);
case "request_human_input": return this.#requestHumanInput(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "create_task": return this.#createTask(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "set_dependencies": return this.#setDependencies(input);
default: throw new Error("paperclip_runner_tool_not_bound");
}
}
async #callApi(callId: string, value: unknown): Promise<unknown> {
const bound = await this.#boundContext();
const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode };
const { input, operation } = validateRunnerApiCall(value, context);
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
if (!apiUrl) throw new Error("Paperclip API origin is unavailable");
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId);
if (!token) throw new Error("Paperclip run authentication is unavailable");
const execute = async () => {
const current = await this.#boundContext();
if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
return executeRunnerApi(input, { ...context, workMode: current.issue.workMode }, {
apiUrl, token,
beforeDispatch: async () => {
const fresh = await this.#boundContext();
if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
validateRunnerApiCall(input, { ...context, workMode: fresh.issue.workMode });
},
readFile: (file) => this.#readApiFile(file),
saveResponse: async (bytes, contentType) => {
const storage = this.binding.storage ?? getStorageService();
const saved = await storage.putFile({ companyId: this.binding.companyId, namespace: "runner-api", originalFilename: contentType.includes("json") ? "response.json" : "response.bin", contentType, body: bytes });
const asset = await assetService(this.db).create(this.binding.companyId, { ...saved, createdByAgentId: this.binding.agentId });
const activity = await persistActivity(this.db, { companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId, issueId: this.binding.issueId, action: "asset.created", entityType: "asset", entityId: asset.id, details: { source: "runner.call_api", byteSize: saved.byteSize } });
publishActivity(activity.publication);
return { artifactId: asset.id, url: `/api/assets/${asset.id}/content`, contentType, byteSize: saved.byteSize, sha256: saved.sha256 };
},
});
};
if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute();
if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required");
// Reserve durably before HTTP dispatch, without holding a DB lock over an
// HTTP route that itself needs DB locks. A crash leaves an explicit unknown
// outcome rather than replaying a possibly committed external mutation.
const key = createHash("sha256").update(callId).digest("hex");
const digest = createHash("sha256").update(canonicalJson(input)).digest("hex");
const prior = await this.db.transaction(async (tx) => {
const locked = await this.#lockAuthorizedMutationContext(tx as unknown as Db);
const resultJson = record(locked.run.resultJson);
const receipts = record(resultJson.apiToolReceipts);
const existing = record(receipts[key]);
if (receipts[key]) {
if (existing.digest !== digest) throw badRequest("API call id was reused with different arguments");
return { result: existing.result ?? { ok: false, status: null, error: "api_outcome_unknown", outcome: "unknown", guidance: "Inspect state before issuing another mutation." } };
}
if (Object.keys(receipts).length >= 512) throw badRequest("Run API mutation limit reached");
receipts[key] = { digest, state: "pending" };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
return null;
});
if (prior) return prior.result;
const result = await execute();
// Some older routes attribute the agent but omit runId. Retain their domain
// event and add the run-bound HTTP receipt, without logging request bodies.
const apiActivity = await persistActivity(this.db, {
companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
agentId: this.binding.agentId, runId: this.binding.runId, issueId: this.binding.issueId,
action: "runner.api_called", entityType: "issue", entityId: this.binding.issueId,
details: { operationId: operation.operationId, requestFingerprint: digest, pathParams: input.pathParams ?? {}, status: result.status, outcome: record(result).outcome ?? (result.ok ? "succeeded" : "failed") },
});
publishActivity(apiActivity.publication);
await this.db.transaction(async (tx) => {
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, this.binding.runId)).for("update");
const resultJson = record(run?.resultJson);
const receipts = record(resultJson.apiToolReceipts);
receipts[key] = { digest, state: "completed", result };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
});
return result;
}
async #readApiFile(file: RunnerApiFile): Promise<{ bytes: Buffer; filename: string; contentType: string }> {
if (file.artifactId) {
const asset = await assetService(this.db).getById(file.artifactId);
if (!asset || asset.companyId !== this.binding.companyId) throw forbidden("Artifact is not available in this company");
if (asset.byteSize > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit");
const object = await (this.binding.storage ?? getStorageService()).getObject(this.binding.companyId, asset.objectKey);
const chunks: Buffer[] = [];
let length = 0;
try {
for await (const chunk of object.stream) {
const bytes = Buffer.from(chunk);
length += bytes.length;
if (length > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit");
chunks.push(bytes);
}
} finally { object.stream.destroy(); }
return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType };
}
const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" });
const handle = await openRunnerApiWorkspaceFile(resolved.realPath);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit");
const bytes = Buffer.alloc(RUNNER_API_MAX_BYTES + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await handle.read(bytes, length, bytes.length - length, length);
if (!bytesRead) break;
length += bytesRead;
}
if (length > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit");
return { bytes: bytes.subarray(0, length), filename: basename(resolved.realPath), contentType: "application/octet-stream" };
} finally { await handle.close(); }
}
async #approval(id: string) {
const approval = await approvalService(this.db).getById(id);
if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found");
@@ -300,7 +430,7 @@ export class PaperclipRunnerToolAuthority {
return result;
}
async #createTask(input: Record<string, unknown>): Promise<unknown> {
async #createTask(input: Record<string, unknown>, identityContextId: string | null): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
? this.binding.agentId
@@ -321,6 +451,7 @@ export class PaperclipRunnerToolAuthority {
const inputFingerprint = createHash("sha256")
.update(canonicalJson(input))
.digest("hex");
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx) => {
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
@@ -361,6 +492,9 @@ export class PaperclipRunnerToolAuthority {
createdByAgentId: this.binding.agentId,
originKind: "manual",
originId: durableIdempotencyKey,
originRunId: this.binding.runId,
originIdentityContextId: identityContextId,
continuationIdentityContextId: identityContextId,
originFingerprint: inputFingerprint,
actorAgentId: this.binding.agentId,
actorRunId: this.binding.runId,
@@ -386,6 +520,16 @@ export class PaperclipRunnerToolAuthority {
}
}
}
if (!deduplicated) {
const activity = await persistActivity(tx, {
companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
action: "issue.created", entityType: "issue", entityId: child.id,
details: { identifier: child.identifier, title: child.title, parentId: this.binding.issueId,
assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" },
});
publication = activity.publication;
}
const wakeId = `created-child:${child.id}`;
const shouldWake = !deduplicated && childStatus === "todo" && Boolean(child.assigneeAgentId);
return {
@@ -404,6 +548,7 @@ export class PaperclipRunnerToolAuthority {
};
}) as Record<string, unknown>;
if (publication) publishActivity(publication);
const task = record(result.task);
const childId = requiredString(task.id);
const scheduledWakeIds = Array.isArray(result.scheduledWakeIds)
@@ -554,6 +699,10 @@ export class PaperclipRunnerToolAuthority {
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}> {
// Match identity activation and queue mutations before locking the run.
await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, this.binding.issueId), eq(issues.companyId, this.binding.companyId),
)).for("update");
// Authorization for writes is intentionally re-read only after the
// transaction starts. Locking the run and issue in the same statement
// closes the gap between the discovery-time check and the mutation: a
@@ -594,7 +743,7 @@ export class PaperclipRunnerToolAuthority {
return context;
}
async #requestHumanInput(input: Record<string, unknown>): Promise<unknown> {
async #requestHumanInput(input: Record<string, unknown>, identityContextId: string | null): Promise<unknown> {
const interactionKind = requiredString(input.interactionKind);
const interactionKinds = {
confirmation: "request_confirmation",
@@ -663,7 +812,7 @@ export class PaperclipRunnerToolAuthority {
supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true,
} : {}),
},
} as never, { agentId: this.binding.agentId, userId: null });
} as never, { agentId: this.binding.agentId, userId: null, identityContextId });
const activity = await persistActivity(tx, {
companyId: this.binding.companyId,
actorType: "agent",
@@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { buildOpenApiDocument } from "../../routes/openapi.js";
import { badRequest, notFound } from "../../errors.js";
import { runnerApiReference } from "./runner-api-reference.js";
import { runnerApiRestriction } from "./runner-api-policy.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
type Json = Record<string, any>;
export interface RunnerApiOperation {
operationId: string;
method: string;
path: string;
summary: string;
description: string;
parameters: Json[];
requestBody?: Json;
responses: Json;
authorization: Json;
transport: "rest" | "protocol";
dedicatedTools: string[];
allowedModes: string[];
skillReference?: { section: string; description?: string; examples?: { body: unknown }[] };
dedicatedToolGuidance: string;
dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
runnerRestrictions?: string[];
callPolicy: "rest" | "restricted" | "protocol";
}
const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
const SYNONYMS: Record<string, string> = {
task: "issue", tasks: "issues", employee: "agent", employees: "agents",
hire: "agent", hiring: "agents", expense: "cost", expenses: "costs",
cron: "routines", schedule: "routines", recurring: "routines",
blocker: "dependencies", blockers: "dependencies", files: "attachments",
};
function words(text: string): string[] {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
.flatMap((word) => [word, ...(SYNONYMS[word] ? [SYNONYMS[word]] : [])])
.map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
}
function dedicatedTools(method: string, path: string): string[] {
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["set_dependencies", "finish_task", "block_task", "request_review"];
if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
if (/\/approvals\/\{[^}]+\}/.test(path) && method === "GET") return ["get_approval", "get_approval_context"];
return [];
}
// Descriptions and schemas are documentation, never authorization. Routes remain
// authoritative, including conditional role, company and resource checks.
export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): RunnerApiOperation[] {
function dereference(value: any, seen = new Set<string>()): any {
if (Array.isArray(value)) return value.map((entry) => dereference(entry, seen));
if (!value || typeof value !== "object") return value;
if (typeof value.$ref === "string" && value.$ref.startsWith("#/")) {
if (seen.has(value.$ref)) return { description: `Recursive schema: ${value.$ref}` };
const target = value.$ref.slice(2).split("/").reduce((node: any, key: string) => node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], document);
if (!target) throw new Error(`Unresolved API schema: ${value.$ref}`);
return dereference(target, new Set([...seen, value.$ref]));
}
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, dereference(entry, seen)]));
}
const result: RunnerApiOperation[] = [];
for (const [path, item] of Object.entries<Json>(document.paths)) {
for (const [verb, operation] of Object.entries<Json>(item)) {
if (!METHODS.has(verb)) continue;
const method = verb.toUpperCase();
const restriction = runnerApiRestriction(method, path);
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses));
result.push({
operationId: `${method} ${path}`, method, path,
summary: operation.summary ?? `${method} ${path}`,
description: operation.description ?? "",
parameters: dereference([...(item.parameters ?? []), ...(operation.parameters ?? [])]),
...(operation.requestBody ? { requestBody: dereference(operation.requestBody) } : {}),
responses: dereference(operation.responses ?? {}),
authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
transport: protocol ? "protocol" : "rest",
callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest",
dedicatedTools: dedicatedTools(method, path),
dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
}),
runnerRestrictions: [...(restriction ? [restriction] : []), "Active run and assignment must remain authorized.", "Cannot replace checkout, completion, task status/ownership changes, approval decisions, or runner execution control. Dedicated tools retain their existing permissions."],
dedicatedToolGuidance: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
allowedModes: method === "GET" || method === "HEAD" ? ["standard", "ask", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
});
}
}
for (const [path, summary, actor] of [
["/api/companies/{companyId}/events/ws", "Live company events WebSocket; use the existing event client", "board_or_agent"],
["/api/runner/v1/connect/{runId}", "Authenticated runner PRP WebSocket; runner-owned transport", "runner"],
["/api/environment-custom-image-setup-sessions/{sessionId}/terminal/ws", "Image setup terminal WebSocket; use the existing terminal client", "board"],
]) {
if (result.some(operation => operation.path === path)) continue;
result.push({ operationId: `GET ${path}`, method: "GET", path, summary,
description: "WebSocket upgrade. Not callable through call_api.",
parameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => ({ in: "path", name: match[1], required: true, schema: { type: "string" } })),
responses: { "101": { description: "Switching Protocols" } }, authorization: { actor },
transport: "protocol", callPolicy: "protocol", dedicatedTools: [], dedicatedToolGuidance: "Use the existing protocol client.", allowedModes: [],
});
}
return result.sort((a, b) => a.operationId.localeCompare(b.operationId));
}
let cached: RunnerApiOperation[] | undefined;
let cachedDigest: string | undefined;
export function runnerApiCatalog(): RunnerApiOperation[] { return cached ??= buildRunnerApiCatalog(); }
export function runnerApiOperation(id: string): RunnerApiOperation {
const operation = runnerApiCatalog().find((entry) => entry.operationId === id);
if (!operation) throw notFound("Unknown API operation; use search_api to discover its exact operationId");
return operation;
}
export const runnerApiSearchSchema = z.object({ query: z.string().trim().min(1).max(500), limit: z.number().int().min(1).max(10).default(5), cursor: z.string().max(200).optional() }).strict();
export function searchRunnerApi(value: unknown) {
const parsed = runnerApiSearchSchema.safeParse(value);
if (!parsed.success) throw badRequest("Invalid API search query or limit");
const input = parsed.data;
const { query, limit } = input;
const catalog = runnerApiCatalog();
cachedDigest ??= createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
const fingerprint = createHash("sha256").update(cachedDigest).update(query).digest("hex").slice(0, 16);
let offset = 0;
if (input.cursor) {
const match = /^([a-f0-9]{16}):(\d+)$/.exec(input.cursor);
if (!match || match[1] !== fingerprint) throw badRequest("Search cursor belongs to a different query or catalog");
offset = Number(match[2]);
if (!Number.isSafeInteger(offset)) throw badRequest("Invalid search cursor");
}
const exact = catalog.find((entry) => entry.operationId.toLowerCase() === query.trim().toLowerCase());
const terms = [...new Set(words(query))];
const matches = exact ? [exact] : catalog.map((entry) => {
const title = new Set(words(`${entry.method} ${entry.path} ${entry.summary}`));
const description = new Set(words(`${entry.description} ${entry.skillReference?.description ?? ""} ${entry.skillReference?.section ?? ""}`));
return { entry, score: terms.reduce((sum, word) => sum + (title.has(word) ? 5 : description.has(word) ? 1 : 0), 0) };
}).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score || a.entry.operationId.localeCompare(b.entry.operationId)).map(({ entry }) => entry);
const results = matches.slice(offset, offset + limit);
return {
results, total: matches.length,
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit only reads through call_api.",
};
}
@@ -0,0 +1,205 @@
import { z } from "zod";
import { badRequest, forbidden, unprocessable } from "../../errors.js";
import { runnerApiOperation, type RunnerApiOperation } from "./runner-api-catalog.js";
import { runnerApiRestriction } from "./runner-api-policy.js";
export const RUNNER_API_MAX_BYTES = 10 * 1024 * 1024;
export const RUNNER_API_INLINE_BYTES = 24 * 1024;
export const RUNNER_API_TIMEOUT_MS = 30_000;
const fileSchema = z.object({
field: z.string().min(1).max(200).optional(),
artifactId: z.string().min(1).optional(),
path: z.string().min(1).max(1000).optional(),
}).strict().refine((file) => Boolean(file.artifactId) !== Boolean(file.path), "Specify exactly one artifactId or workspace path");
export const runnerApiCallSchema = z.object({
operationId: z.string().min(1).max(500),
pathParams: z.record(z.string().max(200), z.string().max(1000)).optional(),
query: z.record(z.string(), z.unknown()).optional(),
body: z.unknown().optional(),
contentType: z.string().max(120).optional(),
files: z.array(fileSchema).max(10).optional(),
}).strict();
export type RunnerApiCall = z.infer<typeof runnerApiCallSchema>;
export type RunnerApiFile = z.infer<typeof fileSchema>;
export interface RunnerApiContext {
companyId: string;
issueId: string;
issueIdentifier?: string | null;
runId: string;
workMode: string;
}
export interface RunnerApiIo {
apiUrl: string;
token: string;
fetch?: typeof fetch;
/** Revalidate server-owned authority after asynchronous file preparation. */
beforeDispatch?(): Promise<void>;
readFile(file: RunnerApiFile): Promise<{ bytes: Buffer; filename: string; contentType: string }>;
saveResponse(bytes: Buffer, contentType: string): Promise<Record<string, unknown>>;
}
export function validateRunnerApiCall(value: unknown, context: RunnerApiContext): { input: RunnerApiCall; operation: RunnerApiOperation } {
const parsed = runnerApiCallSchema.safeParse(value);
if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues });
const input = parsed.data;
const operation = runnerApiOperation(input.operationId);
if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only");
const restriction = runnerApiRestriction(operation.method, operation.path);
if (restriction) throw forbidden(restriction);
if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");
const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
if (mutation) {
const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(operation.path);
const body = input.body && typeof input.body === "object" && !Array.isArray(input.body) ? input.body as Record<string, unknown> : {};
// Raw uploads must not hide lifecycle fields from the structured policy
// check and subsequently become JSON inside Express's body parser.
const guardedBody = /^\/api\/(issues|agents)\/\{[^}]+\}$/.test(operation.path)
|| (issueRoute && /\/comments$/.test(operation.path));
if (guardedBody && (input.files?.length || (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))))) {
throw forbidden("Lifecycle-sensitive API updates require an inline JSON object");
}
if (/^\/api\/agents\/\{[^}]+\}$/.test(operation.path) && ["status", "runtimeState"].some(key => Object.hasOwn(body, key))) {
throw forbidden("API agent updates cannot bypass runner execution-control authority");
}
if (issueRoute && /\/comments$/.test(operation.path) && ["reopen", "resume", "interrupt"].some(key => body[key] === true)) {
throw forbidden("API comments cannot change runner execution state");
}
if (issueRoute && /\/issues\/\{[^}]+\}$/.test(operation.path)) {
const lifecycle = ["status", "workMode", "executionRunId", "checkoutRunId", "executionState", "executionPolicy", "executionAgentNameKey", "executionLockedAt", "completedAt", "cancelledAt", "startedAt", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "reopen", "resume", "interrupt"];
if (lifecycle.some((key) => Object.hasOwn(body, key))) throw forbidden("Use dedicated completion, review, delegation or dependency tools for task lifecycle changes");
// The real issue router normalizes identifiers and PostgreSQL accepts
// uppercase UUIDs. Apply the same identity comparison before dispatch.
const target = Object.values(input.pathParams ?? {}).map(id => id.trim().toLowerCase());
const activeIds = [context.issueId, context.issueIdentifier].filter((id): id is string => Boolean(id)).map(id => id.toLowerCase());
if (operation.method === "DELETE" && target.some(id => activeIds.includes(id))) throw forbidden("The active runner task cannot delete itself");
}
}
const contentType = (input.contentType ?? "application/json").split(";", 1)[0].trim().toLowerCase();
if (input.body !== undefined && !input.files?.length) {
const requestSchema = operation.requestBody?.content?.[contentType]?.schema as { type?: string } | undefined;
if (contentType.includes("json") && requestSchema?.type === "object" && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))) {
throw badRequest("This operation requires body to be a JSON object. Pass the object directly, not a JSON-encoded string.");
}
if (contentType.includes("json") && requestSchema?.type === "array" && !Array.isArray(input.body)) {
throw badRequest("This operation requires body to be a JSON array. Pass the array directly, not a JSON-encoded string.");
}
}
return { input, operation };
}
export function runnerApiUrl(operation: RunnerApiOperation, input: RunnerApiCall, context: RunnerApiContext, apiUrl: string): URL {
const origin = new URL(apiUrl);
if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash) throw new Error("Invalid configured Paperclip API origin");
const params = { ...input.pathParams };
if (operation.path.includes("{companyId}")) params.companyId ??= context.companyId;
const names = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
for (const name of Object.keys(params)) if (!names.includes(name)) throw badRequest(`Unknown path parameter: ${name}`);
const path = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
const value = params[name];
if (!value || value === "." || value === ".." || /[\\/\x00-\x1f]/.test(value) || /%[0-9a-f]{2}/i.test(value)) throw badRequest(`Invalid or missing path parameter: ${name}`);
return encodeURIComponent(value);
});
const url = new URL(path, origin.origin);
if (url.origin !== origin.origin || !url.pathname.startsWith("/api/")) throw badRequest("Invalid API path");
for (const [key, value] of Object.entries(input.query ?? {})) {
if (value === undefined || value === null) continue;
const parameter = operation.parameters.find((entry) => entry.in === "query" && entry.name === key);
const values = Array.isArray(value) ? value : [value];
if (values.some((entry) => !["string", "number", "boolean"].includes(typeof entry))) throw badRequest(`Query parameter ${key} must contain scalar values`);
if (Array.isArray(value) && parameter?.explode === false) url.searchParams.set(key, values.join(","));
else for (const entry of values) url.searchParams.append(key, String(entry));
}
if (url.href.length > 16_384) throw badRequest("API URL exceeds the request limit");
return url;
}
export async function readBoundedResponse(response: Response, maxBytes = RUNNER_API_MAX_BYTES): Promise<Buffer> {
if (Number(response.headers.get("content-length")) > maxBytes) {
await response.body?.cancel();
throw unprocessable("API response exceeds the transfer limit; narrow the request");
}
const chunks: Uint8Array[] = [];
let bytes = 0;
const reader = response.body?.getReader();
if (!reader) return Buffer.alloc(0);
try {
while (true) {
const next = await reader.read();
if (next.done) break;
bytes += next.value.byteLength;
if (bytes > maxBytes) throw unprocessable("API response exceeds the transfer limit; narrow the request");
chunks.push(next.value);
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
return Buffer.concat(chunks);
}
export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) {
const { operation } = validateRunnerApiCall(input, context);
const url = runnerApiUrl(operation, input, context, io.apiUrl);
if (!io.token) throw new Error("Paperclip run authentication is unavailable");
const headers = new Headers({ Authorization: `Bearer ${io.token}`, "X-Paperclip-Run-Id": context.runId });
let body: BodyInit | undefined;
const contentType = input.contentType ?? (input.files?.length ? "multipart/form-data" : "application/json");
if (/\r|\n/.test(contentType)) throw badRequest("Invalid content type");
let totalBytes = 0;
if (input.files?.length) {
if (["GET", "HEAD"].includes(operation.method)) throw badRequest("Read requests cannot upload files");
const form = new FormData();
if (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))) throw badRequest("Multipart body must be an object of form fields");
for (const [key, value] of Object.entries((input.body ?? {}) as Record<string, unknown>)) {
const text = typeof value === "string" ? value : JSON.stringify(value);
totalBytes += Buffer.byteLength(text);
form.append(key, text);
}
for (const file of input.files) {
const resolved = await io.readFile(file);
totalBytes += resolved.bytes.length;
if (totalBytes > RUNNER_API_MAX_BYTES) throw badRequest("API upload exceeds the transfer limit");
if (contentType === "multipart/form-data") form.append(file.field ?? "file", new Blob([new Uint8Array(resolved.bytes)], { type: resolved.contentType }), resolved.filename);
else {
if (input.files.length !== 1 || input.body !== undefined) throw badRequest("Raw uploads require exactly one file and no body");
body = new Uint8Array(resolved.bytes);
}
}
if (contentType === "multipart/form-data") body = form;
else headers.set("Content-Type", contentType);
} else if (input.body !== undefined) {
if (["GET", "HEAD"].includes(operation.method)) throw badRequest("Read requests cannot have a body");
body = contentType.includes("json") ? JSON.stringify(input.body) : typeof input.body === "string" ? input.body : undefined;
if (body === undefined) throw badRequest("Non-JSON request bodies must be strings");
if (Buffer.byteLength(body) > RUNNER_API_MAX_BYTES) throw badRequest("API request exceeds the transfer limit");
headers.set("Content-Type", contentType);
}
let response: Response;
let bytes: Buffer;
await io.beforeDispatch?.();
try {
response = await (io.fetch ?? fetch)(url, { method: operation.method, headers, body, redirect: "manual", signal: AbortSignal.timeout(RUNNER_API_TIMEOUT_MS) });
bytes = await readBoundedResponse(response);
} catch {
return { ok: false, status: null, operationId: operation.operationId, error: "api_transport_failure", outcome: ["GET", "HEAD", "OPTIONS"].includes(operation.method) ? "read_failed" : "unknown", guidance: "Inspect current state before retrying a mutation; it may already have succeeded." };
}
const type = response.headers.get("content-type") ?? "application/octet-stream";
const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
// A server can commit a write before an error response or redirect. Preserve
// the HTTP evidence, but never describe that write as definitely not applied.
const uncertain = mutation && (response.status >= 500 || response.status === 408 || (response.status >= 300 && response.status < 400));
const uncertainty = { outcome: "unknown", guidance: "Inspect current state before retrying this mutation; it may already have succeeded." };
const base = { ok: response.ok, status: response.status, operationId: operation.operationId, contentType: type, retryAfter: response.headers.get("retry-after"), ...(uncertain ? uncertainty : {}) };
if (response.status >= 300 && response.status < 400) return { ...base, ok: false, error: "api_redirect_not_followed" };
if (!bytes.length) return { ...base, data: null };
if (bytes.length > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) {
return { ...base, artifact: await io.saveResponse(bytes, type), byteSize: bytes.length, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null };
}
const text = bytes.toString("utf8");
if (/json/i.test(type)) {
try { return { ...base, data: JSON.parse(text) as unknown }; }
catch { return { ...base, ok: false, error: "invalid_json_response", data: text, ...(mutation ? uncertainty : {}) }; }
}
return { ...base, data: text };
}
@@ -0,0 +1,25 @@
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { openRunnerApiWorkspaceFile } from "./runner-api-files.js";
describe.skipIf(!["linux", "darwin"].includes(process.platform))("runner API confined workspace uploads", () => {
it("reads regular files but rejects a substituted file or ancestor symlink", async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), "api-file-open-")));
try {
await mkdir(join(root, "allowed"));
await mkdir(join(root, "outside"));
await writeFile(join(root, "allowed", "file.txt"), "allowed");
await writeFile(join(root, "outside", "file.txt"), "outside");
const handle = await openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"));
try { expect(await handle.readFile("utf8")).toBe("allowed"); } finally { await handle.close(); }
await rm(join(root, "allowed", "file.txt"));
await symlink(join(root, "outside", "file.txt"), join(root, "allowed", "file.txt"));
await expect(openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"))).rejects.toThrow();
await rm(join(root, "allowed"), { recursive: true });
await symlink(join(root, "outside"), join(root, "allowed"));
await expect(openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"))).rejects.toThrow();
} finally { await rm(root, { recursive: true, force: true }); }
});
});
@@ -0,0 +1,27 @@
import { constants } from "node:fs";
import { open, type FileHandle } from "node:fs/promises";
import { isAbsolute } from "node:path";
/** Open a previously authorized canonical path without following raced symlinks. */
export async function openRunnerApiWorkspaceFile(path: string): Promise<FileHandle> {
if (!isAbsolute(path)) throw new Error("Workspace file must have a canonical absolute path");
if (process.platform === "darwin") {
// Darwin sys/fcntl.h: O_NOFOLLOW_ANY rejects symlinks at every component.
// Node does not expose this flag in fs.constants. Unsupported kernels fail
// closed instead of falling back to a pathname check followed by open.
return open(path, constants.O_RDONLY | constants.O_NONBLOCK | 0x20000000);
}
if (process.platform !== "linux") throw new Error("Workspace uploads require a platform with confined file opens; use an authorized artifact reference");
const parts = path.split("/").filter(Boolean);
if (!parts.length || parts.some(part => part === "." || part === "..")) throw new Error("Invalid canonical workspace path");
let directory = await open("/", constants.O_RDONLY | constants.O_DIRECTORY);
try {
for (const part of parts.slice(0, -1)) {
// Linux magic descriptor links provide openat-style directory confinement.
const next = await open(`/proc/self/fd/${directory.fd}/${part}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
await directory.close();
directory = next;
}
return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
} finally { await directory.close(); }
}
@@ -0,0 +1,32 @@
/** Ordinary API results and replay receipts are not a secret-value channel. */
export function runnerApiRestriction(method: string, path: string): string | null {
const metadataRead = method === "GET" && [
"/api/agents/me/secrets",
"/api/companies/{companyId}/secrets/catalog",
].includes(path);
if ((/^\/api\/tool-gateway\/sessions(\/|$)/.test(path) && method !== "GET")
|| (!metadataRead && (/\/(secrets|secret-proposals|secret-provider-configs|user-secrets|user-secret-definitions|keys|board-api-keys|credentials|setup-token-login-sessions|board-claim|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(path)
|| /^\/api\/companies\/\{companyId\}\/exports?(\/|$)/.test(path)))) {
return "Use the existing credential broker or secure management client: call_api cannot return secret values, manage credentials, or export company credential configuration.";
}
return ["GET", "HEAD", "OPTIONS"].includes(method) ? null : runnerApiMutationRestriction(path);
}
/** Runner-owned transitions cannot be reached by the generic HTTP escape hatch. */
export function runnerApiMutationRestriction(path: string): string | null {
const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(path);
const routineAnnotation = /^\/api\/routines\/\{[^}]+\}\/description\/annotations(?:\/\{[^}]+\}(?:\/comments)?)?$/.test(path);
if ((!routineAnnotation && /\/(routines|routine-triggers)(\/|$)/.test(path))
|| /\/(runtime-commands|runtime-services)\/\{action\}$/.test(path)
|| /\/(?:tool-gateway|tools)\/runtime-slots\/\{[^}]+\}\/(restart|stop)$/.test(path)
|| /^\/api\/cases\/\{[^}]+\}\/(automation|automations)\//.test(path)
|| /\/skills\/\{[^}]+\}\/test-runs(\/|$)/.test(path)
|| /^\/api\/heartbeat-runs\//.test(path)
|| /^\/api\/agents\/\{[^}]+\}\/(heartbeat|wakeup|pause|resume|terminate|approve|clear-error|runtime-state)(\/|$)/.test(path)
|| /^\/api\/(approvals|decisions)\/\{[^}]+\}\/(approve|reject|decide|cancel|dismiss|request-revision|resubmit)$/.test(path)
|| (issueRoute && /\/queued-comments\/\{[^}]+\}\/steer$/.test(path))
|| (issueRoute && /\/(interactions|accepted-plan-decompositions|stalled-review-decision|tree-holds|watchdog|recovery-actions|scheduled-retry|monitor|admin|checkout|release|cancel|resume|wake|run|retry|recover|tree-control)(\/|$)/.test(path))) {
return "Use the dedicated tools and existing clients: call_api cannot bypass runner lifecycle, execution-control or approval authority";
}
return null;
}
@@ -0,0 +1,689 @@
// Generated by scripts/generate-runner-api-reference.mjs from the legacy skill reference.
export const runnerApiReference: Record<string, { section: string; description?: string; examples?: { body: unknown }[] }> = {
"GET /api/agents/me": {
"section": "Agents",
"description": "Your agent record + chain of command"
},
"GET /api/agents/me/inbox/mine?userId={}": {
"section": "Agents",
"description": "Mine-tab issue list for a specific board user"
},
"GET /api/agents/{}": {
"section": "Agents",
"description": "Agent details + chain of command"
},
"GET /api/companies/{}/agents": {
"section": "Agents",
"description": "List all agents in company"
},
"POST /api/companies/{}/agents": {
"section": "Agents",
"description": "Create agent directly (no approval)"
},
"PATCH /api/agents/{}": {
"section": "Agents",
"description": "Update agent config or budget"
},
"POST /api/agents/{}/pause": {
"section": "Agents",
"description": "Temporarily stop heartbeats"
},
"POST /api/agents/{}/resume": {
"section": "Agents",
"description": "Resume a paused agent"
},
"POST /api/agents/{}/terminate": {
"section": "Agents",
"description": "Permanently deactivate agent (irreversible)"
},
"POST /api/agents/{}/keys": {
"section": "Agents",
"description": "Create long-lived API key (full value shown once)"
},
"POST /api/agents/{}/heartbeat/invoke": {
"section": "Agents",
"description": "Manually trigger a heartbeat"
},
"GET /api/companies/{}/org": {
"section": "Agents",
"description": "Org chart tree"
},
"GET /api/companies/{}/adapters/{}/models": {
"section": "Agents",
"description": "List selectable models for an adapter type"
},
"PATCH /api/agents/{}/instructions-path": {
"section": "Agents",
"description": "Set/clear instructions path (`AGENTS.md`)",
"examples": [
{
"body": {
"path": "agents/cmo/AGENTS.md"
}
},
{
"body": {
"path": "/absolute/path/to/AGENTS.md",
"adapterConfigKey": "adapterSpecificPathField"
}
}
]
},
"GET /api/agents/{}/config-revisions": {
"section": "Agents",
"description": "List config revisions"
},
"POST /api/agents/{}/config-revisions/{}/rollback": {
"section": "Agents",
"description": "Roll back config"
},
"GET /api/companies/{}/issues": {
"section": "Issues (Tasks)",
"description": "List issues, sorted by priority. Filters: `?status=`, `?assigneeAgentId=`, `?assigneeUserId=`, `?projectId=`, `?labelId=`, `?q=` (full-text search across title, identifier, description, comments)"
},
"GET /api/issues/{}": {
"section": "Issues (Tasks)",
"description": "Issue details + ancestors"
},
"GET /api/issues/{}/heartbeat-context": {
"section": "Issues (Tasks)",
"description": "Compact issue context including `currentExecutionWorkspace` when one is linked"
},
"GET /api/issues/{}/diagnostics/blockers": {
"section": "Issues (Tasks)",
"description": "Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags"
},
"GET /api/issues/{}/diagnostics/wakes": {
"section": "Issues (Tasks)",
"description": "Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference"
},
"GET /api/issues/{}/diagnostics/subtree": {
"section": "Issues (Tasks)",
"description": "Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis`"
},
"POST /api/companies/{}/issues": {
"section": "Issues (Tasks)",
"description": "Create issue (supports `blockedByIssueIds: string[]` for dependencies)"
},
"PATCH /api/issues/{}": {
"section": "Issues (Tasks)",
"description": "Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set",
"examples": [
{
"body": {
"executionPolicy": {
"stages": [
{
"type": "review",
"participants": [
{
"type": "agent",
"agentId": "<reviewer-agent-id>"
}
]
}
]
}
}
}
]
},
"POST /api/issues/{}/checkout": {
"section": "Issues (Tasks)",
"description": "Atomic checkout (claim + start). Idempotent if you already own it."
},
"POST /api/issues/{}/release": {
"section": "Issues (Tasks)",
"description": "Release task ownership"
},
"GET /api/issues/{}/comments": {
"section": "Issues (Tasks)",
"description": "List comments"
},
"GET /api/issues/{}/comments/{}": {
"section": "Issues (Tasks)",
"description": "Get a specific comment by ID"
},
"POST /api/issues/{}/comments": {
"section": "Issues (Tasks)",
"description": "Add comment (@-mentions trigger wakeups)"
},
"POST /api/issues/{}/inbox-archive": {
"section": "Issues (Tasks)",
"description": "Archive issue from responsible user's inbox; optional `userId` requires saved target-user opt-in or cross-user grant"
},
"DELETE /api/issues/{}/inbox-archive": {
"section": "Issues (Tasks)",
"description": "Reverse inbox archive; same target and policy rules"
},
"GET /api/issues/{}/interactions": {
"section": "Issues (Tasks)",
"description": "List issue-thread interactions"
},
"POST /api/issues/{}/interactions": {
"section": "Issues (Tasks)",
"description": "Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`)",
"examples": [
{
"body": {
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{
"id": "draft-report-march",
"label": "Old draft report",
"description": "QA test pass, March."
},
{
"id": "tmp-export-2025",
"label": "tmp/export-2025.csv"
}
],
"defaultSelectedOptionIds": [
"draft-report-march"
],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"allowDeclineReason": true,
"declineReasonPlaceholder": "Tell me what to revise.",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
},
{
"body": {
"kind": "request_item_verdicts",
"idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
"title": "Review generated artifacts",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Review each generated artifact.",
"detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.",
"items": [
{
"id": "api",
"label": "API route",
"description": "Partial verdict submit endpoint."
},
{
"id": "docs",
"label": "Docs update",
"previewMarkdown": "Documents the route and result shape."
}
],
"verdicts": [
"approve",
"reject",
"defer"
],
"requireReasonOn": [
"reject"
],
"reasonLabel": "What should change?",
"allowBulkApprove": true,
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
}
]
},
"POST /api/issues/{}/interactions/{}/accept": {
"section": "Issues (Tasks)",
"description": "Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`)"
},
"POST /api/issues/{}/interactions/{}/reject": {
"section": "Issues (Tasks)",
"description": "Reject suggested tasks or confirmation"
},
"POST /api/issues/{}/interactions/{}/respond": {
"section": "Issues (Tasks)",
"description": "Respond to structured questions"
},
"POST /api/issues/{}/interactions/{}/verdicts": {
"section": "Issues (Tasks)",
"description": "Submit partial item verdicts for `request_item_verdicts`",
"examples": [
{
"body": {
"verdicts": [
{
"id": "api",
"verdict": "approve"
},
{
"id": "docs",
"verdict": "reject",
"reason": "Needs install instructions."
}
]
}
}
]
},
"POST /api/issues/{}/interactions/{}/withdraw": {
"section": "Issues (Tasks)",
"description": "Withdraw any pending interaction; optional `{ \"reason\": string }`; creator agent, current assignee agent, or board user"
},
"GET /api/issues/{}/documents": {
"section": "Issues (Tasks)",
"description": "List issue documents"
},
"GET /api/issues/{}/documents/{}": {
"section": "Issues (Tasks)",
"description": "Get issue document by key"
},
"PUT /api/issues/{}/documents/{}": {
"section": "Issues (Tasks)",
"description": "Create or update issue document (send `baseRevisionId` when updating)"
},
"GET /api/issues/{}/documents/{}/revisions": {
"section": "Issues (Tasks)",
"description": "Document revision history"
},
"DELETE /api/issues/{}/documents/{}": {
"section": "Issues (Tasks)",
"description": "Delete document (board-only)"
},
"GET /api/issues/{}/approvals": {
"section": "Issues (Tasks)",
"description": "List approvals linked to issue"
},
"POST /api/issues/{}/approvals": {
"section": "Issues (Tasks)",
"description": "Link approval to issue"
},
"DELETE /api/issues/{}/approvals/{}": {
"section": "Issues (Tasks)",
"description": "Unlink approval from issue"
},
"GET /api/execution-workspaces/{}": {
"section": "Issues (Tasks)",
"description": "Execution workspace detail including runtime services and service URLs"
},
"POST /api/execution-workspaces/{}/runtime-services/start": {
"section": "Issues (Tasks)",
"description": "Start configured workspace services"
},
"POST /api/execution-workspaces/{}/runtime-services/restart": {
"section": "Issues (Tasks)",
"description": "Restart configured workspace services"
},
"POST /api/execution-workspaces/{}/runtime-services/stop": {
"section": "Issues (Tasks)",
"description": "Stop workspace runtime services"
},
"GET /api/companies": {
"section": "Companies, Projects, Goals",
"description": "List all companies"
},
"POST /api/companies": {
"section": "Companies, Projects, Goals",
"description": "Create company"
},
"GET /api/companies/{}": {
"section": "Companies, Projects, Goals",
"description": "Company details"
},
"PATCH /api/companies/{}": {
"section": "Companies, Projects, Goals",
"description": "Update company fields"
},
"POST /api/companies/{}/logo": {
"section": "Companies, Projects, Goals",
"description": "Upload company logo (multipart)"
},
"POST /api/companies/{}/archive": {
"section": "Companies, Projects, Goals",
"description": "Archive company"
},
"GET /api/companies/{}/projects": {
"section": "Companies, Projects, Goals",
"description": "List projects"
},
"GET /api/projects/{}": {
"section": "Companies, Projects, Goals",
"description": "Project details"
},
"POST /api/companies/{}/projects": {
"section": "Companies, Projects, Goals",
"description": "Create project (optional inline `workspace`)",
"examples": [
{
"body": {
"name": "Paperclip Mobile App",
"description": "Ship iOS + Android client",
"status": "planned",
"goalIds": [
"{goalId}"
],
"workspace": {
"name": "paperclip-mobile",
"cwd": "/Users/me/paperclip-mobile",
"repoUrl": "https://github.com/acme/paperclip-mobile",
"repoRef": "main",
"isPrimary": true
}
}
},
{
"body": {
"name": "Paperclip Mobile App",
"description": "Ship iOS + Android client",
"status": "planned"
}
}
]
},
"PATCH /api/projects/{}": {
"section": "Companies, Projects, Goals",
"description": "Update project"
},
"GET /api/projects/{}/workspaces": {
"section": "Companies, Projects, Goals",
"description": "List project workspaces"
},
"POST /api/projects/{}/workspaces": {
"section": "Companies, Projects, Goals",
"description": "Create project workspace",
"examples": [
{
"body": {
"cwd": "/Users/me/paperclip-mobile",
"repoUrl": "https://github.com/acme/paperclip-mobile",
"repoRef": "main",
"isPrimary": true
}
}
]
},
"PATCH /api/projects/{}/workspaces/{}": {
"section": "Companies, Projects, Goals",
"description": "Update project workspace"
},
"DELETE /api/projects/{}/workspaces/{}": {
"section": "Companies, Projects, Goals",
"description": "Delete project workspace"
},
"GET /api/companies/{}/goals": {
"section": "Companies, Projects, Goals",
"description": "List goals"
},
"GET /api/goals/{}": {
"section": "Companies, Projects, Goals",
"description": "Goal details"
},
"POST /api/companies/{}/goals": {
"section": "Companies, Projects, Goals",
"description": "Create goal"
},
"PATCH /api/goals/{}": {
"section": "Companies, Projects, Goals",
"description": "Update goal"
},
"POST /api/companies/{}/openclaw/invite-prompt": {
"section": "Companies, Projects, Goals",
"description": "Generate OpenClaw invite prompt (CEO/board only)"
},
"GET /api/companies/{}/routines": {
"section": "Routines",
"description": "List all routines in company"
},
"GET /api/routines/{}": {
"section": "Routines",
"description": "Routine details including triggers"
},
"POST /api/companies/{}/routines": {
"section": "Routines",
"description": "Create routine (`assigneeAgentId` + `projectId` required; agents: own only)"
},
"PATCH /api/routines/{}": {
"section": "Routines",
"description": "Update routine (agents: own only, cannot reassign)"
},
"POST /api/routines/{}/triggers": {
"section": "Routines",
"description": "Add trigger (`schedule`, `webhook`, or `api` kind)"
},
"PATCH /api/routine-triggers/{}": {
"section": "Routines",
"description": "Update trigger (e.g. disable, change cron)"
},
"DELETE /api/routine-triggers/{}": {
"section": "Routines",
"description": "Delete trigger"
},
"POST /api/routine-triggers/{}/rotate-secret": {
"section": "Routines",
"description": "Rotate webhook signing secret (previous secret immediately invalidated)"
},
"POST /api/routines/{}/run": {
"section": "Routines",
"description": "Manual run (bypasses schedule; concurrency policy still applies)"
},
"POST /api/routine-triggers/public/{}/fire": {
"section": "Routines",
"description": "Fire webhook trigger from external system"
},
"GET /api/routines/{}/runs": {
"section": "Routines",
"description": "Run history (default 50)"
},
"GET /api/companies/{}/approvals": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "List approvals (`?status=pending`)"
},
"POST /api/companies/{}/approvals": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Create approval request"
},
"POST /api/companies/{}/agent-hires": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Create hire request/agent draft",
"examples": [
{
"body": {
"name": "Marketing Analyst",
"role": "researcher",
"reportsTo": "{manager-agent-id}",
"capabilities": "Market research, competitor analysis",
"budgetMonthlyCents": 5000
}
}
]
},
"GET /api/approvals/{}": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Approval details"
},
"GET /api/approvals/{}/issues": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Issues linked to approval"
},
"GET /api/approvals/{}/comments": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Approval comments"
},
"POST /api/approvals/{}/comments": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Add approval comment"
},
"POST /api/approvals/{}/approve": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Approve approval request"
},
"POST /api/approvals/{}/reject": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Reject approval request"
},
"POST /api/approvals/{}/request-revision": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Board asks for revision"
},
"POST /api/approvals/{}/resubmit": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Resubmit revised approval"
},
"POST /api/companies/{}/cost-events": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Report cost event"
},
"GET /api/companies/{}/costs/summary": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Company cost summary"
},
"GET /api/companies/{}/costs/by-agent": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Costs by agent"
},
"GET /api/companies/{}/costs/by-project": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Costs by project"
},
"GET /api/companies/{}/activity": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Activity log"
},
"GET /api/companies/{}/dashboard": {
"section": "Approvals, Costs, Activity, Dashboard",
"description": "Company health summary"
},
"GET /api/companies/{}/secrets": {
"section": "Secrets",
"description": "List secrets (metadata only)"
},
"POST /api/companies/{}/secrets": {
"section": "Secrets",
"description": "Create secret"
},
"PATCH /api/secrets/{}": {
"section": "Secrets",
"description": "Update secret value (creates new version)"
},
"POST /api/agents/me/secret-proposals": {
"section": "Secrets",
"description": "Propose a secret or agent binding for board approval"
},
"GET /api/agents/me/secret-proposals": {
"section": "Secrets",
"description": "List proposals created by the agent and incoming bindings targeting it"
},
"DELETE /api/agents/me/secret-proposals/{}": {
"section": "Secrets",
"description": "Withdraw one pending proposal created by the agent"
},
"GET /api/agents/me/secrets": {
"section": "Secrets",
"description": "List secrets accessible to the current run (metadata only)"
},
"POST /api/agents/me/secrets/{}/value": {
"section": "Secrets",
"description": "Fetch one granted secret value; request body is empty"
},
"POST /api/companies/company-1/imports/preview": {
"section": "Worked example",
"examples": [
{
"body": {
"source": {
"type": "github",
"url": "https://github.com/acme/agent-company"
},
"include": {
"company": true,
"agents": true,
"projects": true,
"issues": true
},
"target": {
"mode": "existing_company",
"companyId": "company-1"
},
"collisionStrategy": "rename"
}
}
]
},
"POST /api/companies/company-1/imports/apply": {
"section": "Worked example",
"examples": [
{
"body": {
"source": {
"type": "github",
"url": "https://github.com/acme/agent-company"
},
"include": {
"company": true,
"agents": true,
"projects": true,
"issues": false
},
"target": {
"mode": "new_company",
"newCompanyName": "Imported Acme"
},
"collisionStrategy": "rename"
}
}
]
},
"POST /api/companies/company-1/exports/preview": {
"section": "Worked example",
"examples": [
{
"body": {
"include": {
"company": true,
"agents": true,
"projects": true
}
}
}
]
},
"POST /api/companies/company-1/exports": {
"section": "Worked example",
"examples": [
{
"body": {
"include": {
"company": true,
"agents": true,
"projects": true,
"issues": true
},
"selectedFiles": [
"COMPANY.md",
"agents/ceo/AGENTS.md",
"skills/paperclip/SKILL.md",
"tasks/pap-42/TASK.md"
]
}
}
]
}
};
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { runnerApiToolsEnabled } from "./runner-api-rollout.js";
describe("runner API rollout", () => {
it("requires operator opt-in and fails closed for invalid settings", () => {
for (const value of [undefined, "", "TRUE", "1", "invalid"]) {
for (const binding of [undefined, false, true]) expect(runnerApiToolsEnabled("company", binding, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: value })).toBe(false);
}
expect(runnerApiToolsEnabled("company", undefined, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true" })).toBe(true);
});
it("restricts an enabled rollout to exact company IDs", () => {
const env = { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true", PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS: " alpha, beta " };
expect(runnerApiToolsEnabled("alpha", undefined, env)).toBe(true);
expect(runnerApiToolsEnabled("alph", undefined, env)).toBe(false);
expect(runnerApiToolsEnabled("foreign", true, env)).toBe(false);
expect(runnerApiToolsEnabled("alpha", undefined, { ...env, PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS: "" })).toBe(false);
});
it("keeps baseline tools disabled and honors an operator stop over explicit bindings", () => {
expect(runnerApiToolsEnabled("company", false, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true" })).toBe(false);
expect(runnerApiToolsEnabled("company", true, {})).toBe(false);
expect(runnerApiToolsEnabled("company", true, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "false" })).toBe(false);
});
});
@@ -0,0 +1,13 @@
/** Server-owned rollout policy. Never read from agent tool arguments. */
export function runnerApiToolsEnabled(
companyId: string,
bindingOverride?: boolean,
environment: NodeJS.ProcessEnv = process.env,
): boolean {
const enabled = environment.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
// A binding can only narrow operator permission, never opt into this surface.
if (enabled !== "true" || bindingOverride === false) return false;
const companies = environment.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
if (companies === undefined) return true;
return companies.split(",").map(value => value.trim()).filter(Boolean).includes(companyId);
}
@@ -0,0 +1,269 @@
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { chmod, writeFile, symlink } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { documents, heartbeatRuns, issues, routineDocuments, routines } from "@paperclipai/db";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js";
import { runnerApiCatalog } from "./runner-api-catalog.js";
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
describe("runner API against real HTTP routes", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const oldEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true";
server = await startRunnerApiTestServer();
}, 60_000);
afterAll(async () => {
await server?.close();
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
if (oldEnabled === undefined) delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
else process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = oldEnabled;
});
it.skipIf(!process.env.PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION && !existsSync(defaultCapabilityRunnerdBinary()))("runs runnerd → PRP → authority → actual authenticated HTTP", async () => {
const fixture = await server.fixture();
const provider = join(server.root, "scripted-api-provider.mjs");
await writeFile(provider, `#!${process.execPath}
import { createInterface } from 'node:readline';
const send = value => process.stdout.write(JSON.stringify(value)+'\\n');
let step = 0;
const calls = [{tool:'search_api',arguments:{query:'list projects'}},{tool:'call_api',arguments:{operationId:'GET /api/companies/{companyId}/projects'}}];
const next = () => { const c=calls[step++]; if(c) send({id:'call-'+step,method:'item/tool/call',params:{threadId:'api-thread',turnId:'api-turn',itemId:'api-item-'+step,callId:'api-call-'+step,...c}}); else send({method:'turn/completed',params:{turn:{id:'api-turn',status:'completed'}}}); };
for await (const line of createInterface({input:process.stdin})) {
const m=JSON.parse(line);
if(!m.method) {if(String(m.id).startsWith('call-')) next(); continue;}
if(m.method==='initialize') send({id:m.id,result:{userAgent:'scripted-api-provider'}});
else if(m.method==='thread/start') send({id:m.id,result:{thread:{id:'api-thread',sessionId:'api-session'}}});
else if(m.method==='turn/start') {send({id:m.id,result:{turn:{id:'api-turn',status:'inProgress'}}});send({method:'turn/started',params:{turn:{id:'api-turn'}}});next();}
else if(m.id!==undefined) send({id:m.id,result:{}});
}
`);
await chmod(provider, 0o700);
const bundle = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: provider, codexArgs: [],
stateDirectory: join(server.root, "scripted-runner"), lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: { runnerInstanceId: "api-test", environmentLeaseId: "api-test-lease", runId: fixture.runId, normalizedSessionId: "api-test-session", turnId: "api-test-turn", itemId: "api-test-item" },
controlPlaneRegistration: prp => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
});
const results: any[] = [];
bundle.transport.setServerRequestHandler(async request => {
const params = request.params as any;
const result = await fixture.authority.execute({ tool: params.tool, arguments: params.arguments, callId: params.callId });
results.push(result);
return { success: true, contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }] };
});
try {
await bundle.transport.request("initialize", {});
await bundle.transport.request("thread/start", { cwd: fixture.workspace, dynamicTools: await fixture.authority.definitions() });
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Find the project" }] });
for await (const notification of bundle.transport.notifications()) if (notification.method === "turn/completed") break;
expect(results).toHaveLength(2);
expect(results[1]).toMatchObject({ status: 200, data: [{ id: fixture.projectId, name: "Aurora" }] });
expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane");
} finally { await bundle.transport.close(); }
}, 30_000);
it("cannot opt into API tools through a binding when the operator flag is absent", async () => {
const fixture = await server.fixture({ apiToolsEnabled: true });
delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
try {
const names = (await fixture.authority.definitions()).map(tool => tool.name);
expect(names).toContain("get_task_context");
expect(names).not.toContain("search_api");
expect(names).not.toContain("call_api");
await expect(fixture.authority.execute({ tool: "call_api", callId: "disabled", arguments: { operationId: "GET /api/companies/{companyId}/projects" } })).rejects.toThrow("not_advertised");
} finally { process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true"; }
});
it("rejects credential calls before any durable receipt or secret result exists", async () => {
const fixture = await server.fixture();
for (const operationId of ["POST /api/agents/me/secrets/{key}/value", "POST /api/agents/{id}/keys"]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: operationId, arguments: { operationId, pathParams: operationId.includes("{key}") ? { key: "EXAMPLE_SECRET" } : { id: fixture.agentId } } })).rejects.toThrow("credential broker");
}
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
expect((await fixture.snapshot()).activity.filter(row => row.action === "runner.api_called")).toEqual([]);
});
it("rejects issue lifecycle intents before dispatch or receipt creation", async () => {
const fixture = await server.fixture();
for (const field of ["reopen", "resume", "interrupt"]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: field, arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, body: { [field]: true } } })).rejects.toThrow("lifecycle changes");
}
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)?.status).toBe("todo");
});
it("supports routine annotation collaboration without changing scheduling", async () => {
const fixture = await server.fixture();
const [document] = await server.db.select().from(documents).where(eq(documents.companyId, fixture.companyId));
const [routine] = await server.db.insert(routines).values({ companyId: fixture.companyId, projectId: fixture.projectId, title: "Review schedule", description: document.latestBody, assigneeAgentId: fixture.agentId, status: "paused" }).returning();
await server.db.insert(routineDocuments).values({ companyId: fixture.companyId, routineId: routine.id, documentId: document.id, key: "description" });
const exact = "silver-wren", start = document.latestBody.indexOf(exact);
const base = "/api/routines/{id}/description/annotations";
const call = (callId: string, operationId: string, body: unknown, threadId?: string) => fixture.authority.execute({ tool: "call_api", callId, arguments: { operationId, pathParams: { id: routine.id, ...(threadId ? { threadId } : {}) }, body } }) as Promise<any>;
const created = await call("annotation-create", `POST ${base}`, { baseRevisionId: document.latestRevisionId, baseRevisionNumber: document.latestRevisionNumber, selector: { quote: { exact, prefix: document.latestBody.slice(0, start), suffix: document.latestBody.slice(start + exact.length) }, position: { normalizedStart: start, normalizedEnd: start + exact.length, markdownStart: start, markdownEnd: start + exact.length } }, body: "Please clarify this note" });
expect(created).toMatchObject({ status: 201, data: { routineId: routine.id, status: "open" } });
const threadId = created.data.id;
expect(await call("annotation-comment", `POST ${base}/{threadId}/comments`, { body: "Clarified note" }, threadId)).toMatchObject({ status: 201, data: { body: "Clarified note" } });
expect(await call("annotation-resolve", `PATCH ${base}/{threadId}`, { status: "resolved" }, threadId)).toMatchObject({ status: 200, data: { status: "resolved" } });
expect(await call("annotation-reopen", `PATCH ${base}/{threadId}`, { status: "open" }, threadId)).toMatchObject({ status: 200, data: { status: "open" } });
const [unchanged] = await server.db.select().from(routines).where(eq(routines.id, routine.id));
expect(unchanged).toMatchObject({ status: "paused", lastTriggeredAt: null, lastEnqueuedAt: null });
expect((await fixture.snapshot()).activity).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_thread_created", agentId: fixture.agentId, runId: fixture.runId })]));
});
it("rejects every restricted REST mutation before dispatch or durable receipt", async () => {
const fixture = await server.fixture();
const operations = runnerApiCatalog().filter(operation => operation.transport === "rest" && !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.callPolicy === "restricted");
expect(operations.length).toBeGreaterThan(5);
for (const operation of operations) {
await expect(fixture.authority.execute({ tool: "call_api", callId: operation.operationId, arguments: { operationId: operation.operationId } })).rejects.toThrow(/cannot bypass|credential broker/);
}
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
});
it("preserves route validation, authorization and audit; replays mutations once", async () => {
const fixture = await server.fixture();
const call = (callId: string, args: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: args });
await expect(call("foreign", { operationId: "GET /api/projects/{id}", pathParams: { id: fixture.foreignProjectId } })).resolves.toMatchObject({ ok: false, status: 404 });
await expect(call("invalid", { operationId: "POST /api/companies/{companyId}/projects", body: {} })).resolves.toMatchObject({ ok: false, status: 400 });
const args = { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Created through HTTP" } };
const result = await call("create", args);
expect(result).toMatchObject({ status: 201, data: { name: "Created through HTTP" } });
expect(await call("create", args)).toEqual(result);
await expect(call("create", { ...args, body: { name: "Different" } })).rejects.toThrow("reused");
const snapshot = await fixture.snapshot();
expect(snapshot.projects.filter(p => p.name === "Created through HTTP")).toHaveLength(1);
expect(snapshot.activity).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "project.created", agentId: fixture.agentId }),
expect.objectContaining({ action: "runner.api_called", agentId: fixture.agentId, runId: fixture.runId, details: expect.objectContaining({ operationId: args.operationId, status: 201 }) }),
]));
});
it("blocks stale bindings and Ask/Plan mutations before HTTP", async () => {
for (const mode of ["ask", "planning"] as const) {
const fixture = await server.fixture({ mode });
await expect(fixture.authority.execute({ tool: "call_api", callId: "read", arguments: { operationId: "GET /api/companies/{companyId}/projects" } })).resolves.toMatchObject({ status: 200 });
await expect(fixture.authority.execute({ tool: "call_api", callId: "write", arguments: { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Denied" } } })).rejects.toThrow("only reads");
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.runId));
await expect(fixture.authority.execute({ tool: "search_api", callId: "stale", arguments: { query: "projects" } })).rejects.toThrow("binding_not_authorized");
expect((await fixture.snapshot()).projects.some(p => p.name === "Denied")).toBe(false);
}
});
it("allows API-only options while protecting task completion", async () => {
const fixture = await server.fixture();
const call = (body: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.issueId }, body } });
await expect(call({ billingCode: "API-EXTRA" })).resolves.toMatchObject({ status: 200 });
await expect(call({ status: "done" })).rejects.toThrow("lifecycle");
const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId));
expect(issue.billingCode).toBe("API-EXTRA");
expect(issue.status).toBe("in_progress");
for (const id of [fixture.issueId.toUpperCase(), issue.identifier!.toLowerCase(), ` ${issue.identifier!.toLowerCase()} `]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "DELETE /api/issues/{id}", pathParams: { id } } })).rejects.toThrow("cannot delete itself");
}
expect((await fixture.snapshot()).issues.some(row => row.id === fixture.issueId)).toBe(true);
});
it("revokes advertised API tools without disabling dedicated operations", async () => {
const fixture = await server.fixture();
expect(fixture.authority.definitions().some(tool => tool.name === "call_api")).toBe(true);
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", "false");
try {
expect(fixture.authority.definitions().some(tool => tool.name === "search_api")).toBe(false);
await expect(fixture.authority.execute({ tool: "call_api", callId: "revoked", arguments: {
operationId: "POST /api/companies/{companyId}/projects", body: { name: "Must not exist" },
} })).rejects.toThrow("not_advertised");
await expect(fixture.authority.execute({ tool: "get_task_context", callId: "dedicated-after-stop", arguments: {} }))
.resolves.toMatchObject({ activeTask: { id: fixture.issueId } });
expect((await fixture.snapshot()).projects.some(project => project.name === "Must not exist")).toBe(false);
} finally { vi.unstubAllEnvs(); }
});
it("retains an uncertain mutation receipt without dispatching it again", async () => {
const fixture = await server.fixture();
const args = { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Uncertain" } };
let rejectNetwork!: (error: Error) => void;
const fetcher = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise((_resolve, reject) => { rejectNetwork = reject; }));
try {
const call = () => fixture.authority.execute({ tool: "call_api", callId: "uncertain", arguments: args });
const pending = call();
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
expect(await call()).toMatchObject({ status: null, outcome: "unknown", error: "api_outcome_unknown" });
rejectNetwork(new Error("lost response"));
const result = await pending;
expect(result).toMatchObject({ status: null, outcome: "unknown" });
expect(await call()).toEqual(result);
expect(fetcher).toHaveBeenCalledTimes(1);
} finally { fetcher.mockRestore(); }
expect((await fixture.snapshot()).projects.some(p => p.name === "Uncertain")).toBe(false);
});
it("revalidates the active run after reading an upload", async () => {
const fixture = await server.fixture();
const original = server.storage.getObject.bind(server.storage);
const getObject = vi.spyOn(server.storage, "getObject").mockImplementation(async (...args) => {
const object = await original(...args);
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.runId));
return object;
});
try {
await expect(fixture.authority.execute({ tool: "call_api", callId: "expired-during-upload", arguments: {
operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, files: [{ artifactId: fixture.artifactId }],
} })).rejects.toThrow("binding_not_authorized");
expect((await fixture.snapshot()).assets).toHaveLength(2);
} finally { getObject.mockRestore(); }
});
it("contains workspace files, checks artifact ownership, and persists downloads", async () => {
const fixture = await server.fixture();
const foreign = await server.fixture();
const upload = (files: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, files } });
await expect(upload([{ artifactId: foreign.artifactId }])).rejects.toThrow("not available");
await expect(upload([{ path: "../outside.txt" }])).rejects.toThrow();
const outside = join(server.root, "outside.txt");
await writeFile(outside, "must-not-upload");
await symlink(outside, join(fixture.workspace, "escape.txt"));
await expect(upload([{ path: "escape.txt" }])).rejects.toThrow();
await expect(upload([{ path: "sample.txt" }])).resolves.toMatchObject({ status: 201 });
const download = await fixture.authority.execute({ tool: "call_api", callId: "download", arguments: { operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId: fixture.binaryArtifactId } } }) as any;
expect(download).toMatchObject({ status: 200, byteSize: 32000, artifact: { byteSize: 32000 } });
expect((await fixture.snapshot()).assets).toEqual(expect.arrayContaining([expect.objectContaining({ id: download.artifact.artifactId, companyId: fixture.companyId, createdByAgentId: fixture.agentId })]));
});
it("does not accept caller-supplied identity in an API comment", async () => {
const fixture = await server.fixture();
const result = await fixture.authority.execute({ tool: "call_api", callId: "comment", arguments: { operationId: "POST /api/issues/{id}/comments", pathParams: { id: fixture.issueId }, body: { body: "Identity proof", authorAgentId: randomUUID(), authorUserId: "spoofed", runId: randomUUID() } } });
expect(result).toMatchObject({ status: 201 });
expect((await fixture.snapshot()).comments).toEqual(expect.arrayContaining([expect.objectContaining({ body: "Identity proof", authorAgentId: fixture.agentId, authorUserId: null })]));
});
it("creates child tasks after seeding and preserves company numbering", async () => {
const fixture = await server.fixture();
await expect(fixture.authority.execute({ tool: "create_task", callId: "child", arguments: { title: "Verify release notes", description: "Check before shipping.", assigneeActorId: null, idempotencyKey: "child" } })).resolves.toBeDefined();
const children = (await fixture.snapshot()).issues.filter(issue => issue.parentId === fixture.issueId);
expect(children).toHaveLength(1);
expect(children[0].issueNumber).toBe(3);
});
it("resets fixture data and identities for paired comparisons", async () => {
const baseline = await server.fixture({ reset: true, apiToolsEnabled: false });
await server.db.update(issues).set({ billingCode: "previous-attempt" }).where(eq(issues.id, baseline.issueId));
const treatment = await server.fixture({ reset: true });
expect(treatment.companyId).toBe(baseline.companyId);
expect(treatment.issueId).toBe(baseline.issueId);
expect((await treatment.snapshot()).issues[0].billingCode).toBeNull();
const originalTools = (await baseline.authority.definitions()).map(tool => tool.name);
expect((await treatment.authority.definitions()).filter(tool => !["call_api", "search_api"].includes(String(tool.name))).map(tool => tool.name)).toEqual(originalTools);
});
});
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest";
import { runnerApiCatalog, runnerApiOperation, searchRunnerApi } from "./runner-api-catalog.js";
import { executeRunnerApi, readBoundedResponse, runnerApiUrl, validateRunnerApiCall, type RunnerApiIo } from "./runner-api-client.js";
const context = { companyId: "company-a", issueId: "issue-a", issueIdentifier: "API-1", runId: "run-a", workMode: "standard" };
const projects = "GET /api/companies/{companyId}/projects";
const createProject = "POST /api/companies/{companyId}/projects";
const io = (fetcher: typeof fetch): RunnerApiIo => ({ apiUrl: "http://127.0.0.1:3100", token: "private-agent-token", fetch: fetcher, readFile: async () => ({ bytes: Buffer.from("test"), filename: "proof.txt", contentType: "text/plain" }), saveResponse: async (bytes, contentType) => ({ artifactId: "artifact-a", byteSize: bytes.length, contentType }) });
describe("runner API catalog", () => {
it("accounts for unique operations with resolved request contracts", () => {
const catalog = runnerApiCatalog();
expect(catalog.length).toBeGreaterThan(400);
expect(new Set(catalog.map((entry) => entry.operationId)).size).toBe(catalog.length);
expect(JSON.stringify(catalog)).not.toContain('"$ref"');
expect(runnerApiOperation("GET /api/companies/{companyId}/decisions").authorization.actor).toBe("board");
expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").authorization.actor).toBe("board");
expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").dedicatedTools).toEqual([]);
expect(runnerApiOperation(createProject).requestBody?.content["application/json"].schema.required).toContain("name");
});
it.each(runnerApiCatalog().filter(operation => operation.transport === "rest"))("resolves the catalog route $operationId inside the bound origin", operation => {
const pathParams = Object.fromEntries(operation.parameters.filter(parameter => parameter.in === "path").map(parameter => [parameter.name, parameter.name === "companyId" ? context.companyId : "fixture-id"]));
const url = runnerApiUrl(operation, { operationId: operation.operationId, pathParams }, context, "https://paperclip.test");
expect(url.origin).toBe("https://paperclip.test");
expect(url.pathname).not.toContain("{");
expect(operation.responses).toBeDefined();
expect(operation.authorization.actor).toBeTruthy();
});
it("ranks natural language, explains dedicated alternatives, and supports exact lookup", () => {
expect(searchRunnerApi({ query: "create project" }).results.map((entry) => entry.operationId)).toContain(createProject);
expect(searchRunnerApi({ query: "GET /api/companies/{companyId}/issues" }).results[0].dedicatedTools).toContain("search_tasks");
expect(searchRunnerApi({ query: "nothing-zzzzzzzzzz" }).total).toBe(0);
});
it("paginates without duplicates and rejects stale or mismatched cursors", () => {
const first = searchRunnerApi({ query: "project", limit: 1 });
const second = searchRunnerApi({ query: "project", limit: 1, cursor: first.nextCursor! });
expect(second.results[0].operationId).not.toBe(first.results[0].operationId);
expect(() => searchRunnerApi({ query: "agent", cursor: first.nextCursor! })).toThrow("different query");
expect(() => searchRunnerApi({ query: "project", limit: 50 })).toThrow();
});
});
describe("runner API request boundary", () => {
it.each([
"POST /api/execution-workspaces/{id}/runtime-commands/{action}",
"POST /api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}",
"POST /api/tool-gateway/runtime-slots/{slotId}/restart",
"POST /api/cases/{caseId}/automation/current-stage/rerun",
"POST /api/companies/{companyId}/skills/{skillId}/test-runs",
"POST /api/tool-gateway/sessions",
])("keeps execution and gateway control %s out of generic dispatch", async operationId => {
const request = vi.fn<typeof fetch>();
await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow(/cannot bypass|credential broker/);
expect(request).not.toHaveBeenCalled();
});
it.each([
"POST /api/agents/{id}/claude-login",
"POST /api/companies/{companyId}/adapters/{type}/login-sessions",
"POST /api/agents/me/connections/{connectionId}/start-authorization",
])("directs authentication handshake %s to its existing client", async operationId => {
const request = vi.fn<typeof fetch>();
expect(runnerApiOperation(operationId).transport).toBe("protocol");
await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow("existing protocol client");
expect(request).not.toHaveBeenCalled();
});
it.each(runnerApiCatalog().filter(operation => !["GET", "HEAD", "OPTIONS"].includes(operation.method) && /\/(routines|routine-triggers)(\/|$)/.test(operation.path) && !operation.path.includes("/description/annotations")))("keeps scheduled execution $operationId behind its existing client", async operation => {
const request = vi.fn<typeof fetch>();
await expect(executeRunnerApi({ operationId: operation.operationId }, context, io(request))).rejects.toThrow(/cannot bypass|credential broker/);
expect(request).not.toHaveBeenCalled();
expect(operation.callPolicy).toBe("restricted");
});
it.each(runnerApiCatalog().filter(operation => !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.path.includes("/routines/{id}/description/annotations")))("preserves routine collaboration $operationId", async operation => {
const request = vi.fn<typeof fetch>(async () => Response.json({ id: "thread", status: "open" }));
const pathParams = Object.fromEntries(operation.parameters.filter(parameter => parameter.in === "path").map(parameter => [parameter.name, "fixture"]));
await expect(executeRunnerApi({ operationId: operation.operationId, pathParams }, context, io(request))).resolves.toMatchObject({ status: 200 });
expect(request).toHaveBeenCalledOnce();
expect(operation.callPolicy).toBe("rest");
});
it("keeps routine metadata readable", () => {
expect(validateRunnerApiCall({ operationId: "GET /api/companies/{companyId}/routines" }, context).operation.callPolicy).toBe("rest");
});
it.each(["reopen", "resume", "interrupt"])("cannot hide lifecycle intent %s in an ordinary issue patch", async field => {
const request = vi.fn<typeof fetch>();
await expect(executeRunnerApi({ operationId: "PATCH /api/issues/{id}", pathParams: { id: "other-issue" }, body: { [field]: true, billingCode: "safe-extra-field" } }, context, io(request))).rejects.toThrow("lifecycle changes");
expect(request).not.toHaveBeenCalled();
});
it.each([
"POST /api/agents/me/secrets/{key}/value",
"POST /api/agents/{id}/keys",
"DELETE /api/agents/{id}/keys/{keyId}",
"POST /api/companies/{companyId}/secret-proposals/{id}/approve",
"POST /api/agents/me/secret-proposals",
"PATCH /api/secrets/{id}",
"POST /api/companies/{companyId}/exports",
"GET /api/secret-provider-configs/{id}",
])("keeps sensitive operation %s out of model results and receipts", async operationId => {
const request = vi.fn<typeof fetch>();
await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow("credential broker");
expect(request).not.toHaveBeenCalled();
expect(searchRunnerApi({ query: operationId }).results[0]).toMatchObject({ callPolicy: "restricted" });
});
it("retains safe secret metadata discovery", () => {
for (const operationId of ["GET /api/agents/me/secrets", "GET /api/companies/{companyId}/secrets/catalog"]) {
expect(validateRunnerApiCall({ operationId }, context).operation.callPolicy).toBe("rest");
}
});
it("binds the company and encodes query scalars", () => {
const input = { operationId: projects, query: { q: "hello & goodbye", limit: 2, active: false } };
const url = runnerApiUrl(runnerApiOperation(projects), input, context, "https://paperclip.test/api");
expect(url.origin).toBe("https://paperclip.test");
expect(url.pathname).toBe("/api/companies/company-a/projects");
expect(url.searchParams.get("q")).toBe("hello & goodbye");
});
it.each(["../secrets", ".", "..", "%2e%2e", "abc/def", "abc\\def"])("rejects path injection %s", (id) => {
const input = { operationId: "GET /api/projects/{id}", pathParams: { id } };
expect(() => runnerApiUrl(runnerApiOperation(input.operationId), input, context, "https://paperclip.test")).toThrow();
});
it("rejects unknown inputs, foreign companies, and mode bypasses", () => {
expect(() => validateRunnerApiCall({ operationId: projects, headers: { Authorization: "board" } }, context)).toThrow();
expect(() => validateRunnerApiCall({ operationId: projects, pathParams: { companyId: "foreign" } }, context)).toThrow("another company");
for (const workMode of ["planning", "ask"]) {
expect(() => validateRunnerApiCall({ operationId: createProject, body: { name: "bad" } }, { ...context, workMode })).toThrow("only reads");
expect(validateRunnerApiCall({ operationId: projects }, { ...context, workMode }).operation.method).toBe("GET");
}
});
it("retains API-only issue options while guarding lifecycle fields", () => {
const input = { operationId: "PATCH /api/issues/{id}", pathParams: { id: context.issueId }, body: { billingCode: "cost-center" } };
expect(validateRunnerApiCall(input, context).operation.method).toBe("PATCH");
expect(() => validateRunnerApiCall({ ...input, body: { status: "done" } }, context)).toThrow("dedicated");
expect(() => validateRunnerApiCall({ operationId: "POST /api/issues/{id}/checkout", pathParams: { id: context.issueId } }, context)).toThrow("lifecycle");
});
it.each(["issue-a", "ISSUE-A", "API-1", "api-1", " api-1 "])("cannot delete its active task using the route identity alias %s", id => {
expect(() => validateRunnerApiCall({ operationId: "DELETE /api/issues/{id}", pathParams: { id } }, context)).toThrow("cannot delete itself");
});
it("forwards only server-owned authentication and preserves API denials", async () => {
const request = vi.fn<typeof fetch>(async () => Response.json({ error: "Board access required" }, { status: 403 }));
const result = await executeRunnerApi({ operationId: projects }, context, io(request));
expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Board access required" } });
const options = request.mock.calls[0][1]!;
expect(new Headers(options.headers).get("Authorization")).toBe("Bearer private-agent-token");
expect(new Headers(options.headers).get("X-Paperclip-Run-Id")).toBe(context.runId);
expect(options.redirect).toBe("manual");
expect(JSON.stringify(result)).not.toContain("private-agent-token");
});
it("never retries a mutation after a transport failure", async () => {
const request = vi.fn<typeof fetch>(async () => { throw new Error("socket reset"); });
expect(await executeRunnerApi({ operationId: createProject, body: { name: "created?" } }, context, io(request))).toMatchObject({ outcome: "unknown", status: null });
expect(request).toHaveBeenCalledTimes(1);
});
it.each([500, 502, 503, 408, 302])("does not claim a mutation was unapplied after HTTP %s", async status => {
const request = vi.fn<typeof fetch>(async () => Response.json({ error: "Request interrupted after possible commit" }, { status }));
expect(await executeRunnerApi({ operationId: createProject, body: { name: "Maybe created" } }, context, io(request))).toMatchObject({ status, outcome: "unknown", ok: false });
expect(request).toHaveBeenCalledTimes(1);
});
it("retains uncertainty when a successful mutation returns malformed JSON", async () => {
const request = vi.fn<typeof fetch>(async () => new Response("truncated{", { status: 201, headers: { "content-type": "application/json" } }));
expect(await executeRunnerApi({ operationId: createProject, body: { name: "Maybe created" } }, context, io(request))).toMatchObject({ status: 201, outcome: "unknown", error: "invalid_json_response" });
});
it("rejects a string-encoded object before HTTP and allows a corrected request", async () => {
const request = vi.fn<typeof fetch>(async () => Response.json({ name: "Borealis" }, { status: 201 }));
await expect(executeRunnerApi({ operationId: createProject, body: '{"name":"Borealis"}' }, context, io(request))).rejects.toThrow("not a JSON-encoded string");
expect(request).not.toHaveBeenCalled();
expect(await executeRunnerApi({ operationId: createProject, body: { name: "Borealis" } }, context, io(request))).toMatchObject({ status: 201 });
expect(request).toHaveBeenCalledTimes(1);
});
it("does not follow redirects or pretend empty responses failed", async () => {
expect(await executeRunnerApi({ operationId: projects }, context, io(async () => new Response(null, { status: 302, headers: { Location: "https://foreign.test" } })))).toMatchObject({ ok: false, error: "api_redirect_not_followed" });
expect(await executeRunnerApi({ operationId: projects }, context, io(async () => new Response(null, { status: 204 })))).toMatchObject({ ok: true, status: 204, data: null });
});
it("uploads multipart artifacts and returns download references", async () => {
const request = vi.fn<typeof fetch>(async () => new Response("download", { headers: { "content-type": "application/octet-stream" } }));
const result = await executeRunnerApi({ operationId: createProject, files: [{ artifactId: "a", field: "package" }], body: { meta: { name: "example" } } }, context, io(request));
const body = request.mock.calls[0][1]!.body as FormData;
expect(body.get("meta")).toBe('{"name":"example"}');
expect(await (body.get("package") as File).text()).toBe("test");
expect(new Headers(request.mock.calls[0][1]!.headers).has("Content-Type")).toBe(false);
expect(result).toMatchObject({ artifact: { artifactId: "artifact-a", byteSize: 8 }, preview: null });
});
it("encodes text and raw file bodies without pretending they are JSON", async () => {
const request = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
await executeRunnerApi({ operationId: createProject, body: "plain text", contentType: "text/plain" }, context, io(request));
expect(request.mock.calls[0][1]?.body).toBe("plain text");
await executeRunnerApi({ operationId: createProject, files: [{ path: "sample.bin" }], contentType: "application/octet-stream" }, context, io(request));
expect(Buffer.from(request.mock.calls[1][1]?.body as Uint8Array).toString()).toBe("test");
await expect(executeRunnerApi({ operationId: createProject, body: "unexpected", files: [{ artifactId: "a" }], contentType: "application/octet-stream" }, context, io(request))).rejects.toThrow();
});
it("classifies protocols and prevents execution control through alternate routes", () => {
const protocols = runnerApiCatalog().filter(operation => operation.transport === "protocol");
expect(protocols.some(operation => operation.path.endsWith("/events/ws"))).toBe(true);
for (const operation of protocols) expect(() => validateRunnerApiCall({ operationId: operation.operationId }, context)).toThrow("protocol client");
for (const operationId of ["POST /api/issues/{id}/tree-holds", "POST /api/issues/{id}/stalled-review-decision", "POST /api/agents/{id}/runtime-state/reset-session", "POST /api/approvals/{id}/resubmit"]) {
expect(() => validateRunnerApiCall({ operationId, pathParams: { id: "fixture" } }, context)).toThrow("cannot bypass");
}
});
it("keeps skill-test mode consistent with the advertised tool contract", () => {
expect(validateRunnerApiCall({ operationId: createProject, body: { name: "Skill fixture" } }, { ...context, workMode: "skill_test" }).operation.method).toBe("POST");
});
it("cannot hide lifecycle mutations in a raw uploaded JSON body", () => {
for (const operationId of ["PATCH /api/issues/{id}", "PATCH /api/agents/{id}", "POST /api/issues/{id}/comments"]) {
expect(() => validateRunnerApiCall({ operationId, pathParams: { id: "fixture" }, files: [{ path: "hidden-status.json" }], contentType: "application/json" }, context)).toThrow("inline JSON object");
}
});
it("bounds streamed responses even without content-length", async () => {
await expect(readBoundedResponse(new Response("too large"), 3)).rejects.toThrow("transfer limit");
});
});
@@ -23,7 +23,8 @@ vi.mock("../tool-access.js", () => ({
}),
}));
import { buildNativeRuntimeContext } from "./runtime-context.js";
import { createHash } from "node:crypto";
import { buildNativeRuntimeContext, resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
const temporaryRoots: string[] = [];
let previousPaperclipHome: string | undefined;
@@ -297,3 +298,26 @@ describe("buildNativeRuntimeContext", () => {
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
});
});
it("pins both permitted GitHub tool catalogs even when another user’s health probe reports missing credentials", async () => {
const connections = ["github-A", "github-B"].map((id) => ({
id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote",
config: { sourceTemplateKey: "github" }, transportConfig: {},
}));
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })),
installedConnections: connections,
allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })),
});
const limit = vi.fn(async () => [{ responsibleUserId: "A", activeIdentityContextId: "context-A" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" });
const expected = createHash("sha256").update(JSON.stringify({
version: 1, agentId: "agent-1", connections: ["github-A", "github-B"],
tools: ["github-A-get_me", "github-B-get_me"],
})).digest("hex");
expect(snapshot.digest).toBe(expected);
expect(snapshot.bindingId).toBe("native-mcp:run-1");
expect(limit).toHaveBeenCalledOnce();
});
@@ -178,7 +178,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
});
const [runIdentity] = hasGitHubConnection
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
@@ -187,7 +187,10 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
))
.limit(1)
: [];
const resolvedInstalledConnections = await filterResolvedGitHubConnectionsForRun({
// Broker runs pin the permitted tool catalog, not one person’s credential.
// Selection happens at each operation and can change through steering.
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
@@ -200,7 +203,8 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
permitted.has(connection.id)
&& connection.status === "active"
&& connection.enabled
&& !isToolConnectionAttentionHealth(connection.healthStatus)
&& (Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|| !isToolConnectionAttentionHealth(connection.healthStatus))
&& ["mcp_remote", "local_stdio"].includes(connection.transport)
).map((connection) => connection.id));
const assignment = {