feat(connections): add self-serve intent runtime (#12345)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need a governed way to request app connections during issue
work.
> - The catalog now describes the available providers and setup methods.
> - A request must become a durable, company-scoped intent before an
operator acts on it.
> - This pull request adds that intent runtime across server, agent,
CLI, and shared contracts.
> - The benefit is a safe bridge from agent need to operator-approved
setup.

## Linked Issues or Issue Description

Refs #11965

This is stack 7 of 11. It depends on stack 6 and replaces another
reviewable part of #11965.

## What Changed

- Add connection intent types, validation, service logic, and routes.
- Add agent runtime tools and CLI support for connection requests.
- Add issue-thread interaction support for connection intents.
- Add runtime, route, adapter, and contract tests.
- Hold the final resolved-continuation row lock through asynchronous
adapter preparation until an actual process spawn, so parking or
reassignment cannot cross that boundary.
- Report Hermes Gateway's first remote run request through the shared
dispatch hook so the resolved-intent lock is released at the true
dispatch boundary.
- Revalidate the addressed user's live non-viewer membership and
connection-management authority for every intent mutation, including
OAuth completion.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 176 tests passed.
- `pnpm build`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-stale-queue-invalidation.test.ts` (32 passed;
includes non-process dispatch lock-release coverage)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/connection-intents-service.test.ts -t
"addressed-user mutation"` (1 passed)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/tool-access-service.test.ts -t "binds OAuth
callback completion to the initiating board session"` (1 passed)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter test --
src/gateway/server/execute.test.ts` (23 passed; includes dispatch-hook
ordering and exactly-once coverage)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter typecheck`

## Risks

- A malformed intent could create an unusable operator request.
- Validators and company checks reject invalid or cross-company
requests.
- The final continuation gate holds the issue row lock through adapter
preparation until process or remote dispatch; later operator changes use
the normal active-run interruption path.
- The change does not add a database migration.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## 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 linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change 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-08-29 12:08:34 -05:00
1 parent fcb2e99e8f
commit b3343dbd64
96 files changed
+8695 -971

No files matched your search

@@ -102,6 +102,9 @@ function createApp(db: any, deploymentMode: "authenticated" | "local_trusted" =
app.get("/actor", (req, res) => {
res.json(req.actor);
});
app.post("/mcp/gateways/:gatewayPublicId", (req, res) => {
res.json({ reachedGatewayProtocol: true, actorType: req.actor.type });
});
app.get("/companies/:companyId/protected", (req, res) => {
assertCompanyAccess(req, req.params.companyId);
res.json({ ok: true });
@@ -203,6 +206,31 @@ describe("agent auth middleware", () => {
expect(commentWrites).toBe(0);
});
it("leaves public MCP gateway bearers for the gateway protocol to validate", async () => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const publicId = `gw_${"a".repeat(32)}`;
const res = await request(createApp(db, "local_trusted"))
.post(`/mcp/gateways/${publicId}`)
.set("Authorization", "Bearer pcgw_runtime_token")
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ reachedGatewayProtocol: true });
});
it("does not bypass actor authentication for lookalike MCP gateway paths", async () => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db, "local_trusted"))
.post("/mcp/gateways/not-a-public-id")
.set("Authorization", "Bearer pcgw_runtime_token")
.send({ jsonrpc: "2.0", id: 1, method: "initialize" });
expect(res.status).toBe(401);
expect(res.body.error).toContain("Agent token did not verify");
});
it.each([
["terminated", "Agent is terminated"],
["pending_approval", "Agent is pending approval"],
@@ -0,0 +1,579 @@
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
companyMemberships,
companySecretBindings,
companySecrets,
connectionGrantDelegations,
connectionGrants,
createDb,
goals,
heartbeatRuns,
issueThreadInteractions,
issues,
toolApplications,
toolConnectionInstalls,
toolConnections,
toolProfileBindings,
toolProfiles,
userSecretDefinitions,
} from "@paperclipai/db";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { wakeConnectionIntentAfterResolution } from "../routes/connection-intents.js";
import { connectionIntentService } from "../services/connection-intents.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describe("wakeConnectionIntentAfterResolution", () => {
it("preserves resolved interaction evidence in the queued run snapshot", async () => {
const wakeup = vi.fn().mockResolvedValue(null);
await wakeConnectionIntentAfterResolution(
{ wakeup } as Parameters<typeof wakeConnectionIntentAfterResolution>[0],
{
loaded: {
issue: { id: "issue-1", assigneeAgentId: "agent-1", status: "in_progress" },
interaction: {
id: "interaction-1",
resolvedAt: "2026-08-28T13:30:00.000Z",
},
},
status: "accepted",
actorId: "user-1",
},
);
expect(wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
contextSnapshot: expect.objectContaining({
interactionId: "interaction-1",
interactionKind: "connection_intent",
interactionStatus: "accepted",
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
wakeReason: "issue_commented",
}),
}));
});
});
describeEmbeddedPostgres("connectionIntentService", () => {
let db!: ReturnType<typeof createDb>;
let connectionString!: string;
let cleanup: (() => Promise<void>) | undefined;
let claims!: RuntimeToolsTokenClaims;
let runId!: string;
beforeAll(async () => {
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-connection-intents-");
cleanup = tempDb.cleanup;
connectionString = tempDb.connectionString;
db = createDb(connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Connection tests",
issuePrefix: "CONN",
requireBoardApprovalForNewAgents: false,
});
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: "responsible-user",
status: "active",
membershipRole: "member",
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Connect a service",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Researcher",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Read Notion",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
responsibleUserId: "responsible-user",
contextSnapshot: { issueId },
});
claims = {
sub: agentId,
company_id: companyId,
run_id: runId,
responsible_user_id: "responsible-user",
scope: "connection_intents",
iat: 1,
exp: 2,
instance_id: "test",
};
}, 20_000);
afterAll(async () => {
await cleanup?.();
});
async function waitForBlockedMembershipLock() {
for (let attempt = 0; attempt < 80; attempt += 1) {
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
SELECT EXISTS (
SELECT 1
FROM pg_stat_activity
WHERE state = 'active'
AND wait_event_type = 'Lock'
AND query ILIKE '%company_memberships%'
AND query ILIKE '%for update%'
) AS waiting
`);
if (waiting?.waiting) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return false;
}
it("searches first-party definitions without leaking run identity", async () => {
const result = await connectionIntentService(db).search(claims, "notion");
expect(result.results).toEqual([
expect.objectContaining({ service: "notion", state: "available" }),
]);
const serialized = JSON.stringify(result);
expect(serialized).not.toContain("responsible-user");
expect(serialized).not.toContain(claims.sub);
});
it("creates one addressed request, resolves only after delegation and install, and then reports ready", async () => {
const service = connectionIntentService(db);
const first = await service.request(claims, "notion");
const repeated = await service.request(claims, "notion");
expect(repeated.interactionId).toBe(first.interactionId);
const [row] = await db.select().from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, first.interactionId!));
expect(row).toMatchObject({
kind: "connection_intent",
addresseeUserId: "responsible-user",
sourceRunId: runId,
status: "pending",
});
const [application] = await db.insert(toolApplications).values({
companyId: claims.company_id,
applicationKey: `notion-${randomUUID()}`,
name: "Notion",
type: "mcp_http",
status: "active",
metadata: { sourceTemplateKey: "notion" },
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: claims.company_id,
applicationId: application!.id,
name: "Responsible user's Notion",
uid: `notion/${randomUUID()}`,
transport: "mcp_remote",
authKind: "api_key",
credentialPolicy: "per_user",
status: "active",
enabled: true,
healthStatus: "ok",
config: { sourceTemplateKey: "notion" },
transportConfig: { sourceTemplateKey: "notion" },
}).returning();
const [grant] = await db.insert(connectionGrants).values({
companyId: claims.company_id,
connectionId: connection!.id,
kind: "user",
subjectUserId: claims.responsible_user_id,
status: "active",
isDefault: false,
}).returning();
const [otherAgent] = await db.insert(agents).values({
companyId: claims.company_id,
name: "Existing Notion user",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning();
await db.insert(toolConnectionInstalls).values({
companyId: claims.company_id,
connectionId: connection!.id,
targetType: "agent",
targetId: otherAgent!.id,
});
await expect(service.complete(first.interactionId!, connection!.id, "someone-else"))
.rejects.toThrow("Only the addressed user");
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
expect(await db.select().from(issueThreadInteractions).where(
eq(issueThreadInteractions.id, first.interactionId!),
)).toEqual([expect.objectContaining({ status: "pending" })]);
const resolved = await service.complete(
first.interactionId!,
connection!.id,
claims.responsible_user_id,
);
expect(resolved).toMatchObject({
status: "accepted",
result: { outcome: "connected", connectionId: connection!.id },
});
expect(await db.select().from(connectionGrantDelegations).where(
eq(connectionGrantDelegations.grantId, grant!.id),
)).toEqual([
expect.objectContaining({
agentId: claims.sub,
createdByUserId: claims.responsible_user_id,
}),
]);
const installs = await db.select().from(toolConnectionInstalls).where(
eq(toolConnectionInstalls.connectionId, connection!.id),
);
expect(installs).toHaveLength(2);
expect(installs).toEqual(expect.arrayContaining([
expect.objectContaining({ targetType: "agent", targetId: claims.sub }),
expect.objectContaining({ targetType: "agent", targetId: otherAgent!.id }),
]));
const continuationRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: continuationRunId,
companyId: claims.company_id,
agentId: claims.sub,
status: "running",
responsibleUserId: claims.responsible_user_id,
contextSnapshot: { issueId: row!.issueId },
});
const continuationClaims: RuntimeToolsTokenClaims = {
...claims,
run_id: continuationRunId,
};
const readySearch = await service.search(continuationClaims, "notion");
expect(readySearch.results).toEqual([
expect.objectContaining({ service: "notion", state: "ready", connectionId: connection!.id }),
]);
const readyRequest = await service.request(continuationClaims, "notion");
expect(readyRequest).toMatchObject({
state: "ready",
interactionId: null,
connectionId: connection!.id,
});
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
await expect(service.complete(first.interactionId!, connection!.id, claims.responsible_user_id))
.rejects.toThrow("already resolved");
await expect(service.request(claims, "unknown-service"))
.rejects.toThrow("is not available");
});
it("serializes OAuth intent completion behind addressed-user membership revocation", async () => {
const raceCompanyId = randomUUID();
const raceAgentId = randomUUID();
const raceGoalId = randomUUID();
const raceIssueId = randomUUID();
const raceRunId = randomUUID();
const raceUserId = `race-user-${randomUUID()}`;
await db.insert(companies).values({
id: raceCompanyId,
name: "Connection intent authority race",
issuePrefix: `RACE${randomUUID().slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companyMemberships).values({
companyId: raceCompanyId,
principalType: "user",
principalId: raceUserId,
status: "active",
membershipRole: "member",
});
await db.insert(goals).values({
id: raceGoalId,
companyId: raceCompanyId,
title: "Connect Notion during authority race",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: raceAgentId,
companyId: raceCompanyId,
name: "Race agent",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: raceIssueId,
companyId: raceCompanyId,
goalId: raceGoalId,
title: "Use Notion",
status: "in_progress",
priority: "medium",
assigneeAgentId: raceAgentId,
});
await db.insert(heartbeatRuns).values({
id: raceRunId,
companyId: raceCompanyId,
agentId: raceAgentId,
status: "running",
responsibleUserId: raceUserId,
contextSnapshot: { issueId: raceIssueId },
});
const raceClaims: RuntimeToolsTokenClaims = {
sub: raceAgentId,
company_id: raceCompanyId,
run_id: raceRunId,
responsible_user_id: raceUserId,
scope: "connection_intents",
iat: 1,
exp: 2,
instance_id: "test",
};
const completionDb = createDb(connectionString, { maxConnections: 1 });
const revocationDb = createDb(connectionString, { maxConnections: 1 });
const service = connectionIntentService(completionDb);
const pending = await service.request(raceClaims, "notion");
const [application] = await db.insert(toolApplications).values({
companyId: raceClaims.company_id,
applicationKey: `notion-race-${randomUUID()}`,
name: "Notion",
type: "mcp_http",
status: "active",
metadata: { sourceTemplateKey: "notion" },
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: raceClaims.company_id,
applicationId: application!.id,
name: "Personal Notion OAuth",
uid: `notion-race/${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "shared",
status: "active",
enabled: true,
healthStatus: "ok",
config: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" },
transportConfig: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" },
}).returning();
const [secretDefinition] = await db.insert(userSecretDefinitions).values({
companyId: raceClaims.company_id,
key: `notion-oauth-${randomUUID()}`,
name: "Notion OAuth access token",
}).returning();
const [accessSecret] = await db.insert(companySecrets).values({
companyId: raceClaims.company_id,
scope: "user",
ownerUserId: raceClaims.responsible_user_id,
userSecretDefinitionId: secretDefinition!.id,
key: `notion-oauth-${randomUUID()}`,
name: `Notion OAuth ${randomUUID()}`,
}).returning();
await db.insert(connectionGrants).values({
companyId: raceClaims.company_id,
connectionId: connection!.id,
kind: "user",
subjectUserId: raceClaims.responsible_user_id,
credentialSecretRefs: [{
secretId: accessSecret!.id,
versionSelector: "latest",
configPath: "oauth.access_token",
required: true,
label: "OAuth access token",
}],
status: "active",
isDefault: false,
});
const expectNoOAuthCompletionWrites = async () => {
await expect(db.select().from(toolConnections).where(
eq(toolConnections.id, connection!.id),
)).resolves.toEqual([expect.objectContaining({
credentialPolicy: "shared",
credentialRefs: [],
credentialSecretRefs: [],
status: "active",
enabled: true,
})]);
await expect(db.select().from(companySecretBindings).where(and(
eq(companySecretBindings.companyId, raceClaims.company_id),
eq(companySecretBindings.targetType, "tool_connection"),
eq(companySecretBindings.targetId, connection!.id),
))).resolves.toHaveLength(0);
await expect(db.select().from(toolProfiles).where(and(
eq(toolProfiles.companyId, raceClaims.company_id),
eq(toolProfiles.profileKey, `app:${connection!.id}`),
))).resolves.toHaveLength(0);
await expect(db.select().from(toolProfileBindings).where(
eq(toolProfileBindings.companyId, raceClaims.company_id),
)).resolves.toHaveLength(0);
await expect(db.select().from(toolConnectionInstalls).where(
eq(toolConnectionInstalls.connectionId, connection!.id),
)).resolves.toHaveLength(0);
await expect(db.select().from(connectionGrantDelegations).where(
eq(connectionGrantDelegations.companyId, raceClaims.company_id),
)).resolves.toHaveLength(0);
await expect(db.select().from(issueThreadInteractions).where(
eq(issueThreadInteractions.id, pending.interactionId!),
)).resolves.toEqual([expect.objectContaining({ status: "pending" })]);
};
let releaseRevocation!: () => void;
const revocationMayCommit = new Promise<void>((resolve) => {
releaseRevocation = resolve;
});
let membershipLocked!: () => void;
const membershipIsLocked = new Promise<void>((resolve) => {
membershipLocked = resolve;
});
let revocation: Promise<void> | null = null;
let completion: Promise<{ value: unknown; error: unknown }> | null = null;
try {
revocation = revocationDb.transaction(async (tx) => {
await tx
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, raceClaims.company_id),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, raceClaims.responsible_user_id),
))
.for("update");
membershipLocked();
await revocationMayCommit;
await tx
.update(companyMemberships)
.set({ membershipRole: "viewer", updatedAt: new Date() })
.where(and(
eq(companyMemberships.companyId, raceClaims.company_id),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, raceClaims.responsible_user_id),
));
});
await membershipIsLocked;
completion = service.complete(
pending.interactionId!,
connection!.id,
raceClaims.responsible_user_id,
{ canManageOrganizationGrant: true },
).then(
(value) => ({ value, error: null }),
(error: unknown) => ({ value: null, error }),
);
expect(await waitForBlockedMembershipLock()).toBe(true);
await expectNoOAuthCompletionWrites();
releaseRevocation();
await revocation;
const outcome = await completion;
expect(outcome.value).toBeNull();
expect(outcome.error).toMatchObject({
status: 403,
message: expect.stringContaining("no longer authorized"),
});
await expectNoOAuthCompletionWrites();
} finally {
releaseRevocation();
await revocation?.catch(() => undefined);
await completion?.catch(() => undefined);
await completionDb.$client.end({ timeout: 0 }).catch(() => undefined);
await revocationDb.$client.end({ timeout: 0 }).catch(() => undefined);
}
}, 15_000);
it("revalidates the responsible user's active write membership for every token use", async () => {
const service = connectionIntentService(db);
try {
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(
companyMemberships.principalId,
claims.responsible_user_id,
));
await expect(service.search(claims, "notion"))
.rejects.toThrow("no longer authorized for company write access");
await db.update(companyMemberships).set({ membershipRole: "member", status: "inactive" }).where(eq(
companyMemberships.principalId,
claims.responsible_user_id,
));
await expect(service.request(claims, "notion"))
.rejects.toThrow("no longer authorized for company write access");
} finally {
await db.update(companyMemberships).set({ membershipRole: "member", status: "active" }).where(eq(
companyMemberships.principalId,
claims.responsible_user_id,
));
}
});
it("rejects every addressed-user mutation after write membership is revoked", async () => {
const service = connectionIntentService(db);
const pending = await service.request(claims, "posthog");
expect(pending.interactionId).toBeTruthy();
try {
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(
companyMemberships.principalId,
claims.responsible_user_id,
));
await expect(service.updatePhase(
pending.interactionId!,
"authorizing",
claims.responsible_user_id,
)).rejects.toThrow("no longer authorized for company write access");
await expect(service.decline(
pending.interactionId!,
claims.responsible_user_id,
)).rejects.toThrow("no longer authorized for company write access");
await expect(service.complete(
pending.interactionId!,
randomUUID(),
claims.responsible_user_id,
)).rejects.toThrow("no longer authorized for company write access");
} finally {
await db.update(companyMemberships).set({ membershipRole: "member", status: "active" }).where(eq(
companyMemberships.principalId,
claims.responsible_user_id,
));
}
});
it("rejects cross-company claims and tokens after the run ends", async () => {
const service = connectionIntentService(db);
await expect(service.search({ ...claims, company_id: randomUUID() }, "notion"))
.rejects.toThrow("does not match its heartbeat run");
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
await expect(service.search(claims, "notion"))
.rejects.toThrow("no longer active");
});
});
@@ -10,6 +10,7 @@ import {
authUsers,
companies,
companyMemberships,
connectionGrants,
companySecretBindings,
companySecrets,
companySecretVersions,
@@ -29,7 +30,7 @@ import {
toolProfiles,
toolRuntimeSlots,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
@@ -272,7 +273,7 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) {
}
async function createCompany(db: ReturnType<typeof createDb>) {
return db
const company = await db
.insert(companies)
.values({
name: `Generic MCP ${randomUUID()}`,
@@ -280,6 +281,14 @@ async function createCompany(db: ReturnType<typeof createDb>) {
})
.returning()
.then((rows) => rows[0]!);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: "board-user",
status: "active",
membershipRole: "admin",
});
return company;
}
function createRouteApp(
@@ -349,6 +358,24 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
await tempDb?.cleanup();
});
async function waitForBlockedMembershipUpdate() {
for (let attempt = 0; attempt < 80; attempt += 1) {
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
SELECT EXISTS (
SELECT 1
FROM pg_stat_activity
WHERE state = 'active'
AND wait_event_type = 'Lock'
AND query ILIKE '%company_memberships%'
AND query ILIKE '%for update%'
) AS waiting
`);
if (waiting?.waiting) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return false;
}
it("discovers every tool for a public unknown endpoint without activating the draft", async () => {
installMcpOAuthFixture({ auth: "public" });
const company = await createCompany(db);
@@ -359,7 +386,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
expect(result.auth ?? null).toBeNull();
expect(result.actions.readOnly.map((action) => action.toolName)).toEqual(["list_insights"]);
expect(result.actions.canMakeChanges.map((action) => action.toolName)).toEqual(["create_insight"]);
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: ["write", "destructive"] });
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: [] });
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId));
expect(connection).toMatchObject({ transport: "mcp_remote", authKind: "none", status: "draft" });
@@ -437,6 +464,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
const response = await request(app)
.get("/api/tools/oauth/client-metadata")
.set("Host", "paperclip.example.test")
.expect(422);
expect(response.body).toMatchObject({
@@ -753,6 +781,189 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
.toEqual(["oauth.access_token", "oauth.refresh_token"]);
});
it("completes organization OAuth with a single database connection", async () => {
const fixture = installMcpOAuthFixture({ auth: "oauth" });
const company = await createCompany(db);
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const service = toolAccessService(callbackDb);
let deadline: ReturnType<typeof setTimeout> | null = null;
try {
await callbackDb.execute(sql`select pg_backend_pid()`);
const connected = await service.connectGalleryApp(company.id, {
link: MCP_URL,
name: "Fixture single-pool OAuth",
});
const start = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: REDIRECT_URI,
actor: { actorType: "user", actorId: "board-user" },
});
const authorizationUrl = new URL(start.authorizationUrl);
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
const completed = await Promise.race([
service.completeOAuthCallback({
state: authorizationUrl.searchParams.get("state")!,
code,
iss: ISSUER,
redirectUri: REDIRECT_URI,
actor: { actorType: "user", actorId: "board-user" },
}),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
void callbackDb.$client.end({ timeout: 0 })
.finally(() => reject(new Error("OAuth callback self-deadlocked with maxConnections=1")));
}, 5_000);
}),
]);
expect(completed.connection).toMatchObject({ status: "active", enabled: true });
} finally {
if (deadline) clearTimeout(deadline);
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
}
}, 15_000);
it("rejects organization OAuth completion after the initiating user loses write access", async () => {
const fixture = installMcpOAuthFixture({ auth: "oauth" });
const company = await createCompany(db);
const service = toolAccessService(db);
const actor = { actorType: "user" as const, actorId: "board-user" };
const connected = await service.connectGalleryApp(company.id, {
link: MCP_URL,
name: "Fixture revoked organization OAuth",
});
const start = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: REDIRECT_URI,
actor,
});
await db
.update(companyMemberships)
.set({ membershipRole: "viewer" })
.where(eq(companyMemberships.companyId, company.id));
const authorizationUrl = new URL(start.authorizationUrl);
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
await expect(service.completeOAuthCallback({
state: authorizationUrl.searchParams.get("state")!,
code,
iss: ISSUER,
redirectUri: REDIRECT_URI,
actor,
})).rejects.toMatchObject({
status: 403,
message: expect.stringContaining("membership no longer permits connection changes"),
});
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(connection).toMatchObject({ status: "draft" });
expect(connection!.credentialSecretRefs.some((ref) => ref.configPath === "oauth.access_token")).toBe(false);
expect(connection!.credentialSecretRefs.some((ref) => ref.configPath === "oauth.refresh_token")).toBe(false);
});
it("serializes organization OAuth completion behind membership revocation", async () => {
const fixture = installMcpOAuthFixture({ auth: "oauth" });
const company = await createCompany(db);
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const service = toolAccessService(callbackDb);
const actor = { actorType: "user" as const, actorId: "board-user" };
let releaseRemoval!: () => void;
const removalMayCommit = new Promise<void>((resolve) => {
releaseRemoval = resolve;
});
let membershipLocked!: () => void;
const membershipIsLocked = new Promise<void>((resolve) => {
membershipLocked = resolve;
});
const connected = await service.connectGalleryApp(company.id, {
link: MCP_URL,
name: "Fixture concurrent revocation OAuth",
});
const start = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: REDIRECT_URI,
actor,
});
const authorizationUrl = new URL(start.authorizationUrl);
const code = fixture.issueAuthorizationCode(start.authorizationUrl);
const beforeSecrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
const beforeVersions = await db.select().from(companySecretVersions);
const beforeBindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.companyId, company.id));
const beforeGrants = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, company.id),
eq(connectionGrants.connectionId, connected.connectionId),
));
const removal = removalDb.transaction(async (tx) => {
await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, company.id),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, "board-user"),
)).for("update");
membershipLocked();
await removalMayCommit;
await tx.update(companyMemberships).set({
membershipRole: "viewer",
updatedAt: new Date(),
}).where(and(
eq(companyMemberships.companyId, company.id),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, "board-user"),
));
});
await membershipIsLocked;
const completion = service.completeOAuthCallback({
state: authorizationUrl.searchParams.get("state")!,
code,
iss: ISSUER,
redirectUri: REDIRECT_URI,
actor,
}).then(
(value) => ({ value, error: null }),
(error: unknown) => ({ value: null, error }),
);
try {
expect(await waitForBlockedMembershipUpdate()).toBe(true);
releaseRemoval();
await removal;
const outcome = await completion;
expect(outcome.value).toBeNull();
expect(outcome.error).toMatchObject({
status: 403,
message: expect.stringContaining("membership no longer permits connection changes"),
});
const [connection] = await db.select().from(toolConnections).where(eq(
toolConnections.id,
connected.connectionId,
));
expect(connection).toMatchObject({ status: "draft" });
expect(connection!.credentialSecretRefs).toEqual([]);
await expect(db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id)))
.resolves.toHaveLength(beforeSecrets.length);
await expect(db.select().from(companySecretVersions)).resolves.toHaveLength(beforeVersions.length);
await expect(db.select().from(companySecretBindings).where(eq(companySecretBindings.companyId, company.id)))
.resolves.toHaveLength(beforeBindings.length);
const afterGrants = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, company.id),
eq(connectionGrants.connectionId, connected.connectionId),
));
expect(afterGrants).toEqual(beforeGrants);
} finally {
releaseRemoval();
await removal.catch(() => undefined);
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
await removalDb.$client.end({ timeout: 0 }).catch(() => undefined);
}
}, 15_000);
it("discovers a pathful issuer through the OIDC suffix form too", async () => {
installMcpOAuthFixture({ auth: "oauth", wellKnownStyle: "oidc-suffix" });
const company = await createCompany(db);
@@ -2750,7 +2750,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(heartbeatRuns.id, runId));
await db
.update(issues)
.set({ status: "in_review" })
.set({ status: "in_progress" })
.where(eq(issues.id, issueId));
mockAdapterExecute.mockRejectedValueOnce(
@@ -2837,7 +2837,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue).toEqual({
status: "in_review",
status: "in_progress",
executionRunId: retryRun?.id ?? null,
});
@@ -2986,7 +2986,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(heartbeatRuns.id, runId));
await db
.update(issues)
.set({ status: "in_review" })
.set({ status: "in_progress" })
.where(eq(issues.id, issueId));
const heartbeat = heartbeatService(db);
@@ -3030,7 +3030,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue).toEqual({
status: "in_review",
status: "in_progress",
executionRunId: retryRun?.id ?? null,
});
@@ -3108,7 +3108,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(heartbeatRuns.id, runId));
await db
.update(issues)
.set({ status: "in_review" })
.set({ status: "in_progress" })
.where(eq(issues.id, issueId));
mockAdapterExecute.mockRejectedValueOnce(
@@ -135,7 +135,7 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
expect(first[0]).toMatchObject({
name: "Installed MCP",
connectionId: installedConnection!.id,
url: expect.stringMatching(/^https:\/\/paperclip\.example\.test\/api\/tool-gateway\/gateways\/.+\/mcp$/),
url: expect.stringMatching(/^https:\/\/paperclip\.example\.test\/mcp\/gateways\/gw_[a-f0-9]{32}$/),
token: expect.stringMatching(/^pcgw_/),
});
expect(first.some((server) => server.connectionId === uninstalledConnection!.id)).toBe(false);
@@ -317,7 +317,11 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
});
expect(config?.gateways).toHaveLength(1);
expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name });
expect(config?.gateways[0]).toMatchObject({
id: gateways[0]!.id,
name: gateways[0]!.name,
endpointPath: `/mcp/gateways/${gateways[0]!.gatewayPublicId}`,
});
expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false);
});
});
@@ -414,7 +414,7 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
connectionId: installed!.id,
name: installed!.name,
token: expect.stringMatching(/^pcgw_/),
url: expect.stringContaining("/api/tool-gateway/gateways/"),
url: expect.stringMatching(/\/mcp\/gateways\/gw_[a-f0-9]{32}$/),
});
expect(captured?.mcpServers.some((server) => server.connectionId === uninstalled!.id)).toBe(false);
const bearer = captured?.mcpServers[0]?.token;
@@ -138,6 +138,12 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
let db!: ReturnType<typeof createDb>;
let heartbeat!: ReturnType<typeof heartbeatService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let beforeContinuationDispatchCheck:
| ((input: { runId: string; issueId: string }) => Promise<void>)
| null = null;
let afterContinuationDispatchCheck:
| ((input: { runId: string; issueId: string }) => Promise<void>)
| null = null;
const countExecuteCallsForRun = (runId: string) =>
mockAdapterExecute.mock.calls.filter(([context]) => context?.runId === runId).length;
@@ -145,11 +151,20 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-stale-queue-");
db = createDb(tempDb.connectionString);
heartbeat = heartbeatService(db);
heartbeat = heartbeatService(db, {
beforeResolvedInteractionContinuationDispatchCheck: async (input) => {
await beforeContinuationDispatchCheck?.(input);
},
afterResolvedInteractionContinuationDispatchCheck: async (input) => {
await afterContinuationDispatchCheck?.(input);
},
});
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
beforeContinuationDispatchCheck = null;
afterContinuationDispatchCheck = null;
mockAdapterExecute.mockReset();
mockAdapterExecute.mockImplementation(async () => ({
exitCode: 0,
@@ -331,6 +346,368 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
expect(runRows).toHaveLength(0);
});
it("checks guarded issue status and assignee under the enqueue lock", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const parkedIssueId = randomUUID();
const reassignedIssueId = randomUUID();
await db.insert(issues).values([
{
id: parkedIssueId,
companyId,
title: "Parked connection intent",
status: "backlog" as const,
priority: "medium" as const,
assigneeAgentId: agentId,
},
{
id: reassignedIssueId,
companyId,
title: "Reassigned connection intent",
status: "in_progress" as const,
priority: "medium" as const,
assigneeAgentId: null,
},
]);
for (const issueId of [parkedIssueId, reassignedIssueId]) {
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, interactionId: randomUUID() },
contextSnapshot: { issueId, wakeReason: "issue_commented" },
requestedByActorType: "user",
requestedByActorId: "responsible-user",
issueStateGuard: {
statuses: ["in_progress"],
assigneeAgentId: agentId,
},
});
expect(run).toBeNull();
}
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
expect(await db.select({ status: agentWakeupRequests.status, reason: agentWakeupRequests.reason })
.from(agentWakeupRequests)).toEqual([
{ status: "skipped", reason: "issue_state_guard_mismatch" },
{ status: "skipped", reason: "issue_state_guard_mismatch" },
]);
});
it("cancels a resolved connection-intent wake parked before queued-run claim", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Connection intent parked after enqueue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_commented",
invocationSource: "automation",
contextExtras: {
interactionId: randomUUID(),
interactionKind: "connection_intent",
interactionStatus: "accepted",
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
source: "connection_intent.resolved",
},
});
await db.update(issues).set({ status: "backlog" }).where(eq(issues.id, issueId));
await heartbeat.resumeQueuedRuns();
const [run, wakeup, issue] = await Promise.all([
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
db.select({ status: issues.status }).from(issues).where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
]);
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining("no longer in_progress") });
expect(issue?.status).toBe("backlog");
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("does not re-open a resolved connection-intent issue parked after claim", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Connection intent parked between claim and checkout",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_commented",
invocationSource: "automation",
contextExtras: {
interactionId: randomUUID(),
interactionKind: "connection_intent",
interactionStatus: "accepted",
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
source: "connection_intent.resolved",
},
});
await db.execute(sql.raw(`
CREATE OR REPLACE FUNCTION park_connection_intent_after_claim()
RETURNS trigger AS $trigger$
BEGIN
IF NEW.id = '${runId}'::uuid AND NEW.status = 'running' THEN
UPDATE issues SET status = 'backlog' WHERE id = '${issueId}'::uuid;
END IF;
RETURN NEW;
END;
$trigger$ LANGUAGE plpgsql;
CREATE TRIGGER park_connection_intent_after_claim
AFTER UPDATE OF status ON heartbeat_runs
FOR EACH ROW EXECUTE FUNCTION park_connection_intent_after_claim();
`));
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup, issue] = await Promise.all([
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
db.select({ status: issues.status }).from(issues).where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
]);
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining("no longer in_progress") });
expect(issue?.status).toBe("backlog");
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it.each([
{
mutation: "parked",
expectedErrorCode: "issue_not_in_progress",
expectedError: "no longer in_progress",
},
{
mutation: "reassigned",
expectedErrorCode: "issue_assignee_changed",
expectedError: "changed assignee",
},
])(
"cancels a resolved connection-intent wake $mutation after checkout but before adapter dispatch",
async ({ mutation, expectedErrorCode, expectedError }) => {
const { companyId, agentId } = await seedCompanyAndAgent();
const replacementAgentId = randomUUID();
if (mutation === "reassigned") {
await db.insert(agents).values({
id: replacementAgentId,
companyId,
name: "ReplacementCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
}
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: `Connection intent ${mutation} at final dispatch`,
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_commented",
invocationSource: "automation",
contextExtras: {
interactionId: randomUUID(),
interactionKind: "connection_intent",
interactionStatus: "accepted",
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
source: "connection_intent.resolved",
},
});
beforeContinuationDispatchCheck = async ({ runId: guardedRunId, issueId: guardedIssueId }) => {
expect(guardedRunId).toBe(runId);
expect(guardedIssueId).toBe(issueId);
await db
.update(issues)
.set(mutation === "parked"
? { status: "backlog", updatedAt: new Date() }
: { assigneeAgentId: replacementAgentId, updatedAt: new Date() })
.where(eq(issues.id, issueId));
};
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup, issue] = await Promise.all([
db.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
db.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
]);
expect(run).toMatchObject({ status: "cancelled", errorCode: expectedErrorCode });
expect(wakeup).toMatchObject({ status: "skipped", error: expect.stringContaining(expectedError) });
expect(issue).toMatchObject(mutation === "parked"
? { status: "backlog", assigneeAgentId: agentId }
: { status: "in_progress", assigneeAgentId: replacementAgentId });
expect(countExecuteCallsForRun(runId)).toBe(0);
},
);
it("releases the final continuation gate at non-process adapter dispatch", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Connection intent parked at the atomic dispatch gate",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_commented",
invocationSource: "automation",
contextExtras: {
interactionId: randomUUID(),
interactionKind: "connection_intent",
interactionStatus: "accepted",
interactionResolvedAt: "2026-08-28T13:30:00.000Z",
mutation: "interaction",
source: "connection_intent.resolved",
},
});
const ordering: string[] = [];
let parkPromise: Promise<unknown> | null = null;
afterContinuationDispatchCheck = async ({ runId: guardedRunId, issueId: guardedIssueId }) => {
expect(guardedRunId).toBe(runId);
expect(guardedIssueId).toBe(issueId);
ordering.push("validated");
parkPromise = Promise.resolve(
db
.update(issues)
.set({
status: "backlog",
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issueId))
.returning({ id: issues.id }),
).then((rows) => {
expect(rows).toHaveLength(1);
ordering.push("parked");
});
// Give the concurrent update a chance to reach the row lock. It must
// remain blocked until the adapter reports actual remote dispatch.
await new Promise((resolve) => setTimeout(resolve, 25));
expect(ordering).toEqual(["validated"]);
};
mockAdapterExecute.mockImplementation(async (context) => {
ordering.push("preparing");
// Model asynchronous adapter setup before the child process exists.
await new Promise((resolve) => setTimeout(resolve, 25));
expect(ordering).toEqual(["validated", "preparing"]);
ordering.push("dispatched");
context.onDispatch?.();
await waitForCondition(async () => ordering.includes("parked"));
expect(ordering).toEqual(["validated", "preparing", "dispatched", "parked"]);
ordering.push("settled");
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Atomic continuation dispatch test run.",
provider: "test",
model: "test-model",
};
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
});
await parkPromise;
const issue = await db
.select({ status: issues.status })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("backlog");
expect(ordering).toEqual(["validated", "preparing", "dispatched", "parked", "settled"]);
expect(countExecuteCallsForRun(runId)).toBe(1);
});
it("rate-limits skipped generic timer wakes by advancing the timer baseline", async () => {
const { agentId } = await seedCompanyAndAgent({
heartbeatConfig: {
@@ -123,6 +123,118 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
}
it("creates idempotent, human-addressed connection intents and supersedes older runs", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Connection intent");
const agentId = randomUUID();
const firstRunId = randomUUID();
const secondRunId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Researcher",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: firstRunId,
companyId,
agentId,
status: "running",
responsibleUserId: "user-board",
contextSnapshot: { issueId },
},
{
id: secondRunId,
companyId,
agentId,
status: "running",
responsibleUserId: "user-board",
contextSnapshot: { issueId },
},
]);
const payload = {
version: 1 as const,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
requestingAgentId: agentId,
requestingAgentName: "Researcher",
phase: "requested" as const,
};
const first = await interactionsSvc.createConnectionIntent(
{ id: issueId, companyId },
{
payload,
sourceRunId: firstRunId,
addresseeUserId: "user-board",
idempotencyKey: `connection-intent:${firstRunId}:notion`,
},
);
expect(first).toMatchObject({
kind: "connection_intent",
status: "pending",
continuationPolicy: "wake_assignee",
addresseeUserId: "user-board",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
payload,
});
const repeated = await interactionsSvc.createConnectionIntent(
{ id: issueId, companyId },
{
payload,
sourceRunId: firstRunId,
addresseeUserId: "user-board",
idempotencyKey: `connection-intent:${firstRunId}:notion`,
},
);
expect(repeated.id).toBe(first.id);
const newer = await interactionsSvc.createConnectionIntent(
{ id: issueId, companyId },
{
payload,
sourceRunId: secondRunId,
addresseeUserId: "user-board",
idempotencyKey: `connection-intent:${secondRunId}:notion`,
},
);
const superseded = await interactionsSvc.getById(first.id);
expect(superseded).toMatchObject({
status: "expired",
result: {
version: 1,
outcome: "superseded",
supersededByInteractionId: newer.id,
},
});
const [expiredByComment] = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
{ id: issueId, companyId },
{
id: randomUUID(),
createdAt: new Date(Date.now() + 1_000),
authorUserId: "user-board",
createdByRunId: null,
},
{ userId: "user-board" },
);
expect(expiredByComment).toMatchObject({
id: newer.id,
status: "expired",
result: {
version: 1,
outcome: "expired",
reason: "Superseded by a newer user comment",
},
});
});
it("persists addressees without allowing them to bypass human-only governance", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction");
const creatorAgentId = randomUUID();
@@ -26,6 +26,7 @@ const apiPrefixes: Record<string, string> = {
"companies.ts": "/api/companies",
"company-skills.ts": "/api",
"company-skill-policy.ts": "/api",
"connection-intents.ts": "/api",
"costs.ts": "/api",
"dashboard.ts": "/api",
"decision-queues.ts": "/api",
@@ -108,6 +109,12 @@ function resolveMountedPath(file: string, prefix: string, routePath: string) {
if (file === "tool-gateway.ts" && routePath.startsWith("/mcp/gateways/")) {
return routePath;
}
if (
file === "connection-intents.ts"
&& (routePath.startsWith("/mcp/") || routePath.startsWith("/runtime-tools/"))
) {
return routePath;
}
if ((file === "companies.ts" || file === "health.ts") && routePath === "/") {
return prefix;
}
File diff suppressed because it is too large. Load diff
+128 -8
View File
@@ -40,6 +40,8 @@ import {
toolStdioCommandTemplates,
toolRuntimeSlots,
secretAccessEvents,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
@@ -556,9 +558,11 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(secretAccessEvents);
await db.delete(userSecretDeclarations);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(userSecretDefinitions);
await db.delete(issueThreadInteractions);
await db.delete(heartbeatRuns);
await db.delete(issues);
@@ -1557,6 +1561,7 @@ rl.on("line", (line) => {
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
await createActiveMember(db, company.id, "alice");
await createActiveMember(db, company.id, "bob");
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
const values = {
organization: `organization-${randomUUID()}`,
@@ -1569,16 +1574,18 @@ rl.on("line", (line) => {
provider: "local_encrypted",
value: values.organization,
});
const aliceSecret = await secretService(db).create(company.id, {
name: `Alice stdio token ${randomUUID()}`,
key: `alice_stdio_${randomUUID().replace(/-/g, "")}`,
const secrets = secretService(db);
const identityDefinition = await secrets.createUserSecretDefinition(company.id, {
name: `Personal stdio token ${randomUUID()}`,
key: `personal_stdio_${randomUUID().replace(/-/g, "")}`,
provider: "local_encrypted",
});
const aliceSecret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
definitionId: identityDefinition.id,
value: values.alice,
});
const bobSecret = await secretService(db).create(company.id, {
name: `Bob stdio token ${randomUUID()}`,
key: `bob_stdio_${randomUUID().replace(/-/g, "")}`,
provider: "local_encrypted",
const bobSecret = await secrets.createCurrentUserSecretValue(company.id, "bob", {
definitionId: identityDefinition.id,
value: values.bob,
});
const localTool = await createLocalStdioMcpTool(db, company.id, {
@@ -1611,6 +1618,19 @@ rl.on("line", (line) => {
});
`,
});
await secrets.syncUserSecretDeclarationsForTarget(
company.id,
{ targetType: "tool_connection", targetId: localTool.connection.id },
[{
definitionKey: identityDefinition.key,
configPath: "env.IDENTITY_TOKEN",
envKey: "IDENTITY_TOKEN",
versionSelector: "latest",
required: true,
label: "Personal identity",
}],
{ replaceAll: true },
);
const grantRef = (secretId: string, label: string) => ({
secretId,
versionSelector: "latest" as const,
@@ -1695,6 +1715,94 @@ rl.on("line", (line) => {
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
});
it("uses the signed-in tester's personal grant for Test-tab calls", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const userId = `test-user-${randomUUID()}`;
await createActiveMember(db, company.id, userId);
const personalValue = `personal-${randomUUID()}`;
const definitionKey = `personal_test_${randomUUID().replace(/-/g, "")}`;
const [definition] = await db.insert(userSecretDefinitions).values({
companyId: company.id,
key: definitionKey,
name: `Personal Test token ${randomUUID()}`,
provider: "local_encrypted",
managedMode: "paperclip_managed",
}).returning();
const personalSecret = await secretService(db).createCurrentUserSecretValue(company.id, userId, {
definitionId: definition.id,
value: personalValue,
});
const localTool = await createLocalStdioMcpTool(db, company.id, {
applicationKey: "personal-test-tab",
toolName: "identity",
title: "Personal test identity",
envKeys: ["IDENTITY_TOKEN"],
stdioScript: `
const readline = require("node:readline");
const expected = ${JSON.stringify(personalValue)};
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-identity", version: "0.0.0" } } }) + "\\n");
return;
}
if (message.method === "tools/call") {
const identity = process.env.IDENTITY_TOKEN === expected ? "personal" : "wrong";
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
}
});
`,
});
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(
toolConnections.id,
localTool.connection.id,
));
await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: localTool.connection.id,
kind: "user",
subjectUserId: userId,
credentialSecretRefs: [{
secretId: personalSecret.id,
versionSelector: "latest",
configPath: "env.IDENTITY_TOKEN",
required: true,
label: "Personal identity",
}],
status: "active",
isDefault: false,
});
await secretService(db).syncUserSecretDeclarationsForTarget(
company.id,
{ targetType: "tool_connection", targetId: localTool.connection.id },
[{
definitionKey,
configPath: "env.IDENTITY_TOKEN",
envKey: "IDENTITY_TOKEN",
versionSelector: "latest",
required: true,
label: "Personal identity",
}],
{ replaceAll: true },
);
await allowAllToolsForAgent(db, company.id, agent.id);
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
await expect(gateway.executeTestCall({
companyId: company.id,
connectionId: localTool.connection.id,
agentId: agent.id,
userId,
toolName: "identity",
parameters: {},
})).resolves.toMatchObject({
decision: "allowed",
result: { data: { structuredContent: { identity: "personal" } } },
});
});
it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -1991,7 +2099,14 @@ rl.on("line", (line) => {
toolName: "whoami",
riskLevel: "read",
});
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
// Personal credentials live on grants, so a company-level health probe
// may be unable to authenticate even though this user's grant is valid.
// The cached catalog must remain visible so grant resolution can happen.
await db.update(toolConnections).set({
credentialPolicy: "per_user",
healthStatus: "error",
healthMessage: "This app needs you to sign in.",
}).where(eq(toolConnections.id, connection.id));
await allowAllToolsForAgent(db, company.id, agent.id);
const gateway = createTestToolGatewayService(db);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
@@ -2044,6 +2159,9 @@ rl.on("line", (line) => {
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
expect(result).toMatchObject({ status: "completed", result: { content: "connected" } });
expect(fake.requests).toHaveLength(1);
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
eq(toolConnections.id, connection.id),
)).resolves.toEqual([{ healthStatus: "ok" }]);
} finally {
await fake.close();
}
@@ -4106,6 +4224,7 @@ rl.on("line", (line) => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { project, issue, run } = await createIssueAndRun(db, company.id, agent.id);
await db.update(heartbeatRuns).set({ responsibleUserId: "runtime-owner" }).where(eq(heartbeatRuns.id, run.id));
const profile = await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:runtime_status"]);
const gateway = createTestToolGatewayService(db);
const namedGateway = await gateway.createNamedGateway({
@@ -4140,6 +4259,7 @@ rl.on("line", (line) => {
runId: run.id,
issueId: issue.id,
projectId: project.id,
responsibleUserId: "runtime-owner",
});
await request(app)
+59
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "@paperclipai/shared";
import { execute } from "./execute.js";
afterEach(() => {
@@ -6,6 +7,64 @@ afterEach(() => {
});
describe("http adapter execute", () => {
it("delivers the complete runtime connection descriptor and shared guidance", async () => {
const onDispatch = vi.fn();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
expect(onDispatch).toHaveBeenCalledOnce();
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
expect(body.paperclipRuntimeTools).toEqual({
version: 1,
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
mcpEndpoint: "https://paperclip.test/mcp/runtime-tools",
rest: {
connectionsSearch: "https://paperclip.test/runtime-tools/connections/search",
connectionRequest: "https://paperclip.test/runtime-tools/connections/request",
},
bearerToken: "run-token",
expiresAt: "2026-08-26T15:00:00.000Z",
tools: ["connections_search", "connection_request"],
});
return new Response(null, { status: 204 });
});
vi.stubGlobal("fetch", fetchMock);
await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Agent",
adapterType: "http",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: { url: "https://example.test/webhook" },
context: {},
runtimeTools: {
version: 1,
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
mcpEndpoint: "https://paperclip.test/mcp/runtime-tools",
rest: {
connectionsSearch: "https://paperclip.test/runtime-tools/connections/search",
connectionRequest: "https://paperclip.test/runtime-tools/connections/request",
},
bearerToken: "run-token",
expiresAt: "2026-08-26T15:00:00.000Z",
tools: ["connections_search", "connection_request"],
},
onLog: async () => {},
onDispatch,
});
expect(fetchMock).toHaveBeenCalledOnce();
expect(onDispatch).toHaveBeenCalledOnce();
});
it("reports configured request timeout as timed_out", async () => {
vi.stubGlobal(
"fetch",
+11 -1
View File
@@ -10,12 +10,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const timeoutMs = asNumber(config.timeoutMs, 0);
const headers = parseObject(config.headers) as Record<string, string>;
const payloadTemplate = parseObject(config.payloadTemplate);
const body = { ...payloadTemplate, agentId: agent.id, runId, context };
const body = {
...payloadTemplate,
agentId: agent.id,
runId,
context,
...(ctx.runtimeTools ? { paperclipRuntimeTools: ctx.runtimeTools } : {}),
};
const controller = new AbortController();
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
// HTTP adapters have no child-process spawn event. Signal immediately
// before starting the remote request so dispatch gates can release without
// waiting for the endpoint to respond.
ctx.onDispatch?.();
const res = await fetch(url, {
method,
headers: {
+1
View File
@@ -4,6 +4,7 @@ import { testEnvironment } from "./test.js";
export const httpAdapter: ServerAdapterModule = {
type: "http",
runtimeToolDelivery: "invocation_context",
execute,
testEnvironment,
models: [],
+2
View File
@@ -20,6 +20,8 @@ export type {
AdapterRuntimeEvent,
AdapterRuntimeMcpServer,
AdapterRuntimeMcpAccess,
AdapterRuntimeToolAccess,
AdapterRuntimeToolDelivery,
AdapterModelProfileDefinition,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentCheck,
+2
View File
@@ -5,6 +5,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
buildRuntimeToolsEnv,
isForbiddenConfigEnvKey,
isPaperclipRuntimeEnvKey,
buildInvocationEnvForLogs,
@@ -23,6 +24,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v !== "string") continue;
+1
View File
@@ -4,6 +4,7 @@ import { testEnvironment } from "./test.js";
export const processAdapter: ServerAdapterModule = {
type: "process",
runtimeToolDelivery: "environment",
execute,
testEnvironment,
models: [],
+35 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils";
import { requireServerAdapter } from "./registry.js";
import { listServerAdapters, requireServerAdapter } from "./registry.js";
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
// The registry registers a login capability for the two built-in interactive
// adapters. The test checks the scalar values and the presence of the required
@@ -45,3 +46,36 @@ describe("built-in adapter login capabilities", () => {
expect(() => assertValidAdapterLoginCapability(capability, "claude_local")).not.toThrow();
});
});
describe("built-in runtime connection tool delivery", () => {
const expectedStrategies = new Map([
["acpx_local", "environment"],
["claude_local", "native_mcp"],
["codex_local", "native_mcp"],
["cursor_cloud", "invocation_context"],
["cursor", "environment"],
["gemini_local", "environment"],
["grok_local", "environment"],
["hermes_gateway", "invocation_context"],
["hermes_local", "environment"],
["kimi_local", "environment"],
["openclaw_gateway", "invocation_context"],
["opencode_local", "environment"],
["paperclip_runner", "environment"],
["pi_local", "environment"],
["process", "environment"],
["http", "invocation_context"],
] as const);
it("requires every built-in adapter to declare its expected delivery strategy", () => {
const builtIns = listServerAdapters().filter((adapter) => BUILTIN_ADAPTER_TYPES.has(adapter.type));
expect(new Set(builtIns.map((adapter) => adapter.type))).toEqual(BUILTIN_ADAPTER_TYPES);
expect(new Map(builtIns.map((adapter) => [adapter.type, adapter.runtimeToolDelivery]))).toEqual(
expectedStrategies,
);
});
it.each([...expectedStrategies])("delivers %s runtime tools through %s", (type, strategy) => {
expect(requireServerAdapter(type).runtimeToolDelivery).toBe(strategy);
});
});
+20 -2
View File
@@ -256,6 +256,7 @@ const grokLoginCapability: AdapterLoginCapability = {
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
runtimeToolDelivery: "native_mcp",
execute: stampClaudeAgentIdHeader(claudeExecute),
testEnvironment: claudeTestEnvironment,
acp: {
@@ -288,6 +289,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
const acpxLocalAdapter: ServerAdapterModule = {
type: "acpx_local",
runtimeToolDelivery: "environment",
async execute(ctx) {
await ctx.onLog("stderr", `${retiredAcpxMessage}\n`);
await ctx.onMeta?.({
@@ -330,6 +332,7 @@ const acpxLocalAdapter: ServerAdapterModule = {
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
runtimeToolDelivery: "native_mcp",
execute: codexExecute,
testEnvironment: codexTestEnvironment,
acp: {
@@ -361,6 +364,7 @@ const codexLocalAdapter: ServerAdapterModule = {
const paperclipRunnerAdapter: ServerAdapterModule = {
type: "paperclip_runner",
runtimeToolDelivery: "environment",
async execute(ctx) {
const message = "paperclip_runner requires the native runner coordinator";
await ctx.onLog("stderr", `${message}\n`);
@@ -407,6 +411,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
const cursorLocalAdapter: ServerAdapterModule = {
type: "cursor",
runtimeToolDelivery: "environment",
execute: cursorExecute,
testEnvironment: cursorTestEnvironment,
listSkills: listCursorSkills,
@@ -426,6 +431,7 @@ const cursorLocalAdapter: ServerAdapterModule = {
const cursorCloudAdapter: ServerAdapterModule = {
type: "cursor_cloud",
runtimeToolDelivery: "invocation_context",
execute: cursorCloudExecute,
testEnvironment: cursorCloudTestEnvironment,
sessionCodec: cursorCloudSessionCodec,
@@ -441,6 +447,7 @@ const cursorCloudAdapter: ServerAdapterModule = {
const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
runtimeToolDelivery: "environment",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
acp: {
@@ -469,6 +476,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
const grokLocalAdapter: ServerAdapterModule = {
type: "grok_local",
runtimeToolDelivery: "environment",
execute: grokExecute,
testEnvironment: grokTestEnvironment,
listSkills: listGrokSkills,
@@ -491,6 +499,7 @@ const grokLocalAdapter: ServerAdapterModule = {
const kimiLocalAdapter: ServerAdapterModule = {
type: "kimi_local",
runtimeToolDelivery: "environment",
execute: kimiExecute,
testEnvironment: kimiTestEnvironment,
acp: {
@@ -515,12 +524,19 @@ const kimiLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: kimiAgentConfigurationDoc,
};
const hermesGatewayAdapter = createHermesGatewayServerAdapter();
const hermesGatewayAdapter: ServerAdapterModule = {
...createHermesGatewayServerAdapter(),
runtimeToolDelivery: "invocation_context",
};
const hermesLocalAdapter = createHermesLocalServerAdapter();
const hermesLocalAdapter: ServerAdapterModule = {
...createHermesLocalServerAdapter(),
runtimeToolDelivery: "environment",
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
runtimeToolDelivery: "invocation_context",
execute: openclawGatewayExecute,
testEnvironment: openclawGatewayTestEnvironment,
models: openclawGatewayModels,
@@ -532,6 +548,7 @@ const openclawGatewayAdapter: ServerAdapterModule = {
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
runtimeToolDelivery: "environment",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
listSkills: listOpenCodeSkills,
@@ -551,6 +568,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
runtimeToolDelivery: "environment",
execute: piExecute,
testEnvironment: piTestEnvironment,
listSkills: listPiSkills,
+2
View File
@@ -9,6 +9,8 @@ export type {
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
AdapterRuntimeToolAccess,
AdapterRuntimeToolDelivery,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentCheck,
AdapterEnvironmentTestStatus,
+1
View File
@@ -29,6 +29,7 @@ export const resolvePathValue = serverUtils.resolvePathValue;
export const renderTemplate = serverUtils.renderTemplate;
export const redactEnvForLogs = serverUtils.redactEnvForLogs;
export const buildPaperclipEnv = serverUtils.buildPaperclipEnv;
export const buildRuntimeToolsEnv = serverUtils.buildRuntimeToolsEnv;
export const isPaperclipRuntimeEnvKey = serverUtils.isPaperclipRuntimeEnvKey;
export const isForbiddenConfigEnvKey = serverUtils.isForbiddenConfigEnvKey;
export const defaultPathForPlatform = serverUtils.defaultPathForPlatform;
+14
View File
@@ -82,6 +82,10 @@ import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
import {
connectionIntentBoardRoutes,
runtimeConnectionIntentRoutes,
} from "./routes/connection-intents.js";
import { adapterRoutes } from "./routes/adapters.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
@@ -100,6 +104,7 @@ import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
import { pluginJobStore } from "./services/plugin-job-store.js";
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
import { createToolGatewayService } from "./services/tool-gateway.js";
import { heartbeatService } from "./services/heartbeat.js";
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
@@ -356,6 +361,10 @@ export async function createApp(
bindHost: opts.bindHost,
}),
);
// Connection-intent tools carry their own short-lived, run-bound bearer and
// must be reachable by remote adapters that intentionally do not receive an
// agent API key. Every request revalidates the active heartbeat row.
app.use(runtimeConnectionIntentRoutes(db));
app.use(
actorMiddleware(db, {
deploymentMode: opts.deploymentMode,
@@ -574,13 +583,18 @@ export async function createApp(
approveToolActionRequest: (input) => toolGateway.approveActionRequest(input),
}));
app.use(mcpGatewayProtocolRoutes(toolGateway));
const connectionIntentHeartbeat = heartbeatService(db, {
pluginWorkerManager: workerManager,
});
api.use(toolAccessRoutes(db, {
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
authPublicBaseUrl: opts.authPublicBaseUrl,
trustedLocalStdioRuntimeHost,
toolGateway,
connectionIntentHeartbeat,
}));
api.use(connectionIntentBoardRoutes(db, connectionIntentHeartbeat));
api.use(smokeLabRoutes(db, {
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
+15
View File
@@ -211,6 +211,8 @@ interface ActorMiddlewareOptions {
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
}
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
const boardAuth = boardAuthService(db);
return async (req, _res, next) => {
@@ -230,6 +232,19 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
const authHeader = req.header("authorization");
const hasBearerCredentials = /^bearer(?:\s|$)/i.test(authHeader ?? "");
// Public MCP gateway protocol requests carry a pcgw_* bearer that is
// validated by the gateway service itself. Do not interpret that bearer as
// a board key or agent JWT here: doing so rejects the MCP handshake before
// the protocol route can verify its run-scoped credential. Keep this bypass
// restricted to the unguessable public gateway path; all /api routes retain
// the normal actor authentication path below.
if (hasBearerCredentials && publicMcpGatewayProtocolPath.test(req.path)) {
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
if (!hasBearerCredentials) {
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
const cloudTenantActor = await resolveCloudTenantActor(db, req);
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import {
CONNECTION_REQUEST_TOOL_DESCRIPTION,
CONNECTION_RUNTIME_TOOL_NAMES,
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
} from "@paperclipai/shared";
import {
RUNTIME_CONNECTION_TOOL_DEFINITIONS,
wakeConnectionIntentAfterResolution,
} from "./connection-intents.js";
describe("runtime connection MCP contract", () => {
it("advertises both canonical tools with the shared descriptions and narrow schemas", () => {
expect(
RUNTIME_CONNECTION_TOOL_DEFINITIONS.map((tool) => tool.name),
).toEqual(CONNECTION_RUNTIME_TOOL_NAMES);
expect(RUNTIME_CONNECTION_TOOL_DEFINITIONS).toEqual([
{
name: "connections_search",
description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
additionalProperties: false,
},
},
{
name: "connection_request",
description: CONNECTION_REQUEST_TOOL_DESCRIPTION,
inputSchema: {
type: "object",
properties: { service: { type: "string" } },
required: ["service"],
additionalProperties: false,
},
},
]);
});
it("does not accept run identity, task identity, users, or credentials from tool input", () => {
const serialized = JSON.stringify(
RUNTIME_CONNECTION_TOOL_DEFINITIONS.map(
(definition) => definition.inputSchema,
),
);
expect(serialized).not.toMatch(
/companyId|agentId|runId|issueId|responsibleUserId|credential|token/i,
);
});
});
describe("connection intent continuation wake contract", () => {
it.each([
["accepted", "connected"],
["rejected", "declined"],
])(
"emits one idempotent continuation wake for %s intents",
async (status) => {
const wakeup = vi.fn().mockResolvedValue(undefined);
await wakeConnectionIntentAfterResolution({ wakeup } as never, {
loaded: {
issue: {
id: "issue-123",
assigneeAgentId: "agent-123",
status: "in_progress",
},
interaction: { id: "interaction-123" },
},
status,
actorId: "user-123",
});
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(
"agent-123",
expect.objectContaining({
idempotencyKey: `interaction:interaction-123:${status}`,
requestedByActorType: "user",
requestedByActorId: "user-123",
contextSnapshot: expect.objectContaining({
issueId: "issue-123",
interactionId: "interaction-123",
interactionStatus: status,
forceFreshSession: true,
}),
}),
);
},
);
it.each(["backlog", "todo", "in_review", "done", "blocked", "cancelled"])(
"does not wake a parked or closed %s task",
async (issueStatus) => {
const wakeup = vi.fn().mockResolvedValue(undefined);
await wakeConnectionIntentAfterResolution({ wakeup } as never, {
loaded: {
issue: {
id: "issue-closed",
assigneeAgentId: "agent-123",
status: issueStatus,
},
interaction: { id: "interaction-123" },
},
status: "accepted",
actorId: "user-123",
});
expect(wakeup).not.toHaveBeenCalled();
},
);
});
+303
View File
@@ -0,0 +1,303 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
CONNECTION_REQUEST_TOOL_DESCRIPTION,
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
completeConnectionIntentSchema,
connectionRequestInputSchema,
connectionsSearchInputSchema,
declineConnectionIntentSchema,
} from "@paperclipai/shared";
import { forbidden, unauthorized } from "../errors.js";
import { verifyRuntimeToolsToken } from "../runtime-tools-token.js";
import { connectionIntentService } from "../services/connection-intents.js";
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";
function bearer(req: Request) {
const value = req.header("authorization") ?? "";
return /^Bearer\s+/i.test(value) ? value.replace(/^Bearer\s+/i, "").trim() : "";
}
function runtimeClaims(req: Request) {
const claims = verifyRuntimeToolsToken(bearer(req));
if (!claims) throw unauthorized("Runtime tools token is missing, invalid, or expired");
return claims;
}
function resultContent(value: unknown) {
return {
content: [{ type: "text", text: JSON.stringify(value) }],
structuredContent: value,
};
}
export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [
{
name: "connections_search",
description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
additionalProperties: false,
},
},
{
name: "connection_request",
description: CONNECTION_REQUEST_TOOL_DESCRIPTION,
inputSchema: {
type: "object",
properties: { service: { type: "string" } },
required: ["service"],
additionalProperties: false,
},
},
] as const;
/** Public, token-authenticated routes mounted before the general actor middleware. */
export function runtimeConnectionIntentRoutes(db: Db) {
const router = Router();
const service = connectionIntentService(db);
router.get("/mcp/runtime-tools", async (req, res) => {
await service.validate(runtimeClaims(req));
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
});
router.post("/mcp/runtime-tools", async (req, res) => {
const claims = runtimeClaims(req);
// Streamable HTTP lifecycle calls are token uses too. Revalidate the bound
// run before initialize/list as well as before an actual tool call so an
// ended heartbeat cannot keep probing the endpoint with a once-valid token.
await service.validate(claims);
const request = req.body as { jsonrpc?: string; id?: unknown; method?: string; params?: unknown };
const id = request.id ?? null;
if (request.method === "initialize") {
res.json({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "paperclip-runtime-tools", version: "1" },
},
});
return;
}
if (request.method === "notifications/initialized") {
res.status(202).end();
return;
}
if (request.method === "tools/list") {
res.json({
jsonrpc: "2.0",
id,
result: {
tools: RUNTIME_CONNECTION_TOOL_DEFINITIONS,
},
});
return;
}
if (request.method === "tools/call") {
const params = request.params && typeof request.params === "object"
? request.params as { name?: unknown; arguments?: unknown }
: {};
const name = typeof params.name === "string" ? params.name : "";
if (name === "connections_search") {
const input = connectionsSearchInputSchema.parse(params.arguments ?? {});
const result = await service.search(claims, input.query);
res.json({ jsonrpc: "2.0", id, result: resultContent(result) });
return;
}
if (name === "connection_request") {
const input = connectionRequestInputSchema.parse(params.arguments ?? {});
const result = await service.request(claims, input.service);
res.json({ jsonrpc: "2.0", id, result: resultContent(result) });
return;
}
res.status(404).json({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Unknown tool: ${name || "missing"}` },
});
return;
}
res.status(404).json({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Unknown method: ${request.method ?? "missing"}` },
});
});
router.post("/runtime-tools/connections/search", async (req, res) => {
const input = connectionsSearchInputSchema.parse(req.body ?? {});
res.json(await service.search(runtimeClaims(req), input.query));
});
router.post("/runtime-tools/connections/request", async (req, res) => {
const input = connectionRequestInputSchema.parse(req.body ?? {});
res.json(await service.request(runtimeClaims(req), input.service));
});
return router;
}
type Heartbeat = ReturnType<typeof heartbeatService>;
export async function wakeConnectionIntentAfterResolution(
heartbeat: Pick<Heartbeat, "wakeup">,
input: {
loaded: {
issue: { id: string; assigneeAgentId: string | null; status: string };
interaction: { id: string; resolvedAt?: string | Date | null };
};
status: string;
actorId: string;
},
) {
const agentId = input.loaded.issue.assigneeAgentId;
if (!agentId || input.loaded.issue.status !== "in_progress") return;
const resolvedAt = input.loaded.interaction.resolvedAt;
const interactionResolvedAt = resolvedAt instanceof Date ? resolvedAt.toISOString() : resolvedAt;
await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: input.loaded.issue.id,
interactionId: input.loaded.interaction.id,
interactionKind: "connection_intent",
interactionStatus: input.status,
mutation: "interaction",
},
idempotencyKey: `interaction:${input.loaded.interaction.id}:${input.status}`,
requestedByActorType: "user",
requestedByActorId: input.actorId,
contextSnapshot: {
issueId: input.loaded.issue.id,
taskId: input.loaded.issue.id,
interactionId: input.loaded.interaction.id,
interactionKind: "connection_intent",
interactionStatus: input.status,
mutation: "interaction",
wakeReason: "issue_commented",
source: "connection_intent.resolved",
...(interactionResolvedAt
? { interactionResolvedAt }
: {}),
forceFreshSession: true,
},
issueStateGuard: {
statuses: ["in_progress"],
assigneeAgentId: agentId,
},
});
}
export function connectionIntentBoardRoutes(db: Db, heartbeat: Heartbeat) {
const router = Router();
const service = connectionIntentService(db);
const access = accessService(db);
function bypassCurrentMembershipCheck(req: Request) {
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true;
}
async function canManageCompanyConnections(req: Request, companyId: string) {
if (bypassCurrentMembershipCheck(req)) return true;
return Boolean(req.actor.userId && await access.hasPermission(
companyId,
"user",
req.actor.userId,
"tools:manage_connections",
));
}
async function addressedIntent(req: Request) {
assertBoard(req);
const loaded = await service.loadIntent(req.params.interactionId as string);
assertCompanyAccess(req, loaded.issue.companyId);
const userId = req.actor.userId ?? "local-board";
if (loaded.interaction.addresseeUserId !== userId) {
throw forbidden("Only the addressed user can act on this connection request");
}
return { loaded, userId };
}
async function wakeAfterResolution(input: {
loaded: Awaited<ReturnType<typeof service.loadIntent>>;
status: string;
actorId: string;
}) {
// The operator may park or reassign the issue while the connection work
// and activity write are in flight. Re-read immediately before enqueueing
// so the wake decision is not made from addressedIntent's stale snapshot.
const current = await service.loadIntent(input.loaded.interaction.id);
await wakeConnectionIntentAfterResolution(heartbeat, {
...input,
loaded: current,
});
}
router.get("/connection-intents/:interactionId/setup-options", async (req, res) => {
await addressedIntent(req);
res.json(await service.setupOptions(req.params.interactionId as string));
});
router.post("/connection-intents/:interactionId/phase", async (req, res) => {
const { userId } = await addressedIntent(req);
const phase = req.body?.phase;
if (phase !== "requested" && phase !== "authorizing" && phase !== "needs_retry") {
res.status(422).json({ error: "phase must be requested, authorizing, or needs_retry" });
return;
}
res.json(await service.updatePhase(req.params.interactionId as string, phase, userId, {
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
}));
});
router.post("/connection-intents/:interactionId/complete", async (req, res) => {
const { loaded, userId } = await addressedIntent(req);
const input = completeConnectionIntentSchema.parse(req.body);
const interaction = await service.complete(loaded.interaction.id, input.connectionId, userId, {
canManageOrganizationGrant: await canManageCompanyConnections(req, loaded.issue.companyId),
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
await logActivity(db, {
companyId: loaded.issue.companyId,
actorType: "user",
actorId: userId,
action: "issue.connection_intent_connected",
entityType: "issue",
entityId: loaded.issue.id,
details: {
interactionId: interaction.id,
connectionId: interaction.result?.connectionId ?? null,
requestingAgentId: interaction.payload.requestingAgentId,
},
});
await wakeAfterResolution({ loaded, status: interaction.status, actorId: userId });
res.json(interaction);
});
router.post("/connection-intents/:interactionId/decline", async (req, res) => {
const { loaded, userId } = await addressedIntent(req);
const input = declineConnectionIntentSchema.parse(req.body ?? {});
const interaction = await service.decline(loaded.interaction.id, userId, input.reason, {
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
await logActivity(db, {
companyId: loaded.issue.companyId,
actorType: "user",
actorId: userId,
action: "issue.connection_intent_declined",
entityType: "issue",
entityId: loaded.issue.id,
details: { interactionId: interaction.id, reason: input.reason ?? null },
});
await wakeAfterResolution({ loaded, status: interaction.status, actorId: userId });
res.json(interaction);
});
return router;
}
+7 -1
View File
@@ -11347,6 +11347,9 @@ export function issueRoutes(
);
if (!authorizedResolution) return;
const { interactionSvc, current, resolutionAuthorization } = authorizedResolution;
if (current.kind === "connection_intent") {
throw unprocessable("Connection intents must be resolved through the connection intent endpoints");
}
const suggestedTaskEffectsAuthorized = current.kind === "suggest_tasks"
? await assertSuggestedTaskEffectsAllowed(
req,
@@ -11595,7 +11598,10 @@ export function issueRoutes(
interactionId,
);
if (!authorizedResolution) return;
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
const { interactionSvc, current, resolutionAuthorization } = authorizedResolution;
if (current.kind === "connection_intent") {
throw unprocessable("Connection intents must be resolved through the connection intent endpoints");
}
const actor = getActorInfo(req);
const interaction = await interactionSvc.rejectInteraction(issue, interactionId, req.body, {
+127
View File
@@ -204,8 +204,10 @@ import {
startConnectionAuthorizationSchema,
createToolStdioCommandTemplateSchema,
disableToolStdioCommandTemplateSchema,
finalizeOAuthAccessSchema,
finishToolAppSchema,
reconnectToolAppSchema,
startToolOAuthSchema,
updateToolConnectionSchema,
putToolConnectionInstallsSchema,
toolConnectionTestCallSchema,
@@ -227,6 +229,10 @@ import {
importMcpJsonSchema,
toolPolicyTestRequestSchema,
createToolMcpGatewaySchema,
completeConnectionIntentSchema,
connectionRequestInputSchema,
connectionsSearchInputSchema,
declineConnectionIntentSchema,
startClaudeSetupTokenSessionRequestSchema,
submitBrowserCodeRequestSchema,
claudeSetupTokenSessionResponseSchema,
@@ -784,6 +790,7 @@ function registerCurrentRoute(input: {
type OpenApiAuthLevel =
| "public"
| "runtime_tools"
| "authenticated"
| "board"
| "instance_admin";
@@ -791,6 +798,7 @@ type OpenApiAuthLevel =
const BOARD_SESSION_AUTH_SCHEME = "BoardSessionAuth";
const BOARD_API_KEY_AUTH_SCHEME = "BoardApiKeyAuth";
const AGENT_BEARER_AUTH_SCHEME = "AgentBearerAuth";
const RUNTIME_TOOLS_BEARER_AUTH_SCHEME = "RuntimeToolsBearerAuth";
function securityRequirement(name: string): Record<string, string[]> {
return { [name]: [] };
@@ -806,6 +814,17 @@ const AUTHENTICATED_SECURITY: Array<Record<string, string[]>> = [
securityRequirement(AGENT_BEARER_AUTH_SCHEME),
];
const RUNTIME_TOOLS_SECURITY: Array<Record<string, string[]>> = [
securityRequirement(RUNTIME_TOOLS_BEARER_AUTH_SCHEME),
];
const RUNTIME_TOOLS_OPERATIONS = new Set([
"GET /mcp/runtime-tools",
"POST /mcp/runtime-tools",
"POST /runtime-tools/connections/search",
"POST /runtime-tools/connections/request",
]);
const PUBLIC_OPERATIONS = new Set([
"GET /api/health",
"GET /api/openapi.json",
@@ -899,7 +918,9 @@ const BOARD_ONLY_OPERATIONS = new Set([
"POST /api/issues/{id}/interactions/{interactionId}/respond",
"POST /api/issues/{id}/interactions/{interactionId}/withdraw",
"GET /api/companies/{companyId}/tools/gallery",
"GET /api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
"POST /api/companies/{companyId}/tools/apps/connect",
"POST /api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
"POST /api/companies/{companyId}/tools/apps/{connectionId}/finish",
"GET /api/companies/{companyId}/tools/apps/attention",
"GET /api/companies/{companyId}/tools/action-requests",
@@ -934,6 +955,10 @@ const BOARD_ONLY_OPERATIONS = new Set([
"POST /api/agents/me/connections/{connectionId}/token",
"POST /api/tools/oauth/{connectionId}/start",
"GET /api/tools/oauth/callback",
"GET /api/connection-intents/{interactionId}/setup-options",
"POST /api/connection-intents/{interactionId}/phase",
"POST /api/connection-intents/{interactionId}/complete",
"POST /api/connection-intents/{interactionId}/decline",
"GET /api/companies/{companyId}/tools/profiles",
"POST /api/companies/{companyId}/tools/profiles",
"GET /api/companies/{companyId}/tools/profiles/effective/agents/{agentId}",
@@ -1069,6 +1094,7 @@ function isBoardOnlyOperation(method: string, path: string) {
function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLevel {
const key = operationKey(method, path);
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";
return "authenticated";
@@ -1108,6 +1134,13 @@ function applyDocumentFixups(document: any): any {
description:
"Agent API key or Paperclip-issued local agent JWT presented in the Authorization bearer header.",
},
[RUNTIME_TOOLS_BEARER_AUTH_SCHEME]: {
type: "http",
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.",
},
};
document.security = AUTHENTICATED_SECURITY;
@@ -1116,6 +1149,8 @@ function applyDocumentFixups(document: any): any {
const authLevel = resolveOperationAuthLevel(method, path);
if (authLevel === "public") {
operation.security = [];
} else if (authLevel === "runtime_tools") {
operation.security = RUNTIME_TOOLS_SECURITY;
} else if (authLevel === "authenticated") {
operation.security = AUTHENTICATED_SECURITY;
} else {
@@ -1127,6 +1162,8 @@ function applyDocumentFixups(document: any): any {
? { actor: "board", instanceAdmin: true }
: authLevel === "board"
? { actor: "board" }
: authLevel === "runtime_tools"
? { actor: "runtime_tools", heartbeatBound: true }
: authLevel === "authenticated"
? { actor: "board_or_agent" }
: { actor: "public" };
@@ -7143,6 +7180,79 @@ for (const route of [
});
}
// --- Connection intents ------------------------------------------------------
registerCurrentRoute({
method: "get",
path: "/mcp/runtime-tools",
tags: ["connection-intents"],
summary: "Inspect the heartbeat-bound runtime tools MCP endpoint",
});
registerCurrentRoute({
method: "post",
path: "/mcp/runtime-tools",
tags: ["connection-intents"],
summary: "Call the heartbeat-bound runtime tools MCP endpoint",
responses: {
200: r.ok(),
202: r.ok(),
400: r.badRequest,
401: r.unauthorized,
404: r.notFound,
},
});
registerCurrentRoute({
method: "post",
path: "/runtime-tools/connections/search",
tags: ["connection-intents"],
summary: "Search connections available to the active heartbeat run",
body: connectionsSearchInputSchema,
});
registerCurrentRoute({
method: "post",
path: "/runtime-tools/connections/request",
tags: ["connection-intents"],
summary: "Request a connection for the active heartbeat run",
body: connectionRequestInputSchema,
});
registerCurrentRoute({
method: "get",
path: "/api/connection-intents/{interactionId}/setup-options",
tags: ["connection-intents"],
summary: "Get setup options for an addressed connection request",
});
registerCurrentRoute({
method: "post",
path: "/api/connection-intents/{interactionId}/phase",
tags: ["connection-intents"],
summary: "Update the setup phase for an addressed connection request",
body: z.object({
phase: z.enum(["requested", "authorizing", "needs_retry"]),
}).strict(),
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "post",
path: "/api/connection-intents/{interactionId}/complete",
tags: ["connection-intents"],
summary: "Complete an addressed connection request",
body: completeConnectionIntentSchema,
});
registerCurrentRoute({
method: "post",
path: "/api/connection-intents/{interactionId}/decline",
tags: ["connection-intents"],
summary: "Decline an addressed connection request",
body: declineConnectionIntentSchema,
});
// --- Tool access -------------------------------------------------------------
registerCurrentRoute({
@@ -7152,6 +7262,13 @@ registerCurrentRoute({
summary: "List tool app gallery entries",
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
tags: ["tool-access"],
summary: "Inspect a curated app's public MCP and OAuth metadata without credentials or registration",
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/tools/apps/connect",
@@ -7170,6 +7287,15 @@ registerCurrentRoute({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
tags: ["tool-access"],
summary: "Choose personal or company-wide access after OAuth sign-in",
body: finalizeOAuthAccessSchema,
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/tools/apps/attention",
@@ -7453,6 +7579,7 @@ registerCurrentRoute({
path: "/api/tools/oauth/{connectionId}/start",
tags: ["tool-access"],
summary: "Start OAuth sign-in for a tool connection",
body: startToolOAuthSchema,
});
registerCurrentRoute({
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { connectionIntentOAuthOutcomeHtml } from "./tool-access.js";
describe("connection intent OAuth callback document", () => {
it.each(["connected", "declined", "failed"] as const)(
"posts only the interaction id and %s outcome to the same-origin opener",
(outcome) => {
const html = connectionIntentOAuthOutcomeHtml({
interactionId: "interaction-123",
issueId: "issue-456",
outcome,
});
expect(html).toContain(
"window.opener.postMessage(message,window.location.origin)",
);
expect(html).toContain('"interactionId":"interaction-123"');
expect(html).toContain(`"outcome":"${outcome}"`);
expect(html).toContain('"type":"paperclip.connection-intent.oauth"');
expect(html).not.toMatch(
/connectionId|authorizationUrl|bearer|token|credential/i,
);
},
);
it("closes the popup when an opener exists and otherwise returns to the same task", () => {
const html = connectionIntentOAuthOutcomeHtml({
interactionId: "interaction-123",
issueId: "issue with/slash",
outcome: "connected",
});
expect(html).toContain("window.close()");
expect(html).toContain(
'window.location.replace("/issues/issue%20with%2Fslash")',
);
});
it("escapes script-significant interaction ids", () => {
const html = connectionIntentOAuthOutcomeHtml({
interactionId: "</script><script>alert(1)</script>",
issueId: null,
outcome: "failed",
});
expect(html).not.toContain("</script><script>alert(1)</script>");
expect(html).toContain("\\u003c/script>");
expect(html).toContain('window.location.replace("/issues")');
});
});
+265 -17
View File
@@ -1,6 +1,6 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { agents, companies, connectionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db";
import { and, eq, or } from "drizzle-orm";
import {
CONNECTABLE_APP_DEFINITIONS,
@@ -24,6 +24,8 @@ import {
disableToolStdioCommandTemplateSchema,
duplicateToolProfileSchema,
finishToolAppSchema,
finalizeOAuthAccessSchema,
startToolOAuthSchema,
reconnectToolAppSchema,
replaceConnectionGrantMembersSchema,
reviewToolProfileNewToolsSchema,
@@ -54,9 +56,14 @@ import {
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
oauthClientIdMetadataDocument,
} from "../services/tool-access.js";
import { isLoopbackHost } from "../url-utils.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { wakeConnectionIntentAfterResolution } from "./connection-intents.js";
import type { heartbeatService } from "../services/heartbeat.js";
const COMPANY_INSTALL_DENIAL_REASON =
"Only someone who can configure this connection can choose this.";
type Heartbeat = ReturnType<typeof heartbeatService>;
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
@@ -125,6 +132,26 @@ export function filterVisibleToolConnections<T extends {
|| Boolean(actor.userId && connection.createdByUserId === actor.userId));
}
export function connectionIntentOAuthOutcomeHtml(input: {
interactionId: string;
issueId: string | null;
outcome: "connected" | "declined" | "failed";
}) {
// The callback window is only a signal. Connection identity and every
// authorization URL stay server-side; the opener refreshes the task from the
// interaction id instead of trusting provider-window data.
const message = JSON.stringify({
type: "paperclip.connection-intent.oauth",
interactionId: input.interactionId,
outcome: input.outcome,
}).replace(/</g, "\\u003c");
const issuePath = input.issueId
? `/issues/${encodeURIComponent(input.issueId)}`
: "/issues";
const fallback = JSON.stringify(issuePath);
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};if(window.opener&&window.opener!==window){window.opener.postMessage(message,window.location.origin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
}
export function toolAccessRoutes(
db: Db,
options: {
@@ -137,11 +164,77 @@ export function toolAccessRoutes(
remoteHttpEndpointLookup?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpEndpointLookup"];
remoteHttpRequest?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpRequest"];
composioClientFactory?: (apiKey: string) => ComposioClient;
connectionIntentHeartbeat?: Pick<Heartbeat, "wakeup">;
} = {},
) {
const router = Router();
const svc = toolAccessService(db, options);
const policySvc = toolAccessPolicyService(db);
const connectionIntents = connectionIntentService(db);
async function isConnectionIntent(interactionId: string | null | undefined) {
if (!interactionId) return false;
const row = await db
.select({ kind: issueThreadInteractions.kind })
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.limit(1)
.then((rows) => rows[0] ?? null);
return row?.kind === "connection_intent";
}
function bypassCurrentMembershipCheck(req: Request) {
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true;
}
async function finishConnectionIntentOAuth(input: {
interactionId: string;
connectionId?: string;
userId: string;
outcome: "connected" | "declined" | "failed";
canManageOrganizationGrant: boolean;
bypassCurrentMembershipCheck: boolean;
}) {
const loaded = await connectionIntents.loadIntent(input.interactionId);
if (loaded.interaction.addresseeUserId !== input.userId) {
throw forbidden("OAuth callback user does not match the connection request");
}
if (loaded.interaction.status !== "pending") return loaded.interaction;
const interaction = input.outcome === "connected" && input.connectionId
? await connectionIntents.complete(input.interactionId, input.connectionId, input.userId, {
canManageOrganizationGrant: input.canManageOrganizationGrant,
bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck,
})
: input.outcome === "declined"
? await connectionIntents.decline(
input.interactionId,
input.userId,
"Authorization was declined in the provider window",
{ bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck },
)
: await connectionIntents.updatePhase(input.interactionId, "needs_retry", input.userId, {
bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck,
});
if (input.outcome !== "failed" && options.connectionIntentHeartbeat) {
await wakeConnectionIntentAfterResolution(options.connectionIntentHeartbeat, {
loaded,
status: interaction.status,
actorId: input.userId,
});
}
return interaction;
}
function sendConnectionIntentOAuthOutcome(
res: import("express").Response,
input: {
interactionId: string;
issueId: string | null;
outcome: "connected" | "declined" | "failed";
},
) {
res.type("html").send(connectionIntentOAuthOutcomeHtml(input));
}
function configuredPublicBaseUrl() {
const raw = (
@@ -160,15 +253,34 @@ export function toolAccessRoutes(
}
}
function oauthRedirectUri() {
const configured = configuredPublicBaseUrl();
if (!configured) {
function requestLoopbackBaseUrl(req: Request) {
const host = req.get("host")?.trim();
if (!host) return null;
try {
const parsed = new URL(`${req.protocol}://${host}`);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:")
|| parsed.username
|| parsed.password
|| !isLoopbackHost(parsed.hostname)
) {
return null;
}
return parsed.origin;
} catch {
return null;
}
}
function oauthRedirectUri(req: Request) {
const baseUrl = configuredPublicBaseUrl() ?? requestLoopbackBaseUrl(req);
if (!baseUrl) {
throw unprocessable(
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
{ code: "oauth_redirect_origin_unsupported" },
);
}
return new URL("/api/tools/oauth/callback", configured).toString();
return new URL("/api/tools/oauth/callback", baseUrl).toString();
}
async function oauthAppPath(
@@ -184,6 +296,7 @@ export function toolAccessRoutes(
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
return `/${company.issuePrefix}/apps/${connectionId}/${tab}`;
}
const access = accessService(db);
async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) {
@@ -212,9 +325,9 @@ export function toolAccessRoutes(
}
async function isToolConnectionManager(req: Request, companyId: string) {
const membership = activeToolMembership(req, companyId);
if (!membership) return true;
if (membership.membershipRole === "owner" || membership.membershipRole === "admin") return true;
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return Boolean(req.actor.userId && await access.hasPermission(
companyId,
"user",
@@ -227,6 +340,7 @@ export function toolAccessRoutes(
req: Request,
connection: { companyId: string; createdByUserId?: string | null },
) {
activeToolMembership(req, connection.companyId);
if (await isToolConnectionManager(req, connection.companyId)) return;
if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
throw forbidden(
@@ -436,7 +550,7 @@ export function toolAccessRoutes(
subjectUserId: req.body.subjectUserId,
scopes: req.body.scopes,
returnTo: req.body.returnTo,
redirectUri: oauthRedirectUri(),
redirectUri: oauthRedirectUri(req),
});
res.json({ url: result.authorizationUrl });
});
@@ -499,6 +613,14 @@ export function toolAccessRoutes(
});
});
router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const methodKey = typeof req.query.methodKey === "string" ? req.query.methodKey.trim() || null : null;
res.json(await svc.preflightGalleryAppMetadata(req.params.galleryKey as string, methodKey));
});
/**
* Paperclip's Client ID Metadata Document (PAP-17087).
*
@@ -510,7 +632,7 @@ export function toolAccessRoutes(
* data of any kind.
*/
router.get(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH.replace(/^\/api/, ""), (_req, res) => {
const redirectUri = oauthRedirectUri();
const redirectUri = oauthRedirectUri(_req);
const clientId = new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, new URL(redirectUri).origin).toString();
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
});
@@ -529,9 +651,10 @@ export function toolAccessRoutes(
// so this cannot start consent on someone else's behalf.
const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
const start = await svc.startOAuth(companyId, result.connectionId, {
redirectUri: oauthRedirectUri(),
redirectUri: oauthRedirectUri(req),
actor: getActorInfo(req),
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}),
});
result.auth.startUrl = start.authorizationUrl;
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
@@ -583,7 +706,7 @@ export function toolAccessRoutes(
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
await assertToolConnectionAccess(req, existing);
const result = await svc.startOAuth(companyId, existing.id, {
redirectUri: oauthRedirectUri(),
redirectUri: oauthRedirectUri(req),
actor: getActorInfo(req),
subjectUserId: req.body.subjectUserId,
scopes: req.body.scopes,
@@ -593,13 +716,27 @@ export function toolAccessRoutes(
},
);
router.post("/tools/oauth/:connectionId/start", async (req, res) => {
router.post("/tools/oauth/:connectionId/start", validate(startToolOAuthSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
const subjectUserId = req.body?.asCurrentUser === true ? req.actor.userId ?? null : null;
if (req.body?.asCurrentUser === true && !subjectUserId) {
throw forbidden("Connecting an app as yourself requires a signed-in user");
}
if (subjectUserId && existing.credentialPolicy === "per_user") {
// Personal reconnect is consent owned by the fixed subject, not a manager
// configuration action. Membership blocks viewers; the service then
// proves this caller is the connection's retained subject before it can
// create OAuth state. Shared reconnects keep the stricter configure gate.
activeToolMembership(req, existing.companyId);
} else {
await assertToolConnectionConfigureAccess(req, existing);
}
const result = await svc.startOAuth(existing.companyId, existing.id, {
redirectUri: oauthRedirectUri(),
redirectUri: oauthRedirectUri(req),
actor: getActorInfo(req),
...(subjectUserId ? { subjectUserId } : {}),
...(req.body?.interactionId ? { interactionId: req.body.interactionId } : {}),
});
res.json(result);
});
@@ -614,6 +751,7 @@ export function toolAccessRoutes(
throw badRequest("Invalid or expired OAuth state");
}
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId);
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
await assertToolConnectionAccess(req, pendingConnection);
} else {
@@ -636,6 +774,22 @@ export function toolAccessRoutes(
entityId: result.connection.id,
details: { applicationId: result.application.id, catalogEntryCount: result.catalog.length, provider: "gmail" },
});
if (acceptsHtml && pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
await finishConnectionIntentOAuth({
interactionId: pendingState.interactionId,
connectionId: result.connection.id,
userId: req.actor.userId,
outcome: "connected",
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
sendConnectionIntentOAuthOutcome(res, {
interactionId: pendingState.interactionId,
issueId: pendingState.issueId,
outcome: "connected",
});
return;
}
if (acceptsHtml) {
const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test");
res.redirect(303, `${testPath}?success=1`);
@@ -647,6 +801,22 @@ export function toolAccessRoutes(
const details = callbackError instanceof HttpError && callbackError.details && typeof callbackError.details === "object"
? callbackError.details as Record<string, unknown>
: null;
if (pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
const outcome = details?.code === "oauth_authorization_denied" ? "declined" : "failed";
await finishConnectionIntentOAuth({
interactionId: pendingState.interactionId,
userId: req.actor.userId,
outcome,
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
sendConnectionIntentOAuthOutcome(res, {
interactionId: pendingState.interactionId,
issueId: pendingState.issueId,
outcome,
});
return;
}
const params = new URLSearchParams({ oauth: details?.code === "oauth_authorization_denied" ? "denied" : "failed" });
if (typeof details?.code === "string") params.set("code", details.code);
const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup");
@@ -668,6 +838,7 @@ export function toolAccessRoutes(
throw badRequest("Invalid or expired OAuth state");
}
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId);
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
await assertToolConnectionAccess(req, pendingConnection);
} else {
@@ -684,7 +855,7 @@ export function toolAccessRoutes(
// A provider denial is bound and consumed by state alone. Avoid
// requiring this deployment's callback origin just to record that the
// user declined; successful code exchange still validates the origin.
redirectUri: error ? "" : oauthRedirectUri(),
redirectUri: error ? "" : oauthRedirectUri(req),
actor: getActorInfo(req),
});
} catch (callbackError) {
@@ -696,10 +867,46 @@ export function toolAccessRoutes(
? callbackError.details as Record<string, unknown>
: null;
const callbackErrorCode = typeof details?.code === "string" ? details.code : null;
const callbackFailureCode = callbackErrorCode
?? (callbackError instanceof HttpError ? `oauth_callback_http_${callbackError.status}` : "oauth_callback_failed");
await logActivity(db, {
companyId: pendingState.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.oauth_failed",
entityType: "tool_connection",
entityId: pendingState.connectionId,
details: {
code: callbackFailureCode,
status: callbackError instanceof HttpError ? callbackError.status : 500,
// HttpError messages are Paperclip-authored. Provider-authored
// error_description/error_uri values are never read above and cannot
// be reflected into the activity stream.
message: callbackError instanceof HttpError
? callbackError.message
: "OAuth callback failed unexpectedly.",
},
});
if (pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
const outcome = callbackErrorCode === "oauth_authorization_denied" ? "declined" : "failed";
await finishConnectionIntentOAuth({
interactionId: pendingState.interactionId,
userId: req.actor.userId,
outcome,
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
sendConnectionIntentOAuthOutcome(res, {
interactionId: pendingState.interactionId,
issueId: pendingState.issueId,
outcome,
});
return;
}
const params = new URLSearchParams({
oauth: callbackErrorCode === "oauth_authorization_denied" ? "denied" : "failed",
});
if (callbackErrorCode) params.set("code", callbackErrorCode);
params.set("code", callbackFailureCode);
const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup");
res.redirect(303, `${setupPath}?${params.toString()}`);
return;
@@ -716,6 +923,22 @@ export function toolAccessRoutes(
catalogEntryCount: result.catalog.length,
},
});
if (acceptsHtml && pendingConnectionIntent && pendingState.interactionId && req.actor.userId) {
await finishConnectionIntentOAuth({
interactionId: pendingState.interactionId,
connectionId: result.connection.id,
userId: req.actor.userId,
outcome: "connected",
canManageOrganizationGrant: await isToolConnectionManager(req, pendingConnection.companyId),
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
});
sendConnectionIntentOAuthOutcome(res, {
interactionId: pendingState.interactionId,
issueId: pendingState.issueId,
outcome: "connected",
});
return;
}
if (acceptsHtml) {
const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test");
res.redirect(303, `${testPath}?success=1`);
@@ -724,6 +947,31 @@ export function toolAccessRoutes(
res.json(result);
});
router.post(
"/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access",
validate(finalizeOAuthAccessSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
await assertToolConnectionConfigureAccess(req, existing);
const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.oauth_access_finalized",
entityType: "tool_connection",
entityId: result.connection.id,
details: {
grantKind: req.body.grantKind,
profileId: result.profile.id,
profileEntryCount: result.profileEntries.length,
},
});
res.json(result);
},
);
router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRuntimeToolsToken, verifyRuntimeToolsToken } from "./runtime-tools-token.js";
describe("runtime connection tools token", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("binds the token to company, agent, run, responsible user, and scope", () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-runtime-tools-secret");
vi.setSystemTime(new Date("2026-08-26T12:00:00.000Z"));
const minted = createRuntimeToolsToken({
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
responsibleUserId: "user-1",
});
expect(minted).not.toBeNull();
expect(verifyRuntimeToolsToken(minted!.token)).toMatchObject({
sub: "agent-1",
company_id: "company-1",
run_id: "run-1",
responsible_user_id: "user-1",
scope: "connection_intents",
});
});
it("rejects tampering and expiry", () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-runtime-tools-secret");
vi.setSystemTime(new Date("2026-08-26T12:00:00.000Z"));
const minted = createRuntimeToolsToken({
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
responsibleUserId: "user-1",
})!;
expect(verifyRuntimeToolsToken(`${minted.token.slice(0, -1)}x`)).toBeNull();
vi.setSystemTime(new Date("2026-08-26T13:00:01.000Z"));
expect(verifyRuntimeToolsToken(minted.token)).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { resolvePaperclipInstanceId } from "./home-paths.js";
export interface RuntimeToolsTokenClaims {
sub: string;
company_id: string;
run_id: string;
responsible_user_id: string;
scope: "connection_intents";
iat: number;
exp: number;
instance_id: string;
}
const TOKEN_TTL_SECONDS = 60 * 60;
function secret() {
return process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()
|| process.env.BETTER_AUTH_SECRET?.trim()
|| null;
}
function encode(value: unknown) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
function sign(value: string, companyId: string, instanceId: string) {
const master = secret();
if (!master) return null;
const key = createHmac("sha256", master)
.update(`runtime-tools:${instanceId}:${companyId}`)
.digest();
return createHmac("sha256", key).update(value).digest("base64url");
}
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
export function createRuntimeToolsToken(input: {
agentId: string;
companyId: string;
runId: string;
responsibleUserId: string;
}) {
if (!secret()) return null;
const now = Math.floor(Date.now() / 1000);
const instanceId = resolvePaperclipInstanceId();
const claims: RuntimeToolsTokenClaims = {
sub: input.agentId,
company_id: input.companyId,
run_id: input.runId,
responsible_user_id: input.responsibleUserId,
scope: "connection_intents",
iat: now,
exp: now + TOKEN_TTL_SECONDS,
instance_id: instanceId,
};
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
const signature = sign(signingInput, input.companyId, instanceId);
return signature
? { token: `${signingInput}.${signature}`, expiresAt: new Date(claims.exp * 1000).toISOString() }
: null;
}
export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
let header: Record<string, unknown>;
let claims: Record<string, unknown>;
try {
header = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"));
claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
} catch {
return null;
}
if (header.alg !== "HS256") return null;
const companyId = typeof claims.company_id === "string" ? claims.company_id : null;
const instanceId = typeof claims.instance_id === "string" ? claims.instance_id : null;
if (!companyId || !instanceId || instanceId !== resolvePaperclipInstanceId()) return null;
const expected = sign(`${parts[0]}.${parts[1]}`, companyId, instanceId);
if (!expected || !safeEqual(parts[2]!, expected)) return null;
if (
typeof claims.sub !== "string"
|| typeof claims.run_id !== "string"
|| typeof claims.responsible_user_id !== "string"
|| claims.scope !== "connection_intents"
|| typeof claims.iat !== "number"
|| typeof claims.exp !== "number"
|| claims.exp <= Math.floor(Date.now() / 1000)
) return null;
return claims as unknown as RuntimeToolsTokenClaims;
}
+11 -4
View File
@@ -351,7 +351,7 @@ function agentIsInSubtree(
return false;
}
async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
async function loadCompanyAgentHierarchy(db: Db | DbTransaction, companyId: string) {
const rows = await db
.select({ id: agents.id, reportsTo: agents.reportsTo })
.from(agents)
@@ -359,7 +359,12 @@ async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
return new Map(rows.map((agent) => [agent.id, agent]));
}
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
async function isAgentInSubtree(
db: Db | DbTransaction,
companyId: string,
rootAgentId: string,
targetAgentId: string,
) {
return agentIsInSubtree(
await loadCompanyAgentHierarchy(db, companyId),
rootAgentId,
@@ -368,7 +373,7 @@ async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string,
}
async function scopeAllows(
db: Db,
db: Db | DbTransaction,
companyId: string,
grantScope: Record<string, unknown> | null,
requestedScope: Record<string, unknown> | null | undefined,
@@ -526,7 +531,9 @@ export function authorizationDeniedDetails(decision: AuthorizationDecision) {
};
}
export function authorizationService(db: Db) {
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export function authorizationService(db: Db | DbTransaction) {
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
if (
+530
View File
@@ -0,0 +1,530 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
companyMemberships,
heartbeatRuns,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
CONNECTABLE_APP_DEFINITIONS,
connectionIntentPayloadSchema,
getAvailableConnectionMethods,
getConnectableAppDefinition,
type ConnectionIntentInteraction,
type ConnectionIntentSetupOptions,
type ConnectionRequestResult,
type ConnectionsSearchResult,
type ToolApplication,
type ToolConnection,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { toolAccessService } from "./tool-access.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function sourceSlugForApplication(application: ToolApplication | undefined) {
return text(application?.metadata?.sourceTemplateKey) ?? text(application?.metadata?.galleryKey);
}
function sourceSlugForConnection(
connection: ToolConnection,
applications: ReadonlyMap<string, ToolApplication>,
) {
return text(connection.config?.sourceTemplateKey)
?? text(connection.transportConfig?.sourceTemplateKey)
?? sourceSlugForApplication(applications.get(connection.applicationId));
}
function displayDescription(app: (typeof CONNECTABLE_APP_DEFINITIONS)[number]) {
return text(app.description) ?? null;
}
export function connectionIntentService(db: Db) {
const interactions = issueThreadInteractionService(db);
const access = toolAccessService(db);
async function assertCurrentUserWriteAccess(
companyId: string,
userId: string,
bypassCurrentMembershipCheck = false,
) {
if (bypassCurrentMembershipCheck) return;
const membership = await db
.select({
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
})
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
))
.then((rows) => rows[0] ?? null);
if (
!membership
|| membership.status !== "active"
|| !membership.membershipRole
|| membership.membershipRole === "viewer"
) {
throw forbidden("Addressed user is no longer authorized for company write access");
}
}
async function lockCurrentUserWriteAccess(
tx: DbTransaction,
companyId: string,
userId: string,
bypassCurrentMembershipCheck = false,
) {
if (bypassCurrentMembershipCheck) return;
const membership = await tx
.select({
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
})
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
))
.for("update")
.then((rows) => rows[0] ?? null);
if (
!membership
|| membership.status !== "active"
|| !membership.membershipRole
|| membership.membershipRole === "viewer"
) {
throw forbidden("Addressed user is no longer authorized for company write access");
}
}
async function loadRunContext(claims: RuntimeToolsTokenClaims) {
const run = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
responsibleUserId: heartbeatRuns.responsibleUserId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, claims.run_id))
.then((rows) => rows[0] ?? null);
if (
!run
|| run.companyId !== claims.company_id
|| run.agentId !== claims.sub
|| 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);
const issueId = text(snapshot?.issueId) ?? text(snapshot?.taskId);
if (!issueId) throw unprocessable("Connection requests require a task-bound heartbeat run");
const [issue, agent, responsibleMembership] = await Promise.all([
db.select({
id: issues.id,
companyId: issues.companyId,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
}).from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).then((rows) => rows[0] ?? null),
db.select({ id: agents.id, companyId: agents.companyId, name: agents.name })
.from(agents)
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
.then((rows) => rows[0] ?? null),
db.select({
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
}).from(companyMemberships).where(and(
eq(companyMemberships.companyId, run.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, run.responsibleUserId!),
)).then((rows) => rows[0] ?? null),
]);
if (!issue || !agent) throw notFound("Runtime task or agent was not found");
if (
!responsibleMembership
|| responsibleMembership.status !== "active"
|| !responsibleMembership.membershipRole
|| responsibleMembership.membershipRole === "viewer"
) {
throw forbidden("Responsible user is no longer authorized for company write access");
}
if (issue.status === "done" || issue.status === "cancelled") {
throw conflict("Connection requests cannot be created on a closed task");
}
return { run, issue, agent };
}
async function connectionInventory(companyId: string) {
const [applications, connections] = await Promise.all([
access.listApplications(companyId),
access.listConnections(companyId),
]);
return {
applications,
connections,
applicationsById: new Map(applications.map((application) => [application.id, application] as const)),
};
}
async function usableConnectionForAgent(input: {
companyId: string;
agentId: string;
responsibleUserId: string;
serviceSlug: string;
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
}) {
const inventory = input.inventory ?? await connectionInventory(input.companyId);
const matching = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === input.serviceSlug
&& connection.status === "active"
&& connection.enabled
);
if (matching.length === 0) return null;
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
for (const connection of matching) {
if (!installedIds.has(connection.id)) continue;
const { grants } = await access.listConnectionGrants(connection.id, input.companyId);
const usable = grants.some((grant) => {
if (grant.status !== "active") return false;
if (grant.kind === "organization") return true;
return grant.subjectUserId === input.responsibleUserId
&& grant.delegations.some((delegation) => delegation.agentId === input.agentId);
});
if (usable) return connection;
}
return null;
}
async function search(claims: RuntimeToolsTokenClaims, query: string): Promise<ConnectionsSearchResult> {
const { run, agent } = await loadRunContext(claims);
const normalized = query.trim().toLocaleLowerCase();
const inventory = await connectionInventory(run.companyId);
const results = await Promise.all(CONNECTABLE_APP_DEFINITIONS
.filter((app) => !normalized
|| app.slug.toLocaleLowerCase().includes(normalized)
|| app.name.toLocaleLowerCase().includes(normalized)
|| displayDescription(app)?.toLocaleLowerCase().includes(normalized))
.map(async (app) => {
const methods = getAvailableConnectionMethods(app);
const matching = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
&& connection.status !== "archived"
);
const ready = await usableConnectionForAgent({
companyId: run.companyId,
agentId: agent.id,
responsibleUserId: run.responsibleUserId!,
serviceSlug: app.slug,
inventory,
});
return {
service: app.slug,
name: app.name,
description: displayDescription(app),
logoUrl: app.branding.logoUrl ?? null,
methods: methods.map((method) => ({
key: method.key,
label: method.label ?? method.key,
auth: method.auth,
})),
state: app.availability?.available === false || methods.length === 0
? "unavailable" as const
: ready
? "ready" as const
: matching.length > 0
? "needs_user_action" as const
: "available" as const,
connectionId: ready?.id ?? null,
};
}));
return { version: 1, query, results };
}
async function request(
claims: RuntimeToolsTokenClaims,
serviceSlug: string,
): Promise<ConnectionRequestResult> {
const context = await loadRunContext(claims);
const app = getConnectableAppDefinition(serviceSlug);
if (!app || app.availability?.available === false || getAvailableConnectionMethods(app).length === 0) {
throw unprocessable(`Connection service ${serviceSlug} is not available`);
}
const ready = await usableConnectionForAgent({
companyId: context.run.companyId,
agentId: context.agent.id,
responsibleUserId: context.run.responsibleUserId!,
serviceSlug: app.slug,
});
if (ready) {
return {
version: 1,
service: app.slug,
state: "ready",
connectionId: ready.id,
interactionId: null,
instruction: `${app.name} is connected and available in this run.`,
};
}
const interaction = await interactions.createConnectionIntent(
context.issue,
{
payload: {
version: 1,
serviceSlug: app.slug,
serviceName: app.name,
serviceLogoUrl: app.branding.logoUrl ?? null,
serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null,
requestingAgentId: context.agent.id,
requestingAgentName: context.agent.name,
phase: "requested",
},
sourceRunId: context.run.id,
addresseeUserId: context.run.responsibleUserId!,
idempotencyKey: `connection-intent:${context.run.id}:${app.slug}`,
},
);
return {
version: 1,
service: app.slug,
state: "needs_user_action",
connectionId: null,
interactionId: interaction.id,
instruction: `A connection card was sent to the responsible user. End this run and wait for continuation.`,
};
}
async function loadIntent(interactionId: string) {
const row = await db
.select({ interaction: issueThreadInteractions, issue: issues })
.from(issueThreadInteractions)
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0] ?? null);
if (!row || row.interaction.kind !== "connection_intent") throw notFound("Connection intent not found");
const interaction = await interactions.getForIssue(row.issue, interactionId) as ConnectionIntentInteraction;
return { ...row, interaction };
}
async function setupOptions(interactionId: string): Promise<ConnectionIntentSetupOptions> {
const loaded = await loadIntent(interactionId);
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
const app = getConnectableAppDefinition(payload.serviceSlug);
if (!app) throw notFound("Connection service is no longer available");
const inventory = await connectionInventory(loaded.issue.companyId);
const matchingConnections = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
&& connection.status === "active"
&& connection.enabled
);
const existingConnections = (await Promise.all(matchingConnections.map(async (connection) => {
const { grants } = await access.listConnectionGrants(connection.id, loaded.issue.companyId);
const eligible = grants.some((grant) =>
grant.status === "active"
&& (grant.kind === "organization" || grant.subjectUserId === loaded.interaction.addresseeUserId)
);
return eligible ? connection : null;
}))).filter((connection): connection is ToolConnection => connection !== null);
return {
version: 1,
interaction: loaded.interaction,
service: {
service: app.slug,
name: app.name,
description: displayDescription(app),
logoUrl: app.branding.logoUrl ?? null,
methods: getAvailableConnectionMethods(app).map((method) => ({
key: method.key,
label: method.label ?? method.key,
auth: method.auth,
})),
state: existingConnections.length > 0 ? "needs_user_action" : "available",
connectionId: null,
},
existingConnections,
requestedAgentId: payload.requestingAgentId,
};
}
async function complete(
interactionId: string,
connectionId: string,
userId: string,
options: {
canManageOrganizationGrant?: boolean;
bypassCurrentMembershipCheck?: boolean;
} = {},
) {
const loaded = await loadIntent(interactionId);
if (loaded.interaction.status !== "pending") throw conflict("Connection intent is already resolved");
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can connect this service");
await assertCurrentUserWriteAccess(
loaded.issue.companyId,
userId,
options.bypassCurrentMembershipCheck,
);
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
return db.transaction(async (tx) => {
// Membership downgrade/removal takes the same row lock. Whichever side
// commits first is authoritative: a completed revocation makes this
// revalidation fail, while completion holds authority through OAuth
// finalization, every install/delegation, and intent resolution.
await lockCurrentUserWriteAccess(
tx,
loaded.issue.companyId,
userId,
options.bypassCurrentMembershipCheck,
);
const txDb = tx as unknown as Db;
const txAccess = toolAccessService(txDb);
const txInteractions = issueThreadInteractionService(txDb);
let selectedConnection = await txAccess.getConnection(connectionId, loaded.issue.companyId);
const selectedApplication = await txAccess.getApplication(
selectedConnection.applicationId,
loaded.issue.companyId,
);
if (sourceSlugForConnection(
selectedConnection,
new Map([[selectedApplication.id, selectedApplication]]),
) !== payload.serviceSlug) {
throw notFound("Connection does not match this intent");
}
if (selectedConnection.status !== "active" || !selectedConnection.enabled) {
throw conflict("Finish and test this connection before using it for the task");
}
let { grants } = await txAccess.listConnectionGrants(
selectedConnection.id,
loaded.issue.companyId,
);
const pendingPersonalGrant = grants.find((grant) =>
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
);
if (selectedConnection.authKind === "oauth" && pendingPersonalGrant) {
// txAccess is bound to the outer transaction. Its internal transactions
// become savepoints, so activation, credential bindings, the all-agents
// profile, and the company install roll back with any later failure.
await txAccess.finalizeOAuthAccess(
loaded.issue.companyId,
selectedConnection.id,
{ grantKind: "user" },
{ actorType: "user", actorId: userId },
);
selectedConnection = await txAccess.getConnection(
selectedConnection.id,
loaded.issue.companyId,
);
({ grants } = await txAccess.listConnectionGrants(
selectedConnection.id,
loaded.issue.companyId,
));
}
const personalGrant = grants.find((grant) =>
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
);
const organizationGrant = grants.find((grant) =>
grant.kind === "organization" && grant.status === "active"
);
if (!personalGrant && !organizationGrant) {
throw conflict("This connection has no usable identity grant");
}
if (!personalGrant && !options.canManageOrganizationGrant) {
throw forbidden("Sharing a company connection requires connection-management authority");
}
if (personalGrant) {
await txAccess.createConnectionGrantDelegation(
selectedConnection.id,
personalGrant.id,
payload.requestingAgentId,
userId,
);
}
const installs = await txAccess.listConnectionInstalls(
selectedConnection.id,
loaded.issue.companyId,
);
const requestedInstall = { targetType: "agent" as const, targetId: payload.requestingAgentId };
const additiveInstalls = installs.some((install) =>
install.targetType === requestedInstall.targetType && install.targetId === requestedInstall.targetId
) ? installs : [...installs, requestedInstall];
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: additiveInstalls }, {
actorType: "user",
actorId: userId,
});
return txInteractions.resolveConnectionIntent(
loaded.issue,
interactionId,
{ version: 1, outcome: "connected", connectionId: selectedConnection.id },
{ userId },
);
});
}
async function decline(
interactionId: string,
userId: string,
reason?: string,
options: { bypassCurrentMembershipCheck?: boolean } = {},
) {
const loaded = await loadIntent(interactionId);
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can decline this request");
await assertCurrentUserWriteAccess(
loaded.issue.companyId,
userId,
options.bypassCurrentMembershipCheck,
);
return interactions.resolveConnectionIntent(
loaded.issue,
interactionId,
{ version: 1, outcome: "declined", reason: reason?.trim() || null },
{ userId },
);
}
return {
validate: loadRunContext,
search,
request,
loadIntent,
setupOptions,
complete,
decline,
updatePhase: async (
interactionId: string,
phase: "requested" | "authorizing" | "needs_retry",
userId: string,
options: { bypassCurrentMembershipCheck?: boolean } = {},
) => {
const loaded = await loadIntent(interactionId);
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can update this request");
await assertCurrentUserWriteAccess(
loaded.issue.companyId,
userId,
options.bypassCurrentMembershipCheck,
);
return interactions.updateConnectionIntentPhase(loaded.issue, interactionId, phase, { userId });
},
};
}
+273 -15
View File
@@ -7,6 +7,8 @@ import { and, asc, desc, eq, getTableColumns, gt, gte, inArray, isNull, lt, lte,
import type { Db } from "@paperclipai/db";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
CONNECTION_INTENT_AGENT_GUIDANCE,
CONNECTION_RUNTIME_TOOL_NAMES,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ISSUE_DISPOSITION_REPAIR_RETRY_REASON,
MAX_TASK_DRAIN_TTL_MS,
@@ -99,10 +101,12 @@ import type {
AdapterRuntimeEvent,
AdapterRuntimeMcpAccess,
AdapterRuntimeMcpServer,
AdapterRuntimeToolAccess,
AdapterSessionCodec,
UsageSummary,
} from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
@@ -2749,6 +2753,10 @@ interface WakeupOptions {
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
issueStateGuard?: {
statuses: string[];
assigneeAgentId: string;
};
}
type UsageTotals = {
@@ -3467,12 +3475,19 @@ type ManagedMcpGatewayRunConfig = {
}>;
};
function paperclipApiBaseUrl(): string {
function configuredPaperclipApiBaseUrl(): string | null {
const configured = readNonEmptyString(process.env.PAPERCLIP_API_URL);
return configured
? configured.replace(/\/+$/, "").replace(/\/api$/, "")
: null;
}
function paperclipApiBaseUrl(): string {
const configured = configuredPaperclipApiBaseUrl();
if (!configured) {
throw new Error("PAPERCLIP_API_URL is required to deliver managed runtime MCP servers");
}
return configured.replace(/\/+$/, "").replace(/\/api$/, "");
return configured;
}
export async function revokeHeartbeatRunGatewayTokens(input: {
@@ -3607,7 +3622,11 @@ export async function buildPaperclipRuntimeMcpServers(input: {
});
servers.push({
name: connection.name,
url: `${paperclipApiBaseUrl()}/api/tool-gateway/gateways/${gateway.id}/mcp`,
// Runtime MCP clients authenticate with a short-lived gateway bearer, not
// a Paperclip agent JWT. Route them through the public gateway protocol
// endpoint mounted ahead of the API auth middleware; the gateway service
// still validates the bearer and its run binding on every request.
url: `${paperclipApiBaseUrl()}/mcp/gateways/${gateway.gatewayPublicId}`,
token: token.token,
connectionId: connection.id,
});
@@ -3633,6 +3652,40 @@ function createAdapterRuntimeMcpAccess(
});
}
function createAdapterRuntimeToolAccess(input: {
agentId: string;
companyId: string;
runId: string;
responsibleUserId: string | null;
}): AdapterRuntimeToolAccess | undefined {
if (!input.responsibleUserId) return undefined;
const minted = createRuntimeToolsToken({
agentId: input.agentId,
companyId: input.companyId,
runId: input.runId,
responsibleUserId: input.responsibleUserId,
});
if (!minted) return undefined;
// The normal server bootstrap always exports PAPERCLIP_API_URL. Some service
// tests invoke heartbeat execution without booting an HTTP server, however;
// in that context there is no reachable endpoint to advertise and runtime
// tools should simply remain unavailable instead of failing the run.
const baseUrl = configuredPaperclipApiBaseUrl();
if (!baseUrl) return undefined;
return Object.freeze({
version: 1,
guidance: CONNECTION_INTENT_AGENT_GUIDANCE,
mcpEndpoint: `${baseUrl}/mcp/runtime-tools`,
rest: {
connectionsSearch: `${baseUrl}/runtime-tools/connections/search`,
connectionRequest: `${baseUrl}/runtime-tools/connections/request`,
},
bearerToken: minted.token,
expiresAt: minted.expiresAt,
tools: CONNECTION_RUNTIME_TOOL_NAMES,
});
}
const MANAGED_MCP_LOCAL_ADAPTERS = new Set(["codex_local"]);
function adapterSupportsManagedMcpConfig(adapterType: string): boolean {
@@ -3787,7 +3840,9 @@ export async function createManagedMcpRunConfig(input: {
managedGateways.push({
id: gateway.id,
name: gateway.name,
endpointPath: `/api/tool-gateway/gateways/${gateway.id}/mcp`,
// This path must bypass the normal /api agent-JWT middleware. The MCP
// gateway performs its own bearer validation for the run-scoped token.
endpointPath: `/mcp/gateways/${gateway.gatewayPublicId}`,
bearerToken: token.token,
tokenPrefix: token.tokenPrefix,
});
@@ -6909,6 +6964,16 @@ export interface HeartbeatServiceOptions {
pluginWorkerManager?: PluginWorkerManager;
environmentRuntime?: HeartbeatEnvironmentRuntime;
runtimeEnv?: Record<string, string | undefined>;
/** Test seam for changing a continuation issue at the final pre-dispatch boundary. */
beforeResolvedInteractionContinuationDispatchCheck?: (input: {
runId: string;
issueId: string;
}) => Promise<void>;
/** Test seam for racing an issue mutation after validation while its row lock is held. */
afterResolvedInteractionContinuationDispatchCheck?: (input: {
runId: string;
issueId: string;
}) => Promise<void>;
}
type WorkspaceReadyCommentWriter = {
@@ -12920,7 +12985,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const staleness = await evaluateQueuedRunStaleness(run, issueId, context);
if (staleness.stale) {
await cancelQueuedRunForStaleIssue(run, issueId, staleness);
await cancelRunForStaleIssue(run, issueId, staleness);
logger.info(
{ runId: run.id, issueId, errorCode: staleness.errorCode },
"claimQueuedRun: cancelled stale queued run",
@@ -13212,8 +13277,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
run: typeof heartbeatRuns.$inferSelect,
issueId: string,
context: Record<string, unknown>,
dbOrTx: Db = db,
): Promise<QueuedRunStaleness> {
const issue = await db
const issue = await dbOrTx
.select({
id: issues.id,
status: issues.status,
@@ -13241,6 +13307,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const retryReason = readNonEmptyString(context.retryReason) ?? run.scheduledRetryReason ?? null;
const interactionResolvedAt = readNonEmptyString(context.interactionResolvedAt);
const hasResolvedInteractionEvidence = interactionResolvedAt !== null && !Number.isNaN(Date.parse(interactionResolvedAt));
const isResolvedInteractionContinuation = isResolvedInteractionContinuationWakeContext(context);
if (isResolvedInteractionContinuation && issue.status !== "in_progress") {
return {
stale: true,
errorCode: "issue_not_in_progress",
reason: `Cancelled because resolved-interaction continuation issue is no longer in_progress (current status: ${issue.status}) before the queued run could start`,
details: { issueId, currentStatus: issue.status, requiredStatus: "in_progress" },
};
}
if (isResolvedInteractionContinuation && issue.assigneeAgentId !== run.agentId) {
return {
stale: true,
errorCode: "issue_assignee_changed",
reason:
"Cancelled because resolved-interaction continuation issue changed assignee before the queued run could start",
details: {
issueId,
previousAssigneeAgentId: run.agentId,
currentAssigneeAgentId: issue.assigneeAgentId,
},
};
}
if (
issue.status === "in_progress" &&
@@ -13254,7 +13344,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
readNonEmptyString(parseObject(queuedWake.continuationSummary).body);
const currentContinuationSummary = queuedContinuationSummary
? null
: await getIssueContinuationSummaryDocument(db, issueId);
: await getIssueContinuationSummaryDocument(dbOrTx, issueId);
const continuationSummaryBody = queuedContinuationSummary ?? currentContinuationSummary?.body ?? null;
if (continuationSummaryParksExecutor(continuationSummaryBody)) {
return {
@@ -13281,7 +13371,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const recoveryActionId = readNonEmptyString(context.recoveryActionId);
const authorizedSourceScopedRecovery = wakeReason === "source_scoped_recovery_action" && recoveryActionId
? await db
? await dbOrTx
.select({ id: issueRecoveryActions.id })
.from(issueRecoveryActions)
.where(and(
@@ -13373,7 +13463,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { stale: false };
}
async function cancelQueuedRunForStaleIssue(
async function cancelRunForStaleIssue(
run: typeof heartbeatRuns.$inferSelect,
issueId: string,
staleness: Extract<QueuedRunStaleness, { stale: true }>,
@@ -14560,9 +14650,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const issueDependencyReadiness = issueId
? await issuesSvc.listDependencyReadiness(agent.companyId, [issueId]).then((rows) => rows.get(issueId) ?? null)
: null;
if (issueId && issueContext && isResolvedInteractionContinuationWakeContext(context)) {
try {
// Claim the issue under the same in_progress predicate used by the
// queued-run staleness gate. This is the final atomic guard before
// dispatch: an operator parking the issue after claim but before this
// checkout must not be overwritten by the continuation.
await issuesSvc.checkout(issueId, agent.id, ["in_progress"], run.id);
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
} catch (error) {
if (!isCheckoutConflictError(error)) throw error;
const staleness = await evaluateQueuedRunStaleness(run, issueId, context);
if (staleness.stale) {
await cancelRunForStaleIssue(run, issueId, staleness);
return;
}
throw error;
}
issueContext = await getIssueExecutionContext(agent.companyId, issueId);
}
if (
issueId &&
issueContext &&
!isResolvedInteractionContinuationWakeContext(context) &&
shouldAutoCheckoutIssueForWake({
contextSnapshot: context,
issueStatus: issueContext.status,
@@ -16375,6 +16485,76 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
executionTarget,
});
const adapter = getServerAdapter(agent.adapterType);
const dispatchResolvedInteractionContinuationWithAtomicGate = async <T>(
dispatch: (markDispatchStarted: () => void) => Promise<T>,
): Promise<
| { dispatched: true; resultPromise: Promise<T> }
| { dispatched: false }
> => {
if (!issueId || !isResolvedInteractionContinuationWakeContext(context)) {
return { dispatched: true, resultPromise: dispatch(() => {}) };
}
await options.beforeResolvedInteractionContinuationDispatchCheck?.({ runId: run.id, issueId });
const gate = await db.transaction(async (tx) => {
const lockedIssue = await tx
.select({ executionRunId: issues.executionRunId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.for("update")
.then((rows) => rows[0] ?? null);
const staleness = await evaluateQueuedRunStaleness(
run,
issueId,
context,
tx as unknown as Db,
);
if (staleness.stale) {
return { dispatched: false as const, staleness };
}
if (lockedIssue?.executionRunId !== run.id) {
return {
dispatched: false as const,
staleness: {
stale: true as const,
errorCode: "issue_execution_lock_changed" as const,
reason:
"Cancelled because resolved-interaction continuation no longer owns the issue execution lock before adapter dispatch",
details: {
issueId,
expectedExecutionRunId: run.id,
currentExecutionRunId: lockedIssue?.executionRunId ?? null,
},
},
};
}
await options.afterResolvedInteractionContinuationDispatchCheck?.({ runId: run.id, issueId });
let dispatchStarted = false;
let resolveDispatchStarted!: () => void;
const dispatchStartedPromise = new Promise<void>((resolve) => {
resolveDispatchStarted = resolve;
});
const markDispatchStarted = () => {
if (dispatchStarted) return;
dispatchStarted = true;
resolveDispatchStarted();
};
// Keep the issue row locked through the adapter's asynchronous
// preparation and release it only once the adapter reports an
// actual process spawn. If preparation fails or returns without a
// spawn, settling the adapter promise also releases the gate.
const resultPromise = dispatch(markDispatchStarted);
void resultPromise.then(markDispatchStarted, markDispatchStarted);
await dispatchStartedPromise;
return { dispatched: true as const, resultPromise };
});
if (gate.dispatched) return gate;
await cancelRunForStaleIssue(run, issueId, gate.staleness);
return { dispatched: false };
};
const localAgentJwtScope =
issueRef?.workMode === "skill_test"
? { kind: "skill_test" as const, issueId: issueRef.id }
@@ -16617,7 +16797,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
promptMetrics: { promptChars: prompt.length },
context: { provider: "codex", protocolVersion: 1 },
});
adapterResult = await executeNativeCodexRunner({
const guardedDispatch = await dispatchResolvedInteractionContinuationWithAtomicGate((markDispatchStarted) =>
executeNativeCodexRunner({
db,
companyId: agent.companyId,
issueId: issueRef.id,
@@ -16636,16 +16817,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
timeoutMs,
environment,
onLog,
onSpawn,
});
onSpawn: async (meta) => {
markDispatchStarted();
await onSpawn(meta);
},
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
} else {
const adapterContext = { ...context };
const runtimeTools = createAdapterRuntimeToolAccess({
agentId: agent.id,
companyId: agent.companyId,
runId: run.id,
responsibleUserId: run.responsibleUserId,
});
if (!runtimeTools) {
logger.warn(
{
companyId: agent.companyId,
agentId: agent.id,
runId: run.id,
},
"runtime connection tools could not be delivered",
);
}
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
db,
agent,
runId: run.id,
});
const runtimeToolDelivery = adapter.runtimeToolDelivery ?? "invocation_context";
if (runtimeTools && runtimeToolDelivery === "native_mcp") {
runtimeMcpServers.unshift({
name: "Paperclip connections",
url: runtimeTools.mcpEndpoint,
token: runtimeTools.bearerToken,
connectionId: "paperclip-runtime-tools",
});
}
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
if (runtimeTools && runtimeToolDelivery === "invocation_context") {
adapterContext.paperclipRuntimeTools = runtimeTools;
}
const managedMcpConfig = await createManagedMcpRunConfig({
db,
agent,
@@ -16657,7 +16872,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (managedMcpConfig) {
adapterContext.paperclipManagedMcp = managedMcpConfig;
}
adapterResult = await adapter.execute({
const guardedDispatch = await dispatchResolvedInteractionContinuationWithAtomicGate((markDispatchStarted) =>
adapter.execute({
runId: run.id,
agent,
runtime: runtimeForAdapter,
@@ -16669,6 +16885,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
: undefined,
runtimeMcp,
runtimeTools,
onLog,
onMeta: onAdapterMeta,
onEvent: onAdapterEvent,
@@ -16680,9 +16897,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
onRuntimeProgress: async (progress) => {
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
},
onSpawn,
onDispatch: markDispatchStarted,
onSpawn: async (meta) => {
markDispatchStarted();
await onSpawn(meta);
},
authToken: authToken ?? undefined,
});
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
}
// Adapter returned cleanly, which means its workspace-restore finally
// block also ran without throwing. Record the workspace_finalize
@@ -18603,6 +18827,40 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "skipped" as const };
}
const issueStateGuard = opts.issueStateGuard;
if (
issueStateGuard
&& (
!issueStateGuard.statuses.includes(issue.status)
|| issue.assigneeAgentId !== issueStateGuard.assigneeAgentId
)
) {
await tx.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
source,
triggerDetail,
reason: "issue_state_guard_mismatch",
payload: {
...(payload ?? {}),
heartbeatSkip: {
reason: "Issue status or assignee changed before the wake could be queued.",
issueId: issue.id,
expectedStatuses: issueStateGuard.statuses,
actualStatus: issue.status,
expectedAssigneeAgentId: issueStateGuard.assigneeAgentId,
actualAssigneeAgentId: issue.assigneeAgentId,
},
},
status: "skipped",
requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null,
finishedAt: new Date(),
});
return { kind: "skipped" as const };
}
if (worktreeExecutionCutoff && issue.createdAt < worktreeExecutionCutoff) {
await tx.insert(agentWakeupRequests).values({
companyId: agent.companyId,
+258 -18
View File
@@ -13,6 +13,7 @@ import {
issueThreadInteractions,
issues,
toolActionRequests,
toolOauthStates,
} from "@paperclipai/db";
import { trackInteractionCreated, trackInteractionResolved } from "@paperclipai/shared/telemetry";
import type {
@@ -20,6 +21,7 @@ import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
CancelIssueThreadInteraction,
ConnectionIntentInteraction,
CreateIssueThreadInteraction,
InteractionResolverGovernance,
IssueReviewPolicy,
@@ -47,6 +49,8 @@ import {
askUserQuestionsPayloadSchema,
askUserQuestionsResultSchema,
cancelIssueThreadInteractionSchema,
connectionIntentPayloadSchema,
connectionIntentResultSchema,
createIssueThreadInteractionSchema,
legacyIssueThreadInteractionResolverPolicyAlias,
normalizeIssueThreadInteractionResolverPolicy,
@@ -254,6 +258,7 @@ export const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<
request_confirmation: "anyone",
request_checkbox_confirmation: "anyone",
request_item_verdicts: "anyone",
connection_intent: "human_only",
};
const RESOLVER_POLICY_RESTRICTION_RANK: Record<IssueThreadInteractionCanonicalResolverPolicy, number> = {
@@ -387,11 +392,13 @@ type TargetBoundInteraction =
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
...TARGET_BOUND_INTERACTION_KINDS,
"ask_user_questions",
"connection_intent",
] as const;
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
type UserCommentSupersedableInteraction =
| TargetBoundInteraction
| AskUserQuestionsInteraction;
| AskUserQuestionsInteraction
| ConnectionIntentInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
@@ -529,6 +536,13 @@ function hydrateInteraction(
payload: requestItemVerdictsPayloadSchema.parse(row.payload),
result: parseStoredInteractionResult(requestItemVerdictsResultSchema, row.result, row),
} satisfies RequestItemVerdictsInteraction;
case "connection_intent":
return {
...base,
kind: "connection_intent",
payload: connectionIntentPayloadSchema.parse(row.payload),
result: parseStoredInteractionResult(connectionIntentResultSchema, row.result, row),
} satisfies ConnectionIntentInteraction;
default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
}
@@ -608,6 +622,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
}
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
if (interaction.kind === "connection_intent") return true;
return interaction.payload.supersedeOnUserComment === true;
}
@@ -651,6 +666,13 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C
}
function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) {
if (row.kind === "connection_intent") {
return {
version: 1,
outcome: "expired",
reason: "Superseded by a newer user comment",
} as const;
}
if (row.kind === "ask_user_questions") {
return {
version: 1,
@@ -729,6 +751,9 @@ function buildAdministrativeOutcomeResult(
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
reason: string | null = null,
) {
if (row.kind === "connection_intent") {
return { version: 1, outcome: "expired", reason } as const;
}
if (row.kind === "ask_user_questions") {
return { version: 1, outcome, reason, answers: [], summaryMarkdown: null } as const;
}
@@ -909,6 +934,9 @@ function deriveResolutionReason(interaction: IssueThreadInteraction) {
case "cancelled":
return "cancelled";
case "expired": {
if (interaction.kind === "connection_intent") {
return interaction.result?.outcome ?? "expired";
}
if (interaction.kind === "ask_user_questions") {
return interaction.result?.expirationReason ?? "expired";
}
@@ -1007,7 +1035,7 @@ async function emitInteractionResolvedTelemetry(
: undefined;
trackInteractionResolved(telemetryClient, {
interactionKind: interaction.kind,
interactionKind: interaction.kind === "connection_intent" ? "other" : interaction.kind,
status: interaction.status,
resolvedByKind: resolveActorKind(interaction),
resolutionReason: deriveResolutionReason(interaction),
@@ -1034,7 +1062,10 @@ function emitInteractionCreatedTelemetry(args: {
if (!telemetryClient) return;
try {
trackInteractionCreated(telemetryClient, args);
trackInteractionCreated(telemetryClient, {
...args,
interactionKind: args.interactionKind === "connection_intent" ? "other" : args.interactionKind,
});
} catch (error) {
console.error("[paperclip] Failed to emit interaction.created telemetry", error);
}
@@ -1853,6 +1884,181 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
return {
getForIssue,
createConnectionIntent: async (
issue: { id: string; companyId: string },
input: {
payload: ConnectionIntentInteraction["payload"];
sourceRunId: string;
addresseeUserId: string;
idempotencyKey: string;
},
) => {
const payload = connectionIntentPayloadSchema.parse(input.payload);
const existing = await getIdempotentInteraction({
issueId: issue.id,
companyId: issue.companyId,
idempotencyKey: input.idempotencyKey,
});
if (existing) {
if (
existing.kind !== "connection_intent"
|| existing.sourceRunId !== input.sourceRunId
|| existing.addresseeUserId !== input.addresseeUserId
|| !isDeepStrictEqual(existing.payload, payload)
) {
throw conflict("Interaction idempotency key already exists for a different request", {
idempotencyKey: input.idempotencyKey,
});
}
return hydrateInteraction(existing) as ConnectionIntentInteraction;
}
const created = await db.transaction(async (tx) => {
const issueRow = await tx
.select({ status: issues.status })
.from(issues)
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)))
.for("update")
.then((rows) => rows[0] ?? null);
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
throw conflict("Cannot create an interaction on a closed issue");
}
const [row] = await tx
.insert(issueThreadInteractions)
.values({
companyId: issue.companyId,
issueId: issue.id,
kind: "connection_intent",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
idempotencyKey: input.idempotencyKey,
sourceRunId: input.sourceRunId,
title: `Connect ${payload.serviceName}`,
summary: `${payload.requestingAgentName} needs this connection to continue.`,
createdByAgentId: payload.requestingAgentId,
addresseeUserId: input.addresseeUserId,
payload,
})
.returning();
const olderPending = await tx
.select()
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "connection_intent"),
eq(issueThreadInteractions.createdByAgentId, payload.requestingAgentId),
eq(issueThreadInteractions.status, "pending"),
ne(issueThreadInteractions.id, row.id),
));
const supersededIds = olderPending
.filter((candidate) => {
const candidatePayload = connectionIntentPayloadSchema.safeParse(candidate.payload);
return candidatePayload.success && candidatePayload.data.serviceSlug === payload.serviceSlug;
})
.map((candidate) => candidate.id);
if (supersededIds.length > 0) {
await tx
.delete(toolOauthStates)
.where(inArray(toolOauthStates.interactionId, supersededIds));
await tx
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "superseded",
supersededByInteractionId: row.id,
},
resolvedAt: now(),
updatedAt: now(),
})
.where(inArray(issueThreadInteractions.id, supersededIds));
}
await touchIssue(tx, issue.id);
return row;
});
const interaction = hydrateInteraction(created) as ConnectionIntentInteraction;
emitInteractionCreatedTelemetry({
interactionKind: "connection_intent",
usedDeprecatedResolverPolicyAlias: false,
});
return interaction;
},
updateConnectionIntentPhase: async (
issue: { id: string; companyId: string },
interactionId: string,
phase: ConnectionIntentInteraction["payload"]["phase"],
actor: InteractionActor,
) => {
const current = await getPendingInteractionForResolution({ issue, interactionId });
if (current.kind !== "connection_intent") {
throw unprocessable("Only connection_intent interactions have a connection phase");
}
assertInteractionResolutionAllowed(current, actor);
const payload = connectionIntentPayloadSchema.parse(current.payload);
const [updated] = await db
.update(issueThreadInteractions)
.set({ payload: { ...payload, phase }, updatedAt: now() })
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) throw interactionAlreadyResolvedError();
await touchIssue(db, issue.id);
return hydrateInteraction(updated) as ConnectionIntentInteraction;
},
resolveConnectionIntent: async (
issue: { id: string; companyId: string },
interactionId: string,
resultInput: ConnectionIntentInteraction["result"] extends infer T ? NonNullable<T> : never,
actor: InteractionActor,
) => {
const result = connectionIntentResultSchema.parse(resultInput);
const current = await getPendingInteractionForResolution({ issue, interactionId });
if (current.kind !== "connection_intent") {
throw unprocessable("Only connection_intent interactions can be resolved by this operation");
}
if (!actor.userId) throw forbidden("Connection intents require a human resolver");
assertInteractionResolutionAllowed(current, actor);
const status = result.outcome === "connected"
? "accepted"
: result.outcome === "declined"
? "rejected"
: "expired";
const resolvedAt = now();
const [updated] = await db
.update(issueThreadInteractions)
.set({
status,
result,
resolvedByUserId: actor.userId,
resolvedAt,
updatedAt: resolvedAt,
})
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) throw interactionAlreadyResolvedError();
await touchIssue(db, issue.id);
const interaction = hydrateInteraction(updated) as ConnectionIntentInteraction;
await emitInteractionResolvedTelemetry(db, interaction);
return interaction;
},
sweepMergedPullRequestConfirmations: async () => {
const rows = await db
.select({
@@ -2928,21 +3134,26 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of superseded) {
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByCommentResult(row, comment.id),
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
const [updated] = await db.transaction(async (tx) => {
if (row.kind === "connection_intent") {
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
}
return tx
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByCommentResult(row, comment.id),
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
});
if (updated) expired.push(hydrateInteraction(updated));
}
@@ -3025,6 +3236,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
.map((row) => row.id);
const itemVerdictRows = commentRows
.filter((row) => row.kind === "request_item_verdicts");
const connectionIntentRows = commentRows
.filter((row) => row.kind === "connection_intent");
if (questionRowIds.length > 0) {
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
@@ -3086,6 +3299,28 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
.returning();
if (updated) expired.push(hydrateInteraction(updated));
}
for (const row of connectionIntentRows) {
const [updated] = await db.transaction(async (tx) => {
await tx.delete(toolOauthStates).where(eq(toolOauthStates.interactionId, row.id));
return tx
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByCommentResult(row, comment.id),
resolvedByAgentId: null,
resolvedByUserId: comment.authorUserId,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
});
if (updated) expired.push(hydrateInteraction(updated));
}
}
if (expired.length > 0) {
@@ -3194,6 +3429,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
// expires and the execution result lands on it via the gateway's
// lifecycle reflection.
const updated = await db.transaction(async (tx) => {
if (row.kind === "connection_intent") {
await tx
.delete(toolOauthStates)
.where(eq(toolOauthStates.interactionId, row.id));
}
await resolveLinkedToolActionRequests(tx, row, {
status: "expired",
fromStatuses: ["pending", "approved"],
+3 -3
View File
@@ -918,7 +918,7 @@ function assertSelectableProviderConfig(config: {
}
}
export function secretService(db: Db) {
export function secretService(db: Db | DbTransaction) {
const authorization = authorizationService(db);
type NormalizeEnvOptions = {
@@ -1629,7 +1629,7 @@ export function secretService(db: Db) {
accessContext,
});
await context.registerForRedaction(resolution.value);
await logActivity(db, {
await logActivity(db as Db, {
companyId,
actorType: "agent",
actorId: context.agentId,
@@ -1651,7 +1651,7 @@ export function secretService(db: Db) {
};
} catch (error) {
const errorCode = secretResolutionErrorCode(error);
await logActivity(db, {
await logActivity(db as Db, {
companyId,
actorType: "agent",
actorId: context.agentId,
File diff suppressed because it is too large. Load diff
+97 -62
View File
@@ -1,12 +1,13 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lte, ne, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
companies,
companyMemberships,
companySecrets,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
@@ -208,6 +209,8 @@ export interface ToolGatewaySession {
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
/** Human whose personal connection grant applies to this execution. */
responsibleUserId?: string | null;
createdAt: Date;
expiresAt: Date;
}
@@ -922,7 +925,15 @@ export function createToolGatewayService(
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
eq(toolConnections.status, "active"),
eq(toolConnections.enabled, true),
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
// A personal connection has no company-level credential to probe. A
// credential-less health sweep can therefore mark it as errored even
// while the responsible user's grant is valid. Keep its cached active
// catalog discoverable; execution resolves and validates that user's
// grant, and a successful call restores the shared health indicator.
or(
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
eq(toolConnections.credentialPolicy, "per_user"),
),
eq(toolApplications.companyId, companyId),
inArray(toolApplications.type, ["mcp_http", "mcp_stdio"]),
eq(toolApplications.status, "active"),
@@ -2446,6 +2457,73 @@ export function createToolGatewayService(
);
}
async function resolveGrantSecretValue(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
ref: ToolCredentialSecretRef,
configPath = ref.configPath,
): Promise<string> {
const accessContext = {
consumerType: "tool_connection" as const,
consumerId: connection.id,
configPath,
actorType: "system" as const,
actorId: session.agentId,
responsibleUserId: grant.subjectUserId,
issueId: session.issueId,
heartbeatRunId: session.runId,
};
if (grant.kind !== "user") {
return secrets.resolveSecretValue(
connection.companyId,
ref.secretId,
ref.versionSelector ?? "latest",
{ accessContext },
);
}
if (!grant.subjectUserId) {
throw new ToolGatewayHttpError(422, "Personal authorization has no owner", "grant_owner_missing", {
connectionId: connection.id,
grantId: grant.id,
});
}
const [secret] = await db.select({
scope: companySecrets.scope,
ownerUserId: companySecrets.ownerUserId,
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
}).from(companySecrets).where(and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, connection.companyId),
)).limit(1);
if (
!secret
|| secret.scope !== "user"
|| secret.ownerUserId !== grant.subjectUserId
|| !secret.userSecretDefinitionId
) {
throw new ToolGatewayHttpError(422, "Personal authorization has an invalid credential", "grant_credential_invalid", {
connectionId: connection.id,
grantId: grant.id,
credential: configPath,
});
}
const resolved = await secrets.resolveUserSecretValue(connection.companyId, {
definitionId: secret.userSecretDefinitionId,
responsibleUserId: grant.subjectUserId,
version: ref.versionSelector ?? "latest",
required: ref.required ?? true,
}, accessContext);
if (!resolved) {
throw new ToolGatewayHttpError(422, "Personal credential is not configured", "user_secret_missing", {
connectionId: connection.id,
grantId: grant.id,
credential: configPath,
});
}
return resolved.value;
}
async function maybeRefreshPaperclipIdGmailGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
@@ -2480,22 +2558,7 @@ export function createToolGatewayService(
grantId: grant.id,
});
}
const refreshToken = await secrets.resolveSecretValue(
connection.companyId,
refreshRef.secretId,
refreshRef.versionSelector ?? "latest",
{
accessContext: {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: refreshRef.configPath,
actorType: "system",
actorId: session.agentId,
issueId: session.issueId,
heartbeatRunId: session.runId,
},
},
);
const refreshToken = await resolveGrantSecretValue(session, connection, grant, refreshRef);
try {
const credentials = await gmailConnector.refresh({
subject: grant.subjectUserId,
@@ -2562,17 +2625,13 @@ export function createToolGatewayService(
const grantRef = grantRefForHeader(grant, ref);
if (!grantRef) continue;
try {
const value = await secrets.resolveSecretValue(connection.companyId, grantRef.secretId, grantRef.versionSelector ?? "latest", {
accessContext: {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: `credentials.${ref.name}`,
actorType: "system",
actorId: session.agentId,
issueId: session.issueId,
heartbeatRunId: session.runId,
},
});
const value = await resolveGrantSecretValue(
session,
connection,
grant,
grantRef,
`credentials.${ref.name}`,
);
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
@@ -2587,22 +2646,7 @@ export function createToolGatewayService(
const oauthAccessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
if (oauthAccessRef && headers.Authorization === undefined) {
try {
const value = await secrets.resolveSecretValue(
connection.companyId,
oauthAccessRef.secretId,
oauthAccessRef.versionSelector ?? "latest",
{
accessContext: {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: oauthAccessRef.configPath,
actorType: "system",
actorId: session.agentId,
issueId: session.issueId,
heartbeatRunId: session.runId,
},
},
);
const value = await resolveGrantSecretValue(session, connection, grant, oauthAccessRef);
headers.Authorization = `Bearer ${value}`;
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
@@ -2852,7 +2896,7 @@ export function createToolGatewayService(
eq(heartbeatRuns.companyId, session.companyId),
)).limit(1)
: [];
const actingUserId = run?.responsibleUserId ?? null;
const actingUserId = run?.responsibleUserId ?? session.responsibleUserId ?? null;
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
const findUserGrant = async () => {
if (!actingUserId) return undefined;
@@ -3077,22 +3121,7 @@ export function createToolGatewayService(
const grantRef = grant.credentialSecretRefs.find((ref) => ref.configPath === `env.${key}`);
if (!grantRef) continue;
try {
env[key] = await secrets.resolveSecretValue(
connection.companyId,
grantRef.secretId,
grantRef.versionSelector ?? "latest",
{
accessContext: {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: grantRef.configPath,
actorType: "system",
actorId: session.agentId,
issueId: session.issueId,
heartbeatRunId: session.runId,
},
},
);
env[key] = await resolveGrantSecretValue(session, connection, grant, grantRef);
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured local stdio credential could not be resolved.");
throw new ToolGatewayHttpError(
@@ -4171,6 +4200,7 @@ export function createToolGatewayService(
}
let agentId = row.gateway.agentId;
let runId: string | null = null;
let responsibleUserId: string | null = null;
let issueId = row.gateway.issueId;
let projectId = row.gateway.projectId;
if (row.token.subjectType === "heartbeat_run") {
@@ -4189,6 +4219,7 @@ export function createToolGatewayService(
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
responsibleUserId: heartbeatRuns.responsibleUserId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, tokenRunId))
@@ -4230,6 +4261,7 @@ export function createToolGatewayService(
});
agentId = run.agentId;
runId = tokenRunId;
responsibleUserId = run.responsibleUserId;
issueId = runContext.issueId;
projectId = runContext.projectId;
} catch {
@@ -4262,6 +4294,7 @@ export function createToolGatewayService(
gatewayTokenAllowedActions: normalizeGatewayTokenActions(row.token.allowedActions),
actorType: runId ? "agent" : "system",
actorId: runId ? agentId : row.token.id,
responsibleUserId,
createdAt: row.token.createdAt,
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
};
@@ -4504,6 +4537,7 @@ export function createToolGatewayService(
projectId: null,
actorType: "user",
actorId: userId,
responsibleUserId: userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
@@ -5536,6 +5570,7 @@ export function createToolGatewayService(
projectId: null,
actorType: "user",
actorId: input.userId,
responsibleUserId: input.userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};