mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Merge delegated sandbox device-login guard fix
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: fix(connections): allow delegated sandbox device login ownership
This commit is contained in:
commit
770b5dc5ce
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" } });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user