From 49b380483071f673f88a50835b978e41219bfb93 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 2 Nov 2026 12:29:03 -0800 Subject: [PATCH] fix(connections): load permissions and show AI account identity (#18653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages AI agents and their access to saved connections. > - The Permissions page loads account details and permitted agent choices. > - Company-wide AI accounts repeat retained installation checks for every candidate agent. > - This makes target checks grow with the square of the roster size. > - The browser waits without a deadline, and connection names do not identify the provider login. > - This pull request batches direct AI checks and bounds loading with recovery controls. > - Account details load separately and show a provider email or an explicit profile-permission limit. ## Linked Issues or Issue Description **What happened?** Opening a saved AI account can leave Permissions on “Loading AI account…” for minutes. The account itself can be connected. The existing capability batch excludes AI connections, so a company-wide install repeats checks for every installed agent for every candidate. Saved names and Paperclip ownership also do not establish which provider login a credential uses. **Expected behavior** Direct AI accounts reuse retained-target checks within one descriptive request. The page shows loaded account controls or a bounded failure with Retry. Permissions shows the provider email and organization when the selected credential permits profile access. An unavailable profile must not block maintenance or disconnect a working inference credential. Writes still authorize current permissions. **Steps to reproduce** 1. Save an AI account in a company with several agents. 2. Install the account company-wide. 3. Open its Permissions page. 4. Delay the account-list or grants response to exercise the loading failure. The maintained benchmark reproduces database work with synthetic local data. Refs #17945, which added batching for non-AI connections. Searches found no open PR for this AI-account loading fix. ## What Changed - Include direct AI accounts in the existing board capability batch. Referenced pool members, delegated actors, complex selectors, and mutation authorization keep their existing evaluator. - Add opt-in per-caller GET deadlines to the API client. A timed-out caller detaches the stuck coalesced request so Retry starts a fresh fetch. Other callers retain their own cancellation and results. - Apply a 15-second deadline to AI account lists and grants reads. The permissions view retries transient failures once and reports other failures immediately. - Share the parent Permissions grants query with AI account details. Keep loaded controls visible after a background error and offer Retry. - Add a grant-scoped identity GET and OpenAPI contract. The service checks current membership and credential audience before it reads the selected credential. It projects only the provider email and organization; provider IDs, tokens, and raw errors stay private. - Show account details independently of maintenance controls. Anthropic profile denial has an explicit message and preserves connected status. Transient profile failures have a separate Retry action. Observations are cached for five minutes under the company, connection, grant, and grant revision. - Extend real PostgreSQL and authenticated HTTP coverage, client/component regressions, map guidance, and the existing benchmark's AI mode. - Address the Apex file-size finding by moving gallery identity lookup into the existing app display helper module. `AppDetail.tsx` is 984 lines; lookup behavior is unchanged. ## Verification Local source checkout on macOS, Node 25.6.1, pnpm 9.15.4, and disposable PostgreSQL 18.1. Provider calls use fixtures. No hosted deployment or provider qualification is claimed. - Passed `pnpm -r typecheck` and `pnpm build`. - Passed all 87 tests in `connection-management.integration.test.ts` and all 33 tests in `connection-install-capabilities.test.ts`. - Passed 196 AI connection, identity-provider, and OpenAPI tests: `ai-connections.test.ts`, `ai-account-identity.test.ts`, and `openapi-routes.test.ts`. - Passed 107 focused UI/client tests: `client.test.ts`, `ManagedAiConnectionDetails.test.tsx`, `AiConnectionAccountDetails.test.tsx`, and `AppDetail.test.tsx`. - After the Apex extraction, passed UI typecheck/build and 106 component tests across `AppDetail.test.tsx`, `AppNotConnected.test.tsx`, `ManagedAiConnectionDetails.test.tsx`, and `AiConnectionAccountDetails.test.tsx`. - The authenticated HTTP tests compare complete grants responses across human roles, for tool and AI connections. They verify a real install mutation succeeds before revocation and fails afterward. - A company-wide personal AI fixture checks 20 targets instead of 400. Pool-member permission loss still denies the complete pool description. - UI states covered: shared cached grants, stuck account/grants fetches, one automatic retry, terminal failure, manual recovery, authorization failure, background failure with retained controls, and unmount cancellation. Client tests also cover stalled response bodies and independent coalesced callers. - Identity coverage includes actual bearer-key/JWT reads, private-user and cross-company denial before provider access, revoked credentials, provider 401/403/429/500, and grant-change isolation. A real local HTTP fixture stalls the response body, verifies the shared 15-second deadline, and keeps unrelated HTTP requests responsive. The test shortens the native deadline to 100 ms. Profile permission denial leaves the saved account connected. - Passed `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and `git diff --check`. - The full local `pnpm test:run` started with the loading-only change. All six chat shards passed. It recorded one failure in the unchanged `heartbeat-retry-scheduling.test.ts` quota-rejection case. That exact case passed a focused rerun on the final head. The initial failure cause remains unconfirmed and its local log is retained; this is not a full local-suite pass. The redundant local run was stopped with SIGINT after final-head CI completed successfully. - [Final-head CI](https://github.com/paperclipai/paperclip/actions/runs/41515556725) passed on `ead8d276d4a2023614fff54b3e9a858f28c3178b`: typecheck, build, all 12 server shards, all 3 chat shards, workspace suites, all 9 serialized suites, all 8 end-to-end shards, Runner checks, and the aggregate verify gate. Security checks passed. Optional Storybook checks were skipped by workflow policy. GitHub reports the branch mergeable without conflicts. - Apex reviewed head `ead8d276d4a2023614fff54b3e9a858f28c3178b` at 5/5, confirmed the extraction resolves its earlier file-size finding, and has no unresolved review threads. The existing benchmark ran for the loading-only change now recorded in commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce`, with `--purpose=ai --baseline-ref=026fd9e3452`. The identity follow-up leaves that batch source unchanged. Two warmups and five alternating samples exercise each of eight fixture shapes. Complete positional capabilities, reasons, and operations match the baseline in every sample. | AI fixture | SQL calls before → after | Target reads before → after | | --- | ---: | ---: | | 1 agent, company install | 9 → 9 | 1 → 1 | | 32 agents, company install | 5,248 → 164 | 1,024 → 32 | | 100 agents, company install | 50,400 → 504 | 10,000 → 100 | | 100 agents, partial install | 13,175 → 503 | 2,575 → 100 | These counts measure the real service over local PostgreSQL. They do not predict hosted page latency. To reproduce the standalone benchmark, use a checkout of commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce` and run the command below. Its isolation check intentionally rejects the later identity follow-up's additional runtime changes. ```sh node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --purpose=ai --baseline-ref=026fd9e3452 ``` Run the final-head regression suites from the repository root: ```sh cd server pnpm exec vitest run src/__tests__/connection-management.integration.test.ts src/__tests__/connection-install-capabilities.test.ts src/__tests__/ai-connections.test.ts src/services/ai-account-identity.test.ts src/__tests__/openapi-routes.test.ts cd ../ui pnpm exec vitest run src/api/client.test.ts src/components/ai-connections/ManagedAiConnectionDetails.test.tsx src/components/ai-connections/AiConnectionAccountDetails.test.tsx src/pages/apps/AppDetail.test.tsx ``` ## Risks Descriptive permissions reuse retained facts within one request. A concurrent change can leave a displayed choice stale; the next request and every write still authorize fresh. Pools with referenced members retain their existing cost. The account and grants clients now bound each caller's wait. A browser abort does not cancel an authorization read already admitted by the server. The independent profile request uses a fixed Anthropic origin, a bounded body, no redirects, and a 15-second deadline. Inference-only credentials can return no email; that is reported without guessing from a Paperclip owner or changing connection health. Profiles are fetched only for the caller's authorized selected grant. No ambient CLI identity, persistent identity cache, background polling, or billing-identity change is added. Other API reads keep their existing behavior. No migration is required. Staging has not been redeployed. ## Model Used OpenAI `gpt-6.1-sol` through the Codex harness in T3 Code, with xhigh reasoning, code editing, execution, and test tools. The host does not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/AI-CONNECTIONS.md | 17 +++ maps/agent-access-map/app-permissions.md | 14 +- maps/feature-map/app-permissions.md | 14 ++ packages/shared/src/ai-connections.ts | 11 ++ ...nchmark-connection-install-capabilities.ts | 6 +- server/src/__tests__/ai-connections.test.ts | 40 +++++ .../connection-management.integration.test.ts | 102 ++++++++++++- server/src/routes/ai-connections.ts | 16 ++ server/src/routes/openapi.ts | 11 ++ .../src/services/ai-account-identity.test.ts | 72 +++++++++ server/src/services/ai-account-identity.ts | 43 ++++++ server/src/services/ai-connections.ts | 36 ++++- server/src/services/connection-management.ts | 12 +- ui/src/api/ai-connections.ts | 7 +- ui/src/api/client.test.ts | 50 +++++++ ui/src/api/client.ts | 27 +++- ui/src/api/tools.ts | 6 +- .../AiConnectionAccountDetails.test.tsx | 137 ++++++++++++++++++ .../AiConnectionAccountDetails.tsx | 45 ++++++ .../ManagedAiConnectionDetails.test.tsx | 127 ++++++++++++++++ .../ManagedAiConnectionDetails.tsx | 45 ++++-- ui/src/pages/apps/AppDetail.tsx | 23 +-- ui/src/pages/apps/app-definition-display.ts | 17 +++ 23 files changed, 813 insertions(+), 65 deletions(-) create mode 100644 server/src/services/ai-account-identity.test.ts create mode 100644 server/src/services/ai-account-identity.ts create mode 100644 ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx create mode 100644 ui/src/components/ai-connections/AiConnectionAccountDetails.tsx create mode 100644 ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 1bbb8ad26fb..2a47847f46d 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -98,6 +98,23 @@ grant through encrypted vault secret references, with existing consumer bindings Safe provider-reported account identity is optional; secret references, tokens, and authentication paths are never account labels. +Permissions shows account details separately from the connection's maintenance +controls: the provider account email and organization when available, the sign-in +method, and personal or company-shared access. A Paperclip owner name is not +evidence of the provider login. Anthropic setup tokens can permit inference while +denying profile access; in that case the screen explains that Anthropic did not +permit reading the account email. A profile denial does not mark a connected +credential as disconnected. + +`GET /api/companies/:companyId/ai-connections/:connectionId/identity?grantId=...` +checks company membership, connection-management authority, and the selected +credential's human audience before opening the credential. The response contains +only safe account labels and bounded status messages. The Anthropic profile read +has a 15-second headers/body deadline and no redirects. It runs separately from +the account/grants reads, so profile loading or failure cannot block account +maintenance. The UI reuses an observation for five minutes, scoped to the company, +connection, grant, and grant update time. Reconnect requires a new observation. + Company-scoped `/api/companies/:companyId/ai-connections` operations provide list, API-key creation/reconnect, personal defaults, completed login references, and active-run attribution. The list includes `canManageConnections`, evaluated by diff --git a/maps/agent-access-map/app-permissions.md b/maps/agent-access-map/app-permissions.md index 5d91fa39f81..78877ca1bf1 100644 --- a/maps/agent-access-map/app-permissions.md +++ b/maps/agent-access-map/app-permissions.md @@ -30,7 +30,10 @@ Review: `operation-reviewed`. API: `present` — `GET /api/tool-connections/{connectionId}/grants`; JSON REST. Agent access: `available` — agent REST and default native `search_api` / `call_api`. -Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user. +Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user. Human capability descriptions +reuse retained-target checks within one request for direct tool and AI connections. +Pools with referenced accounts and delegated actors retain the full evaluator; +mutations always reauthorize. Evidence: `source-only` — [route](../../server/src/routes/tool-access.ts); reviewed in the working tree based on the source commit above. Verification: `not-run` — individual operation not executed in this audit; shared authorization and representative family flows are exercised by the authenticated integration suite. Follow the route schema, inspect persistence/audit, and assert owner, scope and revoked-authority denial. Gaps: This individual operation has source review but no recorded HTTP verification. Provider availability and live setup remain unqualified. @@ -311,6 +314,15 @@ Capability ID: `app-permissions/account-maintenance`. Review: `operation-reviewed`. +### `get-companies-ai-connections-identity` + +API: `present` — `GET /api/companies/{companyId}/ai-connections/{connectionId}/identity`, optional `grantId` query; JSON REST. +Agent access: `available` — agent REST and default native `search_api` / `call_api` through the registered connection-management operation. +Policy: `connections:read` under current connection-management authority, active company membership, and the selected grant's human audience. Manager status alone cannot read a coworker's personal provider profile. Revoked or disabled accounts do not open credentials. No raw provider IDs or credentials are returned. +Evidence: `verified` — local PostgreSQL and actual key/JWT REST requests in [connection-management integration](../../server/src/__tests__/connection-management.integration.test.ts), with provider fixtures. [AI connection tests](../../server/src/__tests__/ai-connections.test.ts) cover private-user and unauthenticated denial. Native API discovery is registered in [OpenAPI](../../server/src/routes/openapi.ts); native tool execution is source-only. +Verification: `passed` — safe email/organization for an authorized selected grant; another company, another user, invalid grant, and missing authority denied before provider access. Profile permission denial remains an explicit unavailable observation rather than a failed connection. Fixtures do not qualify live provider profile access. +Gaps: Provider profile access depends on the saved token's scopes; an inference-only setup token cannot identify its account email. + ### `get-companies-tools-apps-attention` API: `present` — `GET /api/companies/{companyId}/tools/apps/attention`; JSON REST. diff --git a/maps/feature-map/app-permissions.md b/maps/feature-map/app-permissions.md index 524cc9b4feb..0fd4b55a1df 100644 --- a/maps/feature-map/app-permissions.md +++ b/maps/feature-map/app-permissions.md @@ -97,6 +97,20 @@ still need live account qualification. Automated: [app detail tests](../../ui/src/pages/apps/AppDetail.test.tsx) cover test-dialog/UI contracts. Real provider execution and revocation require the selected provider’s live check. +AI account details share the Permissions page's grants query. Account and grants +reads have a 15-second deadline. The AI panel retries a transient failure once, +then shows an error with Retry. A background failure keeps loaded controls visible. +[AI account loading tests](../../ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx) +exercise stalled account/grants reads, cancellation, retry recovery and cached grants. + +The account-details section shows the provider email and organization when +available, beside the sign-in method and Paperclip ownership. Anthropic profile +permission denial shows an explicit unavailable message without changing the +connection's connected state. Account identity loads independently of maintenance +controls, has a bounded wait and a separate retry for temporary failures, and +is refreshed after a credential grant changes. [Account-details tests](../../ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx) +cover these states and prevent one grant's email from appearing for another. + Manual: Run a read-only action with explicit inputs and inspect returned data and activity. Refresh discovery and review new actions. Revoke or disconnect only the disposable credential, then verify subsequent use fails with an actionable reconnect state. For Daytona, opening an older saved catalog discovers the current native diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index f9771a64b2b..fb784919866 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -190,6 +190,17 @@ export interface AiManagedConnectionSummary { unavailableReason?: string; usageProbeSupported?: boolean; } +/** Safe provider account details for one authorized saved credential. */ +export interface AiConnectionAccountIdentity { + connectionId: string; + grantId: string; + status: "available" | "unavailable" | "error"; + checkedAt: string; + accountLabel: string | null; + organizationLabel: string | null; + errorCode?: "unsupported" | "connection_unavailable" | "authentication_required" | "permission_denied" | "rate_limited" | "provider_unavailable" | "invalid_response"; + message?: string; +} export interface AiConnectionList { currentUserId: string; pools?: import("./ai-connection-router.js").AiConnectionPool[]; diff --git a/scripts/benchmark-connection-install-capabilities.ts b/scripts/benchmark-connection-install-capabilities.ts index 0e6f13e8bc9..9cc24085399 100644 --- a/scripts/benchmark-connection-install-capabilities.ts +++ b/scripts/benchmark-connection-install-capabilities.ts @@ -15,6 +15,8 @@ import type { AuthorizationActor } from "../server/src/services/authorization.js // Run explicitly; this benchmark never reads DATABASE_URL or contacts a hosted DB. // node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --baseline-ref= +const connectionPurpose = process.argv.find(arg => arg.startsWith("--purpose="))?.slice(10) ?? "tool"; +assert.ok(connectionPurpose === "tool" || connectionPurpose === "ai", "Use --purpose=tool or --purpose=ai"); const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15); assert.ok(baselineRef, "Supply an explicit pre-fix --baseline-ref"); const repository = fileURLToPath(new URL("../", import.meta.url)); @@ -101,7 +103,7 @@ try { permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds } }); await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Synthetic benchmark", type: "mcp_http" }); await db.insert(toolConnections).values({ id: connectionId, applicationId, companyId, uid: connectionId, - name: "Synthetic benchmark", transport: "mcp_remote", authKind: "none", config: { provider: "fixture" } }); + name: "Synthetic benchmark", connectionPurpose, transport: connectionPurpose === "ai" ? "runtime_auth" : "mcp_remote", authKind: "none", config: { provider: "fixture" } }); await db.insert(toolConnectionInstalls).values(installs === "company" ? [{ companyId, connectionId, targetType: "company", targetId: companyId }] : candidateIds.slice(0, Math.max(1, Math.floor(n / 4))).map(targetId => ({ companyId, connectionId, targetType: "agent" as const, targetId }))); @@ -151,7 +153,7 @@ try { const expectedRetained = installs === "company" ? n : Math.max(1, Math.floor(n / 4)); assert.equal(targetLookups(captured.baseline), n * expectedRetained + n - expectedRetained); assert.equal(targetLookups(captured.treatment), n); - const result = { n, installs, principal: "cloud noncreator member with target-scoped grant", completePositionalEquality: true, + const result = { n, installs, connectionPurpose, principal: "cloud noncreator member with target-scoped grant", completePositionalEquality: true, baseline: { ...before, ...counters.baseline, targetLookups: targetLookups(captured.baseline) }, treatment: { ...after, ...counters.treatment, targetLookups: targetLookups(captured.treatment) }, speedup: before.medianMs! / after.medianMs!, statements: captured, treatmentPlans: plans }; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index e408200aa50..9a02179171f 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -1496,6 +1496,46 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" } finally { fetchSpy.mockRestore(); } }); + it("reads provider identity only for an authorized credential and preserves connected state when profile access is denied", async () => { + const owner = "identity-owner"; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const account = await service.save(companyId, owner, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Identity account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "identity-selected-secret"); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ account: { email: "avery@example.test", uuid: "private-account-id" }, organization: { name: "Example team", uuid: "private-org-id" } })); + const app = express(); + app.use((req, _res, next) => { + req.actor = req.header("x-test-anonymous") ? { type: "none" } + : req.header("x-agent") ? { type: "agent", agentId, companyId, companyIds: [companyId], onBehalfOfUserId: owner } + : { type: "board", source: "session", userId: req.header("x-test-user") ?? owner, companyIds: [companyId] }; + next(); + }); + app.use("/api", aiConnectionRoutes(db)); + app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); + const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/identity?grantId=${account.grantId}`; + try { + const result = await request(app).get(url); + expect(result.status).toBe(200); + expect(result.headers["cache-control"]).toBe("no-store"); + expect(result.body).toMatchObject({ ...account, status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" }); + expect(fetchSpy).toHaveBeenCalledWith("https://api.anthropic.com/api/oauth/profile", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer identity-selected-secret" }) })); + for (const value of ["identity-selected-secret", "private-account-id", "private-org-id"]) expect(JSON.stringify(result.body)).not.toContain(value); + fetchSpy.mockReset(); + expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404); + expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403); + expect((await request(app).get(url.replace(account.grantId, randomUUID()))).status).toBe(404); + expect((await request(app).get(url).set("x-test-anonymous", "yes")).status).toBe(401); + expect((await request(app).get(url).set("x-agent", "yes")).status).toBe(403); + expect((await request(app).get(url.replace(account.connectionId, "invalid"))).status).toBe(404); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockResolvedValue(Response.json({ error: "private-provider-error" }, { status: 403 })); + expect((await request(app).get(url)).body).toMatchObject({ status: "unavailable", errorCode: "permission_denied", accountLabel: null }); + expect((await service.list(companyId, owner)).find(item => item.id === account.connectionId)?.status).toBe("connected"); + fetchSpy.mockClear(); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId)); + expect((await request(app).get(url)).body).toMatchObject({ status: "unavailable", errorCode: "connection_unavailable" }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { fetchSpy.mockRestore(); } + }); + it("uses the same usage probe for shared accounts across legacy and native runner selections", async () => { const account = await service.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "shared", name: "Shared usage account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "shared-usage-secret"); const selected = { provider: "anthropic", method: "subscription", mode: "shared", ...account } as const; diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 103e81dd964..a1f3f3e301d 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -6,9 +6,10 @@ import { join } from "node:path"; import { createServer } from "node:http"; import { and, eq, sql } from "drizzle-orm"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; -import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db"; +import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, aiConnectionPools, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db"; import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js"; import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js"; +import { aiConnectionService } from "../services/ai-connections.js"; import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js"; import * as remoteEndpointGuard from "../services/remote-http-endpoint-guard.js"; import * as remoteHttpFetch from "../services/remote-http-fetch.js"; @@ -87,13 +88,14 @@ describe("connection management with production default agents", () => { authority: new PaperclipRunnerToolAuthority(server.db, { companyId: base.companyId, agentId: agent.id, issueId: base.issueId, runId: base.runId, apiUrl: server.apiUrl, storage: server.storage }) }; } - it("matches ordinary install capabilities on real PostgreSQL for scoped, retained and fallback resources", async () => { + it.each(["tool", "ai"] as const)("matches ordinary %s install capabilities on real PostgreSQL for scoped, retained and fallback resources", async (connectionPurpose) => { const f = await server.fixture({ connectionScenario: "fresh" }); const candidateIds = [f.agentId, randomUUID(), randomUUID()]; await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Capability target ${index}`, adapterType: "process" }))); const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Capability differential", type: "mcp_http" }).returning(); const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, - uid: randomUUID(), name: "Capability differential", transport: "mcp_remote", authKind: "none", createdByUserId: "different-creator" }).returning(); + uid: randomUUID(), name: "Capability differential", connectionPurpose, + transport: connectionPurpose === "ai" ? "runtime_auth" : "mcp_remote", authKind: "none", createdByUserId: "different-creator" }).returning(); await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!, permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds.slice(0, 2) } }); const service = connectionManagementService(server.db); @@ -177,7 +179,7 @@ describe("connection management with production default agents", () => { expect(await faulting.describeAgentInstallCapabilities(localActor, resource, [candidateIds[1]])).toEqual([denied]); }); - it("preserves the complete grants GET across human roles and denies a real install mutation after revocation", async () => { + it.each(["tool", "ai"] as const)("preserves the complete %s grants GET across human roles and denies a real install mutation after revocation", async (connectionPurpose) => { const f = await server.fixture({ connectionScenario: "fresh" }); const candidateIds = [f.agentId, randomUUID(), randomUUID()]; await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Grant target ${index}`, adapterType: "process" }))); @@ -198,7 +200,8 @@ describe("connection management with production default agents", () => { scope: permissionKey === "tools:manage_profiles" ? { targetAgentIds: candidateIds.slice(0, 2) } : null }))); const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Grants response parity", type: "mcp_http" }).returning(); const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, - uid: randomUUID(), name: "Grants response parity", transport: "mcp_remote", authKind: "none", createdByUserId: users.creator }).returning(); + uid: randomUUID(), name: "Grants response parity", connectionPurpose, + transport: connectionPurpose === "ai" ? "runtime_auth" : "mcp_remote", authKind: "none", createdByUserId: users.creator }).returning(); const grants = await server.db.insert(connectionGrants).values([ { companyId: f.companyId, connectionId: connection.id, kind: "organization", createdByUserId: users.creator, isDefault: true }, ...[users.creator, users.manager, users.member].map((subjectUserId, index) => ({ companyId: f.companyId, connectionId: connection.id, kind: "user" as const, @@ -253,6 +256,65 @@ describe("connection management with production default agents", () => { expect(savedInstalls).toHaveLength(2); }); + it("checks company-wide personal AI installs once per target and preserves pool member authorization", async () => { + const f = await server.fixture({ connectionScenario: "fresh" }); + const candidateIds = [f.agentId, ...Array.from({ length: 19 }, () => randomUUID())]; + await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `AI target ${index}`, adapterType: "process" }))); + const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "AI capability checks", type: "mcp_http" }).returning(); + const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, + uid: randomUUID(), name: "Personal AI account", transport: "runtime_auth", connectionPurpose: "ai", authKind: "api_key", + credentialPolicy: "per_user", createdByUserId: f.responsibleUserId }).returning(); + const [grant] = await server.db.insert(connectionGrants).values({ companyId: f.companyId, connectionId: connection.id, + kind: "user", subjectUserId: f.responsibleUserId, createdByUserId: f.responsibleUserId }).returning(); + await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "company", targetId: f.companyId }); + await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!, + permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds } }); + let targetReads = 0; + const observedDb = new Proxy(server.db, { get(target, key, receiver) { + if (key !== "select") return Reflect.get(target, key, receiver); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const query = from(...args), execute = query.execute.bind(query); + query.execute = (async (...executeArgs: Parameters) => { + if (query.toSQL().sql.includes('"agents"."id" =')) targetReads++; + return execute(...executeArgs); + }) as typeof execute; + return query; + }) as typeof builder.from; + return builder; + }; + } }); + const service = connectionManagementService(observedDb); + const actor: AuthorizationActor = { type: "board", userId: f.responsibleUserId, source: "cloud_tenant" }; + const resource = { type: "connection_management" as const, companyId: f.companyId, connectionId: connection.id, operation: "configure", readOnly: false }; + const baseline = []; + for (const targetAgentId of candidateIds) baseline.push(await service.capability(actor, "connections:profiles", { ...resource, targetAgentId })); + expect(baseline.every(result => result.allowed)).toBe(true); + expect(targetReads).toBe(candidateIds.length ** 2); + targetReads = 0; + expect(await service.describeAgentInstallCapabilities(actor, resource, candidateIds)).toEqual(baseline); + expect(targetReads).toBe(candidateIds.length); + + const [pool] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, + uid: randomUUID(), name: "Pool with private member", transport: "runtime_auth", connectionPurpose: "ai", authKind: "none" }).returning(); + await server.db.insert(aiConnectionPools).values({ id: pool.id, companyId: f.companyId, pluginKey: "fixture-pool", config: { + name: "Pool", enabled: true, mode: "round_robin", thresholdPercent: 90, + members: [{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", connectionId: connection.id, grantId: grant.id }, profile: { provider: "codex", model: "fixture-model" } }], + } }); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.responsibleUserId!))); + await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!, + permissionKey: "tools:manage_profiles", scope: { connectionIds: [pool.id] } }); + const poolResource = { ...resource, connectionId: pool.id }; + expect((await service.describeAgentInstallCapabilities(actor, poolResource, candidateIds.slice(0, 2))).every(result => result.allowed)).toBe(true); + // The pool grant cannot replace the member account's own authority. + await server.db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id)); + await server.db.update(toolConnections).set({ createdByUserId: "different-owner" }).where(eq(toolConnections.id, connection.id)); + const ordinary = await service.capability(actor, "connections:profiles", { ...poolResource, targetAgentId: candidateIds[0] }); + expect(ordinary.allowed).toBe(false); + expect(await service.describeAgentInstallCapabilities(actor, poolResource, candidateIds.slice(0, 2))).toEqual([ordinary, ordinary]); + }); + it("creates and configures tool connections and profiles with fresh grants and actual key/JWT authentication", async () => { const f = await fixture(); const grants = await server.db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.agentId)); @@ -1642,6 +1704,36 @@ describe("connection management with production default agents", () => { } finally { provider = null; unregisterServerAdapter("paperclip_runner"); } }); + it("reads only the authorized Anthropic account identity through actual agent authentication", async () => { + const f = await fixture(); + const account = await aiConnectionService(server.db).save(f.companyId, f.userId, { + provider: "anthropic", method: "subscription", ownership: "personal", name: "Account identity fixture", + loginSessionId: "fixture", agentIds: [f.agentId], allAgents: false, + }, "selected-identity-credential"); + let calls = 0; + provider = async (url, init) => { + calls++; + expect(url).toBe("https://api.anthropic.com/api/oauth/profile"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer selected-identity-credential"); + return Response.json({ account: { uuid: "private-seat", email: "avery@example.test" }, organization: { uuid: "private-org", name: "Example team" } }); + }; + const path = `/companies/${f.companyId}/ai-connections/${account.connectionId}/identity?grantId=${account.grantId}`; + try { + for (const token of [f.key.token, f.jwt]) { + const response = await f.request("GET", path, undefined, token); + expect(response).toMatchObject({ status: 200, body: { ...account, status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" } }); + for (const secret of ["selected-identity-credential", "private-seat", "private-org"]) expect(JSON.stringify(response)).not.toContain(secret); + } + expect(calls).toBe(2); + expect((await f.request("GET", path.replace(f.companyId, f.foreignCompanyId))).status).toBe(403); + expect((await f.request("GET", path.replace(account.grantId, randomUUID()))).status).toBe(404); + expect(calls).toBe(2); + await server.db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId)); + expect(await f.request("GET", path)).toMatchObject({ status: 200, body: { status: "unavailable", errorCode: "connection_unavailable", accountLabel: null } }); + expect(calls).toBe(2); + } finally { provider = null; } + }); + it("filters AI account and pool listings by the agent's resolved connection scope", async () => { const f = await fixture(); provider = async url => { diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 98d19cd8078..69a984263a0 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -251,6 +251,22 @@ export function aiConnectionRoutes(db: Db, options: Parameters { + const companyId = req.params.companyId as string; + assertBoardOrAgent(req); + assertCompanyAccess(req, companyId); + const connectionId = z.string().uuid().safeParse(req.params.connectionId); + const grantId = z.string().uuid().optional().safeParse(req.query.grantId); + if (!connectionId.success || !grantId.success) throw unprocessable("Invalid connection or grant ID"); + res.setHeader("Cache-Control", "no-store"); + const actor = getActorInfo(req); + res.json(await service.accountIdentity(companyId, responsibleUserForAiRequest(req)!, connectionId.data, grantId.data, { + actorType: actor.actorType, actorId: actor.actorId, heartbeatRunId: actor.runId, + })); + }, + ); router.get( "/companies/:companyId/ai-connections/:connectionId/active-runs", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/:connectionId/active-runs", { personal: true }), async (req, res) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6b7e02f026e..c766d6c4836 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -11311,6 +11311,17 @@ registerCurrentRoute({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); +registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", + method: "get", + path: "/api/companies/{companyId}/ai-connections/{connectionId}/identity", + tags: ["ai-connections"], + summary: "Read the selected authorized AI account's provider email and organization", + query: z.object({ grantId: z.string().uuid().optional() }), + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable }, +}); + registerCurrentRoute({ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", diff --git a/server/src/services/ai-account-identity.test.ts b/server/src/services/ai-account-identity.test.ts new file mode 100644 index 00000000000..eb8e67edd2a --- /dev/null +++ b/server/src/services/ai-account-identity.test.ts @@ -0,0 +1,72 @@ +import { createServer } from "node:http"; +import { once } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { probeAiAccountIdentity } from "./ai-account-identity.js"; + +const metadata = { provider: "anthropic", method: "subscription" } as const; +function fixture(body: unknown, status = 200) { + return vi.fn().mockResolvedValue(Response.json(body, { status })); +} + +describe("AI account identity", () => { + it("returns only safe account details from the selected bearer profile", async () => { + const request = fixture({ account: { uuid: "private-seat", email: "avery@example.test", secret: "provider-private" }, + organization: { uuid: "private-organization", name: "Example team" }, access_token: "private-token" }); + const result = await probeAiAccountIdentity(metadata, "selected-credential", request); + expect(result).toMatchObject({ status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" }); + expect(request).toHaveBeenCalledWith("https://api.anthropic.com/api/oauth/profile", expect.objectContaining({ + redirect: "error", signal: expect.any(AbortSignal), + headers: { Authorization: "Bearer selected-credential", "anthropic-beta": "oauth-2025-04-20" }, + })); + for (const secret of ["private-seat", "private-organization", "provider-private", "private-token", "selected-credential"]) { + expect(JSON.stringify(result)).not.toContain(secret); + } + }); + + it.each([401, 403, 429, 500])("reports HTTP %s without exposing the provider response", async status => { + const result = await probeAiAccountIdentity(metadata, "selected-credential", fixture({ error: "private-provider-error" }, status)); + expect(result).toMatchObject({ accountLabel: null, organizationLabel: null }); + expect(result.status).toBe(status === 401 || status === 403 ? "unavailable" : "error"); + if (status === 403) expect(result).toMatchObject({ errorCode: "permission_denied", message: expect.stringContaining("did not permit") }); + expect(JSON.stringify(result)).not.toContain("private-provider-error"); + }); + + it.each([{}, { account: { email: "not-an-email" } }, { account: { email: "avery@example.test\nprivate" } }])("does not infer an email from an incomplete profile %j", async profile => { + expect(await probeAiAccountIdentity(metadata, "selected-credential", fixture(profile))).toMatchObject({ + status: "unavailable", errorCode: "invalid_response", accountLabel: null, + }); + }); + + it("does not call a provider for API keys or other sign-in methods", async () => { + const request = fixture({}); + expect((await probeAiAccountIdentity({ provider: "anthropic", method: "api_key" }, "", request)).errorCode).toBe("unsupported"); + expect((await probeAiAccountIdentity({ provider: "openai", method: "subscription" }, "", request)).errorCode).toBe("unsupported"); + expect(request).not.toHaveBeenCalled(); + }); + + it("aborts a stalled real response body while unrelated requests remain responsive", async () => { + const server = createServer((req, res) => { + if (req.url === "/profile") { res.writeHead(200, { "Content-Type": "application/json" }); res.flushHeaders(); } + else res.end("responsive"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture address"); + const origin = `http://127.0.0.1:${address.port}`; + const timeout = AbortSignal.timeout.bind(AbortSignal); + const deadline = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => timeout(100)); + try { + const pending = probeAiAccountIdentity(metadata, "selected-credential", (_url, init) => fetch(`${origin}/profile`, init)); + expect(await (await fetch(`${origin}/unrelated`)).text()).toBe("responsive"); + const result = await pending; + expect(result.status).toBe("error"); + expect(result.accountLabel).toBeNull(); + expect(deadline).toHaveBeenCalledWith(15_000); + } finally { + deadline.mockRestore(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); +}); diff --git a/server/src/services/ai-account-identity.ts b/server/src/services/ai-account-identity.ts new file mode 100644 index 00000000000..b93fb6da02c --- /dev/null +++ b/server/src/services/ai-account-identity.ts @@ -0,0 +1,43 @@ +import type { AiConnectionAccountIdentity, AiConnectionMetadata } from "@paperclipai/shared"; +import { readUsage } from "./ai-connection-usage.js"; + +type Observation = Omit; +type FailureCode = NonNullable; +const messages: Record = { + unsupported: "Account details are not available for this sign-in method.", + connection_unavailable: "Reconnect or enable this connection to check its account details.", + authentication_required: "Reconnect this account to check its account details.", + permission_denied: "Anthropic did not permit this connection to read the account email.", + rate_limited: "Anthropic limited this account check. Try again later.", + provider_unavailable: "Could not read the account details. Try again later.", + invalid_response: "Anthropic did not return an account email.", +}; +const object = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const label = (value: unknown): string | null => typeof value === "string" && value.trim().length > 0 + && value.length <= 254 && !/[\u0000-\u001f\u007f]/.test(value) ? value.trim() : null; + +/** Never uses ambient CLI identity or exposes provider IDs, tokens, or raw responses. */ +export async function probeAiAccountIdentity( + metadata: AiConnectionMetadata, + credential: string, + request: typeof fetch = fetch, +): Promise { + const base = { checkedAt: new Date().toISOString(), accountLabel: null, organizationLabel: null }; + if (metadata.provider !== "anthropic" || metadata.method !== "subscription" || metadata.routing) { + return { ...base, status: "unavailable", errorCode: "unsupported", message: messages.unsupported }; + } + try { + const profile = await readUsage("https://api.anthropic.com/api/oauth/profile", { + Authorization: `Bearer ${credential}`, "anthropic-beta": "oauth-2025-04-20", + }, request); + const accountLabel = label(object(profile.account).email); + if (!accountLabel || !/^[^\s@/\\]+@[^\s@/\\]+\.[^\s@/\\]+$/.test(accountLabel)) { + return { ...base, status: "unavailable", errorCode: "invalid_response", message: messages.invalid_response }; + } + return { ...base, status: "available", accountLabel, organizationLabel: label(object(profile.organization).name) }; + } catch (error) { + const code = object(error).code; + const errorCode: FailureCode = typeof code === "string" && Object.hasOwn(messages, code) ? code as FailureCode : "provider_unavailable"; + return { ...base, status: errorCode === "authentication_required" || errorCode === "permission_denied" ? "unavailable" : "error", errorCode, message: messages[errorCode] }; + } +} diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index d76f9606c9e..6314a5265d8 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -36,6 +36,7 @@ import { type AiConnectionAttribution, type AiConnectionMetadata, type AiManagedConnectionSummary, + type AiConnectionAccountIdentity, type AiConnectionUsage, type CreateAiConnection, type AiConnectionLoginIntent, @@ -49,6 +50,7 @@ import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { secretService } from "./secrets.js"; import { probeAiConnectionUsage } from "./ai-connection-usage.js"; +import { probeAiAccountIdentity } from "./ai-account-identity.js"; /** Same human audience displayed by the existing Connections identity controls. */ function canUseCredential( @@ -225,28 +227,46 @@ export function aiConnectionService(db: Db) { }); }); } - /** On-demand account observation for either runner; never probes ambient CLI auth. */ - async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise { + async function readableAccount(companyId: string, userId: string, connectionId: string, grantId?: string) { if (!(await membership(companyId, userId))) throw forbidden("An active company member is required"); const candidates = (await rows(companyId)).filter((row) => row.connection.id === connectionId && (!grantId || row.grant.id === grantId)); const audience = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)); const visible = candidates.filter(({ grant }) => canUseCredential(grant, userId, audience.filter((member) => member.grantId === grant.id))); if (!visible.length) throw notFound("AI connection not found"); - if (visible.length > 1) throw unprocessable("Choose a credential grant for this usage check"); + if (visible.length > 1) throw unprocessable("Choose a credential grant for this account check"); const row = visible[0]!; const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai); if (!metadata.success) throw notFound("AI connection not found"); - const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata.data }; + return { row, metadata: metadata.data }; + } + /** On-demand account observation for either runner; never probes ambient CLI auth. */ + async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise { + const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId); + const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata }; const empty = { ...identity, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null }; if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) { return { ...empty, status: "unavailable", errorCode: "connection_unavailable", message: "Reconnect or enable this AI connection before checking usage." }; } - if (!supportsAiConnectionUsage(metadata.data.provider, metadata.data.method)) { - return { ...identity, ...await probeAiConnectionUsage(metadata.data, "") }; + if (!supportsAiConnectionUsage(metadata.provider, metadata.method)) { + return { ...identity, ...await probeAiConnectionUsage(metadata, "") }; } const value = await credential(row); - return { ...identity, ...await probeAiConnectionUsage(metadata.data, value) }; + return { ...identity, ...await probeAiConnectionUsage(metadata, value) }; + } + async function accountIdentity(companyId: string, userId: string, connectionId: string, grantId?: string, + audit?: { actorType: "user" | "agent" | "system"; actorId: string; heartbeatRunId?: string | null }): Promise { + const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId); + const identity = { connectionId: row.connection.id, grantId: row.grant.id }; + if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || + row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) { + return { ...identity, status: "unavailable", checkedAt: new Date().toISOString(), accountLabel: null, organizationLabel: null, + errorCode: "connection_unavailable", message: "Reconnect or enable this connection to check its account details." }; + } + // Unsupported methods must not open their credentials just to report this limit. + const value = metadata.provider === "anthropic" && metadata.method === "subscription" && !metadata.routing + ? await credential(row, 0, { actorType: "user", actorId: userId, ...audit, responsibleUserId: userId }) : ""; + return { ...identity, ...await probeAiAccountIdentity(metadata, value) }; } async function select(input: { companyId: string; @@ -1218,5 +1238,5 @@ export function aiConnectionService(db: Db) { return [{ ...row, summary }]; }); } - return { list, selectDecision, selectFastResponse, selectPromptInjectionPrevention, quotaAccounts, subscriptionAccounts: (companyId: string, userId: string) => quotaAccounts(companyId, userId, true), refreshQuotaCredential, select, credential, runtimeCredential, probeUsage, save, setDefault, membership, markAuthenticationFailed }; + return { list, selectDecision, selectFastResponse, selectPromptInjectionPrevention, quotaAccounts, subscriptionAccounts: (companyId: string, userId: string) => quotaAccounts(companyId, userId, true), refreshQuotaCredential, select, credential, runtimeCredential, probeUsage, accountIdentity, save, setDefault, membership, markAuthenticationFailed }; } diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index cf65d2afb34..39a0c67488f 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -32,7 +32,7 @@ type PreparedAuthorization = { expandedTargets: Set; referencedConnections: string[]; currentGatewayResource: ConnectionManagementResource | undefined; - savedNonAiConnection: boolean; + savedConnection: boolean; }; type ConnectionCapability = { allowed: boolean; reason: string; operation: string }; @@ -88,7 +88,7 @@ export function connectionManagementService(db: Db) { const expandedTargets = new Set(options.targetAgentIds ?? []); const referencedConnections: string[] = []; let currentGatewayResource: ConnectionManagementResource | undefined; - let savedNonAiConnection = false; + let savedConnection = false; if (resource.gatewayId && !options.gatewayContextChecked) { const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, resource.gatewayId), eq(toolMcpGateways.companyId, resource.companyId))); if (!gateway) throw forbidden("Gateway is unavailable in this company"); @@ -159,7 +159,7 @@ export function connectionManagementService(db: Db) { if (resource.connectionId) { const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, resource.connectionId), eq(toolConnections.companyId, resource.companyId))); if (!connection) throw forbidden("Connection is unavailable in this company"); - savedNonAiConnection = connection.connectionPurpose !== "ai"; + savedConnection = true; resource = { ...resource, selfServiceUserId: connection.createdByUserId, purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool", provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -241,7 +241,7 @@ export function connectionManagementService(db: Db) { } } return { actor, action, resource, options, responsibleUserId, workContext, capturedRun, - expandedTargets, referencedConnections, currentGatewayResource, savedNonAiConnection }; + expandedTargets, referencedConnections, currentGatewayResource, savedConnection }; } async function evaluateTarget(prepared: PreparedAuthorization, targetAgentId: string | undefined): Promise { @@ -388,7 +388,9 @@ export function connectionManagementService(db: Db) { } const resolved = prepared.resource; const isCanonicalAgentId = (id: string) => isUuidLike(id) && id === id.toLowerCase(); - const eligible = actor.type === "board" && prepared.savedNonAiConnection + // Direct AI accounts use the same target checks. Pools with members still + // use the full evaluator through referencedConnections below. + const eligible = actor.type === "board" && prepared.savedConnection && [resolved.profileId, resolved.gatewayId, resolved.routineId, resolved.projectId, resolved.issueId].every(id => id == null) && !Object.hasOwn(resolved, "requestedProvider") && !Object.hasOwn(resolved, "requestedInstallCompanyIds") && !resolved.selfServiceAgentInstalls diff --git a/ui/src/api/ai-connections.ts b/ui/src/api/ai-connections.ts index 419e60ed011..8ba35a7ba6f 100644 --- a/ui/src/api/ai-connections.ts +++ b/ui/src/api/ai-connections.ts @@ -1,6 +1,7 @@ -import type { AiConnectionList, AiConnectionUsage, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; -import { api } from "./client"; +import type { AiConnectionList, AiConnectionUsage, AiConnectionAccountIdentity, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; +import { api, type RequestOptions } from "./client"; export const aiConnectionsApi = { + accountIdentity: (companyId: string, connectionId: string, grantId: string, options?: RequestOptions) => api.get(`/companies/${companyId}/ai-connections/${connectionId}/identity?grantId=${encodeURIComponent(grantId)}`, { timeoutMs: 20_000, ...options }), loginSetup: (companyId: string, sessionId: string) => api.get<{ sessionId: string; intent: AiConnectionLoginIntent; environmentId: string; status: string; connectionId: string | null; grantId: string | null }>(`/companies/${companyId}/ai-connections/login/${encodeURIComponent(sessionId)}/setup`), probeUsage: (companyId: string, connectionId: string, grantId?: string) => api.get(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`), startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post(`/companies/${companyId}/ai-connections/local/attempts`, input), @@ -9,7 +10,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(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`), + list: (companyId: string, agentId?: string, options?: RequestOptions) => api.get(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`, { timeoutMs: 15_000, ...options }), 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/api/client.test.ts b/ui/src/api/client.test.ts index 132fbc0b995..00156e922a8 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -41,6 +41,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe("tenant-session recovery", () => { @@ -102,6 +103,55 @@ describe("tenant-session recovery", () => { }); describe("in-tab GET coalescing", () => { + it("times out a caller without cancelling another and starts a fresh fetch on retry", async () => { + vi.useFakeTimers(); + const first = deferred(); + fetchMock.mockReturnValueOnce(first.promise); + const survivorController = new AbortController(); + const survivor = api.get("/deadline-shared", { signal: survivorController.signal }); + const timed = api.get("/deadline-shared", { timeoutMs: 100 }).catch(error => error); + const sharedSignal = fetchMock.mock.calls[0][1].signal as AbortSignal; + await vi.advanceTimersByTimeAsync(100); + expect(await timed).toMatchObject({ status: 408, body: { error: "TIMEOUT" } }); + expect(sharedSignal.aborted).toBe(false); + fetchMock.mockResolvedValueOnce(jsonResponse({ fresh: true })); + await expect(api.get("/deadline-shared", { timeoutMs: 100 })).resolves.toEqual({ fresh: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + survivorController.abort(); + await expect(survivor).rejects.toMatchObject({ name: "AbortError" }); + expect(sharedSignal.aborted).toBe(true); + first.resolve(jsonResponse({ stale: true })); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bounds response-body reads and aborts the last caller's fetch", async () => { + vi.useFakeTimers(); + const body = deferred(); + fetchMock.mockResolvedValue({ ok: true, status: 200, json: () => body.promise }); + const result = api.get("/deadline-body", { timeoutMs: 100 }).catch(error => error); + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal; + await vi.advanceTimersByTimeAsync(100); + expect(await result).toMatchObject({ status: 408 }); + expect(signal.aborted).toBe(true); + body.resolve({ tooLate: true }); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears a successful or cancelled caller's deadline", async () => { + vi.useFakeTimers(); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await expect(api.get("/deadline-success", { timeoutMs: 100 })).resolves.toEqual({ ok: true }); + expect(vi.getTimerCount()).toBe(0); + const controller = new AbortController(); + fetchMock.mockReturnValueOnce(deferred().promise); + const result = api.get("/deadline-cancel", { signal: controller.signal, timeoutMs: 100 }); + controller.abort(); + await expect(result).rejects.toMatchObject({ name: "AbortError" }); + expect(vi.getTimerCount()).toBe(0); + }); + it("shares one underlying fetch for identical in-flight GETs", async () => { const d = deferred(); fetchMock.mockReturnValue(d.promise); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 7b4b8f8c195..78ec560949b 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,6 +1,7 @@ import { getPageVisibility, getVisibilityHeaderValue } from "@/lib/page-visibility"; import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; import { ApiError, apiErrorFromResponse } from "./api-error"; +import { errorCopy } from "./errors"; export { ApiError, apiErrorFromResponse, type ApiErrorOptions } from "./api-error"; import { readApiJson } from "./response"; @@ -98,6 +99,8 @@ export async function apiFetch(input: string, init?: RequestInit): Promise(path: string, options?: RequestOptions): Promise { const releaseRef = () => { if (!activeEntry.refs.delete(ref)) return; // Last caller gone before the fetch settled → abort the shared request. - if (activeEntry.refs.size === 0 && inflightGets.get(path) === activeEntry) { - inflightGets.delete(path); + if (activeEntry.refs.size === 0) { + if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); activeEntry.controller.abort(); } }; return new Promise((resolve, reject) => { - const onAbort = () => { + let timer: ReturnType | undefined; + const cleanup = () => { signal?.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + const onAbort = () => { + cleanup(); releaseRef(); reject(abortError()); }; if (signal) signal.addEventListener("abort", onAbort); + if (options?.timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + // Other callers keep their request, but retries must not rejoin it. + if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); + releaseRef(); + reject(new ApiError(errorCopy("TIMEOUT").body, 408, { error: "TIMEOUT" })); + }, options.timeoutMs); + } activeEntry.promise.then( (value) => { - signal?.removeEventListener("abort", onAbort); + cleanup(); activeEntry.refs.delete(ref); resolve(value as T); }, (err) => { - signal?.removeEventListener("abort", onAbort); + cleanup(); activeEntry.refs.delete(ref); reject(err); }, diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index f8a726d4583..4bf3021dfc4 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -66,7 +66,7 @@ import type { ToolConnectionCreateCapabilities, ToolAppMetadataPreflightResult, } from "@paperclipai/shared"; -import { api, detachInflightGet } from "./client"; +import { api, detachInflightGet, type RequestOptions } from "./client"; /** * Tools & Access API client (Phase 6, PAP-10389). @@ -367,8 +367,8 @@ export const toolsApi = { // --- Identity grants (PAP-17835): who a connection acts as. The response // carries server-computed capabilities and the audience member directory, so // the UI never rebuilds the permission matrix from `membershipRole`. - listConnectionGrants: (connectionId: string) => - api.get(`/tool-connections/${connectionId}/grants`), + listConnectionGrants: (connectionId: string, options?: RequestOptions) => + api.get(`/tool-connections/${connectionId}/grants`, { timeoutMs: 15_000, ...options }), createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) => api.post( `/tool-connections/${connectionId}/grants/${grantId}/delegations`, diff --git a/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx new file mode 100644 index 00000000000..b4ea87446f9 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { AiConnectionAccountIdentity, AiManagedConnectionSummary, ConnectionGrant, ToolConnection } from "@paperclipai/shared"; +import { AiConnectionAccountDetails } from "./AiConnectionAccountDetails"; +import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails"; + +vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() })); + +const account: AiManagedConnectionSummary = { + id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription", + name: "Saved Claude", ownership: "personal", ownerName: "Sam", ownerUserId: "user", isDefault: true, status: "connected", +}; +const grant = { id: account.grantId, updatedAt: new Date("2020-01-01T00:00:00.000Z"), capabilities: { canRevoke: true, canEditAudience: false } } as ConnectionGrant; +const known: AiConnectionAccountIdentity = { connectionId: account.id, grantId: grant.id, status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team", checkedAt: "2020-01-01T00:00:00.000Z" }; +let root: ReturnType; +let host: HTMLDivElement; +let client: QueryClient; +let observation: AiConnectionAccountIdentity; +let blocked: boolean; +let signals: AbortSignal[]; +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("fetch", fetchMock); + observation = known; + blocked = false; + signals = []; + fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => { + if (url.includes("/identity?")) { + if (blocked) { + const signal = init.signal as AbortSignal; + signals.push(signal); + return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })); + } + return Promise.resolve(Response.json(observation)); + } + if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([])); + if (url.endsWith("/grants")) return Promise.resolve(Response.json({ currentUserId: "user", grants: [grant] })); + return Promise.resolve(Response.json({ currentUserId: "user", connections: [account], pools: [], canManageConnections: true })); + }); + client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity } } }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + host.remove(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function render(value = account, selectedGrant = grant, completePanel = false) { + await act(async () => { + root.render({completePanel + ? + : }); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(10); }); + await act(async () => { await vi.advanceTimersByTimeAsync(10); }); +} +const identityCalls = () => fetchMock.mock.calls.filter(([url]) => url.includes("/identity?")); + +it("shows the provider email and organization separately from the Paperclip owner", async () => { + await render(); + expect(host.textContent).toContain("avery@example.test"); + expect(host.textContent).toContain("Example team"); + expect(host.textContent).toContain("Personal · Sam"); + expect(identityCalls()[0][0]).toBe("/api/companies/company/ai-connections/connection/identity?grantId=grant"); +}); + +it("explains profile permission denial while preserving connected account controls", async () => { + observation = { ...known, status: "unavailable", accountLabel: null, organizationLabel: null, errorCode: "permission_denied", message: "Anthropic did not permit this connection to read the account email." }; + await render(account, grant, true); + expect(host.textContent).toContain("Personal default"); + expect(host.textContent).toContain("Reconnect"); + expect(host.textContent).toContain("did not permit"); + expect(host.textContent).not.toContain("avery@example.test"); + expect(host.textContent).not.toContain("Retry account check"); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(identityCalls()).toHaveLength(1); +}); + +it("keeps maintenance controls visible during a stalled lookup and recovers only the identity request", async () => { + blocked = true; + await render(account, grant, true); + expect(host.textContent).toContain("Personal default"); + expect(host.textContent).toContain("Reconnect"); + expect(host.textContent).toContain("Checking account details…"); + await act(async () => { await vi.advanceTimersByTimeAsync(20_001); }); + expect(signals[0].aborted).toBe(true); + expect(host.textContent).toContain("Personal default"); + expect(host.textContent).not.toContain("Checking account details…"); + const ordinaryCalls = fetchMock.mock.calls.length - identityCalls().length; + blocked = false; + await act(async () => { + [...host.querySelectorAll("button")].find(button => button.textContent === "Retry account check")!.click(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(host.textContent).toContain("avery@example.test"); + expect(fetchMock.mock.calls.length - identityCalls().length).toBe(ordinaryCalls); +}); + +it("does not show the previous credential's identity after the selected grant changes", async () => { + await render(); + expect(host.textContent).toContain("avery@example.test"); + blocked = true; + await render({ ...account, grantId: "other-grant" }, { ...grant, id: "other-grant" }); + expect(host.textContent).not.toContain("avery@example.test"); + expect(host.textContent).toContain("Checking account details…"); +}); + +it("uses saved labels for unsupported methods without opening an identity request", async () => { + await render({ ...account, method: "api_key", accountLabel: "billing@example.test", ownership: "shared" }); + expect(host.textContent).toContain("billing@example.test"); + expect(host.textContent).toContain("Company shared"); + expect(identityCalls()).toHaveLength(0); +}); + +it("reuses recent identity observations and invalidates them after reconnect", async () => { + await render(); + await act(async () => { root.render(null); }); + await render(); + expect(identityCalls()).toHaveLength(1); + observation = { ...known, accountLabel: "new-account@example.test" }; + await render(account, { ...grant, updatedAt: new Date("2020-01-02T00:00:00.000Z") }); + expect(identityCalls()).toHaveLength(2); + expect(host.textContent).toContain("new-account@example.test"); + expect(host.textContent).not.toContain("avery@example.test"); +}); diff --git a/ui/src/components/ai-connections/AiConnectionAccountDetails.tsx b/ui/src/components/ai-connections/AiConnectionAccountDetails.tsx new file mode 100644 index 00000000000..9b2b84305e0 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionAccountDetails.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AiManagedConnectionSummary, ConnectionGrant } from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { describeError } from "@/api/errors"; +import { retryTransientOnly } from "@/lib/query-client"; +import { Button } from "@/components/ui/button"; +import { AI_PROVIDERS, aiMethodLabel } from "./model"; + +/** Provider profile loading is independent of account access and maintenance controls. */ +export function AiConnectionAccountDetails({ account, grant }: { + account: AiManagedConnectionSummary; + grant: ConnectionGrant; +}) { + const canLookUp = account.provider === "anthropic" && account.method === "subscription" && account.status === "connected"; + const identity = useQuery({ + queryKey: ["ai-account-identity", account.companyId, account.id, grant.id, grant.updatedAt], + queryFn: ({ signal }) => aiConnectionsApi.accountIdentity(account.companyId, account.id, grant.id, { signal }), + enabled: canLookUp, + staleTime: 5 * 60_000, + retry: retryTransientOnly(0), + }); + const accountLabel = identity.data?.accountLabel ?? account.accountLabel; + const message = identity.error ? describeError(identity.error).body : identity.data?.message; + const retryable = identity.isError || identity.data?.status === "error"; + return ( +
+

Connected account

+
+
{AI_PROVIDERS[account.provider].name} account
+
{accountLabel ?? "Not available"}
+ {identity.data?.organizationLabel && <> +
Organization
+
{identity.data.organizationLabel}
+ } +
Sign-in method
+
{aiMethodLabel(account.provider, account.method)}
+
Access
+
{account.ownership === "shared" ? "Company shared" : `Personal · ${account.ownerName ?? "Account owner"}`}
+
+ {canLookUp && identity.isPending &&

Checking account details…

} + {message &&

{message}

} + {retryable && } +
+ ); +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx new file mode 100644 index 00000000000..815fd8d871b --- /dev/null +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { AiManagedConnectionSummary, ToolConnection } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails"; + +vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("./AiConnectionAccountControls", () => ({ + AiConnectionAccountControls: ({ account }: { account: AiManagedConnectionSummary }) => ( +
{account.name}
+ ), +})); +vi.mock("./AiConnectionUsagePanel", () => ({ AiConnectionUsagePanel: () => null })); +vi.mock("./AiConnectionAccountDetails", () => ({ AiConnectionAccountDetails: () => null })); + +const account: AiManagedConnectionSummary = { + id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription", + name: "My account", ownership: "personal", isDefault: true, status: "connected", +}; +const connection = { id: account.id, companyId: account.companyId } as ToolConnection; +const accounts = { currentUserId: "user", connections: [account], pools: [], canManageConnections: true }; +const grants = { currentUserId: "user", grants: [{ id: account.grantId }] }; +let root: ReturnType; +let host: HTMLDivElement; +let client: QueryClient; +let blocked: "accounts" | "grants" | null; +let denied: boolean; +let signals: AbortSignal[]; +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("fetch", fetchMock); + blocked = null; + denied = false; + signals = []; + fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => { + if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([])); + const kind = url.endsWith("/grants") ? "grants" : "accounts"; + if (blocked === kind) { + const signal = init.signal as AbortSignal; + signals.push(signal); + return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })); + } + if (denied) return Promise.resolve(Response.json({ error: "Access denied" }, { status: 403 })); + return Promise.resolve(Response.json(kind === "grants" ? grants : accounts)); + }); + client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity } } }); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + host.remove(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function render() { + await act(async () => { + root.render(); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); +} + +it("reuses the grants already loaded by the permissions page", async () => { + client.setQueryData(queryKeys.tools.connectionGrants(account.id), grants); + await render(); + expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name); + expect(fetchMock.mock.calls.some(([url]) => url.endsWith("/grants"))).toBe(false); +}); + +it.each(["accounts", "grants"] as const)("bounds a stuck %s read, retries once, and recovers from the retry button", async kind => { + blocked = kind; + await render(); + expect(host.textContent).toContain("Loading AI account…"); + await act(async () => { await vi.advanceTimersByTimeAsync(31_001); }); + expect(signals).toHaveLength(2); + expect(signals.every(signal => signal.aborted)).toBe(true); + expect(host.textContent).not.toContain("Loading AI account…"); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("too long"); + expect(host.querySelector("button")?.textContent).toBe("Retry"); + blocked = null; + await act(async () => { + host.querySelector("button")!.click(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); + +it("shows an authorization failure without automatically retrying it", async () => { + denied = true; + await render(); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Access denied"); + expect(host.textContent).not.toContain("Loading AI account…"); + const calls = fetchMock.mock.calls.length; + await act(async () => { await vi.advanceTimersByTimeAsync(31_001); }); + expect(fetchMock).toHaveBeenCalledTimes(calls); +}); + +it("keeps loaded controls visible when a background read fails", async () => { + await render(); + denied = true; + await act(async () => { + await client.invalidateQueries(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Access denied"); + expect(host.querySelector("button")?.textContent).toBe("Retry"); +}); + +it("cancels a stuck account read when the panel unmounts", async () => { + blocked = "accounts"; + await render(); + expect(signals).toHaveLength(1); + await act(async () => { root.render(null); }); + expect(signals[0].aborted).toBe(true); +}); diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx index 9d5d43fead4..4f5b62f437f 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -6,8 +6,12 @@ import { toolsApi } from "@/api/tools"; import { useNavigate } from "@/lib/router"; import { AiConnectionAccountControls } from "./AiConnectionAccountControls"; import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel"; +import { AiConnectionAccountDetails } from "./AiConnectionAccountDetails"; import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared"; import { aiMethodLabel } from "./model"; +import { queryKeys } from "@/lib/queryKeys"; +import { retryTransientOnly } from "@/lib/query-client"; +import { describeError } from "@/api/errors"; export function ManagedAiConnectionRow({ connection, @@ -39,11 +43,13 @@ export function ManagedAiConnectionDetails({ }); const accounts = useQuery({ queryKey: ["ai-connections", connection.companyId], - queryFn: () => aiConnectionsApi.list(connection.companyId), + queryFn: ({ signal }) => aiConnectionsApi.list(connection.companyId, undefined, { signal }), + retry: retryTransientOnly(1), }); const grants = useQuery({ - queryKey: ["ai-connection-grants", connection.id], - queryFn: () => toolsApi.listConnectionGrants(connection.id), + queryKey: queryKeys.tools.connectionGrants(connection.id), + queryFn: ({ signal }) => toolsApi.listConnectionGrants(connection.id, { signal }), + retry: retryTransientOnly(1), }); const refresh = () => client.invalidateQueries(); const makeDefault = useMutation({ @@ -64,17 +70,25 @@ export function ManagedAiConnectionDetails({ (a) => a.id === connection.id, ); const grant = grants.data?.grants.find((g) => g.id === account?.grantId); - const error = - accounts.error ?? - grants.error ?? - makeDefault.error ?? - stop.error; - if (error) - return ( -

- {error.message} -

- ); + const loadError = accounts.error ?? grants.error; + const error = makeDefault.error ?? stop.error; + const loadFailure = loadError ? ( +
+

{describeError(loadError).body}

+ +
+ ) : null; + if ((!account || !grant) && loadFailure) return loadFailure; if (!account || !grant) return (

@@ -85,6 +99,9 @@ export function ManagedAiConnectionDetails({ ); return (

+ {loadFailure} + {error &&

{error.message}

} + {account.routing &&
Provider settings
Destination
{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}
API format
{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}
Authentication
{account.routing.auth}
Models
{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}
} toolsApi.listConnectionGrants(connectionId), + queryFn: ({ signal }) => toolsApi.listConnectionGrants(connectionId, { signal }), + retry: retryTransientOnly(1), enabled: !!connectionId && !!activeTab, }); @@ -980,20 +982,3 @@ function accessIncludingInstalls(next: AccessDraft, install: InstallState): Acce agentIds: new Set([...next.agentIds, ...install.agentIds]), }; } - -function galleryEntryFor( - apps: AppGalleryDisplayEntry[], - connection: ToolConnection | undefined, - application: ToolApplication | undefined, -): AppGalleryDisplayEntry | null { - if (!connection) return null; - const sourceSlug = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection); - if (sourceSlug) { - const keyed = apps.find((app) => appDefinitionSlug(app) === sourceSlug); - if (keyed) return keyed; - } - const name = connection.name.toLowerCase(); - return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? - apps.find((app) => appDefinitionSlug(app) === name) ?? - null; -} diff --git a/ui/src/pages/apps/app-definition-display.ts b/ui/src/pages/apps/app-definition-display.ts index b7772797781..a5e32749e41 100644 --- a/ui/src/pages/apps/app-definition-display.ts +++ b/ui/src/pages/apps/app-definition-display.ts @@ -59,3 +59,20 @@ export function appConnectionSourceSlug(connection: ToolConnection | null | unde const source = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey; return typeof source === "string" && source.trim() ? source.trim() : null; } + +export function galleryEntryFor( + apps: AppGalleryDisplayEntry[], + connection: ToolConnection | undefined, + application: ToolApplication | undefined, +): AppGalleryDisplayEntry | null { + if (!connection) return null; + const sourceSlug = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection); + if (sourceSlug) { + const keyed = apps.find((app) => appDefinitionSlug(app) === sourceSlug); + if (keyed) return keyed; + } + const name = connection.name.toLowerCase(); + return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? + apps.find((app) => appDefinitionSlug(app) === name) ?? + null; +}