diff --git a/doc/MCP-RUNTIME-OPERATIONS.md b/doc/MCP-RUNTIME-OPERATIONS.md index cdeb2474874..9a05b0157a6 100644 --- a/doc/MCP-RUNTIME-OPERATIONS.md +++ b/doc/MCP-RUNTIME-OPERATIONS.md @@ -13,6 +13,29 @@ Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set i | `remote_http` | Supported | Supported | Preferred production path. Paperclip proxies calls through the gateway with policy, audit, timeout, and redaction controls. | | `local_stdio` | Supported through approved templates and supervised runtime slots | Supported only when an explicitly trusted MCP runtime worker/host is configured | Set `PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST` only for a worker that is allowed to supervise local processes. Do not enable arbitrary agent-supplied commands. | +## Native runtime gateway ownership + +Paperclip Runner creates an immutable MCP assignment for one agent. Its gateway +stores the owner in `agentId` and the agent context scope. Authentication checks +that the run belongs to that owner and that the gateway and profile metadata +refer to the same assignment. Invalid ownership or assignment metadata rejects +the token with `gateway_token_run_context_invalid`. + +The reserved `native:` profile key also identifies a native assignment if its +gateway metadata is cleared. Clearing metadata cannot turn the assignment into +a shared gateway. Invalid JSON metadata, including JSON `null`, returns the same +authentication rejection. Use an ordinary profile for an explicit shared gateway. + +Older native gateways can have a null `agentId`. Authentication still checks +their metadata owner. When the owner reuses the assignment, Paperclip validates +the gateway and profile before it binds the gateway to that agent. A conflicting +owner is never overwritten. + +Native assignments enter the run configuration once through the runtime MCP +delivery path. Managed gateway discovery excludes historical native assignments. +Their stored rows remain available for existing runs. Explicit company gateways +continue to use their configured scopes. + ## Metrics The board runtime health API summarizes one-hour event windows plus current durable slot state: diff --git a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts index cd97dd97cbe..930c361da49 100644 --- a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts +++ b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts @@ -1,5 +1,8 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { mcpGatewayProtocolRoutes } from "../routes/tool-gateway.js"; +import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -25,6 +28,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js"; +import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; import { toolAccessService } from "../services/tool-access.js"; @@ -151,6 +155,8 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { const gateways = await db.select().from(toolMcpGateways); expect(gateways).toHaveLength(1); + // The column, not metadata, is what named-gateway auth checks run tokens against. + expect(gateways[0]!.agentId).toBe(agent!.id); expect(gateways[0]!.metadata).toMatchObject({ nativeRuntimeAssignmentDigest: first[0]!.connectionId.slice("assignment:".length), agentId: agent!.id, @@ -199,6 +205,242 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { ).resolves.toBeNull(); }); + async function seedAssignedAgents(count: number) { + process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; + const [company] = await db.insert(companies).values({ + name: `Runtime MCP ${randomUUID()}`, + issuePrefix: `RM${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning(); + const [application] = await db.insert(toolApplications).values({ + companyId: company!.id, + applicationKey: `runtime-${randomUUID().slice(0, 8)}`, + name: "Runtime MCP App", + type: "mcp_http", + status: "active", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: company!.id, + applicationId: application!.id, + name: "Installed MCP", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + config: { url: "https://installed.example.test/mcp" }, + }).returning(); + const [profile] = await db.insert(toolProfiles).values({ + companyId: company!.id, + profileKey: `app:${connection!.id}`, + name: "Installed MCP", + defaultAction: "deny", + }).returning(); + await db.insert(toolProfileEntries).values({ + companyId: company!.id, + profileId: profile!.id, + selectorType: "connection", + effect: "include", + applicationId: application!.id, + connectionId: connection!.id, + }); + const seeded = []; + for (let index = 0; index < count; index += 1) { + const [agent] = await db.insert(agents).values({ + companyId: company!.id, + name: `Runtime MCP Agent ${index}`, + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + }).returning(); + await db.insert(toolProfileBindings).values({ + companyId: company!.id, + profileId: profile!.id, + targetType: "agent", + targetId: agent!.id, + }); + await db.insert(toolConnectionInstalls).values({ + companyId: company!.id, + connectionId: connection!.id, + targetType: "agent", + targetId: agent!.id, + }); + const [run] = await db.insert(heartbeatRuns).values({ + companyId: company!.id, + agentId: agent!.id, + status: "running", + contextSnapshot: {}, + }).returning(); + seeded.push({ agent: agent!, run: run! }); + } + return seeded; + } + + it("rejects another agent's run token on a run-scoped gateway", async () => { + const [owner, other] = await seedAssignedAgents(2); + const [server] = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + const [gateway] = await db.select().from(toolMcpGateways); + const service = createToolGatewayService(db); + const otherToken = await service.createNamedGatewayToken({ + companyId: owner!.agent.companyId, + gatewayId: gateway!.id, + body: { + name: "Other agent run", + subjectType: "heartbeat_run", + subjectId: other!.run.id, + allowedActions: ["tools/list", "tools/call"], + expiresAt: new Date(Date.now() + 60_000), + }, + actor: { agentId: other!.agent.id }, + }); + + await expect( + service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: server!.token }), + ).resolves.toMatchObject({ agentId: owner!.agent.id, runId: owner!.run.id }); + const rejection = await service + .initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: otherToken.token }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(ToolGatewayHttpError); + expect((rejection as ToolGatewayHttpError).reasonCode).toBe("gateway_token_run_context_invalid"); + }); + + it("binds a reused run-scoped gateway that was created without an agent", async () => { + const [owner] = await seedAssignedAgents(1); + await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + // Simulate a gateway provisioned before the agentId column was set. + await db.update(toolMcpGateways).set({ agentId: null }); + + await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + + const gateways = await db.select().from(toolMcpGateways); + expect(gateways).toHaveLength(1); + expect(gateways[0]!.agentId).toBe(owner!.agent.id); + }); + + it("rejects another run on a legacy native gateway before the owner runs again", async () => { + const [owner, other] = await seedAssignedAgents(2); + const [server] = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + const [gateway] = await db.select().from(toolMcpGateways); + await db.update(toolMcpGateways).set({ agentId: null }).where(eq(toolMcpGateways.id, gateway!.id)); + const service = createToolGatewayService(db); + const token = await service.createNamedGatewayToken({ + companyId: owner!.agent.companyId, gatewayId: gateway!.id, + body: { name: "Other run", subjectType: "heartbeat_run", subjectId: other!.run.id, + allowedActions: ["tools/list"], expiresAt: new Date(Date.now() + 60_000) }, + actor: { agentId: other!.agent.id }, + }); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: server!.token })) + .resolves.toMatchObject({ agentId: owner!.agent.id }); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: token.token })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + const app = express().use(express.json()).use(mcpGatewayProtocolRoutes(service)); + const initialize = (bearer: string) => request(app).post(`/mcp/gateways/${gateway!.gatewayPublicId}`) + .set("Authorization", `Bearer ${bearer}`).send({ jsonrpc: "2.0", id: 1, method: "initialize" }); + await initialize(server!.token).expect(200); + await initialize(token.token).expect(401); + await db.update(toolMcpGateways).set({ metadata: { nativeRuntimeAssignmentDigest: "invalid" } }) + .where(eq(toolMcpGateways.id, gateway!.id)); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: server!.token })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + await db.update(toolMcpGateways).set({ metadata: gateway!.metadata }).where(eq(toolMcpGateways.id, gateway!.id)); + await db.update(toolProfiles).set({ metadata: {} }).where(eq(toolProfiles.id, gateway!.profileId)); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: server!.token })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + }); + + it("rejects a legacy native gateway with cleared metadata and omits it from managed discovery", async () => { + const [owner, other] = await seedAssignedAgents(2); + const [server] = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + const [gateway] = await db.select().from(toolMcpGateways); + await db.update(toolMcpGateways).set({ agentId: null, contextScopeType: "none", contextScopeId: null, metadata: {} }) + .where(eq(toolMcpGateways.id, gateway!.id)); + const service = createToolGatewayService(db); + const token = await service.createNamedGatewayToken({ + companyId: owner!.agent.companyId, gatewayId: gateway!.id, + body: { name: "Other run", subjectType: "heartbeat_run", subjectId: other!.run.id, + allowedActions: ["tools/list"], expiresAt: new Date(Date.now() + 60_000) }, + actor: { agentId: other!.agent.id }, + }); + for (const bearerToken of [token.token, server!.token]) { + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + } + for (const { agent, run } of [owner!, other!]) { + await expect(createManagedMcpRunConfig({ db, agent, runId: run.id, config: {}, projectId: null, issueId: null })) + .resolves.toBeNull(); + } + // The immutable profile key still identifies the assignment if its metadata + // is also damaged. Neither authentication nor managed delivery can widen it. + await db.update(toolProfiles).set({ metadata: {} }).where(eq(toolProfiles.id, gateway!.profileId)); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: token.token })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + await expect(createManagedMcpRunConfig({ db, agent: other!.agent, runId: other!.run.id, + config: {}, projectId: null, issueId: null })).resolves.toBeNull(); + }); + + it("rejects JSON null profile metadata with an HTTP authentication response", async () => { + const [owner] = await seedAssignedAgents(1); + const [server] = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + const [gateway] = await db.select().from(toolMcpGateways); + await db.update(toolProfiles).set({ metadata: sql`'null'::jsonb` }).where(eq(toolProfiles.id, gateway!.profileId)); + const service = createToolGatewayService(db); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: gateway!.id, bearerToken: server!.token })) + .rejects.toMatchObject({ reasonCode: "gateway_token_run_context_invalid" }); + const app = express().use(express.json()).use(mcpGatewayProtocolRoutes(service)); + await request(app).post(`/mcp/gateways/${gateway!.gatewayPublicId}`) + .set("Authorization", `Bearer ${server!.token}`).send({ jsonrpc: "2.0", id: 1, method: "initialize" }).expect(401); + }); + + it("delivers native assignments once and preserves explicit shared gateways", async () => { + const [owner, other] = await seedAssignedAgents(2); + const native = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + expect(native).toHaveLength(1); + const [gateway] = await db.select().from(toolMcpGateways); + // All assignment history, including gateways written before owner columns + // existed, belongs to the native delivery path rather than managed config. + await db.update(toolMcpGateways).set({ agentId: null, contextScopeType: "none", contextScopeId: null }); + for (const agent of [owner!.agent, other!.agent]) { + await expect(createManagedMcpRunConfig({ db, agent, runId: owner!.run.id, config: {}, projectId: null, issueId: null })) + .resolves.toBeNull(); + } + const [sharedProfile] = await db.insert(toolProfiles).values({ + companyId: owner!.agent.companyId, profileKey: `shared:${randomUUID()}`, + name: "Explicit shared profile", defaultAction: "deny", + }).returning(); + const [nativeEntry] = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, gateway!.profileId)); + await db.insert(toolProfileEntries).values({ + companyId: owner!.agent.companyId, profileId: sharedProfile!.id, selectorType: "connection", effect: "include", + applicationId: nativeEntry!.applicationId, connectionId: nativeEntry!.connectionId, + }); + const service = createToolGatewayService(db); + const shared = await service.createNamedGateway({ + companyId: owner!.agent.companyId, + body: { name: "Explicit company gateway", slug: `shared-${randomUUID()}`, profileId: sharedProfile!.id, + defaultProfileMode: "gateway_only" }, + actor: { agentId: owner!.agent.id }, + }); + const managed = await createManagedMcpRunConfig({ db, agent: other!.agent, runId: other!.run.id, + config: {}, projectId: null, issueId: null }); + expect(managed?.gateways.map((entry) => entry.id)).toEqual([shared.id]); + const token = await service.createNamedGatewayToken({ + companyId: owner!.agent.companyId, gatewayId: shared.id, + body: { name: "Shared run", subjectType: "heartbeat_run", subjectId: other!.run.id, + allowedActions: ["tools/list"], expiresAt: new Date(Date.now() + 60_000) }, + actor: { agentId: other!.agent.id }, + }); + await expect(service.initializeNamedGatewayProtocol({ gatewayId: shared.id, bearerToken: token.token })) + .resolves.toMatchObject({ agentId: other!.agent.id }); + }); + + it("does not repair a gateway with conflicting ownership metadata", async () => { + const [owner, other] = await seedAssignedAgents(2); + const [server] = await buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id }); + await db.update(toolMcpGateways).set({ agentId: null, + metadata: { nativeRuntimeAssignmentDigest: server!.connectionId.slice("assignment:".length), agentId: other!.agent.id } }); + await expect(buildPaperclipRuntimeMcpServers({ db, agent: owner!.agent, runId: owner!.run.id })) + .rejects.toThrow("Invalid native runtime gateway provenance"); + const [gateway] = await db.select().from(toolMcpGateways); + expect(gateway!.agentId).toBeNull(); + }); + it("preserves exact permissions when an aggregate assignment exceeds the public 250-entry edit limit", async () => { process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; const [company] = await db.insert(companies).values({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 14b8ed35ea4..6deec22e7a7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4764,6 +4764,11 @@ export async function buildPaperclipRuntimeMcpServers(input: { } } + if (profile!.metadata?.source !== "paperclip_runner" || profile!.metadata?.agentId !== input.agent.id || + profile!.metadata?.assignmentDigest !== assignmentDigest) { + throw new Error("Invalid native runtime profile provenance"); + } + let [gateway] = ( await input.db .select() @@ -4777,8 +4782,32 @@ export async function buildPaperclipRuntimeMcpServers(input: { ) ).filter( (candidate) => - candidate.metadata?.nativeRuntimeAssignmentDigest === assignmentDigest, + candidate.metadata?.nativeRuntimeAssignmentDigest === assignmentDigest && + candidate.metadata?.agentId === input.agent.id && + candidate.profileId === profile!.id && + (!candidate.agentId || candidate.agentId === input.agent.id), ); + // Gateways created before the agent binding existed have a null agentId, and + // named-gateway auth only rejects another agent's run token when it is set. + // The assignment digest includes the agent id, so a reused gateway is this + // agent's own: bind it here instead of waiting for the assignment to change. + if (gateway && !gateway.agentId) { + await input.db + .update(toolMcpGateways) + .set({ agentId: input.agent.id, contextScopeType: "agent", contextScopeId: input.agent.id, updatedAt: new Date() }) + .where( + and( + eq(toolMcpGateways.id, gateway.id), + eq(toolMcpGateways.companyId, input.agent.companyId), + isNull(toolMcpGateways.agentId), + ), + ); + [gateway] = await input.db + .select() + .from(toolMcpGateways) + .where(eq(toolMcpGateways.id, gateway.id)) + .limit(1); + } if (!gateway) { const slug = `native-${input.agent.id.replaceAll("-", "").slice(0, 12)}-${assignmentDigest.slice(0, 16)}`; try { @@ -4790,6 +4819,9 @@ export async function buildPaperclipRuntimeMcpServers(input: { description: "Run-scoped Paperclip Runner MCP gateway.", profileId: profile!.id, defaultProfileMode: "gateway_only", + agentId: input.agent.id, + contextScopeType: "agent", + contextScopeId: input.agent.id, metadata: { nativeRuntimeAssignmentDigest: assignmentDigest, agentId: input.agent.id, @@ -4817,6 +4849,12 @@ export async function buildPaperclipRuntimeMcpServers(input: { } } + if (gateway!.agentId !== input.agent.id || gateway!.profileId !== profile!.id || + gateway!.metadata?.agentId !== input.agent.id || + gateway!.metadata?.nativeRuntimeAssignmentDigest !== assignmentDigest) { + throw new Error("Invalid native runtime gateway provenance"); + } + const token = await service.createNamedGatewayToken({ companyId: input.agent.companyId, gatewayId: gateway!.id, @@ -5024,6 +5062,15 @@ export async function createManagedMcpRunConfig(input: { eq(toolMcpGateways.companyId, input.agent.companyId), eq(toolMcpGateways.status, "active"), isNull(toolMcpGateways.archivedAt), + // A native profile remains an immutable assignment even if gateway + // metadata is cleared. Explicit shared gateways use ordinary profiles. + sql`not exists ( + select 1 from ${toolProfiles} + where ${toolProfiles.id} = ${toolMcpGateways.profileId} + and ${toolProfiles.companyId} = ${toolMcpGateways.companyId} + and (${toolProfiles.profileKey} like 'native:%' + or ${toolProfiles.metadata}->>'source' = 'paperclip_runner') + )`, ), ) .orderBy(asc(toolMcpGateways.name)); @@ -5094,7 +5141,9 @@ export async function createManagedMcpRunConfig(input: { ); const applicableGateways = rows.filter((gateway) => - gatewayAppliesToRun({ + // The immutable native assignment is delivered separately. Including any + // historical native gateway here duplicates and broadens that assignment. + !Object.hasOwn(gateway.metadata ?? {}, "nativeRuntimeAssignmentDigest") && gatewayAppliesToRun({ gateway, agentId: input.agent.id, projectId: input.projectId, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index c601c6bc8bb..3dd5d20c24c 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -6992,7 +6992,45 @@ export function createToolGatewayService( clientMetadata, }); } - let agentId = row.gateway.agentId; + const nativeMetadata = row.gateway.metadata; + const [profile] = await db.select({ + profileKey: toolProfiles.profileKey, + status: toolProfiles.status, + source: sql`${toolProfiles.metadata}->>'source'`, + agentId: sql`${toolProfiles.metadata}->>'agentId'`, + assignmentDigest: sql`${toolProfiles.metadata}->>'assignmentDigest'`, + }).from(toolProfiles).where(and( + eq(toolProfiles.id, row.gateway.profileId), + eq(toolProfiles.companyId, row.gateway.companyId), + )).limit(1); + // The immutable native profile also identifies legacy assignments when an + // update has cleared the gateway metadata. Missing metadata must fail closed. + const nativeAssignment = Object.hasOwn(nativeMetadata ?? {}, "nativeRuntimeAssignmentDigest") || + profile?.profileKey.startsWith("native:") || profile?.source === "paperclip_runner"; + const nativeOwner = nativeMetadata?.agentId; + if (nativeAssignment && ( + typeof nativeOwner !== "string" || !uuidPattern.test(nativeOwner) || + typeof nativeMetadata?.nativeRuntimeAssignmentDigest !== "string" || + !/^[a-f0-9]{64}$/.test(nativeMetadata.nativeRuntimeAssignmentDigest) || + (row.gateway.agentId && row.gateway.agentId !== nativeOwner) || + row.token.subjectType !== "heartbeat_run" + )) { + return recordNamedGatewayAuthFailure({ + gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId, + bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata, + }); + } + if (nativeAssignment) { + if (profile?.status !== "active" || profile.source !== "paperclip_runner" || + profile.agentId !== nativeOwner || + profile.assignmentDigest !== nativeMetadata!.nativeRuntimeAssignmentDigest) { + return recordNamedGatewayAuthFailure({ + gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId, + bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata, + }); + } + } + let agentId = row.gateway.agentId ?? (nativeAssignment ? nativeOwner as string : null); let runId: string | null = null; let responsibleUserId: string | null = null; let issueId = row.gateway.issueId; @@ -7036,7 +7074,7 @@ export function createToolGatewayService( clientMetadata, }); } - if (row.gateway.agentId && row.gateway.agentId !== run.agentId) { + if (agentId && agentId !== run.agentId) { return recordNamedGatewayAuthFailure({ gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,