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; +}