mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
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:
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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user