mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## 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>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
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;
|
||
}
|