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()}
/>
-
)}
{!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",
}));