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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 10:38:20 -05:00
1 parent f5accbc433
commit d922acf01d
3 files changed
+33 -25

No files matched your search

@@ -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" } });
+3 -23
View File
@@ -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<string> {
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;
}
+9 -1
View File
@@ -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;
}