Files
PaperClipAI/packages/shared/src/ai-connections.ts
T
DottaandPaperclip 8d1f0c20af fix: let responsible users choose either AI subscription or API key (#13351)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - AI Connections select the account used for each run.
> - A responsible-user binding must follow the person whose work the
agent performs.
> - The saved sign-in method currently blocks users with another method
for the same provider.
> - This pull request resolves a personal default by company, user, and
provider.
> - Each user can use a subscription or API key with the same bot and
model.

## Linked Issues or Issue Description

Refs #13247, #13248, #13346, #13347.

**What happened?**
A bot configured with a Claude subscription rejects another responsible
user’s Claude API key. Inline repair also limits that person to the
original sign-in method.

**Expected behavior**
The same bot uses each responsible user’s default Claude account,
whether it is a subscription or API key. Explicit shared account
selections remain fixed.

**Steps to reproduce**
1. User A connects a Claude subscription and creates a bot using the
responsible user’s connection.
2. User C connects a personal Claude API key.
3. User C runs the same bot. Before this fix, credential resolution
fails.

## What Changed

- Add a personal provider-default table. Preserve legacy per-method
preferences and backfill the most recently updated preference, including
unavailable defaults. Repeated migration does not replace a selection. A
database trigger propagates old-server default updates without treating
new accounts as replacement defaults.
- Resolve responsible-user bindings by provider. Retain the method as a
wire compatibility hint for old servers. Explicit selections still
require the exact method and grant.
- Use the selected account’s method for credential isolation, refresh
locking, and run attribution.
- Update onboarding, agent setup, the picker, and inline task repair.
Keep existing authentication components and harness/model settings.
- Add mixed-method runtime, migration, repair, and Storybook coverage.
Include upstream’s duplicate Anthropic option fix through the base
branch.

## Verification

- Focused resolver, migration, connection-intent, onboarding, agent
setup, model, and connector UI suites: 331 tests passed.
- Onboarding and new-agent regression suites passed during the initial
focused run.
- UI typecheck, token gates, and Storybook build passed.
- Live browser checks passed for Claude and Codex API-default execution,
switching both back to subscriptions, and both existing shared-account
bots. Bot configuration remained unchanged.
- One Daytona startup command stalled before Claude launched. The test
run was cancelled, its sandbox stopped, and the same account/task passed
on retry. Startup cancellation remains a separate environment finding;
this PR does not change that command transport.
- Browser review: all eight assertions passed in the new mixed-method
story, including shared selection, return to responsible-user selection,
and unchanged harness/model.
- Repository build and typecheck passed after refreshing upstream
dependencies. Final resolver and historical rollback verification: 38
tests passed. All latest-head CI gates passed, including
server/workspace/serialized suites, browser E2E, build, typecheck, and
runner verification. The extra serial local full-suite run was stopped
after equivalent CI passed; focused local checks completed.

## Risks

- Users with both historical method defaults get their most recently
updated preference as the initial provider default. They can change it
explicitly in Connections.
- A revoked or unavailable default blocks. Connecting an additional
account does not silently replace it.
- Existing legacy authentication is unchanged. Managed responsible-user
bindings intentionally stop pinning a method.
- Live staging: the same Claude and Codex bots completed real API-key
runs after changing only the personal default, then completed
subscription runs after restoring the original defaults. Read-only
database verification confirms unchanged bot configuration and actual
method attribution. Distinct-user concurrency is covered by automated
real-database tests with synthetic credentials, not two live human
logins.

## Model Used

OpenAI GPT-6 (Codex), with reasoning, repository tools, code execution,
and browser interaction. The runtime does not expose a more specific
model ID or 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 (e.g. `docs/...`, `fix/...`)
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-09-12 20:27:26 -05:00

238 lines
7.7 KiB
TypeScript

import { z } from "zod";
/** 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"]),
config: z.object({ provider: z.string().optional() }).passthrough().optional(),
}).refine(
(connection) => connection.transport === "chat_sdk" || connection.config?.provider === "agentmail",
{ message: "REST channel connections require the AgentMail provider", 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",
] 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,
// Legacy wire format only; human access still applies. New UI never creates it.
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).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 }>
>;
}
> = {
anthropic: {
name: "Claude",
methods: {
subscription: {
adapters: ["claude_local"],
envKey: "CLAUDE_CODE_OAUTH_TOKEN",
},
api_key: { adapters: ["claude_local"], envKey: "ANTHROPIC_API_KEY" },
},
},
openai: {
name: "OpenAI",
methods: {
subscription: { adapters: ["codex_local"], envKey: "CODEX_HOME" },
api_key: { adapters: ["codex_local"], envKey: "OPENAI_API_KEY" },
},
},
openrouter: {
name: "OpenRouter",
methods: {
api_key: { adapters: ["opencode_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 {
if (adapterType === "paperclip_runner")
adapterType =
runnerProvider === "claude" ||
(runnerProvider === "acpx" && acpxAgent === "claude")
? "claude_local"
: runnerProvider === "codex"
? "codex_local"
: runnerProvider === "opencode"
? "opencode_local"
: "unsupported";
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 {
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;
accountLabel?: string;
ownership: "personal" | "shared";
ownerUserId?: string;
ownerName?: string;
isDefault: boolean;
status: "connected" | "needs_attention" | "expired" | "revoked";
unavailableReason?: string;
}
export const createAiConnectionSchema = z
.object({
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
apiKey: z.string().trim().min(1).max(32768).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.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: aiProviderSchema,
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";
}
export interface LocalAiLoginAttempt {
sessionId: string;
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" &&
(metadata.data.provider === "openai" || metadata.data.provider === "xai") &&
config?.aiIsolatedSubscription !== true;
}