import { createHash, randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from "vitest"; import { activityLog, agents, applyPendingMigrations, companies, companyMemberships, companySecrets, connectionGrants, createDb, ensurePostgresDatabase, heartbeatRuns, issues, issueThreadInteractions, toolAccessAuditEvents, toolActionRequests, toolApplications, toolCatalogEntries, toolConnections, toolInvocations, userSecretDeclarations, } from "@paperclipai/db"; import { toolAccessService } from "../services/tool-access.js"; import { createToolGatewayService } from "../services/tool-gateway.js"; import { summarizeToolValue } from "../services/tool-content-guards.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js"; import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat/run-preparation.js"; import { createAssignedMcpTools } from "../services/native-runtime/assigned-mcp-tools.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; // These provider fixtures exercise Paperclip's real setup, vault, policy, and // run gateway. They do not establish live provider permissions or deployment. const actor = { actorType: "user" as const, actorId: "deployment-connector-owner" }; const projectRef = "abcdefghijklmnopqrst"; const teamId = "team_fixture123"; const projectId = "prj_fixture123"; const deploymentId = "dpl_fixture123"; const environmentSecret = "synthetic-deployment-environment-secret"; const providers = [ { slug: "supabase", methodKey: "management-api", methodConfig: { projectRef }, read: { name: "supabase_get_project", parameters: {}, receipt: projectRef }, deploy: { name: "supabase_deploy_edge_function", parameters: { slug: "hello", entrypoint_path: "index.ts", verify_jwt: true, files: [{ name: "index.ts", content: 'Deno.serve(() => new Response("hello"))' }], }, receipt: "hello", }, configure: { name: "supabase_update_auth_config", parameters: { config: { site_url: "https://app.example", uri_allow_list: "https://app.example/**" } }, receipt: "https://app.example", }, configureSecret: { name: "supabase_upsert_edge_function_secret", parameters: { name: "SERVICE_TOKEN", secretValue: environmentSecret }, receipt: "SERVICE_TOKEN", }, }, { slug: "vercel", methodKey: "rest-api-key", methodConfig: { teamId }, read: { name: "vercel_list_projects", parameters: {}, receipt: projectId }, deploy: { name: "vercel_create_deployment", parameters: { projectId, name: "fixture-app", files: [{ file: "index.html", content: "

Hello

" }], target: "preview" }, receipt: deploymentId, }, configure: { name: "vercel_upsert_environment_variable", parameters: { projectId, key: "SERVICE_TOKEN", secretValue: environmentSecret, target: ["preview"] }, receipt: "SERVICE_TOKEN", }, }, ] as const; type Provider = typeof providers[number]; const external = process.env.PAPERCLIP_TEST_DATABASE_URL; const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("Deployment connector governance", () => { let db: ReturnType; let temp: Awaited> | undefined; beforeAll(async () => { if (external) { const url = new URL(external); if (process.env.PAPERCLIP_TEST_REUSE_DEPLOYMENT_DATABASE === "1") { // Only an explicitly named synthetic fixture database may be reused. // Each test still creates its own company and connection identities. if (!/^\/deployment_connectors_[a-f0-9]{32}$/.test(url.pathname)) { throw new Error("Reuse requires an isolated deployment_connectors fixture database"); } } else { url.pathname = `/deployment_connectors_${randomUUID().replaceAll("-", "")}`; } await ensurePostgresDatabase(external, url.pathname.slice(1)); await applyPendingMigrations(url.toString()); db = createDb(url.toString()); } else { temp = await startEmbeddedPostgresTestDatabase("deployment-connectors-"); db = createDb(temp.connectionString); } }, 30000); afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); }); async function createCompany() { const [company] = await db.insert(companies).values({ name: "Deployment fixture", issuePrefix: `D${randomUUID().slice(0, 6)}` }).returning(); await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" }); return company; } async function fixture(provider: Provider, personal = false, secretValues = [environmentSecret]) { const company = await createCompany(); let credential = `synthetic-${provider.slug}-credential`; const request = vi.fn(async (url: string, init: RequestInit) => { expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${credential}`); expect(["error", "manual"]).toContain(init.redirect); const parsed = new URL(url); const method = init.method ?? "GET"; if (provider.slug === "supabase") { expect(parsed.origin).toBe("https://api.supabase.com"); const prefix = `/v1/projects/${projectRef}`; if (parsed.pathname === prefix && method === "GET") return Response.json({ id: projectRef, name: "Fixture project", status: "ACTIVE_HEALTHY", organization_id: "fixture-org", region: "us-east-1" }); if (parsed.pathname === `${prefix}/config/auth`) { if (method === "PATCH") expect(JSON.parse(String(init.body))).toEqual(providers[0].configure.parameters.config); return Response.json({ site_url: "https://app.example", uri_allow_list: "https://app.example/**", jwt_secret: credential }); } if (parsed.pathname === `${prefix}/functions/deploy` && method === "POST") { expect(parsed.searchParams.get("slug")).toBe("hello"); expect(Buffer.isBuffer(init.body)).toBe(true); const form = await new Response(init.body, { headers: init.headers }).formData(); expect(JSON.parse(String(form.get("metadata")))).toMatchObject({ entrypoint_path: "index.ts", verify_jwt: true }); expect(await (form.get("file") as Blob).text()).toBe(providers[0].deploy.parameters.files[0].content); return Response.json({ id: randomUUID(), slug: "hello", name: "hello", status: "ACTIVE", version: 1 }); } if (parsed.pathname === `${prefix}/secrets` && method === "POST") { const body = JSON.parse(String(init.body)); expect(body).toEqual([{ name: "SERVICE_TOKEN", value: expect.any(String) }]); expect(secretValues).toContain(body[0].value); return Response.json(body); } } else { expect(parsed.origin).toBe("https://api.vercel.com"); if (parsed.pathname === `/v2/teams/${teamId}` && method === "GET") return Response.json({ id: teamId, name: "Fixture team" }); expect(parsed.searchParams.get("teamId")).toBe(teamId); if (parsed.pathname === "/v10/projects" && method === "GET") return Response.json({ projects: [{ id: projectId, name: "fixture-app", accountId: teamId }], pagination: { count: 1, next: null } }); if (parsed.pathname === `/v9/projects/${projectId}` && method === "GET") return Response.json({ id: projectId, name: "fixture-app", accountId: teamId }); if (parsed.pathname === "/v7/deployments" && method === "GET") { expect(parsed.searchParams.get("projectId")).toBe(projectId); expect(parsed.searchParams.get("limit")).toBe("1"); return Response.json({ deployments: [{ uid: "dpl_existing123", projectId, readyState: "ERROR" }] }); } if (parsed.pathname === "/v13/deployments" && method === "POST") { expect(JSON.parse(String(init.body))).toMatchObject({ project: projectId, files: [{ file: "index.html", data: "

Hello

", encoding: "utf-8" }] }); return Response.json({ id: deploymentId, projectId, name: "fixture-app", url: "fixture-app.vercel.app", readyState: "BUILDING", target: null }); } if (parsed.pathname === `/v10/projects/${projectId}/env` && method === "POST") { expect(parsed.searchParams.get("upsert")).toBe("true"); const body = JSON.parse(String(init.body)); expect(body).toMatchObject({ key: "SERVICE_TOKEN", value: expect.any(String), target: ["preview"], type: "encrypted" }); expect(secretValues).toContain(body.value); return Response.json({ created: { id: "env_fixture", ...body }, failed: [] }); } } throw new Error(`Unexpected ${provider.slug} fixture request: ${method} ${parsed.pathname}`); }); const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }); const connected = await service.connectGalleryApp(company.id, { galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: provider.methodConfig, ...(personal ? { grantKind: "user" as const } : {}), credentialValues: { "credentials.authorization": credential }, }, actor); const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Deployer", role: "engineer", adapterType: "process" }).returning(); await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, }, actor); const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Deploy fixture app", assigneeAgentId: agent.id }).returning(); const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id, scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning(); const gateway = createToolGatewayService(db, { remoteHttpRequest: request, toolActionSigningSecret: "synthetic-deployment-action-signing-secret" }); const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id }); const tools = await gateway.listToolsForSession(session.token); const call = (action: { name: string; parameters: unknown }) => { const tool = tools.find(candidate => candidate.upstreamToolName === action.name); expect(tool, action.name).toBeTruthy(); return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: action.parameters, idempotencyKey: randomUUID() }); }; return { company, service, connected, agent, issue, run, request, gateway, session, tools, call, credential: () => credential, rotateCredential: () => { credential = `synthetic-${provider.slug}-replacement`; return credential; } }; } describe.each(providers)("$slug", provider => { it("recognizes reviewed REST readiness and completes an existing connection card", async () => { const f = await fixture(provider, true); await f.service.putConnectionInstalls(f.connected.connectionId, { installs: [{ targetType: "agent", targetId: f.agent.id }], }, actor); const intents = connectionIntentService(db); const claims = { sub: f.agent.id, company_id: f.company.id, run_id: f.run.id, responsible_user_id: actor.actorId }; f.request.mockClear(); const found = await intents.search(claims, provider.slug); expect(found.results).toContainEqual(expect.objectContaining({ service: provider.slug, state: "ready", connectionId: f.connected.connectionId, })); expect(await intents.request(claims, provider.slug)).toMatchObject({ state: "ready", connectionId: f.connected.connectionId, interactionId: null, }); const [card] = await db.insert(issueThreadInteractions).values({ companyId: f.company.id, issueId: f.issue.id, sourceRunId: f.run.id, kind: "connection_intent", status: "pending", addresseeUserId: actor.actorId, requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", payload: { version: 1, serviceSlug: provider.slug, serviceName: provider.slug, requestingAgentId: f.agent.id, requestingAgentName: f.agent.name, phase: "requested" }, }).returning(); expect(await intents.complete(card.id, f.connected.connectionId, actor.actorId)).toMatchObject({ status: "accepted", result: { outcome: "connected", connectionId: f.connected.connectionId }, }); expect(await intents.usableConnectionForAgent({ companyId: f.company.id, agentId: f.agent.id, responsibleUserId: "another-user", serviceSlug: provider.slug })).toBeNull(); expect(f.request).not.toHaveBeenCalled(); }); it("delivers reviewed REST actions through native assignments without admitting unsupported or inactive connections", async () => { vi.stubEnv("PAPERCLIP_API_URL", "http://127.0.0.1:3100"); onTestFinished(() => vi.unstubAllEnvs()); const f = await fixture(provider, true); await f.service.putConnectionInstalls(f.connected.connectionId, { installs: [{ targetType: "agent", targetId: f.agent.id }], }, actor); const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: f.agent, runId: f.run.id }); expect(snapshot.bindingId).not.toBeNull(); const servers = await buildPaperclipRuntimeMcpServers({ db, agent: f.agent, runId: f.run.id, expectedAssignmentDigest: snapshot.digest }); expect(servers).toHaveLength(1); const assigned = await createAssignedMcpTools({ gateway: f.gateway, gatewayPublicId: new URL(servers[0].url).pathname.split("/").at(-1)!, bearerToken: servers[0].token! }); const found = await assigned.execute({ tool: "paperclip_search_assigned_tools", arguments: { query: provider.read.name } }) as { tools: Array<{ name: string }> }; expect(found.tools).toHaveLength(1); expect(JSON.stringify(await assigned.execute({ tool: "paperclip_call_assigned_tool", arguments: { name: found.tools[0].name, arguments: provider.read.parameters } }))).toContain(provider.read.receipt); const intents = connectionIntentService(db); const original = f.connected.connection; f.request.mockClear(); for (const change of [ { enabled: false }, { status: "archived" as const }, { config: { ...original.config, url: "https://unsupported.example" } }, ]) { await db.update(toolConnections).set({ status: "active", enabled: true, config: original.config, ...change }) .where(eq(toolConnections.id, original.id)); expect(await intents.usableConnectionForAgent({ companyId: f.company.id, agentId: f.agent.id, responsibleUserId: actor.actorId, serviceSlug: provider.slug })).toBeNull(); expect((await resolveNativeRuntimeMcpSnapshot({ db, agent: f.agent, runId: f.run.id })).bindingId).toBeNull(); expect(await buildPaperclipRuntimeMcpServers({ db, agent: f.agent, runId: f.run.id })).toEqual([]); } expect(f.request).not.toHaveBeenCalled(); }); it("rejects unknown setup settings before saving credentials or contacting the provider", async () => { const company = await createCompany(); const request = vi.fn(async () => { throw new Error("Unexpected provider request"); }); const service = toolAccessService(db, { remoteHttpRequest: request }); await expect(service.connectGalleryApp(company.id, { galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: { ...provider.methodConfig, serverUrl: "https://attacker.example" }, credentialValues: { "credentials.authorization": "synthetic-rejected-setup-key" }, }, actor)).rejects.toThrow(); expect(request).not.toHaveBeenCalled(); expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0); expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0); }); it("cleans up failed personal setup without echoing provider credentials", async () => { const company = await createCompany(); const credential = "synthetic-invalid-provider-token"; const request = vi.fn(async () => Response.json({ error: `Rejected ${credential}` }, { status: 401 })); const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }); let failure: unknown; try { await service.connectGalleryApp(company.id, { galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: provider.methodConfig, grantKind: "user", credentialValues: { "credentials.authorization": credential }, }, actor); } catch (error) { failure = error; } expect(failure).toBeInstanceOf(Error); expect(String(failure)).not.toContain(credential); expect(request).toHaveBeenCalled(); expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0); const secrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id)); expect(secrets.every(secret => secret.status !== "active" || secret.deletedAt !== null)).toBe(true); expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.companyId, company.id))).toHaveLength(0); }); it.each([false, true])("vaults the selected identity and executes governed deployment/configuration (personal=%s)", async personal => { const f = await fixture(provider, personal); const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connected.connectionId)); expect(grant).toMatchObject({ kind: personal ? "user" : "organization", status: "active" }); expect(grant.credentialSecretRefs).toHaveLength(1); expect(grant.credentialSecretRefs[0].configPath).toBe("credentials.authorization"); const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId)); expect(secret).toMatchObject({ scope: personal ? "user" : "company", ownerUserId: personal ? actor.actorId : null }); if (personal) { const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, f.connected.connectionId)); expect(declarations).toEqual(expect.arrayContaining([expect.objectContaining({ configPath: "credentials.authorization", userSecretDefinitionId: secret.userSecretDefinitionId, })])); expect(f.connected.connection.credentialSecretRefs).toEqual([]); } expect(f.connected.catalog.find(entry => entry.toolName === provider.read.name)).toMatchObject({ isReadOnly: true, riskLevel: "read" }); for (const action of [provider.deploy, provider.configure]) { expect(f.connected.catalog.find(entry => entry.toolName === action.name)).toMatchObject({ isReadOnly: false }); } expect(JSON.stringify(f.connected)).not.toContain(f.credential()); f.request.mockClear(); await expect(f.service.mintConnectionTokenForAgent({ connectionId: f.connected.connectionId, companyId: f.company.id, agentId: f.agent.id, runId: f.run.id, body: {}, })).rejects.toMatchObject({ status: 403, message: expect.stringContaining("cannot be exported") }); expect(f.request).not.toHaveBeenCalled(); const actions = [provider.read, provider.deploy, provider.configure, ...(provider.slug === "supabase" ? [provider.configureSecret] : [])]; for (const action of actions) { const result = await f.call(action); expect(JSON.stringify(result)).toContain(action.receipt); expect(JSON.stringify(result)).not.toContain(f.credential()); expect(JSON.stringify(result)).not.toContain(environmentSecret); } if (provider.slug === "supabase") { expect(f.request.mock.calls.some(([url, init]) => new URL(url).pathname === `/v1/projects/${projectRef}/config/auth` && init.method === "PATCH")).toBe(true); } const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.connectionId, f.connected.connectionId)); expect(invocations).toHaveLength(actions.length); expect(invocations.every(invocation => invocation.runId === f.run.id && invocation.issueId === f.issue.id && invocation.agentId === f.agent.id && invocation.status === "succeeded")).toBe(true); const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)); const activity = await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)); const visible = JSON.stringify({ connection: await f.service.getConnection(f.connected.connectionId), invocations, audits, activity }); expect(visible).not.toContain(f.credential()); expect(visible).not.toContain(environmentSecret); }); it("enforces changed Off policies and quarantined deployment contracts on an existing run", async () => { const f = await fixture(provider); await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, { enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.toolName !== provider.configure.name).map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] }, }, actor); f.request.mockClear(); await expect(f.call(provider.configure)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); const deployment = f.connected.catalog.find(entry => entry.toolName === provider.deploy.name)!; await db.update(toolCatalogEntries).set({ schemaHash: "earlier-schema", versionHash: "earlier-version" }).where(eq(toolCatalogEntries.id, deployment.id)); expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === provider.deploy.name)).toBe(false); await expect(f.call(provider.deploy)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); const refreshed = await f.service.refreshCatalog(f.connected.connectionId, actor, { enableAllByDefault: true }); expect(refreshed.catalog.find(entry => entry.toolName === provider.deploy.name)?.status).toBe("quarantined"); f.request.mockClear(); await expect(f.call(provider.deploy)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); }); it("posts an Ask-first deployment request without reaching the provider", async () => { const f = await fixture(provider); const deployment = f.connected.catalog.find(entry => entry.toolName === provider.deploy.name)!; await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, { enabledCatalogEntryIds: f.connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [deployment.id], access: { agentIds: [f.agent.id] }, }, actor); f.request.mockClear(); await expect(f.call(provider.deploy)).rejects.toMatchObject({ reasonCode: "approval_required" }); expect(f.request).not.toHaveBeenCalled(); const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); expect(requests).toHaveLength(1); expect(requests[0]).toMatchObject({ status: "pending", companyId: f.company.id, issueId: f.issue.id, requestedByAgentId: f.agent.id }); expect(JSON.stringify(requests)).not.toContain(f.credential()); }); it("encrypts pending secret configuration and executes the original value only after approval", async () => { const f = await fixture(provider); const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure; const entry = f.connected.catalog.find(candidate => candidate.toolName === configure.name)!; await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, { enabledCatalogEntryIds: f.connected.catalog.map(candidate => candidate.id), askFirstCatalogEntryIds: [entry.id], access: { agentIds: [f.agent.id] }, }, actor); f.request.mockClear(); await expect(f.call(configure)).rejects.toMatchObject({ reasonCode: "approval_required" }); expect(f.request).not.toHaveBeenCalled(); const [pending] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); expect(pending.status).toBe("pending"); expect(pending.signedArguments).toEqual(expect.any(String)); expect(Buffer.from(pending.signedArguments!, "base64url").toString("utf8")).not.toContain(environmentSecret); expect(JSON.stringify(pending)).not.toContain(environmentSecret); const approved = await f.gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: pending.id, actor: { userId: actor.actorId } }); expect(approved).toMatchObject({ status: "executed" }); // Provider fixture assertions check the decrypted original value exactly. expect(f.request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1); const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id)); const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)); const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); expect(JSON.stringify({ approved, invocations, audits, requests })).not.toContain(environmentSecret); expect(JSON.stringify({ approved, invocations, audits, requests })).not.toContain(f.credential()); }); it.each(["allowed", "ask_first", "ask_first_legacy"] as const)("distinguishes changed secrets and replays identical writes without exposing values (%s)", async mode => { const askFirst = mode !== "allowed"; const secrets = ["synthetic-secret-first-value", "synthetic-secret-second-value"]; const f = await fixture(provider, false, secrets); const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure; const tool = f.tools.find(candidate => candidate.upstreamToolName === configure.name)!; const write = (secretValue: string) => f.gateway.executeTool({ sessionToken: f.session.token, tool: tool.name, parameters: { ...configure.parameters, secretValue }, }); if (askFirst) { const entry = f.connected.catalog.find(candidate => candidate.toolName === configure.name)!; await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, { enabledCatalogEntryIds: f.connected.catalog.map(candidate => candidate.id), askFirstCatalogEntryIds: [entry.id], access: { agentIds: [f.agent.id] }, }, actor); } const execute = async (secretValue: string) => { if (askFirst) { await expect(write(secretValue)).rejects.toMatchObject({ reasonCode: "approval_required" }); const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); const pending = requests.filter(request => request.status === "pending"); expect(pending).toHaveLength(1); await f.gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: pending[0].id, actor: { userId: actor.actorId } }); if (mode === "ask_first_legacy" && secretValue === secrets[0]) { // Historical rows used the redacted summary hash. Their signed // payload must still distinguish a later change to the secret. await db.update(toolActionRequests).set({ canonicalArgumentsHash: summarizeToolValue({ ...configure.parameters, secretValue }).sha256, }).where(eq(toolActionRequests.id, pending[0].id)); } } return write(secretValue); }; f.request.mockClear(); const first = await execute(secrets[0]); const second = await execute(secrets[1]); const replay = await write(secrets[1]); expect(first.status).toBe(askFirst ? "replayed" : "completed"); expect(second.status).toBe(askFirst ? "replayed" : "completed"); expect(second.invocationId).not.toBe(first.invocationId); expect(replay).toMatchObject({ status: "replayed", invocationId: second.invocationId }); const writes = f.request.mock.calls.filter(([, init]) => init.method === "POST"); expect(writes).toHaveLength(2); expect(writes.map(([, init]) => { const body = JSON.parse(String(init.body)); return provider.slug === "supabase" ? body[0].value : body.value; })).toEqual(secrets); const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id)); expect(invocations).toHaveLength(2); expect(new Set(invocations.map(invocation => invocation.argumentsHash)).size).toBe(2); const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)); const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); expect(requests).toHaveLength(askFirst ? 2 : 0); const visible = JSON.stringify({ first, second, replay, invocations, audits, requests }); for (const secret of secrets) expect(visible).not.toContain(secret); }); it("requires reconciliation before retrying an ambiguous completed legacy secret write", async () => { const secrets = ["synthetic-legacy-secret-first-value", "synthetic-legacy-secret-second-value"]; const f = await fixture(provider, false, secrets); const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure; const tool = f.tools.find(candidate => candidate.upstreamToolName === configure.name)!; const write = (secretValue: string, idempotencyKey?: string) => f.gateway.executeTool({ sessionToken: f.session.token, tool: tool.name, parameters: { ...configure.parameters, secretValue }, idempotencyKey, }); const first = await write(secrets[0]); expect(first.status).toBe("completed"); const [saved] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, first.invocationId)); const argumentsHash = saved.argumentsSummary!.sha256; const legacyScope = { companyId: saved.companyId, runId: saved.runId, issueId: saved.issueId, applicationId: saved.applicationId, connectionId: saved.connectionId, catalogEntryId: saved.catalogEntryId, toolName: saved.toolName, argumentsHash, }; // This reproduces the persisted default key used before full argument // identity. Its redacted summary cannot prove which secret was written. const canonicalScope = Object.fromEntries(Object.keys(legacyScope).sort().map(key => [key, legacyScope[key as keyof typeof legacyScope]])); const legacyKey = `side_effect:${createHash("sha256").update(JSON.stringify(canonicalScope)).digest("hex")}`; await db.update(toolInvocations).set({ argumentsHash, idempotencyKey: legacyKey }).where(eq(toolInvocations.id, first.invocationId)); f.request.mockClear(); for (const secretValue of secrets) { await expect(write(secretValue)).rejects.toMatchObject({ status: 409, reasonCode: "legacy_tool_idempotency_unverifiable", details: { reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: first.invocationId }, }); } expect(f.request).not.toHaveBeenCalled(); const explicitKey = randomUUID(); const second = await write(secrets[1], explicitKey); const replay = await write(secrets[1], explicitKey); expect(second).toMatchObject({ status: "completed" }); expect(second.invocationId).not.toBe(first.invocationId); expect(replay).toMatchObject({ status: "replayed", invocationId: second.invocationId }); const writes = f.request.mock.calls.filter(([, init]) => init.method === "POST"); expect(writes).toHaveLength(1); const body = JSON.parse(String(writes[0][1].body)); expect(provider.slug === "supabase" ? body[0].value : body.value).toBe(secrets[1]); const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id)); expect(invocations).toHaveLength(2); const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)); const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); expect(requests).toHaveLength(0); const visible = JSON.stringify({ first, second, replay, invocations, audits }); for (const secret of secrets) expect(visible).not.toContain(secret); }); it("keeps another company and another responsible user outside a personal connection", async () => { const f = await fixture(provider, true); const foreign = await createCompany(); await expect(f.service.getConnection(f.connected.connectionId, foreign.id)).rejects.toMatchObject({ status: 404 }); await expect(f.service.reconnectGalleryApp(f.connected.connectionId, foreign.id, { credentialValues: { "credentials.authorization": "synthetic-foreign-replacement" }, }, actor)).rejects.toMatchObject({ status: 404 }); const [agent] = await db.insert(agents).values({ companyId: foreign.id, name: "Foreign deployer", role: "engineer", adapterType: "process" }).returning(); const [run] = await db.insert(heartbeatRuns).values({ companyId: foreign.id, agentId: agent.id, scopeKind: "company", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId }).returning(); const session = await f.gateway.createSession({ companyId: foreign.id, agentId: agent.id, runId: run.id }); const deploy = f.tools.find(tool => tool.upstreamToolName === provider.deploy.name)!; expect((await f.gateway.listToolsForSession(session.token)).some(tool => tool.upstreamToolName === provider.deploy.name)).toBe(false); f.request.mockClear(); await expect(f.gateway.executeTool({ sessionToken: session.token, tool: deploy.name, parameters: provider.deploy.parameters })).rejects.toThrow(); await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: "another-user", membershipRole: "admin", status: "active" }); await expect(f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": "synthetic-foreign-replacement" }, }, { actorType: "user", actorId: "another-user" })).rejects.toMatchObject({ status: 403 }); await db.update(heartbeatRuns).set({ responsibleUserId: "another-user" }).where(eq(heartbeatRuns.id, f.run.id)); await expect(f.call(provider.deploy)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); }); it("rotates credentials without widening saved policies and removes access and owned secrets", async () => { const f = await fixture(provider, true); await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, { enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.toolName !== provider.deploy.name).map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] }, }, actor); const replacement = f.rotateCredential(); const reconnected = await f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": replacement }, }, actor); expect(reconnected.connection.id).toBe(f.connected.connectionId); expect(JSON.stringify(reconnected)).not.toContain(replacement); expect(JSON.stringify(await f.call(provider.read))).toContain(provider.read.receipt); f.request.mockClear(); await expect(f.call(provider.deploy)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); const removed = await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor); expect(removed.connection).toMatchObject({ status: "archived", enabled: false }); expect(removed.removal.secretsRevoked).toBeGreaterThan(0); expect(JSON.stringify(removed)).not.toContain(replacement); const secrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id)); expect(secrets.every(secret => secret.status !== "active" || secret.deletedAt !== null)).toBe(true); expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === provider.read.name)).toBe(false); f.request.mockClear(); await expect(f.call(provider.read)).rejects.toThrow(); expect(f.request).not.toHaveBeenCalled(); }); }); it.each(["mcp_remote", "rest_api"] as const)("rejects a transport switch on an active Supabase application before saving credentials (from %s)", async transport => { const f = await fixture(providers[0]); const applicationType = transport === "mcp_remote" ? "mcp_http" : "rest_api"; if (transport === "mcp_remote") { await db.update(toolConnections).set({ transport, config: { ...f.connected.connection.config, url: "https://mcp.supabase.com/mcp", connectionMethodKey: "mcp-api-key", } }).where(eq(toolConnections.id, f.connected.connectionId)); await db.update(toolApplications).set({ type: applicationType }).where(eq(toolApplications.id, f.connected.application.id)); } const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, f.connected.application.id)); const connectionsBefore = await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id)); const secretsBefore = await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id)); expect(application).toMatchObject({ status: "active", type: applicationType }); expect(connectionsBefore).toHaveLength(1); expect(connectionsBefore[0]).toMatchObject({ status: "active", transport }); expect(secretsBefore).toHaveLength(1); f.request.mockClear(); await expect(f.service.connectGalleryApp(f.company.id, { galleryKey: "supabase", applicationId: application.id, connectionMethodKey: transport === "mcp_remote" ? "management-api" : "mcp-api-key", configValues: { projectRef }, credentialValues: { "credentials.authorization": "synthetic-rejected-transport-switch-token" }, }, actor)).rejects.toMatchObject({ status: 409, details: { code: "connection_transport_change_not_supported" }, }); expect(f.request).not.toHaveBeenCalled(); const [applicationAfter] = await db.select().from(toolApplications).where(eq(toolApplications.id, application.id)); expect(applicationAfter).toEqual(application); expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id))).toEqual(connectionsBefore); expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id))).toEqual(secretsBefore); }); it("keeps an archived Supabase MCP identity separate when connecting Management API", async () => { const provider = providers[0]; const f = await fixture(provider); await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor); await db.update(toolConnections).set({ transport: "mcp_remote", config: { ...f.connected.connection.config, url: "https://mcp.supabase.com/mcp", connectionMethodKey: "mcp-api-key", } }).where(eq(toolConnections.id, f.connected.connectionId)); await db.update(toolApplications).set({ type: "mcp_http" }).where(eq(toolApplications.id, f.connected.application.id)); const input = { galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: provider.methodConfig, credentialValues: { "credentials.authorization": f.credential() }, }; f.request.mockClear(); for (const retained of [{ reconnectConnectionId: f.connected.connectionId }, { applicationId: f.connected.application.id }]) { await expect(f.service.connectGalleryApp(f.company.id, { ...input, ...retained }, actor)).rejects.toMatchObject({ status: 409, details: { code: "connection_transport_change_not_supported" }, }); } expect(f.request).not.toHaveBeenCalled(); const [retained] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connected.connectionId)); expect(retained).toMatchObject({ status: "archived", enabled: false, transport: "mcp_remote", credentialSecretRefs: [] }); const connected = await f.service.connectGalleryApp(f.company.id, input, actor); expect(connected.connectionId).not.toBe(f.connected.connectionId); expect(connected.application.id).not.toBe(f.connected.application.id); expect(connected.application.type).toBe("rest_api"); expect(connected.connection.transport).toBe("rest_api"); await f.service.finishGalleryAppConnection(f.company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] }, }, actor); const tools = await f.gateway.listToolsForSession(f.session.token); const read = tools.find(tool => tool.upstreamToolName === provider.read.name)!; expect(read).toBeTruthy(); expect(JSON.stringify(await f.gateway.executeTool({ sessionToken: f.session.token, tool: read.name, parameters: {} }))).toContain(projectRef); }); });