From d922acf01d7bca7e7123529dc59f4071dc46a78f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 10:38:20 -0500 Subject: [PATCH] fix(connections): allow delegated sandbox device login ownership Use the shared connection authority for device-login management while preserving the responsible user as immutable owner. Keep refreshed continuation context available at dispatch. Add bearer-authenticated saved-session reads, cancellation, and owner isolation coverage. Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 22 +++++++++++++++- server/src/routes/agents.ts | 26 +++---------------- server/src/services/connection-management.ts | 10 ++++++- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 4bfd63ee9e1..0f31b6af0b4 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { createServer } from "node:http"; import { and, eq } from "drizzle-orm"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolProfileBindings, plugins } from "@paperclipai/db"; +import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolProfileBindings, plugins, adapterAuthSessions, environments } from "@paperclipai/db"; import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js"; import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js"; import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js"; @@ -113,6 +113,26 @@ describe("connection management with production default agents", () => { } finally { validation.mockRestore(); } }); + it("reaches sandbox device-login management as the responsible user and protects another owner's saved session", async () => { + const f = await fixture(); + registerServerAdapter({ ...getServerAdapter("paperclip_runner"), type: "codex_local", loginCapability: { + panelMode: "displayed_code", timeoutPolicy: "caller_bounded", getCommand: () => "codex login --device-auth", parsePrompt: () => null, + } }); + try { + const [environment] = await server.db.insert(environments).values({ name: `Login fixture ${f.agentId}`, driver: "sandbox" }).returning(); + const [session] = await server.db.insert(adapterAuthSessions).values({ companyId: f.companyId, environmentId: environment.id, adapterType: "codex_local", startedByUserId: f.userId, + publicSessionId: randomUUID(), status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000), aiConnection: { ownership: "personal", name: "Saved login", allAgents: false, agentIds: [] } }).returning(); + const path = `/companies/${f.companyId}/adapters/codex_local/login-sessions`; + expect(await f.request("GET", `${path}/active`)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } }); + expect(await f.request("GET", `${path}/${session.publicSessionId}`, undefined, f.jwt)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } }); + expect(await f.request("POST", path, {})).toMatchObject({ status: 400 }); + expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 200 }); + await server.db.update(adapterAuthSessions).set({ startedByUserId: randomUUID() }).where(eq(adapterAuthSessions.id, session.id)); + expect(await f.request("GET", `${path}/${session.publicSessionId}`)).toMatchObject({ status: 404 }); + expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 404 }); + } finally { unregisterServerAdapter("codex_local"); } + }); + it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => { const f = await fixture(); const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 625bd20a4a2..99eaf190974 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1452,33 +1452,13 @@ export function agentRoutes( if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } - // The single owner-authorization helper for the three adapter login routes. It - // requires a board actor, company access, and the same configuration - // permission as the adapter Test route (`agents:create`). It returns the - // immutable owner identifier: the board user that starts, reads, or cancels the - // session. The start route persists this identifier; the status and cancel - // routes compare it to the session owner and return 404 on a mismatch, so a - // non-owner cannot enumerate a session. async function assertCanManageAdapterLogin( req: Request, companyId: string, ): Promise { - assertBoard(req); - assertCompanyAccess(req, companyId); - const decision = await access.decide({ - actor: req.actor, - action: "agents:create", - resource: { type: "company", companyId }, - }); - if (!decision.allowed) { - throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); - } - const userId = req.actor.userId; - if (!userId) { - throw forbidden( - "A board user identity is required to manage an adapter login session.", - ); - } + await assertBoardCanManageAgentsForCompany(req, companyId); + const userId = connectionRequestUserId(req); + if (!userId) throw forbidden("A responsible user identity is required to manage an adapter login session."); return userId; } diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index 8d5acdea00b..3ca9b976d82 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -234,5 +234,13 @@ export function connectionManagementService(db: Db) { * keeps its original actor, user, task, trust and grant checks; it is never an * option accepted from an HTTP caller. Cancelled tasks/runs remain ineligible. */ export function continuingConnectionManagement(context?: ConnectionManagementContext): ConnectionManagementContext | undefined { - return context && { ...context, reauthorize: () => context.reauthorize({ afterHumanAction: true }) }; + if (!context) return undefined; + const continuation: ConnectionManagementContext = { ...context, reauthorize: async () => { + await context.reauthorize({ afterHumanAction: true }); + continuation.workContext = context.workContext; + continuation.resource = context.resource; + continuation.decision = context.decision; + continuation.audit = context.audit; + } }; + return continuation; }