diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index e74390255f..67d8f897ff 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -59,6 +59,16 @@ The additive `ai_provider_defaults` table preserves the legacy per-method prefer Revocation retains the unavailable default; connecting another account does not silently replace it. Change it explicitly on the account detail page. +Agent settings offer **Reconnect account** when the current personal default +needs attention. This repairs the same connection and keeps its default and +agent access. **Connect another account** states that the new account will +become the user's provider default. It selects the returned grant before +adopting the binding and retains the actual sign-in method. A failed default +update stays visible and can be retried without another login. New account +setup shows an agent-access checkbox, enabled for all company agents by default +for connection managers. The owner can limit access to the current agent. This access applies only to +the owner's tasks. Reconnect never expands existing access. + ## Storage and API AI connections pair `connectionPurpose: ai` with `transport: runtime_auth`. @@ -77,8 +87,10 @@ and authentication paths are never account labels. Company-scoped `/api/companies/:companyId/ai-connections` operations provide list, API-key creation/reconnect, personal defaults, completed login references, and -active-run attribution. Existing Connections operations handle naming, access, -and revocation. Mutation authorization is enforced server-side. OpenAPI documents the new board-only +active-run attribution. The list includes `canManageConnections`, evaluated by +the same server permission check as creation, including custom +`tools:manage_connections` grants. Existing Connections operations handle naming, +access, and revocation. Mutation authorization is enforced server-side. OpenAPI documents the new board-only operations. Agent-originated configuration and environment tests resolve the authenticated request’s responsible user; an agent ID is never a personal-account owner. A missing responsible identity blocks personal-default resolution. @@ -137,6 +149,13 @@ run results or logs. A historical generic terminal-limit message alone does not establish quota exhaustion. `prepareManagedAiRuntime` is shared by runs, environment tests, and adoption. +Test and Save mark the tested account as needing attention when its provider +hello test rejects authentication or its API-key check returns 401 or 403. +Network, quota, runtime, and environment failures +do not change credential health. The same generation check protects a newer +reconnect from a late test result. Claude ACP's typed `access` failure is its +provider `auth_required` signal and enters the existing sign-in recovery path, +including when only the generic terminal-access fallback message is available. Claude ACP validates working directories on the selected execution target. A sandbox directory does not need to exist on the Paperclip server. When the agent has no configured directory, the test uses the remote target's working directory. diff --git a/packages/adapters/claude-local/src/server/acp.quota.test.ts b/packages/adapters/claude-local/src/server/acp.quota.test.ts index 42fb254407..a3c83177f5 100644 --- a/packages/adapters/claude-local/src/server/acp.quota.test.ts +++ b/packages/adapters/claude-local/src/server/acp.quota.test.ts @@ -180,3 +180,23 @@ it("does not infer quota from the historical generic terminal-limit error", () = title: "ACP agent reported a terminal limit failure.", }, now)).toBeNull(); }); + +it.each([ + "Failed to authenticate. API Error: 401 Invalid bearer token", + "OAuth token has expired. Please obtain a new token or refresh your existing token.", + "Authentication required. Please run claude login.", + "Sign in to continue using Claude.", + "ACP agent reported a terminal access failure.", +])("routes a typed provider login rejection to sign-in recovery: %s", async (title) => { + const { result } = await executeFailure(title, "access"); + expect(result).toMatchObject({ exitCode: 1, errorCode: "claude_auth_required" }); + if (title === "ACP agent reported a terminal access failure.") expect(result.errorMessage).toBe("Claude sign-in failed. Sign in again and try again."); + expect(result.errorFamily).not.toBe("provider_quota"); +}); + +it.each([ + "Permission denied while opening workspace file.", + "Tool authorization denied.", +])("does not treat a tool or workspace request failure as a login rejection: %s", (title) => { + expect(classifyClaudeTerminalSessionFailure({ category: "request", title }, now)).toBeNull(); +}); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 02e8689ef2..1ba468e42d 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -320,6 +320,10 @@ export function classifyClaudeTerminalSessionFailure( failure: AcpxTerminalSessionFailure, now: Date, ): AcpxTerminalFailureClassification | null { + // Claude's typed AIR access category is emitted for auth_required. This is + // a provider signal, including its generic fallback, not a tool permission + // error inferred from text. Keep it on the existing sign-in recovery path. + if (failure.category === "access") return { errorCode: "acpx_auth_required" }; // `limit` also includes context, turn, rate and configured budget limits. // Only the provider's quota wording qualifies for a quota wait. if (failure.category !== "limit") return null; @@ -368,14 +372,20 @@ const CLAUDE_AUTH_REQUIRED_ERROR_CODE = "claude_auth_required"; * `acpx_auth_required` code for every adapter. The user interface run gate reads * the Claude-specific `claude_auth_required` code, the same code the Claude CLI * lane emits. Without this translation the default ACP run never shows the login - * prompt. The function changes only the error code and keeps every other field, - * so the error message and the error metadata stay intact. + * prompt. Provider diagnostics stay intact; the generic terminal-access + * fallback instead explains that Claude needs sign-in. */ export function mapClaudeAcpAuthErrorCode( result: AdapterExecutionResult, ): AdapterExecutionResult { if (result.errorCode !== ACPX_AUTH_REQUIRED_ERROR_CODE) return result; - return { ...result, errorCode: CLAUDE_AUTH_REQUIRED_ERROR_CODE }; + return { + ...result, + errorCode: CLAUDE_AUTH_REQUIRED_ERROR_CODE, + ...(result.errorMessage === "ACP agent reported a terminal access failure." + ? { errorMessage: "Claude sign-in failed. Sign in again and try again." } + : {}), + }; } export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor { diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 496d374456..55bfc67136 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -173,6 +173,11 @@ export interface AiManagedConnectionSummary { status: "connected" | "needs_attention" | "expired" | "revoked"; unavailableReason?: string; } +export interface AiConnectionList { + currentUserId: string; + canManageConnections: boolean; + connections: AiManagedConnectionSummary[]; +} export const createAiConnectionSchema = z .object({ ...requirement, diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index bbae76c4b8..d4f28de63d 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -68,6 +68,87 @@ function hired(response: request.Response) { } describe("agent-created hires use managed AI connections", () => { + for (const operation of ["test", "save"] as const) { + it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (status: %s)`, async (status) => { + const f = await fixture("anthropic", "api_key"); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); + const original = getServerAdapter(f.adapterType); + registerServerAdapter({ ...original, testEnvironment: async () => ({ adapterType: f.adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }) }); + const network = vi.spyOn(globalThis, "fetch"); + if (status === null) network.mockRejectedValue(new Error("Network unavailable")); + else network.mockResolvedValue(new Response(null, { status })); + try { + const response = operation === "test" + ? await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} }) + : await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } }); + expect(response.status, JSON.stringify(response.body)).toBe(operation === "test" ? 200 : 422); + const rejected = status === 401 || status === 403; + expect(await aiConnectionService(db).list(f.companyId, f.userId)).toEqual([expect.objectContaining({ status: rejected ? "needs_attention" : "connected" })]); + } finally { network.mockRestore(); unregisterServerAdapter(f.adapterType); } + }); + } + + it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default", async (operation) => { + const f = await fixture("anthropic", "subscription"); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); + const original = getServerAdapter(f.adapterType); + registerServerAdapter({ ...original, testEnvironment: async () => ({ + adapterType: f.adapterType, status: "fail", testedAt: new Date().toISOString(), + checks: [{ code: "claude_hello_probe_auth_required", level: "error", message: "The account needs sign-in." }], + }) }); + try { + const response = operation === "test" + ? await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} }) + : await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } }); + expect(response.status, JSON.stringify(response.body)).toBe(operation === "test" ? 200 : 422); + if (operation === "test") expect(response.body.status).toBe("fail"); + const service = aiConnectionService(db); + expect(await service.list(f.companyId, f.userId)).toEqual([expect.objectContaining({ id: f.account.connectionId, isDefault: true, status: "needs_attention" })]); + await expect(service.select({ companyId: f.companyId, userId: f.userId, agentId: f.agentId, adapterType: f.adapterType, binding: f.binding })).rejects.toThrow("Reconnect"); + const repaired = await service.save(f.companyId, f.userId, { + provider: "anthropic", method: "subscription", name: "Ignored reconnect name", ownership: "personal", + connectionId: f.account.connectionId, allAgents: true, agentIds: [], loginSessionId: "fixture", + }, "repaired-token"); + expect(repaired).toEqual(f.account); + expect(await service.list(f.companyId, f.userId)).toEqual([expect.objectContaining({ id: f.account.connectionId, isDefault: true, status: "connected" })]); + const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.account.connectionId)); + expect(installs).toEqual([expect.objectContaining({ targetType: "agent", targetId: f.agentId })]); + registerServerAdapter({ ...original, testEnvironment: async () => ({ + adapterType: f.adapterType, status: "pass", testedAt: new Date().toISOString(), + checks: [{ code: "claude_hello_probe_passed", level: "info", message: "hello" }], + }) }); + const saved = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterConfig: { model: "changed-model" } }); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + const runtime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, adapterType: f.adapterType, binding: f.binding, config: saved.body.adapterConfig }); + try { + expect(runtime.attribution.grantId).toBe(f.account.grantId); + expect((runtime.config.env as Record).CLAUDE_CODE_OAUTH_TOKEN).toBe("repaired-token"); + } finally { await runtime.cleanup(); } + } finally { unregisterServerAdapter(f.adapterType); } + }); + + it.each([false, true])("a failed environment test preserves connection health for a runtime failure or a newer reconnect (reconnected: %s)", async (reconnected) => { + const f = await fixture("anthropic", "subscription"); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); + const original = getServerAdapter(f.adapterType); + registerServerAdapter({ ...original, testEnvironment: async () => { + if (reconnected) await aiConnectionService(db).save(f.companyId, f.userId, { + provider: "anthropic", method: "subscription", name: "My Claude", ownership: "personal", + connectionId: f.account.connectionId, allAgents: false, agentIds: [f.agentId], loginSessionId: "fixture", + }, "newer-token"); + return { + adapterType: f.adapterType, status: "fail", testedAt: new Date().toISOString(), + checks: [{ code: reconnected ? "claude_hello_probe_auth_required" : "claude_cli_not_found", level: "error", message: "Test failed." }], + }; + } }); + try { + const response = await request(f.app).post(`/api/companies/${f.companyId}/adapters/${f.adapterType}/test-environment`).send({ agentId: f.agentId, aiConnection: f.binding, adapterConfig: {} }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.status).toBe("fail"); + expect(await aiConnectionService(db).list(f.companyId, f.userId)).toEqual([expect.objectContaining({ status: "connected" })]); + } finally { unregisterServerAdapter(f.adapterType); } + }); + for (const endpoint of ["agent-hires", "agents"]) { it.each([ ["anthropic", "api_key"], ["anthropic", "subscription"], diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 4ee7c7dbb8..7d5e425505 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -116,6 +116,11 @@ vi.mock("../routes/ai-connections.js", async (importOriginal) => ({ ...(await importOriginal()), validateAiApiKey: mockValidateAiApiKey, })); +const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("../services/ai-connections.js", async (importOriginal) => ({ + ...(await importOriginal()), + aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }), +})); function mockManagedRuntime(method: "api_key" | "subscription") { mockPrepareManagedAiRuntime.mockImplementation( @@ -451,7 +456,10 @@ describe("agent test-environment route", () => { it("fails adoption of an api_key connection the provider no longer accepts", async () => { mockManagedRuntime("api_key"); - mockValidateAiApiKey.mockRejectedValueOnce(Object.assign(new Error("The provider rejected this API key."), { status: 422 })); + const { unprocessable } = await import("../errors.js"); + mockValidateAiApiKey.mockRejectedValueOnce(unprocessable("The provider rejected this API key.", { + code: "ai_connection_api_key_rejected", + })); const app = await createApp(); const res = await request(app) .post("/api/companies/company-1/adapters/external_test/test-environment") @@ -462,6 +470,10 @@ describe("agent test-environment route", () => { expect(res.status).toBe(200); expect(res.body.status).toBe("fail"); expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_rejected"); + expect(mockMarkAuthenticationFailed).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + attribution: expect.objectContaining({ connectionId: "conn-1", grantId: "grant-1" }), + })); }); it("still fails subscription adoption when no hello probe can run", async () => { diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index fd7288029e..7cc0a499d6 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -8,7 +8,7 @@ import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets } from "@paperclipai/db"; +import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres"; import { aiConnectionService } from "../services/ai-connections.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; @@ -46,6 +46,21 @@ beforeAll(async () => { afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); }); describe("managed AI connections", () => { + it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => { + const userId = `custom-manager-${manager}`; + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" }); + if (manager) await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "tools:manage_connections" }); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] }; + next(); + }); + app.use("/api", aiConnectionRoutes(db)); + const response = await request(app).get(`/api/companies/${companyId}/ai-connections`); + expect(response.status).toBe(200); + expect(response.body.canManageConnections).toBe(manager); + }); + it.each([ ["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true], ] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 494fdd5cce..686ada2e48 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3314,7 +3314,24 @@ export function agentRoutes( }); } - async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding) { + async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>, agentId?: string) { + const startedAt = new Date(); + const result = await probeManagedEnvironment(adapterType, context, binding); + // A provider rejection invalidates the tested credential generation. A + // missing CLI, unavailable environment, or other runtime error does not. + if (result.status === "fail" && result.checks.some(check => + check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code) + || check.code === "ai_connection_api_key_rejected", + )) { + await aiConnectionService(db).markAuthenticationFailed({ + companyId: context.companyId, agentId, runStartedAt: startedAt, + attribution: { ...managed.attribution, identity: managed.identity }, + }); + } + return result; + } + + async function probeManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding) { await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget); const result = await requireServerAdapter(adapterType).testEnvironment(context); if (result.status === "fail") return result; @@ -3343,7 +3360,8 @@ export function agentRoutes( result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." }); } catch (error) { result.status = "fail"; - result.checks.push({ code: "ai_connection_api_key_rejected", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." }); + const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected"; + result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." }); } return result; } @@ -3382,7 +3400,7 @@ export function agentRoutes( try { if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption"); managed = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }); - const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding); + const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId); if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", { code: "ai_connection_validation_failed", checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })), @@ -3577,7 +3595,7 @@ export function agentRoutes( const managed = aiBinding ? await prepareManagedAiRuntime(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }) : null; let result; try { - result = managed && aiBinding ? await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName }); + result = managed && aiBinding ? await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName }); if (managed) result.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` }); } finally { await managed?.cleanup(); } diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index da293a37a1..059497dda6 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -21,6 +21,7 @@ import { type AiConnectionLoginIntent, type AiProvider, type AiConnectionBinding, + type AiConnectionList, } from "@paperclipai/shared"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { forbidden, notFound, unprocessable } from "../errors.js"; @@ -36,6 +37,14 @@ export function responsibleUserForAiRequest(req: Request): string | null { : getActorInfo(req).actorId; } +async function canManageAiConnections(db: Db, req: Request, companyId: string): Promise { + const membership = req.actor.memberships?.find((m) => m.companyId === companyId && m.status === "active"); + if (membership?.membershipRole === "viewer") return false; + return req.actor.source === "local_implicit" || Boolean(req.actor.isInstanceAdmin) + || membership?.membershipRole === "owner" || membership?.membershipRole === "admin" + || await accessService(db).hasPermission(companyId, "user", getActorInfo(req).actorId, "tools:manage_connections"); +} + export async function assertAiConnectionCreateAccess( db: Db, req: Request, @@ -76,17 +85,7 @@ export async function assertAiConnectionCreateAccess( const membership = req.actor.memberships?.find( (m) => m.companyId === companyId && m.status === "active", ); - const manager = - req.actor.source === "local_implicit" || - req.actor.isInstanceAdmin || - membership?.membershipRole === "owner" || - membership?.membershipRole === "admin" || - (await accessService(db).hasPermission( - companyId, - "user", - userId, - "tools:manage_connections", - )); + const manager = await canManageAiConnections(db, req, companyId); if ( !input.connectionId && !manager && @@ -155,7 +154,7 @@ export async function validateAiApiKey( : { Authorization: `Bearer ${key}` }, }); } catch { - throw unprocessable("Could not verify the account. Try again."); + throw unprocessable("Could not verify the account. Try again.", { code: "ai_connection_verification_failed" }); } await response.body?.cancel(); if (!response.ok) @@ -163,6 +162,7 @@ export async function validateAiApiKey( response.status === 401 || response.status === 403 ? "The provider rejected this API key." : "The provider could not verify this account. Try again.", + { code: response.status === 401 || response.status === 403 ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed" }, ); } @@ -216,12 +216,13 @@ export function aiConnectionRoutes(db: Db, options: Parameters api.post(`/companies/${companyId}/ai-connections/local/attempts`, input), @@ -6,7 +6,7 @@ export const aiConnectionsApi = { cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`), connectLocal: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/local`, input), activeRuns: (companyId: string, connectionId: string) => api.get>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`), - list: (companyId: string, agentId?: string) => api.get<{ currentUserId: string; connections: AiManagedConnectionSummary[] }>(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`), + list: (companyId: string, agentId?: string) => api.get(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`), create: (companyId: string, input: CreateAiConnection) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections`, input), setDefault: (companyId: string, grantId: string) => api.put(`/companies/${companyId}/ai-connections/default`, { grantId }), loginResult: (companyId: string, sessionId: string) => api.get<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/login/${encodeURIComponent(sessionId)}`), diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx new file mode 100644 index 0000000000..7ab32e4afe --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import type { AiManagedConnectionSummary } from "@paperclipai/shared"; +import { AiConnectionField } from "./AiConnectionField"; +import type { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; + +const mocks = vi.hoisted(() => ({ list: vi.fn(), setDefault: vi.fn() })); +let credentialProps: ComponentProps | undefined; +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: mocks })); +vi.mock("./AiConnectionCredentialStep", () => ({ + AiConnectionCredentialStep: (props: ComponentProps) => { + credentialProps = props; + return
Provider sign-in
; + }, +})); +vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null })); +vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null })); + +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +const onChange = vi.fn(); +const account = (overrides: Partial = {}): AiManagedConnectionSummary => ({ + id: "old-connection", grantId: "old-grant", companyId: "company", + provider: "anthropic", method: "subscription", name: "My Claude", + ownership: "personal", ownerUserId: "owner", isDefault: true, + status: "needs_attention", ...overrides, +}); +async function settle() { + for (let i = 0; i < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + flushSync(() => {}); + } +} +async function mount(connections: AiManagedConnectionSummary[], canManageConnections = true) { + mocks.list.mockResolvedValue({ currentUserId: "owner", connections, canManageConnections }); + flushSync(() => root.render( + + )); + await settle(); +} +function click(label: string) { + const button = Array.from(document.querySelectorAll("button")).find(item => item.textContent === label); + expect(button, `Missing button: ${label}`).toBeDefined(); + flushSync(() => button!.click()); +} +beforeEach(() => { + vi.clearAllMocks(); + credentialProps = undefined; + mocks.setDefault.mockResolvedValue({}); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); +}); +afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); }); + +it("reconnects the unavailable personal default in place", async () => { + await mount([account()]); + click("Reconnect account"); + expect(credentialProps).toMatchObject({ connectionId: "old-connection", initialMethod: "subscription", fixedMethod: true }); + credentialProps!.onComplete({ connectionId: "old-connection", grantId: "old-grant", method: "subscription" }); + await settle(); + expect(mocks.setDefault).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" }); +}); + +it("selects the returned new grant and actual method before adopting the personal default", async () => { + await mount([account()]); + click("Connect another account"); + expect(document.body.textContent).toContain("default"); + expect(credentialProps).toMatchObject({ connectionId: undefined, allAgents: true }); + let resolveDefault!: () => void; + mocks.setDefault.mockImplementation(() => new Promise(resolve => { resolveDefault = resolve; })); + credentialProps!.onComplete({ connectionId: "new-connection", grantId: "new-grant", method: "api_key" }); + await settle(); + expect(mocks.setDefault).toHaveBeenCalledWith("company", "new-grant"); + expect(onChange).not.toHaveBeenCalled(); + resolveDefault(); + await settle(); + expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "api_key", mode: "responsible_user" }); +}); + +it("lets the owner limit a new personal connection to this agent", async () => { + await mount([]); + click("Connect another account"); + const checkbox = document.querySelector('[role="checkbox"]')!; + expect(checkbox).not.toBeNull(); + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + flushSync(() => checkbox.click()); + expect(credentialProps).toMatchObject({ allAgents: false, agentIds: ["agent"] }); +}); + +it("keeps default-update failures visible and retries without another provider login", async () => { + await mount([account()]); + click("Connect another account"); + mocks.setDefault.mockRejectedValueOnce(new Error("Default update failed")); + credentialProps!.onComplete({ connectionId: "new-connection", grantId: "new-grant", method: "api_key" }); + await settle(); + expect(document.body.textContent).toContain("Default update failed"); + expect(onChange).not.toHaveBeenCalled(); + click("Retry default selection"); + await settle(); + expect(mocks.setDefault).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "api_key", mode: "responsible_user" }); +}); + +it("does not offer reconnection for a healthy default or another owner's account", async () => { + await mount([account({ status: "connected" }), account({ id: "someone-else", ownerUserId: "other" })]); + expect(document.body.textContent).not.toContain("Reconnect account"); +}); + +it("keeps an ordinary member's new connection scoped to the current agent by default", async () => { + await mount([], false); + click("Connect another account"); + expect(credentialProps).toMatchObject({ allAgents: false, agentIds: ["agent"] }); +}); + +it("uses the server's connection-manager permission for company-wide access", async () => { + await mount([], true); + click("Connect another account"); + expect(credentialProps).toMatchObject({ allAgents: true }); +}); diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index 5533cf3c99..adf57ec293 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -1,17 +1,19 @@ import { useRef, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { aiConnectionBindingSchema, isAiConnectionCompatible, type AiConnectionBinding, type AiAuthMethod, type AiProvider, + type AiManagedConnectionSummary, } from "@paperclipai/shared"; import { aiConnectionsApi } from "@/api/ai-connections"; import { AiConnectionPicker } from "./AiConnectionPicker"; import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -62,6 +64,9 @@ export function AiConnectionField({ const [adopting, setAdopting] = useState(false); const [pendingAdoption, setPendingAdoption] = useState(); const [connecting, setConnecting] = useState(false); + const [reconnecting, setReconnecting] = useState(); + const [allAgents, setAllAgents] = useState(true); + const [savedAccount, setSavedAccount] = useState<{ connectionId: string; grantId: string; method: AiAuthMethod }>(); const changeBinding = (next: AiConnectionBinding) => { if (legacy && !value) { if (!connecting) returnFocus.current = document.activeElement as HTMLElement; setPendingAdoption(next); } else onChange(next); @@ -72,6 +77,28 @@ export function AiConnectionField({ queryFn: () => aiConnectionsApi.list(companyId, agentId), enabled: Boolean(provider), }); + const personalDefault = accounts.data?.connections.find((account) => account.provider === provider && account.isDefault && account.ownership === "personal" && account.ownerUserId === accounts.data.currentUserId); + const selectDefault = useMutation({ + mutationFn: async (result: NonNullable) => { + // Reconnect retains the existing default and its access. A new account + // must be selected explicitly before a responsible-user binding uses it. + if (!reconnecting) await aiConnectionsApi.setDefault(companyId, result.grantId); + return result; + }, + onSuccess: async (result) => { + await client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); + changeBinding({ provider: provider!, method: result.method, mode: "responsible_user" }); + setConnecting(false); + }, + }); + const openConnection = (reconnect?: AiManagedConnectionSummary) => { + returnFocus.current = document.activeElement as HTMLElement; + setReconnecting(reconnect); + setAllAgents(accounts.data?.canManageConnections ?? false); + setSavedAccount(undefined); + selectDefault.reset(); + setConnecting(true); + }; const method: AiAuthMethod = (value?.mode !== "responsible_user" ? value?.method : undefined) ?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method ?? (provider === "openrouter" ? "api_key" : "subscription"); @@ -104,7 +131,8 @@ export function AiConnectionField({ onChange={(binding) => changeBinding(aiConnectionBindingSchema.parse(binding)) } - onConnect={() => { returnFocus.current = document.activeElement as HTMLElement; setConnecting(true); }} + onConnect={() => openConnection()} + onReconnect={(!value || value.mode === "responsible_user") && personalDefault && personalDefault.status !== "connected" ? () => openConnection(personalDefault) : undefined} onRetry={() => void accounts.refetch()} /> - + { if (!selectDefault.isPending) setConnecting(open); }}> - Connect account + {reconnecting ? "Reconnect account" : "Connect account"} + + {reconnecting ? "Sign in again to repair your current default account. Its agent access stays the same." : "This account will become your default for this provider. Your tasks will use it; other users keep their own default."} + + {!reconnecting && !savedAccount && } + {savedAccount ?
+ {selectDefault.error ? <> +

{selectDefault.error.message}

+ + :

Selecting your default account…

} +
: setConnecting(false)} - onComplete={() => { - void client.invalidateQueries({ - queryKey: ["ai-connections", companyId], - }); - setConnecting(false); - changeBinding({ provider, method, mode: "responsible_user" }); + onComplete={(result) => { + setSavedAccount(result); + selectDefault.mutate(result); }} - /> + />}
diff --git a/ui/src/components/ai-connections/AiConnectionPicker.tsx b/ui/src/components/ai-connections/AiConnectionPicker.tsx index 65bf35eed2..782c358107 100644 --- a/ui/src/components/ai-connections/AiConnectionPicker.tsx +++ b/ui/src/components/ai-connections/AiConnectionPicker.tsx @@ -26,6 +26,7 @@ export interface AiConnectionPickerProps { readOnly?: boolean; onChange: (binding: AiConnectionBinding) => void; onConnect: () => void; + onReconnect?: () => void; onRetry?: () => void; } @@ -40,6 +41,7 @@ export function AiConnectionPicker({ readOnly, onChange, onConnect, + onReconnect, onRetry, }: AiConnectionPickerProps) { const compatible = connections.filter((connection) => @@ -128,14 +130,16 @@ export function AiConnectionPicker({

)} {!readOnly && ( - +
+ {onReconnect && } + +
)} )} diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 1838a82963..c1b3eccb56 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -42,6 +42,7 @@ const state = vi.hoisted(() => ({ const managedApi = vi.hoisted(() => ({ list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })), create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), + setDefault: vi.fn(async () => ({})), })); vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); vi.mock("@/api/agents", () => ({ agentsApi: api })); @@ -543,6 +544,7 @@ describe("New agent setup", () => { await act(async () => connectButton.click()); await settle(); expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant"); expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ provider: "openrouter", method: "api_key", apiKey: "example-test-secret", }));