fix(connections): load permissions and show AI account identity (#18653)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-02 12:29:03 -08:00
1 parent 33fc7fcddc
commit 49b3804830
23 files changed
+813 -65

No files matched your search

+17
View File
@@ -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
+13 -1
View File
@@ -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.
+14
View File
@@ -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
+11
View File
@@ -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[];
@@ -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=<pre-fix SHA>
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 };
@@ -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;
@@ -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<typeof target.select>) => {
const builder = target.select(...fields), from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
const query = from(...args), execute = query.execute.bind(query);
query.execute = (async (...executeArgs: Parameters<typeof execute>) => {
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 => {
+16
View File
@@ -251,6 +251,22 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
res.json(await service.probeUsage(companyId, responsibleUserForAiRequest(req)!, connectionId.data, grantId.data));
},
);
router.get(
"/companies/:companyId/ai-connections/:connectionId/identity", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/:connectionId/identity", { personal: true }),
async (req, res) => {
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) => {
+11
View File
@@ -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",
@@ -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<typeof fetch>().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<void>(resolve => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,43 @@
import type { AiConnectionAccountIdentity, AiConnectionMetadata } from "@paperclipai/shared";
import { readUsage } from "./ai-connection-usage.js";
type Observation = Omit<AiConnectionAccountIdentity, "connectionId" | "grantId">;
type FailureCode = NonNullable<Observation["errorCode"]>;
const messages: Record<FailureCode, string> = {
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<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
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<Observation> {
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] };
}
}
+28 -8
View File
@@ -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<AiConnectionUsage> {
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<AiConnectionUsage> {
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<AiConnectionAccountIdentity> {
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 };
}
+7 -5
View File
@@ -32,7 +32,7 @@ type PreparedAuthorization = {
expandedTargets: Set<string>;
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<AuthorizationDecision> {
@@ -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
+4 -3
View File
@@ -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<AiConnectionAccountIdentity>(`/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<AiConnectionUsage>(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post<LocalAiLoginAttempt>(`/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<Array<{ id: string; agentId: string; agentName: string; status: string }>>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`),
list: (companyId: string, agentId?: string) => api.get<AiConnectionList>(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`),
list: (companyId: string, agentId?: string, options?: RequestOptions) => api.get<AiConnectionList>(`/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)}`),
+50
View File
@@ -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<Response>();
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<unknown>();
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<Response>().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<Response>();
fetchMock.mockReturnValue(d.promise);
+22 -5
View File
@@ -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<Respo
}
export interface RequestOptions {
/** Per-caller GET deadline, including the response body. A retry starts a fresh fetch. */
timeoutMs?: number;
/** Abort signal wired through to `fetch` and coalescing (per-caller). */
signal?: AbortSignal;
/** Extra request headers (e.g. the async-import opt-in). Mutations only. */
@@ -200,28 +203,42 @@ function coalescedGet<T>(path: string, options?: RequestOptions): Promise<T> {
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<T>((resolve, reject) => {
const onAbort = () => {
let timer: ReturnType<typeof setTimeout> | 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);
},
+3 -3
View File
@@ -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<ConnectionGrantsResponse>(`/tool-connections/${connectionId}/grants`),
listConnectionGrants: (connectionId: string, options?: RequestOptions) =>
api.get<ConnectionGrantsResponse>(`/tool-connections/${connectionId}/grants`, { timeoutMs: 15_000, ...options }),
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
api.post<ConnectionGrantDelegation>(
`/tool-connections/${connectionId}/grants/${grantId}/delegations`,
@@ -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<typeof createRoot>;
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(<QueryClientProvider client={client}>{completePanel
? <ManagedAiConnectionDetails connection={{ id: value.id, companyId: value.companyId } as ToolConnection} />
: <AiConnectionAccountDetails account={value} grant={selectedGrant} />}</QueryClientProvider>);
});
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");
});
@@ -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 (
<section className="space-y-3" aria-label="Connected account">
<h3 className="text-sm font-semibold">Connected account</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<dt className="text-muted-foreground">{AI_PROVIDERS[account.provider].name} account</dt>
<dd className="break-words">{accountLabel ?? "Not available"}</dd>
{identity.data?.organizationLabel && <>
<dt className="text-muted-foreground">Organization</dt>
<dd className="break-words">{identity.data.organizationLabel}</dd>
</>}
<dt className="text-muted-foreground">Sign-in method</dt>
<dd>{aiMethodLabel(account.provider, account.method)}</dd>
<dt className="text-muted-foreground">Access</dt>
<dd>{account.ownership === "shared" ? "Company shared" : `Personal · ${account.ownerName ?? "Account owner"}`}</dd>
</dl>
{canLookUp && identity.isPending && <p role="status" className="text-xs text-muted-foreground">Checking account details…</p>}
{message && <p role={retryable ? "alert" : undefined} className="text-xs text-muted-foreground">{message}</p>}
{retryable && <Button variant="outline" size="sm" disabled={identity.isFetching} onClick={() => void identity.refetch()}>Retry account check</Button>}
</section>
);
}
@@ -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 }) => (
<section aria-label="AI account settings">{account.name}</section>
),
}));
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<typeof createRoot>;
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(<QueryClientProvider client={client}><ManagedAiConnectionDetails connection={connection} /></QueryClientProvider>);
});
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);
});
@@ -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 (
<p role="alert" className="text-sm text-destructive">
{error.message}
</p>
);
const loadError = accounts.error ?? grants.error;
const error = makeDefault.error ?? stop.error;
const loadFailure = loadError ? (
<div role="alert" className="space-y-2">
<p className="text-sm text-destructive">{describeError(loadError).body}</p>
<Button
variant="outline"
size="sm"
disabled={accounts.isFetching || grants.isFetching}
onClick={() => {
if (accounts.error) void accounts.refetch();
if (grants.error) void grants.refetch();
}}
>
Retry
</Button>
</div>
) : null;
if ((!account || !grant) && loadFailure) return loadFailure;
if (!account || !grant)
return (
<p role="status" className="text-sm text-muted-foreground">
@@ -85,6 +99,9 @@ export function ManagedAiConnectionDetails({
);
return (
<div className="space-y-4">
{loadFailure}
{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}
<AiConnectionAccountDetails account={account} grant={grant} />
{account.routing && <details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">Provider settings</summary><dl className="mt-4 grid grid-cols-2 gap-3 text-sm"><dt className="text-muted-foreground">Destination</dt><dd className="break-all">{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}</dd><dt className="text-muted-foreground">API format</dt><dd>{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}</dd><dt className="text-muted-foreground">Authentication</dt><dd>{account.routing.auth}</dd><dt className="text-muted-foreground">Models</dt><dd>{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}</dd></dl></details>}
<AiConnectionAccountControls
account={account}
+4 -19
View File
@@ -13,7 +13,6 @@ import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Pencil } from "lucide-react";
import type {
ToolApplication,
ToolConnection,
ToolPolicy,
ToolProfileWithDetails,
@@ -29,6 +28,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { retryTransientOnly } from "@/lib/query-client";
import { consumeSkillSourceReturn } from "@/lib/skill-source-connect-return";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
@@ -50,6 +50,7 @@ import {
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
galleryEntryFor,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
@@ -156,7 +157,8 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
// the banner must not offer that action to anyone else.
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(connectionId),
queryFn: () => 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;
}
@@ -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;
}