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

@@ -771,11 +771,8 @@ describe("agent live run routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
// caller actually supplied so empty-body callers (e.g. e2e suites) match
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
// caller supplies reason / payload / forceFreshSession those are
// forwarded; idempotencyKey is omitted unless explicitly set.
// Optional wake fields retain their existing shape; execution identity
// always comes from the authenticated caller.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
@@ -790,6 +787,8 @@ describe("agent live run routes", () => {
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
responsibleUserId: "local-board",
originIdentityContextId: null,
forceFreshSession: true,
},
});
@@ -813,6 +812,8 @@ describe("agent live run routes", () => {
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
responsibleUserId: "local-board",
originIdentityContextId: null,
},
});
});
@@ -0,0 +1,113 @@
import express from "express";
import request from "supertest";
import { runtimeConnectionIntentRoutes } from "../routes/connection-intents.js";
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
import { errorHandler } from "../middleware/index.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb, heartbeatRuns, issueComments, issues, runIdentityContexts, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../services/run-identity.js";
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
const vault = vi.hoisted(() => ({
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
}));
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("operation-time GitHub credential resolution", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
beforeAll(async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-github-broker-signing-secret"); database = await startEmbeddedPostgresTestDatabase("paperclip-github-operation-"); db = createDb(database.connectionString); }, 30_000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); }, 60_000);
async function seed() {
const companyId=randomUUID(), agentId=randomUUID(), runId=randomUUID(), issueId=randomUUID();
await db.insert(companies).values({ id:companyId, name:companyId, issuePrefix:companyId.slice(0,8) });
await db.insert(agents).values({ id:agentId, companyId, name:"Shared", role:"engineer", adapterType:"codex_local" });
await db.insert(issues).values({ id:issueId, companyId, title:"Identity test" });
await db.insert(heartbeatRuns).values({ id:runId, companyId, agentId, status:"running", contextSnapshot:{issueId} });
await db.insert(companyMemberships).values(["A","B"].map(principalId => ({companyId, principalType:"user", principalId, status:"active", membershipRole:"member"})));
await initializeRunIdentity(db,{companyId,runId,responsibleUserId:"A",cause:"instruction"});
return {companyId,agentId,runId,issueId};
}
async function grant(input: Awaited<ReturnType<typeof seed>>, user: string, dedicated=false) {
const applicationId=randomUUID(), connectionId=randomUUID(), secretId=randomUUID(), definitionId=randomUUID(), id=randomUUID();
await db.insert(toolApplications).values({id:applicationId, companyId:input.companyId, name:applicationId,type:"mcp_http"});
await db.insert(toolConnections).values({id:connectionId,companyId:input.companyId,applicationId,name:connectionId,uid:connectionId,transport:"mcp_remote",status:"active",enabled:true,credentialPolicy:dedicated?"per_agent":"per_user",config:{sourceTemplateKey:"github"}});
await db.insert(toolConnectionInstalls).values({companyId:input.companyId,connectionId,targetType:"agent",targetId:input.agentId});
if (!dedicated) await db.insert(userSecretDefinitions).values({id:definitionId,companyId:input.companyId,key:definitionId,name:"Test GitHub"});
await db.insert(companySecrets).values({id:secretId,companyId:input.companyId,key:secretId,name:"Test token",scope:dedicated?"company":"user",ownerUserId:dedicated?null:user,userSecretDefinitionId:dedicated?null:definitionId});
await db.insert(connectionGrants).values({id,companyId:input.companyId,connectionId,kind:dedicated?"agent":"user",subjectUserId:dedicated?null:user,subjectAgentId:dedicated?input.agentId:null,status:"active",credentialSecretRefs:[{secretId,configPath:"oauth.access_token",versionSelector:"latest"}],providerTenant:{github:{userId:user,login:user,installationCount:1,repositoryCount:1,repositorySelection:"selected",installationIds:["1"],installationOwnerLogins:[user]}}});
return {id,connectionId};
}
async function switchTo(input: Awaited<ReturnType<typeof seed>>, user:string) {
const id=randomUUID();
await db.insert(issueComments).values({id,companyId:input.companyId,issueId:input.issueId,authorUserId:user,body:"Next instruction"});
const context=await reserveSteeredIdentity(db,{...input,messageId:id});
await acceptSteeredIdentity(db,context!);
}
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
const input=await seed(); await grant(input,"A"); await grant(input,"B");
for (const user of ["A","B","A"]) {
await switchTo(input,user);
const result=await resolveGitHubOperationCredentials(db,input);
expect(result).toMatchObject({status:"available",login:user,source:"personal"});
expect(result.env.GH_TOKEN).toBe(`test-token-${user}`);
expect(result.env.GIT_AUTHOR_EMAIL).toBe(`${user}+${user}@users.noreply.github.com`);
}
const history=await db.select().from(runIdentityContexts).where(eq(runIdentityContexts.runId,input.runId));
expect(JSON.stringify(history)).not.toContain("test-token-");
});
it("returns no credential for unconnected users, removed membership, or ambiguous personal accounts", async () => {
const input=await seed(); await grant(input,"A");
await switchTo(input,"B");
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
await switchTo(input,"A");
await db.update(companyMemberships).set({status:"inactive"}).where(eq(companyMemberships.companyId,input.companyId));
expect((await resolveGitHubOperationCredentials(db,input)).status).toBe("unavailable");
await db.update(companyMemberships).set({status:"active"}).where(eq(companyMemberships.companyId,input.companyId));
await grant(input,"A");
expect((await resolveGitHubOperationCredentials(db,input)).reason).toMatch(/More than one/);
await expect(resolveGitHubOperationCredentials(db,{...input,companyId:randomUUID()})).rejects.toThrow();
});
it("honors dedicated overrides and never substitutes personal credentials when revoked or disabled", async () => {
const input=await seed(); await grant(input,"A"); const dedicated=await grant(input,"robot",true);
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"available",source:"dedicated",login:"robot"});
await db.update(connectionGrants).set({status:"revoked"}).where(eq(connectionGrants.id,dedicated.id));
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
await db.update(connectionGrants).set({status:"active"}).where(eq(connectionGrants.id,dedicated.id));
await db.update(toolConnections).set({enabled:false}).where(eq(toolConnections.id,dedicated.connectionId));
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
});
it("does not resolve the company default person's GitHub", async () => {
const input=await seed(); await grant(input,"A");
await db.update(runIdentityContexts).set({cause:"company_default"}).where(eq(runIdentityContexts.runId,input.runId));
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
});
it("requires a run-scoped runtime capability and never accepts browser authentication or supplied identities", async () => {
const input = await seed(); await grant(input, "A");
const app = express(); app.use(express.json()); app.use(runtimeConnectionIntentRoutes(db)); app.use(errorHandler);
const tokenInput = {...input, responsibleUserId: "A", scope: "github_credentials" as const};
const token = createRuntimeToolsToken(tokenInput)!.token;
const post = () => request(app).post("/runtime-tools/github/credentials");
const a = await post().set("Authorization", `Bearer ${token}`).send({responsibleUserId: "B"});
expect((await post().set("Authorization", `Bearer ${token}`).set("Sec-Fetch-Mode", "cors")).status).toBe(200);
expect(a.status).toBe(200); expect(a.body.login).toBe("A"); expect(a.headers["cache-control"]).toBe("no-store");
for (const [header, value] of [["Origin", "http://127.0.0.1"], ["Cookie", "session=test"], ["Sec-Fetch-Site", "same-origin"]]) {
expect((await post().set("Authorization", `Bearer ${token}`).set(header!,value!)).status).toBe(403);
}
const wrongScope = createRuntimeToolsToken({...tokenInput, scope: "connection_intents"})!.token;
expect((await post().set("Authorization", `Bearer ${wrongScope}`)).status).toBe(401);
const wrongAgent = createRuntimeToolsToken({...tokenInput, agentId: randomUUID()})!.token;
expect((await post().set("Authorization", `Bearer ${wrongAgent}`)).status).toBe(403);
// The runner bridge replaces Authorization, but forwards the separate run capability.
expect((await post().set("Authorization", "Bearer bridge-host-token").set("x-paperclip-github-capability",token)).status).toBe(200);
await switchTo(input,"B");
const b = await post().set("Authorization", `Bearer ${token}`);
expect(b.status).toBe(200); expect(b.body.env).toEqual({});
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id,input.runId));
expect((await post().set("Authorization", `Bearer ${token}`)).status).toBe(403);
});
});
@@ -21,6 +21,32 @@ import type { AuthorizationActor, AuthorizationDecision } from "../services/auth
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("does not preflight or resolve legacy GitHub token bindings for managed executions", async () => {
const assertNoGitHubBinding = (env: Record<string, unknown>) => {
for (const key of Object.keys(env)) if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN)$/.test(key)) {
throw new Error("Unavailable legacy GitHub secret must not be resolved at startup");
}
};
const missing = { type: "user_secret_ref", key: "old-github-token", required: true };
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1", agentId: "agent-1", environmentId: "environment-1",
projectId: "project-1", routineId: "routine-1", managedGitHubCredentials: true,
executionRunConfig: { env: { GH_TOKEN: missing, AGENT_VALUE: "ok" } },
environmentEnv: { GITHUB_TOKEN: missing, ENVIRONMENT_VALUE: "ok" },
projectEnv: { GH_ENTERPRISE_TOKEN: missing, PROJECT_VALUE: "ok" },
routineEnv: { GITHUB_ENTERPRISE_TOKEN: missing, PAPERCLIP_GIT_TOKEN: missing, ROUTINE_VALUE: "ok" },
secretsSvc: {
collectMissingRuntimeBindings: vi.fn(async (_companyId, env) => { assertNoGitHubBinding(env); return []; }),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId, config) => {
assertNoGitHubBinding(config.env); return { config, secretKeys: new Set(), manifest: [] };
}),
resolveEnvBindings: vi.fn(async (_companyId, env) => {
assertNoGitHubBinding(env); return { env, secretKeys: new Set(), manifest: [] };
}),
} as any,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_VALUE: "ok", ENVIRONMENT_VALUE: "ok", PROJECT_VALUE: "ok", ROUTINE_VALUE: "ok" });
});
it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {
@@ -0,0 +1,105 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, issueRelations } from "@paperclipai/db";
import { documentService } from "../../services/documents.js";
import { startEmbeddedPostgresTestDatabase } from "./embedded-postgres.js";
import { createApp } from "../../app.js";
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
import { createStorageService } from "../../storage/service.js";
import { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } from "../../realtime/runner-prp-ws.js";
import { PaperclipRunnerToolAuthority } from "../../services/native-runtime/paperclip-runner-tool-authority.js";
/** Disposable real routes, database and storage. A fresh company isolates each attempt. */
export async function startRunnerApiTestServer() {
const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-"));
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
const db = createDb(temporary.connectionString);
const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage")));
const app = await createApp(db, {
uiMode: "none", serverPort: 0, storageService: storage,
deploymentMode: "authenticated", deploymentExposure: "private",
allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true,
companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`,
localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [],
decisionServiceOptions: { wakeOriginAgent: async () => undefined },
});
const http = createServer(app);
const sockets = new Set<import("node:net").Socket>();
http.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve));
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing eval listener");
const apiUrl = `http://127.0.0.1:${address.port}`;
setupRunnerPrpWebSocketServer(http, { apiUrl });
return {
db, root, apiUrl, storage,
async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean } = {}) {
// This DB is created inside this helper, never supplied by a caller. Paid
// paired runs reset it between attempts so modeled IDs and data match.
if (options.reset) await db.execute(sql`TRUNCATE companies CASCADE`);
const id = (key: string) => {
if (!options.reset) return randomUUID();
const hex = createHash("sha256").update(`runner-api-fixture:${key}`).digest("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
};
const companyId = id("company"), agentId = id("agent"), issueId = id("issue"), runId = id("run"), projectId = id("project");
const foreignCompanyId = id("foreign-company"), foreignProjectId = id("foreign-project");
const projectWorkspaceId = id("workspace"), artifactId = id("artifact"), binaryArtifactId = id("binary-artifact"), goalId = id("goal");
const blockerId = id("blocker"), approvalId = id("approval");
const workspace = await mkdtemp(join(root, "workspace-"));
await writeFile(join(workspace, "sample.txt"), "API escape hatch fixture\n");
await db.insert(companies).values([
{ id: companyId, name: "API eval", issueCounter: 2, issuePrefix: "E" + companyId.replaceAll("-", "").slice(0, 8) },
{ id: foreignCompanyId, name: "Isolated foreign company", issuePrefix: "O" + foreignCompanyId.replaceAll("-", "").slice(0, 8) },
]);
await db.insert(agents).values({ id: agentId, companyId, name: "API eval agent", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", cwd: workspace }, runtimeConfig: { heartbeat: { enabled: false } }, status: "active" });
await db.insert(projects).values([
{ id: projectId, companyId, name: "Aurora", description: "The project verification code is violet-otter.", status: "in_progress" },
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Private project", description: "foreign-data-must-not-leak" },
]);
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Fixture workspace", cwd: workspace, isPrimary: true });
await db.insert(goals).values({ id: goalId, companyId, title: "Ship Aurora", level: "company", status: "active" });
for (const [id, body, contentType, filename] of [[artifactId, Buffer.from("API escape hatch fixture\n"), "text/plain", "sample.txt"], [binaryArtifactId, Buffer.alloc(32_000, 65), "application/octet-stream", "large.bin"]] as const) {
const saved = await storage.putFile({ companyId, namespace: "eval", originalFilename: filename, contentType, body });
await db.insert(assets).values({ id, companyId, ...saved, createdByAgentId: agentId });
}
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-1", title: "Verify runner API tools", description: "Fixture marker: amber-fox.", status: "in_progress", workMode: options.mode ?? "standard", 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));
await db.insert(issues).values({ id: blockerId, companyId, projectId, issueNumber: 2, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-2", title: "Dependency gate", description: "Complete before shipping.", status: "todo", assigneeAgentId: agentId });
await db.insert(approvals).values({ id: approvalId, companyId, type: "runner_review", status: "pending", requestedByAgentId: agentId, payload: { title: "Launch review" } });
await documentService(db).upsertIssueDocument({ issueId, key: "notes", title: "Fixture notes", format: "markdown", body: "Document verification code: silver-wren.", baseRevisionId: null, changeSummary: null, createdByAgentId: agentId, createdByRunId: runId });
const binding = { companyId, agentId, issueId, runId, apiUrl, storage, apiToolsEnabled: options.apiToolsEnabled ?? true };
return {
...binding, projectId, projectWorkspaceId, artifactId, binaryArtifactId, goalId, blockerId, approvalId, foreignCompanyId, foreignProjectId, workspace,
authority: new PaperclipRunnerToolAuthority(db, binding),
async snapshot() {
return {
issues: await db.select().from(issues).where(eq(issues.companyId, companyId)),
projects: await db.select().from(projects).where(eq(projects.companyId, companyId)),
activity: await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)),
comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)),
assets: await db.select().from(assets).where(eq(assets.companyId, companyId)),
goals: await db.select().from(goals).where(eq(goals.companyId, companyId)),
approvals: await db.select().from(approvals).where(eq(approvals.companyId, companyId)),
documents: await db.select().from(documents).where(eq(documents.companyId, companyId)),
issueRelations: await db.select().from(issueRelations).where(eq(issueRelations.companyId, companyId)),
};
},
};
},
async close() {
runnerPrpWebSocketInternals.resetForTests();
await app.locals.paperclipShutdown();
for (const socket of sockets) socket.destroy();
http.closeAllConnections();
await new Promise<void>((resolve) => http.close(() => resolve()));
await temporary.cleanup();
await rm(root, { recursive: true, force: true });
},
};
}
+5 -8
View File
@@ -13,6 +13,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROUTES_DIR = path.resolve(__dirname, "../routes");
const apiPrefixes: Record<string, string> = {
"pipelines.ts": "/api",
"cases.ts": "/api",
"smoke-lab.ts": "/api",
"access.ts": "/api",
"activity.ts": "/api",
"adapters.ts": "/api",
@@ -70,14 +73,7 @@ const apiPrefixes: Record<string, string> = {
const ROUTE_LITERAL_PATTERN = /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/;
const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
const explicitOpenApiCoverageExclusions = new Set([
// Pipeline routes are experimental and not yet represented in the public OpenAPI document.
"pipelines.ts",
// Case routes are experimental (enableCases flag) and not yet in the public OpenAPI document.
"cases.ts",
// Smoke lab routes are experimental and not yet represented in the public OpenAPI document.
"smoke-lab.ts",
]);
const explicitOpenApiCoverageExclusions = new Set<string>();
// The set of contract-first routes whose OpenAPI document leads the mounted
// request handler. The company-and-environment Claude setup-token login routes
@@ -294,6 +290,7 @@ describe("openapi routes", () => {
const { spec } = loadSpecRoutes();
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
expect(spec.paths["/runtime-tools/github/credentials"].post.security).toEqual([{ RuntimeToolsBearerAuth: [] }]);
expect(spec.paths["/api/plugins/install"].post.security).toEqual([
{ BoardSessionAuth: [] },
{ BoardApiKeyAuth: [] },
@@ -30,6 +30,9 @@ describe("opencode_local environment diagnostics", () => {
it("treats an empty OPENAI_API_KEY override as missing", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-empty-key-"));
// This case tests environment precedence, not model-discovery retry delays.
const fakeOpencode = path.join(cwd, "opencode");
await fs.writeFile(fakeOpencode, "#!/bin/sh\necho openai/test-model\n", { mode: 0o755 });
const originalOpenAiKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-host-value";
@@ -38,7 +41,7 @@ describe("opencode_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "opencode_local",
config: {
command: process.execPath,
command: fakeOpencode,
cwd,
env: {
OPENAI_API_KEY: "",
+188
View File
@@ -0,0 +1,188 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("durable execution identity", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-run-identity-");
db = createDb(database.connectionString);
}, 30_000);
afterAll(async () => { await database?.cleanup(); }, 60_000);
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Identity tests", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Shared agent", role: "engineer", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Shared task", responsibleUserId: "owner" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
const messageIds: string[] = [];
for (const author of ["A", "B", "A"]) {
const id = randomUUID();
await db.insert(issueComments).values({ id, companyId, issueId, authorUserId: author, body: "Instruction" });
messageIds.push(id);
}
return { companyId, agentId, issueId, runId, messageIds };
}
it("retains mixed-author delivery order without rewriting task ownership", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, responsibleUserId: "owner", cause: "queued" });
const history = await listRunIdentityContexts(db, input.companyId, input.runId);
expect(history.map((row) => row.responsibleUserId)).toEqual(["owner", "A", "B", "A"]);
expect(history.map((row) => row.revision)).toEqual([1, 2, 3, 4]);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
expect(issue.responsibleUserId).toBe("owner");
await initializeRunIdentity(db, { ...input, responsibleUserId: "B", cause: "restart" });
expect(await listRunIdentityContexts(db, input.companyId, input.runId)).toHaveLength(4);
});
it("holds acquisition during uncertain steering, preserves snapshots, and never rewinds on replay", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [input.messageIds[0]], responsibleUserId: "A", cause: "instruction" });
const startedUnderA = await captureRunIdentity(db, input);
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
expect(b).not.toBeNull();
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await expect(reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] })).rejects.toThrow(/reconciled/);
await db.transaction(async tx => {
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)).for("update");
await acceptSteeredIdentity(tx, b!);
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
expect(startedUnderA.context?.responsibleUserId).toBe("A");
const a = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] });
await acceptSteeredIdentity(db, a!);
await acceptSteeredIdentity(db, b!);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
});
it("keeps rejected steering unchanged and preserves the originating identity on retry", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await rejectSteeredIdentity(db, b!);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "B", cause: "retry" });
expect(retry.responsibleUserId).toBe("A");
expect(retry.parentContextId).toBe((await captureRunIdentity(db, input)).context?.id);
await expect(captureRunIdentity(db, { ...input, companyId: randomUUID() })).rejects.toThrow();
await expect(captureRunIdentity(db, { ...input, agentId: randomUUID() })).rejects.toThrow();
});
it("preserves an explicitly absent identity through a continuation initiated by another person", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, {
...input, messageIds: [], responsibleUserId: null, cause: "routine",
});
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId, companyId: input.companyId, agentId: input.agentId,
status: "running", responsibleUserId: "B",
});
const continued = await initializeRunIdentity(db, {
companyId: input.companyId, runId, parentContextId: origin.id,
responsibleUserId: "B", cause: "retry",
});
expect(continued.responsibleUserId).toBeNull();
const captured = await captureRunIdentity(db, { ...input, runId });
expect(captured.run.responsibleUserId).toBeNull();
expect(captured.context?.parentContextId).toBe(origin.id);
});
it("recovers an uncertain acknowledgement from the authenticated native event journal", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const pending = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await db.insert(heartbeatRunEvents).values({ companyId: input.companyId, runId: input.runId,
agentId: input.agentId, seq: 1, eventType: "item.completed", sourceEventId: randomUUID(),
payload: { prpEvent: { turnId: "turn-1", itemId: `turn-1:steer:${pending!.messageId}`, payload: {kind: "steering_acknowledgement"} } },
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id, input.runId));
expect((await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] }))?.status).toBe("accepted");
});
it("preserves an operation's origin through approval and delegation after another person steers", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
});
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await acceptSteeredIdentity(db, b!);
for (const approval of [true, false]) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId, issueId: input.issueId,
...(approval ? { interactionId } : { parentContextId: origin.id }),
responsibleUserId: "B", cause: approval ? "approval" : "delegation",
});
expect(continued.responsibleUserId).toBe("A");
expect(continued.parentContextId).toBe(origin.id);
}
});
it("retains continuation and approval attribution after deleting the originating agent and runs", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
});
await db.update(issues).set({ originIdentityContextId: origin.id }).where(eq(issues.id, input.issueId));
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, input.agentId));
await db.delete(agents).where(eq(agents.id, input.agentId));
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: input.companyId, name: "Replacement", role: "engineer", adapterType: "codex_local" });
const [task] = await db.select().from(issues).where(eq(issues.id, input.issueId));
expect(task.continuationIdentityContextId).toBe(origin.id);
for (const source of [{ parentContextId: task.originIdentityContextId }, { parentContextId: task.continuationIdentityContextId }, { interactionId }]) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId, status: "running" });
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId,
issueId: input.issueId, ...source, responsibleUserId: "B", cause: "continuation" });
expect(continued.responsibleUserId).toBe("A");
expect(continued.parentContextId).toBe(origin.id);
}
});
it("does not deadlock identity initialization against a task mutation that also updates the run", async () => {
const input = await seed();
let initialization!: ReturnType<typeof initializeRunIdentity>;
await db.transaction(async (tx) => {
await tx.select().from(issues).where(eq(issues.id, input.issueId)).for("update");
const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
initialization = initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
// Wait until initialization is blocked by this task mutation, rather than
// relying on timing to decide whether it has acquired its first lock.
let waiting = false;
for (let attempt = 0; attempt < 100; attempt++) {
const [state] = await db.execute(sql`select exists (
select 1 from pg_stat_activity where ${backend.pid} = any(pg_blocking_pids(pid))
) as waiting`) as unknown as Array<{ waiting: boolean }>;
if (state.waiting) { waiting = true; break; }
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(waiting).toBe(true);
await tx.execute(sql`set local lock_timeout = '1s'`);
await tx.update(heartbeatRuns).set({ updatedAt: new Date() }).where(eq(heartbeatRuns.id, input.runId));
});
await expect(initialization).resolves.toMatchObject({ responsibleUserId: "A" });
});
it("does not turn a company-default fallback into personal consent on continuation", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "company_default" });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "A", cause: "retry" });
expect(retry.cause).toBe("company_default");
});
});
@@ -38,6 +38,8 @@ import {
toolRuntimeMetricCounters,
toolRuntimeSlots,
toolStdioCommandTemplates,
userSecretDefinitions,
userSecretDeclarations,
} from "@paperclipai/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
@@ -5001,6 +5003,36 @@ describeEmbeddedPostgres("tool access service", () => {
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual(["app_paused"]);
});
it("preserves all active personal OAuth declarations through pause, resume, and metadata edits", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const [application] = await db.insert(toolApplications).values({ companyId: company.id, name: "GitHub", type: "mcp_http" }).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id, applicationId: application.id, name: "GitHub", uid: randomUUID(),
transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user",
config: { url: "https://api.githubcopilot.com/mcp/", sourceTemplateKey: "github" },
}).returning();
const [sharedDefinition] = await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "OAuth access token" }).returning();
const definitionIds = [sharedDefinition.id];
for (const user of ["A", "B", "revoked"]) {
const definition = user === "revoked"
? (await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "Revoked identity" }).returning())[0]
: sharedDefinition;
const [secret] = await db.insert(companySecrets).values({ companyId: company.id, key: randomUUID(), name: user,
scope: "user", ownerUserId: user, userSecretDefinitionId: definition.id }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "user",
subjectUserId: user, status: user === "revoked" ? "revoked" : "active",
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token", versionSelector: "latest" }],
});
}
for (const edit of [{ enabled: false }, { enabled: true }, { name: "Renamed GitHub" }]) {
await service.updateConnection(connection.id, edit);
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection.id));
expect(declarations.map((row) => row.userSecretDefinitionId).sort()).toEqual([...definitionIds].sort());
expect(declarations.every((row) => row.configPath === "oauth.access_token")).toBe(true);
}
});
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
@@ -8,6 +8,9 @@ import {
companies,
companySecretBindings,
companySecrets,
companyMemberships,
toolConnectionInstalls,
issueComments,
connectionGrants,
createDb,
heartbeatRuns,
@@ -26,6 +29,7 @@ import {
} from "@paperclipai/db";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../services/run-identity.js";
import { secretService } from "../services/secrets.js";
import {
createToolGatewayService,
@@ -1203,6 +1207,53 @@ describeEmbeddedPostgres("tool gateway service", () => {
expect(storedGrant?.externalCredential).toMatchObject({ tokenId: "stk_fresh" });
});
it("keeps managed GitHub personal identity even under a legacy shared policy", async () => {
const { company, agent, issue, run } = await createRunFixture(db);
const { connection } = await createRemoteMcpToolFixture(db, company.id);
await db.update(toolConnections).set({ authKind: "oauth", credentialSource: "paperclip_vault",
config: { ...connection.config, sourceTemplateKey: "github" },
}).where(eq(toolConnections.id, connection.id));
await db.insert(toolConnectionInstalls).values({ companyId: company.id,
connectionId: connection.id, targetType: "agent", targetId: agent.id });
await db.insert(companyMemberships).values(["A", "B"].map(principalId => ({ companyId: company.id,
principalType: "user", principalId, status: "active", membershipRole: "member" })));
const grants = await db.insert(connectionGrants).values(["A", "B"].map(subjectUserId => ({
companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId,
status: "active", credentialSecretRefs: [],
}))).returning();
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
responsibleUserId: "A", cause: "instruction" });
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads",
policyType: "allow", selectors: { riskLevel: "read" } });
const resolvedGrants: string[] = [];
const gateway = createTestToolGatewayService(db, {
oauthGrantRefresher: async ({ grantId }) => {
resolvedGrants.push(grantId);
return db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId)).then(rows => rows[0]!);
},
remoteHttpRequest: async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0",
id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] },
}), { status: 200, headers: { "content-type": "application/json" } }),
});
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!;
for (const [index, user] of ["A", "B", "A"].entries()) {
if (index > 0) {
const [message] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id,
authorUserId: user, body: "Next instruction" }).returning();
const pending = await reserveSteeredIdentity(db, { companyId: company.id, runId: run.id,
issueId: issue.id, messageId: message.id });
await reconcileSteeredIdentity(db, pending!);
}
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).status).toBe("completed");
}
expect(resolvedGrants).toEqual([grants[0].id, grants[1].id, grants[0].id]);
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, company.id));
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ reasonCode: "grant_owner_membership_inactive" });
expect(resolvedGrants).toHaveLength(3);
});
it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => {
const { company, agent, run } = await createRunFixture(db);
const { connection } = await createRemoteMcpToolFixture(db, company.id);
@@ -1222,6 +1273,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
await db.update(toolConnections).set({
authKind: "oauth",
credentialSource: "paperclip_vault",
credentialRefs: [{ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", secretId: accessSecret.id, versionSelector: "latest" }],
config: {
url: "https://example.invalid/mcp",
oauth: {
+14 -2
View File
@@ -7737,7 +7737,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
const reservePort = async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const probe = net.createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
// macOS can allocate an entire ephemeral range above 55535. Pick a
// bounded candidate so the test's HMR companion remains a valid port.
await new Promise<void>((resolve) => {
probe.once("error", () => resolve());
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
});
if (!probe.listening) continue;
const address = probe.address();
const port = typeof address === "object" && address ? address.port : null;
await new Promise<void>((resolve, reject) => {
@@ -8078,7 +8084,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
const reservePort = async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const probe = net.createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
// macOS can allocate an entire ephemeral range above 55535. Pick a
// bounded candidate so the test's HMR companion remains a valid port.
await new Promise<void>((resolve) => {
probe.once("error", () => resolve());
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
});
if (!probe.listening) continue;
const address = probe.address();
const candidate = typeof address === "object" && address ? address.port : null;
await new Promise<void>((resolve, reject) => {
+2
View File
@@ -981,6 +981,8 @@ export async function createApp(
const shutdownAppServices = (): Promise<void> => {
if (appServicesShutdown) return appServicesShutdown;
appServicesShutdown = (async () => {
scheduler.stop();
jobCoordinator.stop();
disableFeedbackExportFlushes();
if (importTransferSweepTimer) {
clearInterval(importTransferSweepTimer);
+14 -1
View File
@@ -24,6 +24,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { captureRunIdentity } from "../services/run-identity.js";
import { boardAuthService } from "../services/board-auth.js";
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
@@ -379,7 +380,18 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
const onBehalfOfUserId = claims.responsible_user_id !== undefined
const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
const onBehalfOfUserId = identityRun?.activeIdentityContextId
? identityRun.responsibleUserId
: claims.responsible_user_id !== undefined
? normalizeOptionalString(claims.responsible_user_id)
: await resolveLegacyRunResponsibleUserId(db, {
companyId: claims.company_id,
@@ -399,6 +411,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
runId: claims.run_id,
onBehalfOfUserId,
identityContextId: identityRun?.activeIdentityContextId ?? null,
onBehalfOfMemberships,
source: "agent_jwt",
};
+12 -3
View File
@@ -5205,12 +5205,16 @@ export function agentRoutes(
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
payload: req.actor.type === "agent" && req.body.payload
? { ...req.body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
: req.body.payload ?? null,
idempotencyKey: req.body.idempotencyKey ?? null,
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot: {
triggeredBy: req.actor.type,
originIdentityContextId: req.actor.identityContextId ?? null,
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
forceFreshSession: req.body.forceFreshSession === true,
...(req.body.reason === "rerun_with_provider_trace" &&
@@ -5312,6 +5316,8 @@ export function agentRoutes(
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
originIdentityContextId: req.actor.identityContextId ?? null,
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
@@ -5336,7 +5342,9 @@ export function agentRoutes(
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
wakeOpts.payload = req.actor.type === "agent"
? { ...body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
: body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
@@ -6036,7 +6044,7 @@ export function agentRoutes(
run.companyId,
run.id,
redactCurrentUserValue(
{ ...decoratedRun, retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
{ ...decoratedRun, identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
await getCurrentUserRedactionOptions(),
),
));
@@ -6673,3 +6681,4 @@ export function agentRoutes(
return router;
}
import { listRunIdentityContexts } from "../services/run-identity.js";
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod";
export const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
export const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
export const DEFAULT_EVENTS_LIMIT = 100;
export const MAX_EVENTS_LIMIT = 500;
export const jsonObjectSchema = z.record(z.string(), z.unknown());
export const caseStatusSchema = z.enum(CASE_STATUSES);
export const caseTypeSchema = z.string().trim().min(1).max(120);
export const caseKeySchema = z.string().trim().min(1).max(512);
export const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
export const createCaseSchema = z.object({
projectId: z.string().guid().nullable().optional(),
caseType: caseTypeSchema,
key: caseKeySchema.nullable().optional(),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
status: caseStatusSchema.optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
}).strict();
export const patchCaseSchema = z.object({
projectId: z.string().guid().nullable().optional(),
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
status: caseStatusSchema.optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
labels: z.array(z.string().guid()).max(100).optional(),
labelIds: z.array(z.string().guid()).max(100).optional(),
}).strict();
export const createIssueLinkSchema = z.object({
issueId: z.string().guid(),
role: z.enum(CASE_LINK_ROLES),
}).strict();
export const upsertCaseDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
format: z.string().trim().min(1).max(80).optional().default("markdown"),
body: z.string().max(200_000),
changeSummary: z.string().trim().max(1_000).nullable().optional(),
baseRevisionId: z.string().guid().nullable().optional(),
}).strict();
export const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
export const listCasesQuerySchema = z.object({
type: z.string().trim().min(1).max(120).optional(),
types: queryListParamSchema,
status: z.string().trim().min(1).max(120).optional(),
statuses: queryListParamSchema,
project: z.string().guid().optional(),
projectId: z.string().guid().optional(),
projectIds: queryListParamSchema,
includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
label: z.string().guid().optional(),
labelId: z.string().guid().optional(),
parent: z.string().guid().optional(),
q: z.string().trim().min(1).max(200).optional(),
includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
}).strict();
export const listEventsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
}).strict();
+18 -69
View File
@@ -1,3 +1,21 @@
import {
CASE_STATUSES,
CASE_LINK_ROLES,
DEFAULT_EVENTS_LIMIT,
MAX_EVENTS_LIMIT,
jsonObjectSchema,
caseStatusSchema,
caseTypeSchema,
caseKeySchema,
documentKeySchema,
createCaseSchema,
patchCaseSchema,
createIssueLinkSchema,
upsertCaseDocumentSchema,
queryListParamSchema,
listCasesQuerySchema,
listEventsQuerySchema,
} from "./cases-schemas.js";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
@@ -36,75 +54,6 @@ import { assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"
type CaseRouteDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
type CaseActor = ReturnType<typeof getActorInfo>;
const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
const DEFAULT_EVENTS_LIMIT = 100;
const MAX_EVENTS_LIMIT = 500;
const jsonObjectSchema = z.record(z.string(), z.unknown());
const caseStatusSchema = z.enum(CASE_STATUSES);
const caseTypeSchema = z.string().trim().min(1).max(120);
const caseKeySchema = z.string().trim().min(1).max(512);
const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
const createCaseSchema = z.object({
projectId: z.string().guid().nullable().optional(),
caseType: caseTypeSchema,
key: caseKeySchema.nullable().optional(),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
status: caseStatusSchema.optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
}).strict();
const patchCaseSchema = z.object({
projectId: z.string().guid().nullable().optional(),
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
status: caseStatusSchema.optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
labels: z.array(z.string().guid()).max(100).optional(),
labelIds: z.array(z.string().guid()).max(100).optional(),
}).strict();
const createIssueLinkSchema = z.object({
issueId: z.string().guid(),
role: z.enum(CASE_LINK_ROLES),
}).strict();
const upsertCaseDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
format: z.string().trim().min(1).max(80).optional().default("markdown"),
body: z.string().max(200_000),
changeSummary: z.string().trim().max(1_000).nullable().optional(),
baseRevisionId: z.string().guid().nullable().optional(),
}).strict();
const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
const listCasesQuerySchema = z.object({
type: z.string().trim().min(1).max(120).optional(),
types: queryListParamSchema,
status: z.string().trim().min(1).max(120).optional(),
statuses: queryListParamSchema,
project: z.string().guid().optional(),
projectId: z.string().guid().optional(),
projectIds: queryListParamSchema,
includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
label: z.string().guid().optional(),
labelId: z.string().guid().optional(),
parent: z.string().guid().optional(),
q: z.string().trim().min(1).max(200).optional(),
includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
}).strict();
const listEventsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
}).strict();
function eventActorValues(actor: CaseActor) {
return {
actorType: actor.actorType,
+14
View File
@@ -15,6 +15,7 @@ import { logActivity } from "../services/activity-log.js";
import { accessService } from "../services/access.js";
import type { heartbeatService } from "../services/heartbeat.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
function bearer(req: Request) {
const value = req.header("authorization") ?? "";
@@ -61,6 +62,19 @@ export function runtimeConnectionIntentRoutes(db: Db) {
const router = Router();
const service = connectionIntentService(db);
router.post("/runtime-tools/github/credentials", async (req, res) => {
// This capability is never accepted as board/session authentication.
// Node fetch sends Sec-Fetch-Mode too; browsers additionally send Origin or Sec-Fetch-Site.
if (req.headers.origin || req.headers.cookie || req.headers["sec-fetch-site"]) throw forbidden("GitHub credentials require runtime authentication");
const claims = verifyRuntimeToolsToken(typeof req.headers["x-paperclip-github-capability"] === "string"
? req.headers["x-paperclip-github-capability"] : bearer(req), "github_credentials");
if (!claims) throw unauthorized("Invalid GitHub runtime capability");
res.setHeader("Cache-Control", "no-store");
res.json(await resolveGitHubOperationCredentials(db, {
companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id,
}));
});
router.get("/mcp/runtime-tools", async (req, res) => {
await service.validate(runtimeClaims(req));
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
@@ -0,0 +1,605 @@
// Generated by scripts/generate-runner-experimental-api-metadata.mjs.
export const experimentalApiMetadata: Record<string, { successStatuses: number[]; boardOnly: boolean; source: string }> = {
"GET /api/companies/{companyId}/pipelines": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/companies/{companyId}/pipelines-attention": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/companies/{companyId}/case-events": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/companies/{companyId}/pipelines": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/companies/{companyId}/review-cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/companies/{companyId}/review-cases/bulk": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}/health": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}/intake-form": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PATCH /api/pipelines/{pipelineId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/pipelines/{pipelineId}/stages": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PATCH /api/pipelines/{pipelineId}/stages/{stageId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PATCH /api/pipelines/{pipelineId}/stages/{stageId}/automation-env": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"DELETE /api/pipelines/{pipelineId}/stages/{stageId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PUT /api/pipelines/{pipelineId}/transitions": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PUT /api/pipelines/{pipelineId}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}/documents/{key}/revisions": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/pipelines/{pipelineId}/cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/pipelines/{pipelineId}/cases/batch": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/breakdown": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/pipelines/{pipelineId}/cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PUT /api/cases/{caseId}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/documents/{key}/revisions": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/children": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PATCH /api/cases/{caseId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/claim": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/release": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/transition": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/suggest-transition": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/resolve-suggestion": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/acknowledge-drift": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/review": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"PUT /api/cases/{caseId}/blockers": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/open-conversation": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/issue-links": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/outputs": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/issue-links": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"DELETE /api/cases/{caseId}/issue-links/{linkId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/events": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/children/tree": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/rollup": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/context-pack": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"GET /api/cases/{caseId}/automation/retry-plan": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/automation/retry": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/automations/{automationId}/retry": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/cases/{caseId}/automation/current-stage/rerun": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/pipelines.ts"
},
"POST /api/companies/{companyId}/cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/companies/{companyId}/cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}/documents/{key}/annotations": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}/documents/{key}/annotations/{threadId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/documents/{key}/annotations": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/documents/{key}/annotations/{threadId}/comments": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"PATCH /api/cases/{id}/documents/{key}/annotations/{threadId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"PUT /api/cases/{id}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/documents/{key}/lock": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/documents/{key}/unlock": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/documents/{key}/revisions/{revisionId}/restore": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"DELETE /api/cases/{id}/documents/{key}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/links": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"POST /api/cases/{id}/attachments": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}/events": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}/documents/{key}/revisions": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/issues/{issueId}/cases": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/cases/{id}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"PATCH /api/cases/{id}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/cases.ts"
},
"GET /api/companies/{companyId}/smoke-lab/oauth/authorize": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/oauth/authorize": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/oauth/token": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"GET /api/companies/{companyId}/smoke-lab/oauth/userinfo": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/oauth/revoke": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"GET /api/companies/{companyId}/smoke-lab/services": {
"successStatuses": [
200
],
"boardOnly": true,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/services/start": {
"successStatuses": [
200
],
"boardOnly": true,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/services/stop": {
"successStatuses": [
200
],
"boardOnly": true,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/install-fixtures": {
"successStatuses": [
200
],
"boardOnly": true,
"source": "server/src/routes/smoke-lab.ts"
},
"GET /api/companies/{companyId}/smoke-lab/runs": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/runs": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"GET /api/companies/{companyId}/smoke-lab/runs/{runId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"PATCH /api/companies/{companyId}/smoke-lab/runs/{runId}": {
"successStatuses": [
200
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/runs/{runId}/steps": {
"successStatuses": [
201
],
"boardOnly": false,
"source": "server/src/routes/smoke-lab.ts"
},
"POST /api/companies/{companyId}/smoke-lab/reset": {
"successStatuses": [
200
],
"boardOnly": true,
"source": "server/src/routes/smoke-lab.ts"
}
};
+106
View File
@@ -0,0 +1,106 @@
// Experimental routes share their runtime validators with OpenAPI discovery.
import { z } from "zod";
import * as pipelineSchemas from "./pipelines-schemas.js";
import * as caseSchemas from "./cases-schemas.js";
import * as sharedSchemas from "@paperclipai/shared";
export const experimentalApiPaths: readonly [string, string, z.ZodTypeAny | undefined][] = [
["get", "/api/companies/{companyId}/pipelines", undefined],
["get", "/api/companies/{companyId}/pipelines-attention", undefined],
["get", "/api/companies/{companyId}/case-events", undefined],
["post", "/api/companies/{companyId}/pipelines", pipelineSchemas.createPipelineSchema],
["get", "/api/companies/{companyId}/review-cases", undefined],
["post", "/api/companies/{companyId}/review-cases/bulk", pipelineSchemas.bulkReviewSchema],
["get", "/api/pipelines/{pipelineId}", undefined],
["get", "/api/pipelines/{pipelineId}/health", undefined],
["get", "/api/pipelines/{pipelineId}/intake-form", undefined],
["patch", "/api/pipelines/{pipelineId}", pipelineSchemas.updatePipelineSchema],
["post", "/api/pipelines/{pipelineId}/stages", pipelineSchemas.createStageSchema],
["patch", "/api/pipelines/{pipelineId}/stages/{stageId}", pipelineSchemas.updateStageSchema],
["patch", "/api/pipelines/{pipelineId}/stages/{stageId}/automation-env", pipelineSchemas.updateStageAutomationEnvSchema],
["delete", "/api/pipelines/{pipelineId}/stages/{stageId}", undefined],
["put", "/api/pipelines/{pipelineId}/transitions", pipelineSchemas.replaceTransitionsSchema],
["get", "/api/pipelines/{pipelineId}/documents/{key}", undefined],
["put", "/api/pipelines/{pipelineId}/documents/{key}", pipelineSchemas.upsertPipelineDocumentSchema],
["get", "/api/pipelines/{pipelineId}/documents/{key}/revisions", undefined],
["post", "/api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore", undefined],
["post", "/api/pipelines/{pipelineId}/cases", pipelineSchemas.ingestCaseSchema],
["post", "/api/pipelines/{pipelineId}/cases/batch", pipelineSchemas.batchIngestSchema],
["post", "/api/cases/{caseId}/breakdown", pipelineSchemas.breakdownCaseSchema],
["get", "/api/pipelines/{pipelineId}/cases", undefined],
["get", "/api/cases/{caseId}", undefined],
["get", "/api/cases/{caseId}/documents/{key}", undefined],
["put", "/api/cases/{caseId}/documents/{key}", pipelineSchemas.upsertPipelineCaseDocumentSchema],
["get", "/api/cases/{caseId}/documents/{key}/revisions", undefined],
["post", "/api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore", undefined],
["get", "/api/cases/{caseId}/children", undefined],
["patch", "/api/cases/{caseId}", pipelineSchemas.casePatchSchema],
["post", "/api/cases/{caseId}/claim", pipelineSchemas.claimCaseSchema],
["post", "/api/cases/{caseId}/release", pipelineSchemas.releaseCaseSchema],
["post", "/api/cases/{caseId}/transition", pipelineSchemas.transitionCaseSchema],
["post", "/api/cases/{caseId}/suggest-transition", pipelineSchemas.suggestTransitionSchema],
["post", "/api/cases/{caseId}/resolve-suggestion", pipelineSchemas.resolveSuggestionSchema],
["post", "/api/cases/{caseId}/acknowledge-drift", pipelineSchemas.acknowledgeDriftSchema],
["post", "/api/cases/{caseId}/review", pipelineSchemas.reviewCaseSchema],
["put", "/api/cases/{caseId}/blockers", pipelineSchemas.blockersSchema],
["post", "/api/cases/{caseId}/open-conversation", undefined],
["get", "/api/cases/{caseId}/issue-links", undefined],
["get", "/api/cases/{caseId}/outputs", undefined],
["post", "/api/cases/{caseId}/issue-links", pipelineSchemas.createIssueLinkSchema],
["delete", "/api/cases/{caseId}/issue-links/{linkId}", undefined],
["get", "/api/cases/{caseId}/events", undefined],
["get", "/api/cases/{caseId}/children/tree", undefined],
["get", "/api/cases/{caseId}/rollup", undefined],
["get", "/api/cases/{caseId}/context-pack", undefined],
["get", "/api/cases/{caseId}/automation/retry-plan", undefined],
["post", "/api/cases/{caseId}/automation/retry", sharedSchemas.pipelineAutomationRetryRequestSchema],
["post", "/api/cases/{caseId}/automations/{automationId}/retry", undefined],
["post", "/api/cases/{caseId}/automation/current-stage/rerun", undefined],
["post", "/api/companies/{companyId}/cases", caseSchemas.createCaseSchema],
["get", "/api/companies/{companyId}/cases", undefined],
["get", "/api/cases/{id}/documents/{key}", undefined],
["get", "/api/cases/{id}/documents/{key}/annotations", undefined],
["get", "/api/cases/{id}/documents/{key}/annotations/{threadId}", undefined],
["post", "/api/cases/{id}/documents/{key}/annotations", sharedSchemas.createDocumentAnnotationThreadSchema],
["post", "/api/cases/{id}/documents/{key}/annotations/{threadId}/comments", sharedSchemas.createDocumentAnnotationCommentSchema],
["patch", "/api/cases/{id}/documents/{key}/annotations/{threadId}", sharedSchemas.updateDocumentAnnotationThreadSchema],
["put", "/api/cases/{id}/documents/{key}", caseSchemas.upsertCaseDocumentSchema],
["post", "/api/cases/{id}/documents/{key}/lock", undefined],
["post", "/api/cases/{id}/documents/{key}/unlock", undefined],
["post", "/api/cases/{id}/documents/{key}/revisions/{revisionId}/restore", undefined],
["delete", "/api/cases/{id}/documents/{key}", undefined],
["post", "/api/cases/{id}/links", caseSchemas.createIssueLinkSchema],
["post", "/api/cases/{id}/attachments", undefined],
["get", "/api/cases/{id}/events", undefined],
["get", "/api/cases/{id}/documents/{key}/revisions", undefined],
["get", "/api/issues/{issueId}/cases", undefined],
["get", "/api/cases/{id}", undefined],
["patch", "/api/cases/{id}", caseSchemas.patchCaseSchema],
["get", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/token", undefined],
["get", "/api/companies/{companyId}/smoke-lab/oauth/userinfo", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/revoke", undefined],
["get", "/api/companies/{companyId}/smoke-lab/services", undefined],
["post", "/api/companies/{companyId}/smoke-lab/services/start", undefined],
["post", "/api/companies/{companyId}/smoke-lab/services/stop", undefined],
["post", "/api/companies/{companyId}/smoke-lab/install-fixtures", undefined],
["get", "/api/companies/{companyId}/smoke-lab/runs", undefined],
["post", "/api/companies/{companyId}/smoke-lab/runs", sharedSchemas.createSmokeRunSchema],
["get", "/api/companies/{companyId}/smoke-lab/runs/{runId}", undefined],
["patch", "/api/companies/{companyId}/smoke-lab/runs/{runId}", sharedSchemas.updateSmokeRunSchema],
["post", "/api/companies/{companyId}/smoke-lab/runs/{runId}/steps", sharedSchemas.recordSmokeRunStepSchema],
["post", "/api/companies/{companyId}/smoke-lab/reset", undefined],
];
// Query contracts shared with the runtime parser where one exists.
export const experimentalApiQueries: Record<string, z.ZodObject<any>> = {
"GET /api/companies/{companyId}/cases": caseSchemas.listCasesQuerySchema,
"GET /api/cases/{id}/events": caseSchemas.listEventsQuerySchema,
"GET /api/cases/{caseId}/automation/retry-plan": pipelineSchemas.retryAutomationQuerySchema,
"GET /api/companies/{companyId}/pipelines-attention": z.object({ limit: z.coerce.number().int().positive().optional() }),
"GET /api/companies/{companyId}/case-events": z.object({ types: z.string().optional(), limit: z.coerce.number().int().positive().optional(), offset: z.coerce.number().int().nonnegative().optional() }),
"GET /api/companies/{companyId}/review-cases": z.object({ pipelineId: z.string().optional(), parentCaseId: z.string().optional() }),
"GET /api/pipelines/{pipelineId}/cases": z.object({ stageKey: z.string().optional(), q: z.string().optional(), terminal: z.enum(["true", "false"]).optional(), includeRetired: z.enum(["true", "false"]).optional(), parentCaseId: z.string().optional() }),
"DELETE /api/pipelines/{pipelineId}/stages/{stageId}": z.object({ moveCasesToStageId: z.string().optional() }),
};
+15 -1
View File
@@ -1,3 +1,4 @@
import { storedSteeringAcknowledgement, reconcileSteeredIdentity, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity } from "../services/run-identity.js";
import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
@@ -9227,6 +9228,7 @@ export function issueRoutes(
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
originRunId: createBody.originRunId ?? actor.runId,
originIdentityContextId: req.actor.identityContextId ?? null,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
@@ -11963,6 +11965,10 @@ export function issueRoutes(
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
const actor = getActorInfo(req);
const steeringIdentity = await reserveSteeredIdentity(db, {
companyId: issue.companyId, runId: req.body.targetRunId, issueId: issue.id, messageId: commentId,
});
let steeringDeliveryAttempted = false;
let acknowledgedTurnId: string | null = null;
let duplicate = false;
let queue: IssueQueuedCommentQueue;
@@ -12075,11 +12081,14 @@ export function issueRoutes(
});
}
const acknowledgement = await steerNativeSession({
steeringDeliveryAttempted = true;
const acknowledgement = (steeringIdentity ? await storedSteeringAcknowledgement(tx, steeringIdentity) : null) ?? await steerNativeSession({
runId: locked.activeRun.id,
message: entry.comment.body,
correlationId: commentId,
onAcknowledged: steeringIdentity ? () => reconcileSteeredIdentity(db, steeringIdentity) : undefined,
});
if (steeringIdentity) await acceptSteeredIdentity(tx, steeringIdentity);
acknowledgedTurnId = acknowledgement.turnId;
const remainingIds = locked.queue.entries
.map((candidate) => candidate.comment.id)
@@ -12130,6 +12139,10 @@ export function issueRoutes(
});
});
} catch (error) {
const uncertain = steeringDeliveryAttempted && (!(error instanceof NativeSessionSteeringError)
|| error.code === "steering_timeout");
if (steeringIdentity && !uncertain) await rejectSteeredIdentity(db, steeringIdentity);
if (error instanceof NativeSessionSteeringError) {
throw conflict(error.message, { code: error.code, retryable: true });
}
@@ -12226,6 +12239,7 @@ export function issueRoutes(
...req.body,
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
}, {
identityContextId: req.actor.identityContextId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
+33 -2
View File
@@ -1,3 +1,5 @@
import { experimentalApiMetadata } from "./experimental-api-metadata.js";
import { experimentalApiPaths, experimentalApiQueries } from "./experimental-api-paths.js";
import { Router } from "express";
import { z } from "zod";
import {
@@ -826,6 +828,7 @@ const RUNTIME_TOOLS_SECURITY: Array<Record<string, string[]>> = [
];
const RUNTIME_TOOLS_OPERATIONS = new Set([
"POST /runtime-tools/github/credentials",
"GET /mcp/runtime-tools",
"POST /mcp/runtime-tools",
"POST /runtime-tools/connections/search",
@@ -862,6 +865,8 @@ const BOARD_ONLY_PREFIXES = [
];
const BOARD_ONLY_OPERATIONS = new Set([
"DELETE /api/issues/{id}/documents/{key}",
"GET /api/companies/{companyId}/decisions",
"GET /api/cloud/stacks",
"GET /api/companies",
"POST /api/companies",
@@ -1110,7 +1115,7 @@ function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLev
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin";
if (isBoardOnlyOperation(method, path)) return "board";
if (isBoardOnlyOperation(method, path) || experimentalApiMetadata[`${method.toUpperCase()} ${path}`]?.boardOnly) return "board";
return "authenticated";
}
@@ -1153,7 +1158,7 @@ function applyDocumentFixups(document: any): any {
scheme: "bearer",
bearerFormat: "Heartbeat-bound runtime tools token",
description:
"Short-lived token bound to an active heartbeat run and presented in the Authorization bearer header.",
"Scoped token bound to an active heartbeat run and presented in the Authorization bearer header. The GitHub credential endpoint requires the distinct github_credentials scope.",
},
};
document.security = AUTHENTICATED_SECURITY;
@@ -7480,6 +7485,14 @@ for (const route of [
// --- Connection intents ------------------------------------------------------
registerCurrentRoute({
method: "post",
path: "/runtime-tools/github/credentials",
tags: ["connection-intents"],
summary: "Resolve operation credentials using a run capability with github_credentials scope; browser sessions are rejected",
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
});
registerCurrentRoute({
method: "get",
path: "/mcp/runtime-tools",
@@ -8396,6 +8409,24 @@ registerCurrentRoute({
}),
});
// Every experimental REST route remains discoverable while its runtime feature
// flag and actor checks stay authoritative. Shared validators prevent drift.
for (const [method, path, body] of experimentalApiPaths) {
const query = experimentalApiQueries[`${method.toUpperCase()} ${path}`];
const metadata = experimentalApiMetadata[`${method.toUpperCase()} ${path}`];
registry.registerPath({
method, path, tags: ["Experimental"],
summary: `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "").replace(/\/api\//, "").replaceAll("/", " ")}`,
description: "Experimental API; the corresponding instance feature must be enabled. Existing route authorization applies." + (path.startsWith("/api/cases/{caseId}") ? " Pipeline case resource. On overlapping /cases routes the server selects the handler by resource identity; use a pipeline case ID." : path.startsWith("/api/cases/{id}") ? " Cases resource (not a pipeline case). Overlapping /cases routes select their handler by resource identity." : ""),
request: {
params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map((match) => [match[1], z.string()]))),
...(query ? { query } : {}),
...(path === "/api/cases/{id}/attachments" ? { body: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["file"], properties: { file: { type: "string", format: "binary" } } } } } } } : body ? { body: { required: true, content: { "application/json": { schema: body } } } } : {}),
},
responses: { ...Object.fromEntries((metadata?.successStatuses ?? [200]).map(status => [status, responses.ok()])), 400: responses.badRequest, 403: responses.forbidden, 404: responses.notFound },
});
}
// ─── Spec builder ─────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import { envConfigSchema, pipelineAutomationRetryScopeSchema } from "@paperclipai/shared";
export const stageKindSchema = z.enum(["open", "working", "review", "done", "cancelled"]);
export const jsonObjectSchema = z.record(z.string(), z.unknown());
export const stageConfigSchema = z.record(z.string(), z.unknown()).default({});
export const casePatchSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
workspaceRef: jsonObjectSchema.nullable().optional(),
parentCaseId: z.string().guid().nullable().optional(),
expectedVersion: z.number().int().positive().optional(),
leaseToken: z.string().guid().nullable().optional(),
});
export const ingestCaseSchema = z.object({
caseKey: z.string().max(1_024).nullable().optional(),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
stageKey: z.string().trim().min(1).max(120).optional(),
parentCaseId: z.string().guid().nullable().optional(),
requestKey: z.string().trim().min(1).max(512).optional(),
workspaceRef: jsonObjectSchema.nullable().optional(),
blockedByCaseIds: z.array(z.string().guid()).max(100).optional(),
blockedByCaseKeys: z.array(z.string().max(1_024)).max(100).optional(),
});
export const createPipelineSchema = z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
description: z.string().max(8_000).nullable().optional(),
projectId: z.string().guid().nullable().optional(),
enforceTransitions: z.boolean().optional(),
stages: z.array(z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
kind: stageKindSchema,
position: z.number().int().optional(),
config: stageConfigSchema.optional(),
})).optional(),
});
export const updatePipelineSchema = z.object({
name: z.string().trim().min(1).max(200).optional(),
description: z.string().max(8_000).nullable().optional(),
enforceTransitions: z.boolean().optional(),
archived: z.boolean().optional(),
});
export const createStageSchema = z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
kind: stageKindSchema,
position: z.number().int(),
config: stageConfigSchema.optional(),
});
export const updateStageSchema = z.object({
key: z.string().trim().min(1).max(120).optional(),
name: z.string().trim().min(1).max(200).optional(),
kind: stageKindSchema.optional(),
position: z.number().int().optional(),
config: stageConfigSchema.optional(),
});
export const updateStageAutomationEnvSchema = z.object({
env: envConfigSchema.nullable(),
baseRoutineRevisionId: z.string().guid().nullable().optional(),
});
export const replaceTransitionsSchema = z.object({
transitions: z.array(z.object({
fromStageKey: z.string().trim().min(1).max(120),
toStageKey: z.string().trim().min(1).max(120),
label: z.string().max(200).nullable().optional(),
})).max(500),
enforceTransitions: z.boolean().optional(),
});
export const batchIngestSchema = z.object({ items: z.array(ingestCaseSchema).max(200) });
export const breakdownCaseSchema = z.object({
items: z.array(z.object({
key: z.string().trim().min(1).max(200),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
})).max(200),
});
export const claimCaseSchema = z.object({ leaseSeconds: z.number().int().positive().max(86_400).optional() });
export const releaseCaseSchema = z.object({
leaseToken: z.string().guid().nullable().optional(),
force: z.boolean().optional(),
});
export const transitionCaseSchema = z.object({
toStageKey: z.string().trim().min(1).max(120),
expectedVersion: z.number().int().positive(),
leaseToken: z.string().guid().nullable().optional(),
reason: z.string().max(4_000).nullable().optional(),
force: z.boolean().optional(),
acceptSuggestionId: z.string().guid().optional(),
});
export const suggestTransitionSchema = z.object({
toStageKey: z.string().trim().min(1).max(120),
rationale: z.string().trim().min(1).max(8_000),
confidence: z.number().min(0).max(1).optional(),
});
export const resolveSuggestionSchema = z.object({
suggestionId: z.string().guid(),
resolution: z.enum(["accept", "dismiss"]),
expectedVersion: z.number().int().positive().optional(),
reason: z.string().max(4_000).nullable().optional(),
leaseToken: z.string().guid().nullable().optional(),
});
export const acknowledgeDriftSchema = z.object({
expectedVersion: z.number().int().positive().optional(),
});
export const retryAutomationQuerySchema = z.object({
scope: pipelineAutomationRetryScopeSchema.default("previous_stage"),
targetStageId: z.string().guid().optional(),
});
export const reviewEditsSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
});
export const reviewCaseSchema = z.object({
decision: z.enum(["approve", "reject", "request_changes"]),
reason: z.string().max(4_000).nullable().optional(),
edits: reviewEditsSchema.optional(),
expectedVersion: z.number().int().positive(),
leaseToken: z.string().guid().nullable().optional(),
});
export const blockersSchema = z.object({ blockedByCaseIds: z.array(z.string().guid()).max(100) });
export const issueLinkRoleSchema = z.enum(["origin", "conversation", "work", "automation"]);
export const createIssueLinkSchema = z.object({
issueId: z.string().guid(),
role: issueLinkRoleSchema,
});
export const bulkReviewSchema = z.object({
items: z.array(reviewCaseSchema.extend({ caseId: z.string().guid() })).max(100),
});
export const upsertPipelineDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
body: z.string().max(200_000),
baseRevisionId: z.string().guid().nullable().optional(),
});
export const upsertPipelineCaseDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
format: z.string().trim().min(1).max(80).optional().default("markdown"),
body: z.string().max(200_000),
changeSummary: z.string().trim().max(1_000).nullable().optional(),
baseRevisionId: z.string().guid().nullable().optional(),
});
+30 -145
View File
@@ -1,3 +1,33 @@
import {
stageKindSchema,
jsonObjectSchema,
stageConfigSchema,
casePatchSchema,
ingestCaseSchema,
createPipelineSchema,
updatePipelineSchema,
createStageSchema,
updateStageSchema,
updateStageAutomationEnvSchema,
replaceTransitionsSchema,
batchIngestSchema,
breakdownCaseSchema,
claimCaseSchema,
releaseCaseSchema,
transitionCaseSchema,
suggestTransitionSchema,
resolveSuggestionSchema,
acknowledgeDriftSchema,
retryAutomationQuerySchema,
reviewEditsSchema,
reviewCaseSchema,
blockersSchema,
issueLinkRoleSchema,
createIssueLinkSchema,
bulkReviewSchema,
upsertPipelineDocumentSchema,
upsertPipelineCaseDocumentSchema,
} from "./pipelines-schemas.js";
import { Router, type Request } from "express";
import { z } from "zod";
import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
@@ -86,151 +116,6 @@ import {
const STAGE_INSTRUCTIONS_PREFIX = "stage-instructions:";
type PipelineRouteDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
const stageKindSchema = z.enum(["open", "working", "review", "done", "cancelled"]);
const jsonObjectSchema = z.record(z.string(), z.unknown());
const stageConfigSchema = z.record(z.string(), z.unknown()).default({});
const casePatchSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
workspaceRef: jsonObjectSchema.nullable().optional(),
parentCaseId: z.string().guid().nullable().optional(),
expectedVersion: z.number().int().positive().optional(),
leaseToken: z.string().guid().nullable().optional(),
});
const ingestCaseSchema = z.object({
caseKey: z.string().max(1_024).nullable().optional(),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
stageKey: z.string().trim().min(1).max(120).optional(),
parentCaseId: z.string().guid().nullable().optional(),
requestKey: z.string().trim().min(1).max(512).optional(),
workspaceRef: jsonObjectSchema.nullable().optional(),
blockedByCaseIds: z.array(z.string().guid()).max(100).optional(),
blockedByCaseKeys: z.array(z.string().max(1_024)).max(100).optional(),
});
const createPipelineSchema = z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
description: z.string().max(8_000).nullable().optional(),
projectId: z.string().guid().nullable().optional(),
enforceTransitions: z.boolean().optional(),
stages: z.array(z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
kind: stageKindSchema,
position: z.number().int().optional(),
config: stageConfigSchema.optional(),
})).optional(),
});
const updatePipelineSchema = z.object({
name: z.string().trim().min(1).max(200).optional(),
description: z.string().max(8_000).nullable().optional(),
enforceTransitions: z.boolean().optional(),
archived: z.boolean().optional(),
});
const createStageSchema = z.object({
key: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(200),
kind: stageKindSchema,
position: z.number().int(),
config: stageConfigSchema.optional(),
});
const updateStageSchema = z.object({
key: z.string().trim().min(1).max(120).optional(),
name: z.string().trim().min(1).max(200).optional(),
kind: stageKindSchema.optional(),
position: z.number().int().optional(),
config: stageConfigSchema.optional(),
});
const updateStageAutomationEnvSchema = z.object({
env: envConfigSchema.nullable(),
baseRoutineRevisionId: z.string().guid().nullable().optional(),
});
const replaceTransitionsSchema = z.object({
transitions: z.array(z.object({
fromStageKey: z.string().trim().min(1).max(120),
toStageKey: z.string().trim().min(1).max(120),
label: z.string().max(200).nullable().optional(),
})).max(500),
enforceTransitions: z.boolean().optional(),
});
const batchIngestSchema = z.object({ items: z.array(ingestCaseSchema).max(200) });
const breakdownCaseSchema = z.object({
items: z.array(z.object({
key: z.string().trim().min(1).max(200),
title: z.string().trim().min(1).max(500),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
})).max(200),
});
const claimCaseSchema = z.object({ leaseSeconds: z.number().int().positive().max(86_400).optional() });
const releaseCaseSchema = z.object({
leaseToken: z.string().guid().nullable().optional(),
force: z.boolean().optional(),
});
const transitionCaseSchema = z.object({
toStageKey: z.string().trim().min(1).max(120),
expectedVersion: z.number().int().positive(),
leaseToken: z.string().guid().nullable().optional(),
reason: z.string().max(4_000).nullable().optional(),
force: z.boolean().optional(),
acceptSuggestionId: z.string().guid().optional(),
});
const suggestTransitionSchema = z.object({
toStageKey: z.string().trim().min(1).max(120),
rationale: z.string().trim().min(1).max(8_000),
confidence: z.number().min(0).max(1).optional(),
});
const resolveSuggestionSchema = z.object({
suggestionId: z.string().guid(),
resolution: z.enum(["accept", "dismiss"]),
expectedVersion: z.number().int().positive().optional(),
reason: z.string().max(4_000).nullable().optional(),
leaseToken: z.string().guid().nullable().optional(),
});
const acknowledgeDriftSchema = z.object({
expectedVersion: z.number().int().positive().optional(),
});
const retryAutomationQuerySchema = z.object({
scope: pipelineAutomationRetryScopeSchema.default("previous_stage"),
targetStageId: z.string().guid().optional(),
});
const reviewEditsSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
summary: z.string().max(8_000).nullable().optional(),
fields: jsonObjectSchema.optional(),
parentCaseId: z.string().guid().nullable().optional(),
});
const reviewCaseSchema = z.object({
decision: z.enum(["approve", "reject", "request_changes"]),
reason: z.string().max(4_000).nullable().optional(),
edits: reviewEditsSchema.optional(),
expectedVersion: z.number().int().positive(),
leaseToken: z.string().guid().nullable().optional(),
});
const blockersSchema = z.object({ blockedByCaseIds: z.array(z.string().guid()).max(100) });
const issueLinkRoleSchema = z.enum(["origin", "conversation", "work", "automation"]);
const createIssueLinkSchema = z.object({
issueId: z.string().guid(),
role: issueLinkRoleSchema,
});
const bulkReviewSchema = z.object({
items: z.array(reviewCaseSchema.extend({ caseId: z.string().guid() })).max(100),
});
const upsertPipelineDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
body: z.string().max(200_000),
baseRevisionId: z.string().guid().nullable().optional(),
});
const upsertPipelineCaseDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
format: z.string().trim().min(1).max(80).optional().default("markdown"),
body: z.string().max(200_000),
changeSummary: z.string().trim().max(1_000).nullable().optional(),
baseRevisionId: z.string().guid().nullable().optional(),
});
const intakeFieldTypes = new Set(["select", "text", "multiline"]);
function stageAutomationRoutineId(config: unknown) {
+7 -5
View File
@@ -6,7 +6,7 @@ export interface RuntimeToolsTokenClaims {
company_id: string;
run_id: string;
responsible_user_id: string;
scope: "connection_intents";
scope: "connection_intents" | "github_credentials";
iat: number;
exp: number;
instance_id: string;
@@ -44,6 +44,7 @@ export function createRuntimeToolsToken(input: {
companyId: string;
runId: string;
responsibleUserId: string;
scope?: RuntimeToolsTokenClaims["scope"];
}) {
if (!secret()) return null;
const now = Math.floor(Date.now() / 1000);
@@ -53,9 +54,10 @@ export function createRuntimeToolsToken(input: {
company_id: input.companyId,
run_id: input.runId,
responsible_user_id: input.responsibleUserId,
scope: "connection_intents",
scope: input.scope ?? "connection_intents",
iat: now,
exp: now + TOKEN_TTL_SECONDS,
// Broker tokens remain scoped to a live run, which is rechecked on every use.
exp: now + (input.scope === "github_credentials" ? 30 * 24 * 60 * 60 : TOKEN_TTL_SECONDS),
instance_id: instanceId,
};
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
@@ -65,7 +67,7 @@ export function createRuntimeToolsToken(input: {
: null;
}
export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims | null {
export function verifyRuntimeToolsToken(token: string, scope: RuntimeToolsTokenClaims["scope"] = "connection_intents"): RuntimeToolsTokenClaims | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
let header: Record<string, unknown>;
@@ -86,7 +88,7 @@ export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims
typeof claims.sub !== "string"
|| typeof claims.run_id !== "string"
|| typeof claims.responsible_user_id !== "string"
|| claims.scope !== "connection_intents"
|| claims.scope !== scope
|| typeof claims.iat !== "number"
|| typeof claims.exp !== "number"
|| claims.exp <= Math.floor(Date.now() / 1000)
+2
View File
@@ -14,6 +14,7 @@ import {
projects,
goals,
heartbeatRuns,
runIdentityContexts,
heartbeatRunEvents,
costEvents,
financeEvents,
@@ -536,6 +537,7 @@ export function companyService(db: Db) {
}
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
+2 -1
View File
@@ -126,6 +126,7 @@ export function connectionIntentService(db: Db) {
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
responsibleUserId: heartbeatRuns.responsibleUserId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
@@ -135,7 +136,7 @@ export function connectionIntentService(db: Db) {
!run
|| run.companyId !== claims.company_id
|| run.agentId !== claims.sub
|| run.responsibleUserId !== claims.responsible_user_id
|| (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id)
) throw forbidden("Runtime tool token does not match its heartbeat run");
if (run.status !== "running") throw forbidden("Runtime tool token is no longer active");
const snapshot = record(run.contextSnapshot);
+44 -18
View File
@@ -1,5 +1,6 @@
import {
companySecrets,
heartbeatRuns,
companyMemberships,
connectionGrantDelegations,
connectionGrants,
@@ -48,6 +49,7 @@ export type GitCredential = {
/** The company-secret name the token came from; null for a server-environment token. */
secretName: string | null;
githubIdentity?: { userId: string; login: string };
identitySource?: "personal" | "dedicated";
};
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
@@ -260,6 +262,23 @@ export function createGitRemoteAuthProvider(
return async (remoteUrl: string) => {
if (!isSupportedGitHubRemoteUrl(remoteUrl)) return null;
if (db && context?.heartbeatRunId && context.agentId) {
const [run] = await db.select({ contextId: heartbeatRuns.activeIdentityContextId }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, context.heartbeatRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, context.agentId),
));
if (run?.contextId) {
const { resolveGitHubOperationCredentials } = await import("./github-operation-credentials.js");
const result = await resolveGitHubOperationCredentials(db, {
companyId, runId: context.heartbeatRunId, agentId: context.agentId,
});
const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null });
return { ...anonymous, env: {
...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null",
GIT_AUTHOR_NAME: "", GIT_AUTHOR_EMAIL: "", GIT_COMMITTER_NAME: "", GIT_COMMITTER_EMAIL: "",
...result.env,
} };
}
}
credentialPromise ??= resolveCredential();
const credential = await credentialPromise;
if (!credential) return null;
@@ -273,16 +292,16 @@ export async function resolveManagedGitHubIdentitySelection(
context: {
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
},
): Promise<{
configured: boolean;
identitySource?: "personal" | "dedicated";
grant?: typeof connectionGrants.$inferSelect;
error?: string;
}> {
const connections = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
));
const githubConnections = connections.filter((connection) => {
const config = connection.config && typeof connection.config === "object" ? connection.config as Record<string, unknown> : {};
@@ -300,7 +319,7 @@ export async function resolveManagedGitHubIdentitySelection(
));
const eligibleConnectionIds = new Set(githubConnections.filter((connection) => installs.some((install) =>
install.connectionId === connection.id && (
install.targetType === "company"
(install.targetType === "company" && install.targetId === companyId)
|| (install.targetType === "agent" && install.targetId === context.agentId)
)
)).map((connection) => connection.id));
@@ -323,7 +342,7 @@ export async function resolveManagedGitHubIdentitySelection(
const personal = context.responsibleUserId
? grants.filter((grant) => grant.kind === "user" && grant.subjectUserId === context.responsibleUserId)
: [];
const delegated = !context.responsibleUserId && context.agentId
const delegated = context.allowStandingDelegation !== false && !context.responsibleUserId && context.agentId
? await db.select({ grantId: connectionGrantDelegations.grantId }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, companyId),
eq(connectionGrantDelegations.agentId, context.agentId),
@@ -334,17 +353,22 @@ export async function resolveManagedGitHubIdentitySelection(
})
: [];
const candidates = dedicated.length > 0 ? dedicated : personal.length > 0 ? personal : delegated;
const identitySource = dedicated.length > 0 ? "dedicated" as const : "personal" as const;
if (candidates.length !== 1) {
return {
configured: true,
configured: true, identitySource,
error: candidates.length === 0
? "No managed GitHub identity is available for this run"
: "More than one managed GitHub identity matches this run",
};
}
const grant = candidates[0]!;
if (grant.status !== "active") return { configured: true, error: "The managed GitHub identity must be reconnected" };
return { configured: true, grant };
const connection = githubConnections.find((candidate) => candidate.id === grant.connectionId);
if (!connection?.enabled || connection.status !== "active") {
return { configured: true, identitySource, error: "The managed GitHub connection is unavailable" };
}
if (grant.status !== "active") return { configured: true, identitySource, error: "The managed GitHub identity must be reconnected" };
return { configured: true, identitySource, grant };
}
export async function filterResolvedGitHubConnectionsForRun<T extends {
@@ -379,7 +403,7 @@ export async function filterResolvedGitHubConnectionsForRun<T extends {
);
}
async function resolveManagedGitHubCredential(
export async function resolveManagedGitHubCredential(
db: Db,
secrets: GitCredentialSecretsDeps,
companyId: string,
@@ -388,20 +412,21 @@ async function resolveManagedGitHubCredential(
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
},
): Promise<{ configured: boolean; credential?: GitCredential; error?: string }> {
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
if (!selection.configured) return { configured: false };
if (!selection.grant) return { configured: true, error: selection.error };
if (!selection.grant) return { configured: true, identitySource: selection.identitySource, error: selection.error };
let grant = selection.grant;
if (grant.kind === "user" && grant.subjectUserId) {
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
const [membership] = await db.select({ id: companyMemberships.id, role: companyMemberships.membershipRole }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, grant.subjectUserId),
eq(companyMemberships.status, "active"),
)).limit(1);
if (!membership) return { configured: true, error: "The managed GitHub identity owner is not an active company member" };
if (!membership || membership.role === "viewer") return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity owner is not an authorized company member" };
}
const expiresAt = grant.providerTenant?.oauth?.accessTokenExpiresAt;
const refreshedAt = grant.providerTenant?.oauth?.refreshedAt;
@@ -423,9 +448,9 @@ async function resolveManagedGitHubCredential(
}
const accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
const github = grant.providerTenant?.github;
if (!accessRef || !github) return { configured: true, error: "The managed GitHub identity is incomplete" };
if (!accessRef || !github) return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity is incomplete" };
if (github.installationCount < 1 || github.repositoryCount < 1) {
return { configured: true, error: "The managed GitHub identity no longer has repository access" };
return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity no longer has repository access" };
}
const accessContext = {
consumerType: "system" as const,
@@ -439,7 +464,7 @@ async function resolveManagedGitHubCredential(
let token: string;
if (grant.kind === "user") {
if (!grant.subjectUserId || !secrets.resolveUserSecretValue) {
return { configured: true, error: "The personal GitHub credential cannot be resolved" };
return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential cannot be resolved" };
}
const [secret] = await db.select({
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
@@ -448,25 +473,26 @@ async function resolveManagedGitHubCredential(
eq(companySecrets.id, accessRef.secretId),
eq(companySecrets.ownerUserId, grant.subjectUserId),
)).limit(1);
if (!secret?.userSecretDefinitionId) return { configured: true, error: "The personal GitHub credential is invalid" };
if (!secret?.userSecretDefinitionId) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is invalid" };
const resolved = await secrets.resolveUserSecretValue(companyId, {
definitionId: secret.userSecretDefinitionId,
responsibleUserId: grant.subjectUserId,
version: accessRef.versionSelector ?? "latest",
required: true,
}, accessContext);
if (!resolved) return { configured: true, error: "The personal GitHub credential is missing" };
if (!resolved) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is missing" };
token = resolved.value;
} else {
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
}
return {
configured: true,
configured: true, identitySource: selection.identitySource,
credential: {
token,
source: "managed_connection",
secretName: null,
githubIdentity: { userId: github.userId, login: github.login },
identitySource: grant.kind === "agent" ? "dedicated" : "personal",
},
};
}
@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { runIdentityContexts, type Db } from "@paperclipai/db";
import { forbidden } from "../errors.js";
import { captureRunIdentity } from "./run-identity.js";
import { buildGitAuthInvocation, resolveManagedGitHubCredential } from "./git-credentials.js";
import { secretService } from "./secrets.js";
export type GitHubCredentialSummary = {
status: "available" | "absent" | "unavailable";
source?: "personal" | "dedicated";
login?: string;
reason?: string;
};
/** No company secrets or ambient credentials are consulted by this path. */
export async function resolveGitHubOperationCredentials(db: Db, input: {
companyId: string; agentId: string; runId: string;
}) {
const { run, context } = await captureRunIdentity(db, input);
if (!context) throw forbidden("This run predates managed GitHub credentials");
let summary: GitHubCredentialSummary;
let env: Record<string, string> = {};
try {
const resolved = await resolveManagedGitHubCredential(db, secretService(db), input.companyId, {
agentId: input.agentId, heartbeatRunId: input.runId,
allowStandingDelegation: false,
responsibleUserId: context?.cause === "company_default" ? null : context?.responsibleUserId ?? null,
issueId: typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null,
});
if (resolved.credential) {
summary = { status: "available", source: resolved.credential.identitySource, login: resolved.credential.githubIdentity?.login };
env = buildGitAuthInvocation(resolved.credential).env;
} else {
summary = { status: resolved.configured ? "unavailable" : "absent", source: resolved.identitySource ?? "personal", reason: resolved.error ?? "No GitHub identity connected" };
}
} catch {
// Provider/secret errors can contain sensitive response bodies. Never persist them.
summary = { status: "unavailable", reason: "GitHub credentials are temporarily unavailable" };
}
if (context) await db.update(runIdentityContexts).set({ github: summary }).where(eq(runIdentityContexts.id, context.id));
return { identityContextId: context?.id ?? null, revision: context?.revision ?? null, ...summary, env };
}
+198 -147
View File
@@ -1,3 +1,6 @@
import { initializeRunIdentity } from "./run-identity.js";
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, startAdapterExecutionTargetPaperclipBridge } from "@paperclipai/adapter-utils/execution-target";
import fs from "node:fs/promises";
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "./work-folder-retention.js";
import { prepareSandboxWorkFolders } from "./sandbox-work-folders.js";
@@ -106,7 +109,6 @@ import {
createGitRemoteAuthProvider,
describeGitAuthFailure,
filterResolvedGitHubConnectionsForRun,
GIT_CREDENTIAL_TOKEN_ENV_KEY,
scrubGitCredentialText,
type GitRemoteAuthProvider,
} from "./git-credentials.js";
@@ -1214,14 +1216,19 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
// 3. Any other PAPERCLIP_*-named binding is user data and flows through to
// the run env like any non-prefixed binding.
const FORBIDDEN_ENV_BINDING_KEYS = new Set(["PAPERCLIP_API_KEY"]);
const MANAGED_GITHUB_TOKEN_KEYS = new Set([
"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN",
]);
function stripForbiddenEnvBindings(
envValue: unknown,
managedGitHubCredentials = false,
): Record<string, unknown> | null {
const record = parseObject(envValue);
const filtered = Object.fromEntries(
Object.entries(record).filter(
([key]) => !FORBIDDEN_ENV_BINDING_KEYS.has(key),
([key]) => !FORBIDDEN_ENV_BINDING_KEYS.has(key)
&& !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)),
),
);
return Object.keys(filtered).length > 0 ? filtered : null;
@@ -1229,11 +1236,12 @@ function stripForbiddenEnvBindings(
function stripForbiddenEnvFromAdapterConfig(
config: Record<string, unknown>,
managedGitHubCredentials = false,
): Record<string, unknown> {
if (!Object.prototype.hasOwnProperty.call(config, "env")) return config;
return {
...config,
env: stripForbiddenEnvBindings(config.env) ?? {},
env: stripForbiddenEnvBindings(config.env, managedGitHubCredentials) ?? {},
};
}
@@ -1284,16 +1292,18 @@ export async function resolveExecutionRunAdapterConfig(input: {
reason: string;
remediation: string;
};
/** Managed GitHub tokens are resolved only when operations start. */
managedGitHubCredentials?: boolean;
/** Audited class-3 values resolved by an internal credential broker. */
trustedEnvProjection?: Record<string, string>;
trustedEnvSecretKeys?: string[];
}) {
const executionRunConfig = stripForbiddenEnvFromAdapterConfig(
input.executionRunConfig,
input.executionRunConfig, input.managedGitHubCredentials,
);
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv);
const projectEnv = stripForbiddenEnvBindings(input.projectEnv);
const routineEnv = stripForbiddenEnvBindings(input.routineEnv);
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv, input.managedGitHubCredentials);
const projectEnv = stripForbiddenEnvBindings(input.projectEnv, input.managedGitHubCredentials);
const routineEnv = stripForbiddenEnvBindings(input.routineEnv, input.managedGitHubCredentials);
const agentEnv = parseObject(executionRunConfig.env);
const lowTrustAllowedBindingIds =
input.trustPreset?.kind === "low_trust_review"
@@ -4092,7 +4102,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
input.agent.id,
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(
and(
@@ -4102,8 +4112,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
),
)
.limit(1);
const resolvedInstalledConnections =
await filterResolvedGitHubConnectionsForRun({
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
@@ -4149,7 +4159,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
permittedConnectionIds.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)) &&
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio"),
);
@@ -4229,7 +4240,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
if (!profile) {
const fullConnectionIds = new Set(
effective.entries
.filter((entry) => entry.effect === "include" && entry.connectionId)
.filter((entry) => entry.effect === "include" && entry.selectorType === "connection" && entry.connectionId)
.map((entry) => entry.connectionId!),
);
const entries = [
@@ -4578,7 +4589,7 @@ export async function createManagedMcpRunConfig(input: {
),
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(
and(
@@ -4588,7 +4599,9 @@ export async function createManagedMcpRunConfig(input: {
),
)
.limit(1);
const resolvedAvailableInstalls = await filterResolvedGitHubConnectionsForRun(
const resolvedAvailableInstalls = runIdentity?.activeIdentityContextId
? installRows.filter((install) => install.enabled && install.status === "active").map((install) => ({ id: install.connectionId }))
: await filterResolvedGitHubConnectionsForRun(
{
db: input.db,
companyId: input.agent.companyId,
@@ -8998,6 +9011,8 @@ export function heartbeatService(
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
updatedAt: issues.updatedAt,
})
.from(issues)
@@ -9112,6 +9127,7 @@ export function heartbeatService(
routineId: issueContext.originId,
env: snapshot.routine.env ?? null,
responsibleUserId:
routineRun?.responsibleUserId ??
revision?.responsibleUserId ??
snapshot.routine.responsibleUserId ??
null,
@@ -9232,6 +9248,27 @@ export function heartbeatService(
input.requestedByActorType === "user"
? readNonEmptyString(input.requestedByActorId)
: null;
const messageIds = Array.isArray(input.contextSnapshot.wakeCommentIds)
? input.contextSnapshot.wakeCommentIds.filter((id): id is string => typeof id === "string")
: readNonEmptyString(input.contextSnapshot.wakeCommentId) ? [String(input.contextSnapshot.wakeCommentId)] : [];
if (input.issueContext && messageIds.length && !input.contextSnapshot.retryOfRunId) {
const messages = await db.select({ id: issueComments.id, authorUserId: issueComments.authorUserId })
.from(issueComments).where(and(eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueContext.id), inArray(issueComments.id, messageIds)));
for (const id of [...messageIds].reverse()) {
const author = messages.find((message) => message.id === id)?.authorUserId;
if (author) {
delete input.contextSnapshot.executionIdentityCause;
return author;
}
}
}
const retryOfRunId = readNonEmptyString(input.contextSnapshot.retryOfRunId);
if (retryOfRunId) {
const [origin] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, retryOfRunId)));
if (origin?.responsibleUserId) return origin.responsibleUserId;
}
if (contextResponsibleUserId) return contextResponsibleUserId;
if (input.existingRunResponsibleUserId)
return input.existingRunResponsibleUserId;
@@ -9245,9 +9282,8 @@ export function heartbeatService(
input.issueContext?.parentId,
);
if (parentResponsibleUserId) return parentResponsibleUserId;
if (input.issueContext)
return resolveCompanyDefaultResponsibleUserId(input.companyId);
if (requestedUserId) return requestedUserId;
if (!input.issueContext && requestedUserId) return requestedUserId;
input.contextSnapshot.executionIdentityCause = "company_default";
return resolveCompanyDefaultResponsibleUserId(input.companyId);
}
@@ -10486,6 +10522,7 @@ export function heartbeatService(
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
@@ -10747,6 +10784,7 @@ export function heartbeatService(
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
@@ -11269,7 +11307,7 @@ export function heartbeatService(
triggerDetail: "system",
reason: RUN_LIVENESS_CONTINUATION_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
@@ -11641,7 +11679,7 @@ export function heartbeatService(
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
@@ -18042,6 +18080,7 @@ export function heartbeatService(
let nativeTaskSessionPersisted = false;
let beforeWorkFolderCompletion: (() => Promise<void>) | undefined;
let workFolderLeaseId: string | null = null;
let githubLauncherLocation: Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
let nativeSessionResumeScheduled = false;
let nativeWorkspaceFinalizeScheduled = false;
let nativeWorkspaceSync: Awaited<
@@ -18314,19 +18353,30 @@ export function heartbeatService(
agent.companyId,
issueContext,
);
const responsibleUserId = await resolveResponsibleUserIdForRun({
let responsibleUserId: string | null = await resolveResponsibleUserIdForRun({
run,
contextSnapshot: context,
issueContext,
routineEnvContext,
});
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
await db
.update(heartbeatRuns)
.set({ responsibleUserId, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, run.id));
run = { ...run, responsibleUserId };
}
const identityContext = await initializeRunIdentity(db, {
companyId: agent.companyId, runId: run.id, responsibleUserId,
interactionId: readNonEmptyString(context.interactionId),
issueId, messageIds: run.retryOfRunId || context.retryOfRunId ? [] : queuedCommentIdsFromRunContext(context).length
? queuedCommentIdsFromRunContext(context)
: Array.isArray(context.wakeCommentIds) ? context.wakeCommentIds.filter((id): id is string => typeof id === "string")
: wakeCommentId ? [wakeCommentId] : [],
parentContextId: run.retryOfRunId || context.retryOfRunId ? null
: readNonEmptyString(context.originIdentityContextId)
?? (run.triggerDetail === "manual" || context.parentRunId ? null : issueContext?.continuationIdentityContextId ?? issueContext?.originIdentityContextId),
parentRunId: run.retryOfRunId ?? readNonEmptyString(context.retryOfRunId) ?? readNonEmptyString(context.parentRunId),
cause: readNonEmptyString(context.executionIdentityCause) ?? readNonEmptyString(context.wakeReason) ?? "dispatch",
});
// Initialization has persisted the active context, including an explicit
// absence of identity inherited from an automatic continuation.
responsibleUserId = identityContext.responsibleUserId;
run = { ...run, activeIdentityContextId: identityContext.id, responsibleUserId };
context.executionIdentityRunId = run.id;
if (
responsibleUserId &&
issueContext &&
@@ -18840,23 +18890,9 @@ export function heartbeatService(
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
: runScopedMentionedSkillKeys;
const pushCapabilityPreflightRequired = requiresPushCapabilityPreflight({
adapterType: agent.adapterType,
issueId,
explicitRunScopedSkillKeys: runScopedMentionedSkillKeys,
});
const githubRunAuth = await createGitRemoteAuthProvider(
db,
agent.companyId,
{
issueId,
heartbeatRunId: run.id,
responsibleUserId,
agentId: agent.id,
},
)("https://github.com/paperclipai/credential-probe.git");
const { resolvedConfig, secretKeys, secretManifest } =
await resolveExecutionRunAdapterConfig({
managedGitHubCredentials: true,
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
@@ -18873,25 +18909,6 @@ export function heartbeatService(
routineEnv: routineEnvContext.env,
secretsSvc,
trustPreset,
...(githubRunAuth
? {
trustedEnvProjection: githubRunAuth.env,
trustedEnvSecretKeys: [
"GH_TOKEN",
"GITHUB_TOKEN",
GIT_CREDENTIAL_TOKEN_ENV_KEY,
],
}
: {}),
requiredScopedEnvBinding: pushCapabilityPreflightRequired
? {
keys: [...PUSH_CAPABILITY_ENV_KEYS],
consumerScopes: ["agent", "project"],
reason: "push_write_credential_missing",
remediation:
"GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.",
}
: undefined,
});
if (secretManifest.length > 0) {
context.paperclipSecrets = {
@@ -19195,9 +19212,8 @@ export function heartbeatService(
: null,
issueId,
});
// One credential provider per run: base-ref refreshes during workspace realization and
// restore authenticate against private GitHub remotes with the same company-secret token
// the managed clone uses.
// The run-scoped provider resolves the active identity at each Git operation,
// including base-ref refreshes, workspace realization, and restore.
const workspaceGitAuthProvider = createGitRemoteAuthProvider(
db,
agent.companyId,
@@ -19895,6 +19911,19 @@ export function heartbeatService(
} else {
delete context.paperclipScratch;
}
const githubBrokerToken = createRuntimeToolsToken({
agentId: agent.id, companyId: agent.companyId, runId: run.id,
responsibleUserId: responsibleUserId ?? "", scope: "github_credentials",
});
const githubBrokerEnv = githubBrokerEnvironment(parseObject(runtimeConfig.env), {
url: configuredPaperclipApiBaseUrl() ?? "", token: githubBrokerToken?.token ?? "",
});
githubLauncherLocation = { runId: run.id, target: executionTarget };
runtimeConfig = { ...runtimeConfig, env: await prepareGitHubOperationLaunchers({
runId: run.id, target: executionTarget, cwd: executionWorkspace.cwd,
env: githubBrokerEnv,
}) };
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
context.paperclipEnvironment = {
id: selectedEnvironment.id,
name: selectedEnvironment.name,
@@ -20385,20 +20414,8 @@ export function heartbeatService(
environmentDriver: selectedEnvironment.driver,
leaseMetadata: activeEnvironmentLease.lease.metadata,
});
await assertPushCapabilityCheckoutValid({
enabled:
pushCapabilityPreflightRequired &&
executionTarget?.kind === "local",
issue: issueRef
? {
id: issueRef.id,
identifier: issueRef.identifier,
}
: null,
cwd: executionWorkspace.cwd,
});
const adapterEnv = Object.fromEntries(
Object.entries({ ...parseObject(resolvedConfig.env), ...sandboxWorkFolders?.env }).filter(
Object.entries({ ...parseObject(runtimeConfig.env), ...sandboxWorkFolders?.env }).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" && typeof entry[1] === "string",
),
@@ -21404,77 +21421,104 @@ export function heartbeatService(
endedAtMs: nativeDispatchAtMs,
},
);
const guardedDispatch =
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) =>
executePaperclipNativeSession({
db,
execution: nativeExecution,
runnerInstanceId: nativeRunnerInstanceId,
leaseOwner: runOptions.nativeLeaseOwner,
restartRecovery: runOptions.nativeRestartRecovery,
backend:
options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
onLog,
onEvent: onAdapterEvent,
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
runnerEnvironment: {
...buildNativeProviderEnvironment(
adapterEnv,
process.env,
executionWorkspace.cwd,
// Native Git/gh uses the same authenticated remote callback
// transport as managed adapters. A bridge failure must not make
// GitHub a prerequisite for otherwise unrelated native work.
let nativeGitHubBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTarget?.kind === "remote" && adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN) {
try {
nativeGitHubBridge = await startAdapterExecutionTargetPaperclipBridge({
runId: run.id,
target: executionTarget,
runtimeRootDir: path.posix.join(executionTarget.remoteCwd, ".paperclip-runtime", "github", run.id),
adapterKey: "native-github",
hostApiToken: adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN,
hostApiUrl: adapterEnv.PAPERCLIP_GITHUB_BROKER_URL,
onLog,
});
} catch {
await onLog("stderr", "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n");
}
}
try {
const guardedDispatch =
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) =>
executePaperclipNativeSession({
db,
execution: nativeExecution,
runnerInstanceId: nativeRunnerInstanceId,
leaseOwner: runOptions.nativeLeaseOwner,
restartRecovery: runOptions.nativeRestartRecovery,
backend:
options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
onLog,
onEvent: onAdapterEvent,
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
runnerEnvironment: {
...buildNativeProviderEnvironment(
adapterEnv,
process.env,
executionWorkspace.cwd,
),
...(nativeGitHubBridge ? {
PAPERCLIP_GITHUB_BROKER_URL: nativeGitHubBridge.env.PAPERCLIP_API_URL,
PAPERCLIP_GITHUB_BRIDGE_TOKEN: nativeGitHubBridge.env.PAPERCLIP_API_KEY,
} : {}),
...(nativeMcpServer
? {
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
}
: {}),
...(providerTraceCapture
? {
PAPERCLIP_PROVIDER_TRACE_PATH:
providerTraceCapture.path,
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
PROVIDER_TRACE_MAX_BYTES,
),
}
: {}),
},
runnerExecutionTarget: executionTarget,
runnerIngressAuthorized: isRunnerIngressAuthorized(
nativeRuntimeResolution,
),
...(nativeMcpServer
? {
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
}
: {}),
...(providerTraceCapture
? {
PAPERCLIP_PROVIDER_TRACE_PATH:
providerTraceCapture.path,
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
PROVIDER_TRACE_MAX_BYTES,
),
}
: {}),
},
runnerExecutionTarget: executionTarget,
runnerIngressAuthorized: isRunnerIngressAuthorized(
nativeRuntimeResolution,
),
runnerPublicUrl:
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath:
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
null,
runnerRemoteBinaryPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
null,
runnerRemoteCodexPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
null,
runnerRemoteCodexNpmSpec:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
null,
runnerRemoteProviderPackPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
null,
enqueueWakeup,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
},
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
runnerPublicUrl:
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath:
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
null,
runnerRemoteBinaryPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
null,
runnerRemoteCodexPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
null,
runnerRemoteCodexNpmSpec:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
null,
runnerRemoteProviderPackPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
null,
enqueueWakeup,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
},
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
} finally {
await nativeGitHubBridge?.stop();
}
} else {
const interactionId = readNonEmptyString(context.interactionId);
const legacyQuestionResponse =
@@ -22911,6 +22955,13 @@ export function heartbeatService(
latestRun?.status,
);
if (!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled) {
// Keep launchers during same-run recovery. At a terminal boundary all
// operations have settled; clean before the remote lease can be stopped.
if (githubLauncherLocation && latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
await cleanupGitHubOperationLaunchers(githubLauncherLocation).catch((err) => {
logger.warn({ err, runId: run.id }, "failed to clean managed GitHub launchers");
});
}
if (workFolderSaveFailed && workFolderLeaseId) {
await retainUnsavedWorkFolderLease(db, { id: workFolderLeaseId, companyId: run.companyId }).catch((error) => {
logger.error({ err: error, runId: run.id }, "Could not record work folder retention; lease remains active");
@@ -7,6 +7,7 @@ import {
companies,
documents,
heartbeatRuns,
runIdentityContexts,
issueComments,
issueDocuments,
issueQuestionResponseDeliveries,
@@ -101,6 +102,7 @@ export { extractGitHubPullRequestReferences } from "./github-pull-request-merge.
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
type InteractionActor = {
identityContextId?: string | null;
agentId?: string | null;
runId?: string | null;
userId?: string | null;
@@ -2707,10 +2709,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
}
}
let sourceIdentityContextId: string | null = null;
if (data.sourceRunId) {
const sourceRun = await db
.select({
companyId: heartbeatRuns.companyId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, data.sourceRunId))
@@ -2718,6 +2722,14 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
throw unprocessable("sourceRunId must belong to the same company");
}
sourceIdentityContextId = actor.identityContextId ?? sourceRun.activeIdentityContextId;
if (sourceIdentityContextId) {
const [origin] = await db.select({id: runIdentityContexts.id}).from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, sourceIdentityContextId), eq(runIdentityContexts.companyId, issue.companyId),
eq(runIdentityContexts.runId, data.sourceRunId), eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw unprocessable("Interaction execution identity is unavailable");
}
}
const requiresCurrentTarget =
@@ -2770,6 +2782,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
idempotencyKey: data.idempotencyKey ?? null,
sourceCommentId: data.sourceCommentId ?? null,
sourceRunId: data.sourceRunId ?? null,
sourceIdentityContextId,
title: data.title ?? null,
summary: data.summary ?? null,
createdByAgentId: actor.agentId ?? null,
@@ -3018,6 +3031,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
billingCode: task.billingCode ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
originIdentityContextId: interaction.sourceIdentityContextId ?? null,
originRunId: interaction.sourceRunId ?? null,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.userId ?? null,
} as Parameters<ReturnType<typeof issueService>["createChild"]>[1]);
+2
View File
@@ -3266,6 +3266,8 @@ const issueListSelect = {
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
originFingerprint: issues.originFingerprint,
requestDepth: issues.requestDepth,
billingCode: issues.billingCode,
@@ -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 = {
+217
View File
@@ -0,0 +1,217 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues, runIdentityContexts, type Db } from "@paperclipai/db";
import { conflict, forbidden } from "../errors.js";
export type RunIdentityContext = typeof runIdentityContexts.$inferSelect;
type Executor = Pick<Db, "select" | "insert" | "update">;
/** Match task mutation ordering: lock the task before the run, never the reverse. */
async function lockIdentityTask(executor: Pick<Db, "select">, companyId: string, runId: string) {
const [run] = await executor.select({ context: heartbeatRuns.contextSnapshot, issueId: heartbeatRuns.nativeIssueId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)));
const issueId = run?.context?.issueId ?? run?.context?.taskId ?? run?.issueId;
if (typeof issueId === "string") await executor.select({ id: issues.id }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))).for("update");
}
async function append(executor: Executor, input: {
companyId: string; runId: string; responsibleUserId: string | null;
messageId?: string | null; parentContextId?: string | null; cause: string;
correlationId: string; status?: "accepted" | "pending";
}) {
const [existing] = await executor.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, input.runId), eq(runIdentityContexts.correlationId, input.correlationId),
));
if (existing) return existing;
const [last] = await executor.select().from(runIdentityContexts)
.where(eq(runIdentityContexts.runId, input.runId)).orderBy(desc(runIdentityContexts.revision)).limit(1);
const [created] = await executor.insert(runIdentityContexts).values({
...input, revision: (last?.revision ?? 0) + 1,
acceptedAt: input.status === "pending" ? null : new Date(),
}).returning();
if (!created) throw new Error("Failed to persist run identity");
if (created.status === "accepted") await activate(executor, created);
return created;
}
async function activate(executor: Executor, context: RunIdentityContext) {
const [run] = await executor.update(heartbeatRuns).set({
responsibleUserId: context.responsibleUserId,
activeIdentityContextId: context.id, updatedAt: new Date(),
}).where(and(eq(heartbeatRuns.id, context.runId), eq(heartbeatRuns.companyId, context.companyId))).returning();
const issueId = run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId;
if (typeof issueId === "string") await executor.update(issues).set({ continuationIdentityContextId: context.id })
.where(and(eq(issues.id, issueId), eq(issues.companyId, context.companyId)));
}
/** Called only for newly dispatched runs. Historical rows are never backfilled. */
export async function initializeRunIdentity(db: Db, input: {
companyId: string; runId: string; responsibleUserId: string | null;
messageIds?: string[]; issueId?: string | null; interactionId?: string | null; parentRunId?: string | null; parentContextId?: string | null; cause: string;
}) {
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
)).for("update");
if (!run) throw forbidden("Run identity does not belong to this company");
if (run.activeIdentityContextId) {
const [current] = await tx.select().from(runIdentityContexts).where(eq(runIdentityContexts.id, run.activeIdentityContextId));
return current!;
}
const [parent] = input.parentRunId ? await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.parentRunId), eq(heartbeatRuns.companyId, input.companyId),
)) : [];
const [interaction] = input.interactionId && input.issueId ? await tx.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.id, input.interactionId), eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
)) : [];
const parentId = interaction?.sourceIdentityContextId ?? input.parentContextId ?? parent?.activeIdentityContextId;
const [origin] = parentId ? await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, parentId), eq(runIdentityContexts.companyId, input.companyId),
eq(runIdentityContexts.status, "accepted"),
)) : [];
if (parentId && !origin) throw forbidden("Originating execution identity is unavailable");
let current = await append(tx, {
companyId: input.companyId, runId: input.runId,
responsibleUserId: origin ? origin.responsibleUserId : input.responsibleUserId,
parentContextId: origin?.id ?? null,
cause: origin?.cause === "company_default" ? "company_default" : input.cause, correlationId: "dispatch",
});
const ids = [...new Set(input.messageIds ?? [])];
const comments = ids.length && input.issueId ? await tx.select().from(issueComments).where(and(
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), inArray(issueComments.id, ids),
)) : [];
for (const id of ids) {
const comment = comments.find((c) => c.id === id);
if (!comment?.authorUserId) continue;
current = await append(tx, {
companyId: input.companyId, runId: input.runId, responsibleUserId: comment.authorUserId,
messageId: id, parentContextId: current.id, cause: "instruction", correlationId: `message:${id}`,
});
}
return current;
});
}
/** Caller holds the task and run row locks, in that order. Reserve before delivery so acquisitions cannot guess. */
export async function prepareSteeredIdentity(executor: Executor, input: {
companyId: string; runId: string; messageId: string; issueId: string;
}) {
const [run] = await executor.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
));
const [comment] = await executor.select().from(issueComments).where(and(
eq(issueComments.id, input.messageId), eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
));
if (!run || !comment?.authorUserId) throw forbidden("Steering requires an authenticated message author");
if (run.status !== "running" || (run.contextSnapshot?.issueId !== input.issueId && run.contextSnapshot?.taskId !== input.issueId)) {
throw conflict("Steering targets a different or inactive execution");
}
return append(executor, {
companyId: input.companyId, runId: input.runId, responsibleUserId: comment.authorUserId,
messageId: comment.id, parentContextId: run.activeIdentityContextId,
cause: "steering", correlationId: `message:${comment.id}`, status: "pending",
});
}
export async function reserveSteeredIdentity(db: Db, input: Parameters<typeof prepareSteeredIdentity>[1]) {
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
)).for("update");
// Processes started before the broker rollout keep their original environment.
if (!run?.activeIdentityContextId) return null;
const [pending] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending"),
)).limit(1);
if (pending && pending.messageId !== input.messageId) {
if (await storedSteeringAcknowledgement(tx, pending)) await acceptSteeredIdentity(tx, pending);
else throw conflict("A prior steering acknowledgement must be reconciled first");
}
const [existing] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.messageId, input.messageId),
eq(runIdentityContexts.status, "accepted"),
));
if (existing) return existing;
const context = await prepareSteeredIdentity(tx, input);
if (context.status === "rejected") {
await tx.update(runIdentityContexts).set({ status: "pending" }).where(eq(runIdentityContexts.id, context.id));
return { ...context, status: "pending" };
}
return context;
});
}
export async function rejectSteeredIdentity(db: Db, context: RunIdentityContext) {
await db.update(runIdentityContexts).set({ status: "rejected" }).where(and(
eq(runIdentityContexts.id, context.id), eq(runIdentityContexts.status, "pending"),
));
}
export async function acceptSteeredIdentity(executor: Executor, context: RunIdentityContext) {
// A replay must not reactivate a historical identity after later instructions.
const [accepted] = await executor.update(runIdentityContexts)
.set({ status: "accepted", acceptedAt: new Date() }).where(and(
eq(runIdentityContexts.id, context.id), eq(runIdentityContexts.status, "pending"),
)).returning();
if (accepted) await activate(executor, accepted);
}
export async function captureRunIdentity(db: Db, input: { companyId: string; runId: string; agentId: string }) {
// Lock acquisition serializes with steering delivery and its durable acknowledgement.
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
)).for("update");
if (!run || run.status !== "running") throw forbidden("Credential acquisition requires this agent's active run");
const [pending] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending"),
)).limit(1);
if (pending) {
const acknowledgement = await storedSteeringAcknowledgement(tx, pending);
if (!acknowledgement) throw conflict("Message acceptance is being reconciled; retry credential acquisition");
await acceptSteeredIdentity(tx, pending);
run.activeIdentityContextId = pending.id;
run.responsibleUserId = pending.responsibleUserId;
}
const [context] = run.activeIdentityContextId ? await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, run.activeIdentityContextId), eq(runIdentityContexts.runId, run.id),
)) : [];
return { run, context: context ?? null };
});
}
export async function listRunIdentityContexts(db: Db, companyId: string, runId: string) {
return db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.companyId, companyId), eq(runIdentityContexts.runId, runId),
)).orderBy(asc(runIdentityContexts.revision));
}
/** Late native acknowledgements settle reservations even when the HTTP caller timed out. */
export async function reconcileSteeredIdentity(db: Db, context: RunIdentityContext) {
await db.transaction(async (tx) => {
await lockIdentityTask(tx, context.companyId, context.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, context.runId), eq(heartbeatRuns.companyId, context.companyId),
)).for("update");
if (!run) return;
await acceptSteeredIdentity(tx, context);
});
}
/** Only events validated and persisted by the native control-plane transport count. */
export async function storedSteeringAcknowledgement(executor: Pick<Db, "select">, context: RunIdentityContext) {
if (!context.messageId) return null;
const [receipt] = await executor.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, context.companyId), eq(heartbeatRunEvents.runId, context.runId),
eq(heartbeatRunEvents.eventType, "item.completed"),
sql`${heartbeatRunEvents.sourceEventId} is not null`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind' = 'steering_acknowledgement'`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'itemId' like ${`%:steer:${context.messageId}`}`,
)).limit(1);
const event = receipt?.payload?.prpEvent as { turnId?: string } | undefined;
return typeof event?.turnId === "string" ? { turnId: event.turnId } : null;
}
+18 -3
View File
@@ -1,3 +1,4 @@
import { captureRunIdentity } from "./run-identity.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, ne, sql } from "drizzle-orm";
@@ -2832,7 +2833,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
async function loadBrokerRunContext(input: { companyId: string; agentId: string; runId: string }) {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
const [initialRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
const run = initialRun?.activeIdentityContextId ? (await captureRunIdentity(db, input)).run : initialRun;
if (!run || run.companyId !== input.companyId || run.agentId !== input.agentId) {
throw forbidden("Agent run context does not match the authenticated actor");
}
@@ -2841,7 +2843,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
const snapshot = asRecord(run.contextSnapshot);
const paperclipIssue = asRecord(snapshot.paperclipIssue);
const responsibleUserId = runSnapshotString(snapshot, "responsibleUserId", "responsible_user_id")
const responsibleUserId = run.activeIdentityContextId ? run.responsibleUserId : runSnapshotString(snapshot, "responsibleUserId", "responsible_user_id")
?? runSnapshotString(paperclipIssue, "responsibleUserId", "responsible_user_id")
?? run.responsibleUserId;
if (!responsibleUserId) {
@@ -4195,6 +4197,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
eq(companySecretBindings.targetId, connection.id),
),
);
// A metadata edit or pause/resume must retain declarations for every
// active personal/dedicated grant, not just connection-owned credentials.
const activeGrants = await dbClient.select({ refs: connectionGrants.credentialSecretRefs })
.from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.status, "active"),
));
const rawBindings = [
...connection.credentialRefs.map((ref) => ({
secretId: ref.secretId,
@@ -4204,7 +4214,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
required: true,
label: null,
})),
...[...connection.credentialSecretRefs, ...grantSecretRefs].map((ref) => ({
...[...connection.credentialSecretRefs, ...grantSecretRefs, ...activeGrants.flatMap((grant) => grant.refs)].map((ref) => ({
secretId: ref.secretId,
configPath: ref.configPath,
projectionClass: ref.projectionClass ?? "unclassified",
@@ -13880,6 +13890,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
);
}
if (runContext.run.activeIdentityContextId && (
connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github"
)) {
await fail(409, "Use managed git, gh, or GitHub tools for this run", "denied", "managed_github_invocation_required");
}
const requestedSubject = input.body.subject;
if (requestedSubject?.type === "user" && requestedSubject.userId !== runContext.responsibleUserId) {
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
+8 -1
View File
@@ -86,6 +86,7 @@ export function signToolArguments(args: {
canonicalArguments: string;
approvalSnapshot?: unknown;
executionOnApprove?: boolean;
identityContextId?: string;
signingSecret?: string;
}) {
const payloadValue: Record<string, unknown> = {
@@ -93,6 +94,7 @@ export function signToolArguments(args: {
toolName: args.toolName,
canonicalArguments: args.canonicalArguments,
};
if (args.identityContextId) payloadValue.identityContextId = args.identityContextId;
if (args.executionOnApprove === true) {
payloadValue.executionOnApprove = true;
}
@@ -111,6 +113,7 @@ export function verifyToolArgumentsSignature(input: {
canonicalArguments: string;
approvalSnapshot?: unknown;
executionOnApprove?: boolean;
identityContextId?: string;
signingSecret?: string;
}) {
if (!input.signedArguments) return false;
@@ -127,6 +130,7 @@ export function verifyToolArgumentsSignature(input: {
toolName: input.toolName,
canonicalArguments: input.canonicalArguments,
};
if (input.identityContextId) expectedPayloadValue.identityContextId = input.identityContextId;
if (input.executionOnApprove !== undefined) {
expectedPayloadValue.executionOnApprove = input.executionOnApprove;
}
@@ -146,7 +150,7 @@ export function readSignedToolArgumentsPayload(input: {
invocationId: string;
toolName: string;
signingSecret?: string;
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean } | null {
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean; identityContextId?: string } | null {
if (!input.signedArguments) return null;
let parsed: { payload?: unknown };
try {
@@ -161,6 +165,7 @@ export function readSignedToolArgumentsPayload(input: {
canonicalArguments?: unknown;
approvalSnapshot?: unknown;
executionOnApprove?: unknown;
identityContextId?: unknown;
};
try {
payload = JSON.parse(parsed.payload);
@@ -176,6 +181,7 @@ export function readSignedToolArgumentsPayload(input: {
canonicalArguments: payload.canonicalArguments,
approvalSnapshot: payload.approvalSnapshot,
executionOnApprove: payload.executionOnApprove === true ? true : undefined,
identityContextId: typeof payload.identityContextId === "string" ? payload.identityContextId : undefined,
signingSecret: input.signingSecret,
})) {
return null;
@@ -185,6 +191,7 @@ export function readSignedToolArgumentsPayload(input: {
arguments: JSON.parse(payload.canonicalArguments) as unknown,
...(payload.approvalSnapshot !== undefined ? { approvalSnapshot: payload.approvalSnapshot } : {}),
...(payload.executionOnApprove === true ? { executionOnApprove: true } : {}),
...(typeof payload.identityContextId === "string" ? { identityContextId: payload.identityContextId } : {}),
};
} catch {
return null;
+127 -7
View File
@@ -1,3 +1,6 @@
import { runIdentityContexts } from "@paperclipai/db";
import { captureRunIdentity } from "./run-identity.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
@@ -257,6 +260,8 @@ export interface ToolGatewaySession {
actorId?: string | null;
/** Human whose personal connection grant applies to this execution. */
responsibleUserId?: string | null;
/** Captured by the controller for this request, never accepted from tool arguments. */
identityContextId?: string | null;
createdAt: Date;
expiresAt: Date;
}
@@ -1306,6 +1311,7 @@ export function createToolGatewayService(
projectId: input.session?.projectId ?? null,
runId: input.runId,
gatewaySessionId: input.session?.id ?? null,
identityContextId: input.session?.identityContextId ?? null,
gatewayId: input.session?.gatewayId ?? null,
gatewayPublicId: input.session?.gatewayPublicId ?? null,
gatewayName: input.session?.gatewayName ?? null,
@@ -1450,7 +1456,21 @@ export function createToolGatewayService(
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(toolGatewaySessions.id, row.id));
return gatewaySessionFromRow({ ...row, lastUsedAt: now, updatedAt: now });
const session = gatewaySessionFromRow({ ...row, lastUsedAt: now, updatedAt: now });
return captureSessionIdentity(session);
}
async function captureSessionIdentity(session: ToolGatewaySession): Promise<ToolGatewaySession> {
// Authentication creates a fresh operation snapshot on every invocation.
// Never trust a previously attached context on a reusable transport session.
// Approved operations restore their signed origin after authentication.
if (!session.runId || !session.agentId) return session;
const [run] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId)));
if (!run?.activeIdentityContextId) return session;
const captured = await captureRunIdentity(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId });
return { ...session, identityContextId: captured.context?.id,
responsibleUserId: captured.context?.cause === "company_default" ? null : captured.context?.responsibleUserId };
}
function normalizeGatewayTokenActions(value: unknown): ToolMcpGatewayTokenAction[] {
@@ -1523,9 +1543,10 @@ export function createToolGatewayService(
resultHash: input.resultSummary?.sha256 ?? null,
resultSummary: input.resultSummary ?? null,
resultSizeBytes: input.resultSummary?.sizeBytes ?? null,
metadata: Object.keys(metadata).length > 0 || input.metadata || input.session.projectId
metadata: Object.keys(metadata).length > 0 || input.metadata || input.session.projectId || input.session.identityContextId
? {
...metadata,
identityContextId: input.session.identityContextId ?? null,
gatewayId: input.session.gatewayId ?? null,
gatewayName: input.session.gatewayName ?? null,
projectId: input.session.projectId ?? null,
@@ -1709,6 +1730,7 @@ export function createToolGatewayService(
canonicalArguments,
approvalSnapshot: approvalSnapshot ?? undefined,
executionOnApprove: true,
identityContextId: input.session.identityContextId ?? undefined,
signingSecret: options.toolActionSigningSecret,
});
} catch (error) {
@@ -2017,6 +2039,21 @@ export function createToolGatewayService(
if (!tool) {
throw new ToolGatewayHttpError(404, `Tool "${toolName}" not found`, "tool_not_found", { tool: toolName });
}
if (session.identityContextId && session.agentId && tool.connectionId) {
const [connection] = await db.select().from(toolConnections).where(and(
eq(toolConnections.id, tool.connectionId), eq(toolConnections.companyId, session.companyId),
));
if (connection?.config.sourceTemplateKey === "github" || connection?.transportConfig?.sourceTemplateKey === "github") {
const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, {
agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false,
});
if (!selected.grant) throw new ToolGatewayHttpError(409, selected.error ?? "No GitHub identity connected", "github_identity_unavailable");
const target = connectedTools.find((candidate) => candidate.connectionId === selected.grant!.connectionId
&& candidate.upstreamToolName === tool.upstreamToolName && candidate.providerType === tool.providerType);
if (!target) throw new ToolGatewayHttpError(404, "This GitHub tool is unavailable for the responsible person", "github_tool_unavailable");
return target;
}
}
return tool;
}
@@ -2773,6 +2810,28 @@ export function createToolGatewayService(
}
async function resolveCredentialHeaders(
session: ToolGatewaySession, connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect, resolveOptions: { forceRefresh?: boolean } = {},
): Promise<Record<string, string>> {
const tracked = session.identityContextId && (connection.config.sourceTemplateKey === "github"
|| connection.transportConfig?.sourceTemplateKey === "github");
try {
const headers = await resolveCredentialHeadersUnrecorded(session, connection, grant, resolveOptions);
if (tracked) await db.update(runIdentityContexts).set({ github: {
status: "available", login: grant.providerTenant?.github?.login,
source: grant.kind === "agent" ? "dedicated" : "personal",
} }).where(and(eq(runIdentityContexts.id, session.identityContextId!), eq(runIdentityContexts.companyId, session.companyId)));
return headers;
} catch (error) {
if (tracked) await db.update(runIdentityContexts).set({ github: {
status: "unavailable", reason: "GitHub authorization is unavailable",
source: grant.kind === "agent" ? "dedicated" : "personal",
} }).where(and(eq(runIdentityContexts.id, session.identityContextId!), eq(runIdentityContexts.companyId, session.companyId)));
throw error;
}
}
async function resolveCredentialHeadersUnrecorded(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
@@ -2913,7 +2972,10 @@ export function createToolGatewayService(
connection,
grant,
grantRef,
`credentials.${ref.name}`,
// OAuth grants declare their canonical oauth.* path. Treating
// this header projection as a generic credentials.* binding loses
// the personal secret declaration created by the OAuth callback.
grantRef.configPath.startsWith("oauth.") ? grantRef.configPath : `credentials.${ref.name}`,
);
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
} catch {
@@ -3114,7 +3176,7 @@ export function createToolGatewayService(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
): Promise<typeof connectionGrants.$inferSelect> {
const [run] = session.runId
const [run] = session.runId && !session.identityContextId
? await db.select({
responsibleUserId: heartbeatRuns.responsibleUserId,
invocationSource: heartbeatRuns.invocationSource,
@@ -3123,8 +3185,30 @@ export function createToolGatewayService(
eq(heartbeatRuns.companyId, session.companyId),
)).limit(1)
: [];
const actingUserId = run?.responsibleUserId ?? session.responsibleUserId ?? null;
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
const actingUserId = session.identityContextId ? session.responsibleUserId ?? null : run?.responsibleUserId ?? session.responsibleUserId ?? null;
if (session.identityContextId && session.agentId && (
connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github"
)) {
const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, {
agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false,
});
if (!selected.grant || selected.grant.connectionId !== connection.id) {
throw new ToolGatewayHttpError(409, selected.error ?? "GitHub identity changed; retry through the managed tool", "github_identity_unavailable");
}
if (selected.grant.kind === "user") {
const [member] = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, session.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, selected.grant.subjectUserId!), eq(companyMemberships.status, "active"),
)).limit(1);
if (!member || member.role === "viewer") {
throw new ToolGatewayHttpError(403, "The personal grant owner is not an authorized company member", "grant_owner_membership_inactive");
}
}
// Managed GitHub selection is final: a legacy shared policy cannot replace
// the captured person's grant with an organization or teammate's account.
return selected.grant;
}
const autonomous = !session.identityContextId && (run?.invocationSource === "automation" || run?.invocationSource === "timer");
const findUserGrant = async () => {
if (!actingUserId) return undefined;
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
@@ -4898,7 +4982,7 @@ export function createToolGatewayService(
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
};
await assertNamedGatewayProtocolLimit(session, input.protocolMethod, clientMetadata);
return session;
return captureSessionIdentity(session);
}
/**
@@ -5510,6 +5594,17 @@ export function createToolGatewayService(
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
if (signedPayload.identityContextId) {
const [origin] = await db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, signedPayload.identityContextId),
eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!),
eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw new ToolGatewayHttpError(409, "Approved action identity is unavailable", "identity_context_unavailable");
session.identityContextId = origin.id;
session.responsibleUserId = origin.cause === "company_default" ? null : origin.responsibleUserId;
}
let tool: ToolGatewayDescriptor;
let liveApprovalSnapshot: Awaited<ReturnType<typeof connectedRemoteApprovalSnapshot>>;
try {
@@ -6771,6 +6866,30 @@ export function createToolGatewayService(
let invocationId = String(randomUUID());
const startedAt = Date.now();
// A retry carries the signed originating operation, even if steering has
// since accepted instructions from someone else in this same run.
if (input.approvedActionRequestId) {
const [request] = await db.select().from(toolActionRequests).where(and(
eq(toolActionRequests.id, input.approvedActionRequestId), eq(toolActionRequests.companyId, session.companyId),
));
const [invocation] = request ? await db.select().from(toolInvocations).where(and(
eq(toolInvocations.id, request.invocationId), eq(toolInvocations.companyId, session.companyId),
eq(toolInvocations.runId, session.runId!), eq(toolInvocations.agentId, session.agentId!),
)) : [];
const payload = request && invocation ? readSignedToolArgumentsPayload({
signedArguments: request.signedArguments, invocationId: invocation.id,
toolName: invocation.toolName, signingSecret: options.toolActionSigningSecret,
}) : null;
if (payload?.identityContextId) {
const [origin] = await db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, payload.identityContextId), eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!), eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw new ToolGatewayHttpError(409, "Approved action identity is unavailable", "identity_context_unavailable");
session.identityContextId = origin.id;
session.responsibleUserId = origin.cause === "company_default" ? null : origin.responsibleUserId;
}
}
let tool = await findToolForSession(session, input.tool);
let virtualToolName: string | null = null;
let requestedParameters: unknown = input.parameters ?? {};
@@ -7109,6 +7228,7 @@ export function createToolGatewayService(
canonicalArguments: storedCanonical,
approvalSnapshot: signedPayload.approvalSnapshot,
executionOnApprove: signedPayload.executionOnApprove,
identityContextId: signedPayload.identityContextId,
signingSecret: options.toolActionSigningSecret,
})
) {
@@ -48,7 +48,7 @@ afterEach(async () => {
function serviceCommand() {
// Answers `/api/health` the way a real Paperclip dev runtime does: managed
// publication requires semantic health, not just a 200 (PAP-17572).
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000].filter(q=>q<65536))http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
}
/**
@@ -612,7 +612,8 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
expect(calls).toEqual(["reserve", "expose", "remove"]);
}, 20_000);
it("reaches a terminal failure naming the port and address when a guest still binds the wildcard", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("reaches a terminal failure naming the port and address when a guest still binds the wildcard", async () => {
const { broker, calls } = createBroker();
installDeps({ broker });
@@ -630,7 +631,8 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
expect(calls).toEqual(["reserve", "remove"]);
}, 20_000);
it("explains rather than only coding the failure, so the next operator can act", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("explains rather than only coding the failure, so the next operator can act", async () => {
const { broker } = createBroker();
installDeps({ broker });
@@ -676,7 +678,9 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
serviceName: "paperclip-dev",
command: declared,
expose: { ...LEGACY_HTTP_EXPOSE, tailscaleHttps: false },
port: { type: "auto", envKey: "PORT" },
// This guest opens a second listener at appPort + 10000. An arbitrary
// ephemeral app port can overflow the valid TCP port range.
port: await findFreeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN),
}));
expect(calls).toEqual([]);
@@ -726,7 +730,8 @@ describe("readiness probes loopback for an exposed runtime (PAP-17256)", () => {
});
describe("the deployed failure shape: loopback app port, wildcard HMR (PAP-17256)", () => {
it("fails terminally naming the HMR port, because forcing the bind cannot reach Vite's own listener", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("fails terminally naming the HMR port, because forcing the bind cannot reach Vite's own listener", async () => {
// Plain master's app.ts passes Vite `hmr.port` without `hmr.server` or
// `server.host`, so the HMR websocket binds `::` no matter what the bind mode
// is. The argv rewrite fixes the app port; only the preflight catches this.
+1
View File
@@ -29,6 +29,7 @@ declare global {
keyScope?: AgentApiKeyScope;
runId?: string;
onBehalfOfUserId?: string | null;
identityContextId?: string | null;
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
};
}