Files
PaperClipAI/packages/shared/src/ai-connections.ts
T
Devin FoleyandPaperclip 49b3804830 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>
2026-11-02 12:29:03 -08:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { aiProviderRoutingSchema, aiRoutingHarness, isAiRoutingCompatible, type AiProviderRouting } from "./ai-provider-routing.js";
/** Runtime authentication is a separate transport, never a tool or channel. */
export const connectionPurposeTransportSchema = z.discriminatedUnion(
"connectionPurpose",
[
z.object({
connectionPurpose: z.literal("tool"),
transport: z.enum(["mcp_remote", "rest_api", "local_stdio"]),
}),
z.object({
connectionPurpose: z.literal("channel"),
transport: z.enum(["chat_sdk", "rest_api", "voice"]),
config: z.object({ provider: z.string().optional() }).passthrough().optional(),
}).refine(
(connection) => connection.transport === "voice" ? connection.config?.provider === "speko" : connection.transport === "chat_sdk" ? connection.config?.provider !== "speko" : connection.config?.provider === "agentmail",
{ message: "Channel transport must match its provider: voice for Speko, REST for AgentMail, Chat SDK otherwise", path: ["config", "provider"] },
),
z.object({
connectionPurpose: z.literal("ai"),
transport: z.literal("runtime_auth"),
}),
],
);
export type ConnectionPurposeTransport = z.infer<
typeof connectionPurposeTransportSchema
>;
export const AI_PROVIDERS = [
"anthropic",
"openai",
"openrouter",
"xai",
"github",
"google",
] as const;
export const aiProviderSchema = z.enum(AI_PROVIDERS);
export const aiAuthMethodSchema = z.enum(["subscription", "api_key"]);
export type AiProvider = z.infer<typeof aiProviderSchema>;
export type AiAuthMethod = z.infer<typeof aiAuthMethodSchema>;
const requirement = { provider: aiProviderSchema, method: aiAuthMethodSchema };
export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [
z.object({
provider: aiProviderSchema,
// Retained on the wire for older servers during rolling upgrades. The
// responsible user's provider default determines the actual run method.
method: aiAuthMethodSchema,
mode: z.literal("responsible_user"),
}).strict(),
z
.object({
...requirement,
mode: z.literal("shared"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
z
.object({
...requirement,
// Explicit personal selection. Human access still applies on every run.
mode: z.literal("delegated"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
]);
export type AiConnectionBinding = z.infer<typeof aiConnectionBindingSchema>;
export const aiConnectionMetadataSchema = z.object({ ...requirement, routing: aiProviderRoutingSchema.optional() }).strict();
export type AiConnectionMetadata = z.infer<typeof aiConnectionMetadataSchema>;
/** Existing integrations only. This table describes compatibility, never routing. */
export const AI_CONNECTION_CAPABILITIES: Record<
AiProvider,
{
name: string;
methods: Partial<
Record<AiAuthMethod, { adapters: readonly string[]; envKey: string }>
>;
}
> = {
github: {
name: "GitHub Copilot",
methods: { api_key: { adapters: ["copilot_runtime"], envKey: "COPILOT_GITHUB_TOKEN" } },
},
google: { name: "Google", methods: {
api_key: { adapters: ["gemini_local", "pi_local", "antigravity_runtime"], envKey: "GEMINI_API_KEY" },
subscription: { adapters: ["antigravity_runtime"], envKey: "GEMINI_HOME" },
} },
anthropic: {
name: "Claude",
methods: {
subscription: {
adapters: ["claude_local"],
envKey: "CLAUDE_CODE_OAUTH_TOKEN",
},
api_key: { adapters: ["claude_local", "pi_local"], envKey: "ANTHROPIC_API_KEY" },
},
},
openai: {
name: "OpenAI",
methods: {
subscription: { adapters: ["codex_local"], envKey: "CODEX_HOME" },
api_key: { adapters: ["codex_local", "pi_local"], envKey: "OPENAI_API_KEY" },
},
},
openrouter: {
name: "OpenRouter",
methods: {
api_key: { adapters: ["opencode_local", "pi_local"], envKey: "OPENROUTER_API_KEY" },
},
},
xai: {
name: "Grok",
methods: {
subscription: { adapters: ["grok_local"], envKey: "GROK_HOME" },
api_key: { adapters: ["grok_local"], envKey: "XAI_API_KEY" },
},
},
};
export function isAiConnectionCompatible(
requirement: AiConnectionMetadata | AiConnectionBinding,
adapterType: string,
model?: unknown,
runnerProvider?: unknown,
acpxAgent?: unknown,
): boolean {
adapterType = aiRoutingHarness(adapterType, runnerProvider, acpxAgent);
// Antigravity uses its own Google runtime sign-in or a Gemini API key.
// Tool OAuth and API-compatible endpoints cannot authenticate this harness.
if (adapterType === "antigravity_runtime") return requirement.provider === "google"
&& (requirement.method === "api_key" || requirement.method === "subscription")
&& !("routing" in requirement && requirement.routing);
if ("routing" in requirement && requirement.routing) return requirement.method === "api_key" && isAiRoutingCompatible(requirement.routing, adapterType);
// A fixed binding contains identity only. The service checks authoritative
// connection metadata before resolving credentials or running the harness.
if (adapterType === "pi_local") return (requirement.method === "api_key" || ("mode" in requirement && requirement.mode === "responsible_user"))
&& ["openrouter", "openai", "anthropic", "google"].includes(requirement.provider)
&& typeof model === "string" && model.startsWith(`${requirement.provider}/`);
if ("mode" in requirement && requirement.mode !== "responsible_user" && requirement.method === "api_key")
return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local", "copilot_runtime"].includes(adapterType);
const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods;
const candidates = "mode" in requirement && requirement.mode === "responsible_user"
? Object.values(methods)
: requirement.method ? [methods[requirement.method]] : [];
return (
candidates.some((method) => method?.adapters.includes(adapterType)) &&
(requirement.provider !== "openrouter" ||
(typeof model === "string" && model.startsWith("openrouter/")))
);
}
export type AiConnectionUnavailableReason =
| "responsible_user_missing"
| "membership_missing"
| "default_missing"
| "connection_missing"
| "connection_unavailable"
| "incompatible"
| "access_denied"
| "credential_missing";
export interface AiConnectionAttribution {
/** Billing account at execution time; never resolved again from mutable settings. */
subscriptionId?: string;
connectionId: string;
grantId: string;
provider: AiProvider;
method: AiAuthMethod;
mode: AiConnectionBinding["mode"];
responsibleUserId: string | null;
}
export type AiConnectionResolution =
| { ok: true; attribution: AiConnectionAttribution }
| { ok: false; reason: AiConnectionUnavailableReason; message: string };
export interface AiManagedConnectionSummary {
id: string;
grantId: string;
companyId: string;
provider: AiProvider;
method: AiAuthMethod;
name: string;
routing?: AiProviderRouting;
accountLabel?: string;
ownership: "personal" | "shared";
ownerUserId?: string;
ownerName?: string;
isDefault: boolean;
status: "connected" | "needs_attention" | "expired" | "revoked";
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[];
canManageConnections: boolean;
connections: AiManagedConnectionSummary[];
}
export const createAiConnectionSchema = z
.object({
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
routing: aiProviderRoutingSchema.optional(),
apiKey: z.string().trim().min(1).max(32768).optional(),
environmentId: z.string().uuid().nullable().optional(),
loginSessionId: z.string().max(128).optional(),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict()
.superRefine((v, ctx) => {
if (!AI_CONNECTION_CAPABILITIES[v.provider].methods[v.method])
ctx.addIssue({ code: "custom", message: "Unsupported sign-in method" });
if (v.routing && (v.method !== "api_key" || (v.routing.kind === "openrouter" && v.provider !== "openrouter") || (v.routing.kind === "bedrock" && v.provider !== "anthropic")))
ctx.addIssue({ code: "custom", message: "Routing requires the matching provider and API authentication." });
if (v.routing && ["gateway", "local"].includes(v.routing.kind) && v.provider !== (v.routing.protocol === "messages" ? "anthropic" : "openai"))
ctx.addIssue({ code: "custom", message: "The provider must match the endpoint’s API format." });
if (v.routing?.auth === "none") {
if (v.apiKey || v.loginSessionId) ctx.addIssue({ code: "custom", message: "Provide only the selected authentication method." });
return;
}
if (
v.method === "api_key"
? !v.apiKey || Boolean(v.loginSessionId)
: !v.loginSessionId || Boolean(v.apiKey)
) {
ctx.addIssue({
code: "custom",
message:
"Provide exactly the credential for the selected sign-in method",
});
}
});
export type CreateAiConnection = z.infer<typeof createAiConnectionSchema>;
export const aiConnectionLoginIntentSchema = z
.object({
provider: z.enum(["anthropic", "openai", "xai", "google"]),
method: z.literal("subscription"),
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict();
export type AiConnectionLoginIntent = z.infer<
typeof aiConnectionLoginIntentSchema
>;
export const localAiConnectionSchema = aiConnectionLoginIntentSchema.extend({
localSessionId: z.string().uuid().optional(),
});
export const localAiLoginStartSchema = aiConnectionLoginIntentSchema.extend({ restart: z.boolean().optional() });
export interface LocalAiLoginStatus {
status: "ready" | "sign_in_required" | "expired";
authorizationUrl?: string;
code?: string;
error?: string;
accountLabel?: string;
}
export interface LocalAiLoginAttempt {
sessionId: string;
/** Grok still uses terminal sign-in until it has an in-app login runner. */
command?: string;
expiresAt: string;
}
/** Preview-era copies of rotating local credentials must be reconnected. */
export function aiSubscriptionNeedsIsolatedLogin(config: Record<string, unknown> | undefined): boolean {
const metadata = aiConnectionMetadataSchema.safeParse(config?.ai);
return metadata.success && metadata.data.method === "subscription" &&
(["openai", "xai", "google"].includes(metadata.data.provider)) &&
config?.aiIsolatedSubscription !== true;
}