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