fix(heartbeat): validate native MCP gateway ownership (#14914)

## Thinking Path

> - Paperclip lets people manage agents and govern their tool access.
> - Native runs receive an immutable MCP tool assignment for one agent.
> - The gateway must enforce that owner when it authenticates a run
token.
> - Older gateway rows stored the owner only in metadata.
> - This change validates the gateway and profile, binds new rows, and
repairs valid older rows on reuse.
> - It also delivers each native assignment once.
> - Other agents cannot use the assignment, and explicit shared gateways
keep their configured scope.

## Linked Issues or Issue Description

Builds on #14012 by @busla (Jón Levy). That PR adds agent binding and
seven regressions. This PR carries that fix onto current master and adds
legacy authentication, profile validation, and duplicate-delivery
coverage. Related: #14864 improves discovery memory use.

**What happened?**
Native gateway creation stored an owner in metadata but left `agentId`
null. Authentication could therefore accept another agent's run token.
Managed discovery could also deliver historical native assignments
again.

**Expected behavior**
A native assignment accepts only its owner's run token. The gateway and
profile must refer to the same immutable assignment. The current
assignment enters the run configuration once.

**Steps to reproduce**
1. Run the database fixtures in `heartbeat-runtime-mcp-servers.test.ts`
on the baseline.
2. Create a native assignment and inspect its stored gateway owner.
3. Authenticate with another agent's run token, then inspect legacy
reuse and managed delivery.
4. The baseline fails six ownership and delivery cases. The fix passes
all twelve cases.

**Paperclip version or commit**
The red baseline is `f2e0f1963`. This PR is based on `cad26c6bf`, which
includes the merged discovery fix.

**Deployment mode**
Native Paperclip Runner execution and managed Codex MCP delivery.
Reproduction uses isolated database and HTTP fixtures.

## What Changed

- Store the agent owner and agent context on new native gateways.
- Validate profile and gateway assignment metadata before reuse or token
creation.
- Bind valid legacy rows with a company-scoped, null-owner update and
validate the result.
- Reject mismatched run tokens before legacy repair.
- Identify native assignments by gateway metadata, the reserved profile
key, or profile source. Reject missing or malformed provenance,
including JSON null.
- Exclude historical native assignments from managed gateway delivery.
Keep their rows for existing runs.
- Add twelve database and HTTP regressions and document the runtime
contract.

## Verification

- Red baseline: six regressions fail and four controls pass before the
initial fix. Two additional regressions reproduce metadata-loss
admission and a JSON-null TypeError before the review fix.
- All twelve ownership regressions pass on the final code, including
owner admission, cross-agent rejection, metadata loss, JSON-null HTTP
401, and explicit shared-gateway admission. Policy, listing-memory, and
discovery HTTP coverage also passes.
- Full workspace typecheck and build pass locally. Server typecheck and
compilation pass again after the review fix. The final ownership and
grant patches pass 42 combined database and HTTP regressions.
- [Full
CI](https://github.com/paperclipai/paperclip/actions/runs/37011383657)
passes for `626a08ae66361cf586105877e24d806b1a7a9c20`: all 54 checks
succeed; two optional Storybook checks skip. This includes full
typecheck, build, all test shards, all eight E2E shards, runner
verification, and the canary dry run.
- Greptile scores that exact head 5/5. No review threads remain
unresolved.

## Risks

Invalid historical native gateway or profile metadata now rejects
authentication. Valid unbound rows are repaired only when their owner
reuses the assignment. Conflicting owners are never overwritten.
Historical rows are retained for existing runs. Explicit shared gateways
use ordinary profiles and keep their configured scopes. The reserved
native profile namespace remains agent-owned even when gateway metadata
is cleared. No schema or dependency changes are included.

## Model Used

Original fix and seven regressions in #14012: Anthropic Claude Opus 5.5,
`claude-opus-5-5`, 1M context, as reported by @busla. Extensions and
verification: OpenAI Codex (GPT-6), with reasoning, repository
inspection, code execution, and tests. This session does not expose the
exact serving model identifier or context window.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-02 08:22:33 -05:00
1 parent 483dbc8890
commit c46e41e81c
4 files changed
+357 -5

No files matched your search

+23
View File
@@ -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:
@@ -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({
+51 -2
View File
@@ -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,
+40 -2
View File
@@ -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<string | null>`${toolProfiles.metadata}->>'source'`,
agentId: sql<string | null>`${toolProfiles.metadata}->>'agentId'`,
assignmentDigest: sql<string | null>`${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,