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>
This commit is contained in:
Dotta 2026-09-12 20:27:26 -05:00 committed by GitHub
parent 422287eecd
commit 8d1f0c20af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 48047 additions and 83 deletions

View File

@ -33,9 +33,9 @@ not change agent execution settings.
API keys are validated against fixed provider endpoints; redirects
and caller-supplied validation URLs are rejected.
`runtimeConfig.aiConnection` contains `provider`, `method`, and `mode`:
`runtimeConfig.aiConnection` contains `provider`, `mode`, and `method`. For responsible-user selections, `method` is a legacy wire hint retained for rolling upgrades; the resolver uses the selected accounts actual method:
- `responsible_user`: resolve the run's responsible user's personal default.
- `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account.
- `shared`: use the named `connectionId` and `grantId`, with audience and agent
access checks.
- `delegated`: retained only to read legacy bindings. It cannot bypass human
@ -52,8 +52,10 @@ A connection choice never changes the harness, model, or provider routing.
Changing those separately may make a binding incompatible; saving then requires
a compatible choice. Agent configuration cannot grant access to another account.
Personal defaults are unique per company, user, provider, and sign-in method.
Personal defaults are unique per company, user, and provider. A Claude bot can use one users subscription and another users API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method.
The first successful personal connection sets a default only when none exists.
The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each users most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default.
Revocation retains the unavailable default; connecting another account does not
silently replace it. Change it explicitly on the account detail page.
@ -62,7 +64,7 @@ silently replace it. Change it explicitly on the account detail page.
AI connections pair `connectionPurpose: ai` with `transport: runtime_auth`.
Database checks and the shared discriminator enforce the pair. These entries
cannot participate in tool discovery, MCP gateways, execution, or channels.
Anthropic retains its existing tool methods alongside its AI methods. Catalog
Anthropic offers Claude subscription and Claude API key; the unsupported duplicate REST API option is excluded. Catalog
validation also pairs AI metadata with runtime authentication and rejects unsupported
sign-in methods. Provider artwork and source provenance live in
`ui/public/brands/apps/manifest.json`; OpenRouter uses its official sign-in assets,

View File

@ -36,8 +36,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
// AI defaults arrive in 0273 and depend on the composite grant key from
// 0232. Rewind that later table before recreating the 0182 grant schema.
// AI defaults arrive in 0273/0277 and depend on the composite grant key
// from 0232. Rewind those tables before recreating the 0182 grant schema.
await sql`DROP TABLE IF EXISTS "ai_provider_defaults"`;
await sql`DROP TABLE IF EXISTS "ai_connection_defaults"`;
await sql`DROP TABLE IF EXISTS "connection_grant_delegations"`;
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;

View File

@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS "ai_provider_defaults" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"grant_id" uuid,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ai_provider_defaults_provider_check" CHECK ("ai_provider_defaults"."provider" in ('anthropic','openai','openrouter','xai'))
);
--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_provider_defaults" ADD CONSTRAINT "ai_provider_defaults_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_provider_defaults" ADD CONSTRAINT "ai_provider_defaults_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_provider_defaults_owner_provider_uq" ON "ai_provider_defaults" USING btree ("company_id","user_id","provider");
--> statement-breakpoint
-- Old servers write only the per-method table. INSERT means first account for
-- that method; UPDATE means an explicit Make default action. Preserve that
-- distinction so adding an account never replaces an unavailable default.
CREATE OR REPLACE FUNCTION sync_ai_provider_default() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO ai_provider_defaults (company_id, user_id, provider, grant_id, updated_at)
VALUES (NEW.company_id, NEW.user_id, NEW.provider, NEW.grant_id, NEW.updated_at)
ON CONFLICT (company_id, user_id, provider) DO NOTHING;
ELSE
INSERT INTO ai_provider_defaults (company_id, user_id, provider, grant_id, updated_at)
VALUES (NEW.company_id, NEW.user_id, NEW.provider, NEW.grant_id, NEW.updated_at)
ON CONFLICT (company_id, user_id, provider) DO UPDATE
SET grant_id = EXCLUDED.grant_id, updated_at = EXCLUDED.updated_at;
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
CREATE OR REPLACE TRIGGER ai_connection_defaults_sync_provider
AFTER INSERT OR UPDATE OF grant_id, updated_at ON ai_connection_defaults
FOR EACH ROW EXECUTE FUNCTION sync_ai_provider_default();
--> statement-breakpoint
-- Install the trigger before reading legacy rows; its table lock holds until
-- this migration commits, so an old-server write cannot miss synchronization.
-- Preserve all legacy preferences. The most recently updated method default
-- becomes the initial provider default, even when unavailable. Never use health
-- to silently replace a revoked or missing credential.
INSERT INTO ai_provider_defaults (company_id, user_id, provider, grant_id, updated_at)
SELECT DISTINCT ON (company_id, user_id, provider)
company_id, user_id, provider, grant_id, updated_at
FROM ai_connection_defaults
ORDER BY company_id, user_id, provider, updated_at DESC, id DESC
ON CONFLICT (company_id, user_id, provider) DO NOTHING;

File diff suppressed because it is too large Load Diff

View File

@ -1926,6 +1926,13 @@
"when": 1789245055965,
"tag": "0276_hard_mandroid",
"breakpoints": true
},
{
"idx": 277,
"version": "7",
"when": 1789260211664,
"tag": "0277_uneven_lady_deathstrike",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,20 @@
import { sql } from "drizzle-orm";
import { check, foreignKey, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { AiProvider } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { connectionGrants } from "./tool_access.js";
/** One selected personal account per provider, independent of sign-in method.
* Legacy per-method defaults remain intact for older server versions. */
export const aiProviderDefaults = pgTable("ai_provider_defaults", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
provider: text("provider").$type<AiProvider>().notNull(),
grantId: uuid("grant_id"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [
uniqueIndex("ai_provider_defaults_owner_provider_uq").on(t.companyId, t.userId, t.provider),
foreignKey({ columns: [t.companyId, t.grantId], foreignColumns: [connectionGrants.companyId, connectionGrants.id], name: "ai_provider_defaults_company_grant_fk" }),
check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai')`),
]);

View File

@ -206,4 +206,5 @@ export { chatDiscordCommandOwners } from "./chat_discord_command_owners.js";
export { chatTelegramDraftIds } from "./chat_telegram_draft_ids.js";
export { aiConnectionDefaults } from "./ai_connection_defaults.js";
export { aiProviderDefaults } from "./ai_provider_defaults.js";
export * from "./email.js";

View File

@ -38,7 +38,13 @@ 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({ ...requirement, mode: z.literal("responsible_user") }).strict(),
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,
@ -103,7 +109,7 @@ export const AI_CONNECTION_CAPABILITIES: Record<
},
};
export function isAiConnectionCompatible(
requirement: AiConnectionMetadata,
requirement: AiConnectionMetadata | AiConnectionBinding,
adapterType: string,
model?: unknown,
runnerProvider?: unknown,
@ -119,12 +125,12 @@ export function isAiConnectionCompatible(
: runnerProvider === "opencode"
? "opencode_local"
: "unsupported";
const method =
AI_CONNECTION_CAPABILITIES[requirement.provider].methods[
requirement.method
];
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 (
Boolean(method?.adapters.includes(adapterType)) &&
candidates.some((method) => method?.adapters.includes(adapterType)) &&
(requirement.provider !== "openrouter" ||
(typeof model === "string" && model.startsWith("openrouter/")))
);

View File

@ -8,14 +8,14 @@ import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests } from "@paperclipai/db";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
@ -46,6 +46,95 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it.each([
["anthropic", "claude_local", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
["openai", "codex_local", "CODEX_HOME", "OPENAI_API_KEY"],
] as const)("runs the same %s agent with each responsible user's subscription or API key", async (provider, adapterType, subscriptionEnv, apiEnv) => {
const subscriptionUser = `${provider}-subscription-user`;
const apiUser = `${provider}-api-user`;
await db.insert(companyMemberships).values([subscriptionUser, apiUser].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
const token = provider === "openai" ? JSON.stringify({ tokens: { access_token: "fixture-subscription", refresh_token: "fixture-refresh", id_token: "fixture-id", account_id: "fixture-account" } }) : "fixture-subscription";
const subscription = await service.save(companyId, subscriptionUser, { provider, method: "subscription", ownership: "personal", name: "Subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, token);
const api = await service.save(companyId, apiUser, { provider, method: "api_key", ownership: "personal", name: "API", apiKey: "fixture", allAgents: true, agentIds: [] }, "fixture-api");
// This is the exact same saved bot config, including a legacy setup method.
const bot = { ...input, adapterType, binding: { provider, method: "subscription", mode: "responsible_user" } as const, config: { model: "unchanged-model", env: { [apiEnv]: "ambient", CLAUDE_CODE_OAUTH_TOKEN: "ambient" } } };
const original = structuredClone(bot);
const [subRun, apiRun] = await Promise.all([subscriptionUser, apiUser].map(responsibleUserId => prepareManagedAiRuntime(db, { ...bot, responsibleUserId })));
try {
expect(subRun.attribution).toMatchObject({ grantId: subscription.grantId, method: "subscription", responsibleUserId: subscriptionUser });
expect(apiRun.attribution).toMatchObject({ grantId: api.grantId, method: "api_key", responsibleUserId: apiUser });
const subEnv = subRun.config.env as Record<string, string>;
const apiEnvValues = apiRun.config.env as Record<string, string>;
expect(subEnv[apiEnv]).toBe("");
expect(apiEnvValues[apiEnv]).toBe("fixture-api");
if (provider === "anthropic") {
expect(subEnv[subscriptionEnv]).toBe(token);
expect(apiEnvValues[subscriptionEnv]).toBe("");
} else {
expect(await readFile(path.join(subEnv.CODEX_HOME, "auth.json"), "utf8")).toBe(token);
expect(JSON.parse(await readFile(path.join(apiEnvValues.CODEX_HOME, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "fixture-api" });
}
expect(subEnv.HOME).not.toBe(apiEnvValues.HOME);
expect(subRun.identity).not.toBe(apiRun.identity);
expect(subRun.config.model).toBe(bot.config.model);
expect(apiRun.config.model).toBe(bot.config.model);
expect(bot).toEqual(original);
expect(aiConnectionBindingSchema.parse(bot.binding)).toEqual(bot.binding);
} finally { await Promise.all([subRun.cleanup(), apiRun.cleanup()]); }
});
it("has one provider default across methods, retains unavailable defaults and honors explicit account methods", async () => {
const userId = "provider-default-user";
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
const api = await create(userId, "Provider API");
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Provider subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-provider-subscription");
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
expect((await service.list(companyId, userId)).filter(account => account.isDefault).map(account => account.grantId)).toEqual([api.grantId]);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, api.grantId));
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
await create(userId, "Another API");
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
await service.setDefault(companyId, userId, subscription.grantId);
expect((await service.select({ ...input, userId })).attribution).toMatchObject({ method: "subscription", grantId: subscription.grantId });
expect((await service.list(companyId, userId)).filter(account => account.isDefault)).toHaveLength(1);
await expect(service.select({ ...input, userId, binding: { ...binding, mode: "delegated", ...subscription } })).rejects.toThrow("incompatible");
});
it("backfills provider defaults repeatably without deleting old preferences or replacing an unavailable choice", async () => {
const userId = "provider-default-migration-user";
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
const api = await create(userId, "Migration API");
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Migration subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-migration-subscription");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, api.grantId));
await db.update(aiConnectionDefaults).set({ updatedAt: new Date("2030-01-01") }).where(eq(aiConnectionDefaults.grantId, api.grantId));
await db.delete(aiProviderDefaults).where(and(eq(aiProviderDefaults.companyId, companyId), eq(aiProviderDefaults.userId, userId)));
const legacyRows = await db.select().from(aiConnectionDefaults).where(eq(aiConnectionDefaults.userId, userId));
const migration = await readFile(new URL("../../../packages/db/src/migrations/0277_uneven_lady_deathstrike.sql", import.meta.url), "utf8");
for (let pass = 0; pass < 2; pass++) for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
expect(await db.select().from(aiConnectionDefaults).where(eq(aiConnectionDefaults.userId, userId))).toEqual(legacyRows);
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
await service.setDefault(companyId, userId, subscription.grantId);
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
expect((await service.select({ ...input, userId })).grant.id).toBe(subscription.grantId);
});
it("observes old-server default changes during rolling upgrades without treating new accounts as default changes", async () => {
const userId = "rolling-upgrade-user";
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
const api = await create(userId, "Rolling API");
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Rolling subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-rolling-subscription");
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
// An older server updates only the legacy per-method row on Make default.
await db.update(aiConnectionDefaults).set({ grantId: subscription.grantId, updatedAt: new Date() })
.where(and(eq(aiConnectionDefaults.userId, userId), eq(aiConnectionDefaults.method, "subscription")));
expect((await service.select({ ...input, userId })).attribution).toMatchObject({ grantId: subscription.grantId, method: "subscription" });
expect((await service.list(companyId, userId)).filter(account => account.isDefault).map(account => account.grantId)).toEqual([subscription.grantId]);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, subscription.grantId));
await create(userId, "Rolling second API");
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
await db.update(aiConnectionDefaults).set({ grantId: api.grantId, updatedAt: new Date() })
.where(and(eq(aiConnectionDefaults.userId, userId), eq(aiConnectionDefaults.method, "api_key")));
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
});
it("checks the selected environment for project auth overrides without exposing their contents", async () => {
const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess");
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace/project" } as Parameters<typeof assertManagedAiProjectAuth>[2];
@ -195,6 +284,8 @@ describe("managed AI connections", () => {
});
it("serializes subscription refresh and releases the lease after execution", async () => {
const subscription = { ...input, binding: { ...binding, method: "subscription" as const }, responsibleUserId: "alice", config: { model: "same-model" } };
const account = (await service.list(companyId, "alice")).find(account => account.provider === "anthropic" && account.method === "subscription")!;
await service.setDefault(companyId, "alice", account.grantId);
const first = await prepareManagedAiRuntime(db, subscription);
const selected = await service.select({ ...subscription, userId: "alice" });
const lockKey = `ai-runtime:${selected.grant.id}`;
@ -240,6 +331,9 @@ describe("managed AI connections", () => {
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "agentmail" } }).success).toBe(true);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "slack" } }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "runtime_auth", config: { provider: "agentmail" } }).success).toBe(false);
expect(aiConnectionBindingSchema.safeParse({ provider: "anthropic", mode: "responsible_user" }).success).toBe(false);
expect(aiConnectionBindingSchema.safeParse({ provider: "anthropic", mode: "shared", connectionId: randomUUID(), grantId: randomUUID() }).success).toBe(false);
expect(isAiConnectionCompatible({ provider: "anthropic", method: "api_key", mode: "responsible_user" }, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "codex")).toBe(false);
expect(isAiConnectionCompatible({ provider: "openrouter", method: "api_key" }, "opencode_local", "anthropic/model")).toBe(false);
@ -249,7 +343,7 @@ describe("managed AI connections", () => {
const otherAgent = randomUUID();
await db.insert(agents).values({ id: otherAgent, companyId, name: "Other" });
await db.insert(connectionGrantDelegations).values({ companyId, grantId: selected.grant.id, agentId: otherAgent, createdByUserId: "bob" });
await expect(service.select({ ...input, agentId: otherAgent, userId: "bob", binding: { ...binding, mode: "delegated", connectionId: selected.connection.id, grantId: selected.grant.id } })).rejects.toThrow("not shared");
await expect(service.select({ ...input, agentId: otherAgent, userId: "bob", binding: { ...binding, method: selected.attribution.method, mode: "delegated", connectionId: selected.connection.id, grantId: selected.grant.id } })).rejects.toThrow("not shared");
const [environment] = await db.select().from(environments).limit(1);
const sessionId = randomUUID();
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Expired", agentIds: [], allAgents: true } as const;

View File

@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
aiConnectionDefaults,
aiProviderDefaults,
agentWakeupRequests,
issueComments,
companies,
@ -798,14 +798,14 @@ describeEmbeddedPostgres("connectionIntentService", () => {
const agentId = randomUUID();
const issueId = randomUUID();
const aiRunId = randomUUID();
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
const binding = { provider: "anthropic", method: "subscription", mode: "responsible_user" } as const;
await db.insert(agents).values({ id: agentId, companyId, name: "AI Agent", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
await db.insert(issues).values({ id: issueId, companyId, title: "AI authentication", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: aiRunId, companyId, agentId, status: "running", responsibleUserId: claims.responsible_user_id, contextSnapshot: { issueId } });
const [app] = await db.insert(toolApplications).values({ companyId, applicationKey: "ai-intent-fixture", name: "AI intent fixture", type: "mcp_http", metadata: { sourceTemplateKey: "anthropic" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: app!.id, name: "Personal Claude API", uid: `ai-${randomUUID()}`, connectionPurpose: "ai", transport: "runtime_auth", authKind: "api_key", credentialPolicy: "per_user", healthStatus: "ok", status: "active", enabled: true, config: { sourceTemplateKey: "anthropic", ai: { provider: "anthropic", method: "api_key" } } }).returning();
const [grant] = await db.insert(connectionGrants).values({ companyId, connectionId: connection!.id, kind: "user", subjectUserId: claims.responsible_user_id, createdByUserId: claims.responsible_user_id }).returning();
await db.insert(aiConnectionDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", method: "api_key", grantId: grant!.id });
await db.insert(aiProviderDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", grantId: grant!.id });
const aiClaims = { ...claims, sub: agentId, run_id: aiRunId };
const service = connectionIntentService(db);
await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({
@ -835,6 +835,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
expect(aiRequest.state).toBe("needs_user_action");
expect(aiRequest.interactionId).not.toBe(toolRequest.id);
expect((await service.setupOptions(aiRequest.interactionId!)).aiConnection).toEqual(binding);
expect((await service.setupOptions(aiRequest.interactionId!)).aiRepair?.connection).toMatchObject({ id: connection!.id, method: "api_key" });
expect((await service.setupOptions(toolRequest.id)).existingConnections).toEqual([]);
await expect(service.complete(toolRequest.id, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy");
await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" });

View File

@ -239,7 +239,7 @@ export async function prepareManagedAiRuntime(
acpxAgent: input.config.acpxAgent,
});
const release =
input.binding.method === "subscription"
selection.attribution.method === "subscription"
? await acquireCredentialLease(db, selection.grant.id)
: async () => {};
let home: string | undefined;
@ -277,7 +277,7 @@ export async function prepareManagedAiRuntime(
};
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
input.binding.method
selection.attribution.method
]!;
const authFile = path.join(providerHome, "auth.json");
if (input.binding.provider === "openai")
@ -287,13 +287,13 @@ export async function prepareManagedAiRuntime(
{ mode: 0o600 },
);
const subscriptionFile =
input.binding.method === "subscription" &&
selection.attribution.method === "subscription" &&
input.binding.provider !== "anthropic";
if (subscriptionFile) await writeFile(authFile, value, { mode: 0o600 });
else env[capability.envKey] = value;
if (
input.binding.provider === "openai" &&
input.binding.method === "api_key"
selection.attribution.method === "api_key"
) {
env.CODEX_API_KEY = value;
await writeFile(authFile, JSON.stringify({ OPENAI_API_KEY: value }), {

View File

@ -6,6 +6,7 @@ import {
authUsers,
adapterAuthSessions,
aiConnectionDefaults,
aiProviderDefaults,
agents,
companyMemberships,
companySecrets,
@ -94,11 +95,11 @@ export function aiConnectionService(db: Db) {
rows(companyId),
db
.select()
.from(aiConnectionDefaults)
.from(aiProviderDefaults)
.where(
and(
eq(aiConnectionDefaults.companyId, companyId),
eq(aiConnectionDefaults.userId, userId),
eq(aiProviderDefaults.companyId, companyId),
eq(aiProviderDefaults.userId, userId),
),
),
db
@ -192,16 +193,17 @@ export function aiConnectionService(db: Db) {
const metadata = aiConnectionMetadataSchema.parse(
row.connection.config.ai,
);
await tx
.insert(aiConnectionDefaults)
// Keep old servers' method preferences intact during an additive rollout.
await tx.insert(aiConnectionDefaults)
.values({ companyId, userId, ...metadata, grantId })
.onConflictDoUpdate({
target: [
aiConnectionDefaults.companyId,
aiConnectionDefaults.userId,
aiConnectionDefaults.provider,
aiConnectionDefaults.method,
],
target: [aiConnectionDefaults.companyId, aiConnectionDefaults.userId, aiConnectionDefaults.provider, aiConnectionDefaults.method],
set: { grantId, updatedAt: new Date() },
});
await tx.insert(aiProviderDefaults)
.values({ companyId, userId, provider: metadata.provider, grantId })
.onConflictDoUpdate({
target: [aiProviderDefaults.companyId, aiProviderDefaults.userId, aiProviderDefaults.provider],
set: { grantId, updatedAt: new Date() },
});
});
@ -245,13 +247,12 @@ export function aiConnectionService(db: Db) {
? (
await db
.select()
.from(aiConnectionDefaults)
.from(aiProviderDefaults)
.where(
and(
eq(aiConnectionDefaults.companyId, companyId),
eq(aiConnectionDefaults.userId, userId!),
eq(aiConnectionDefaults.provider, binding.provider),
eq(aiConnectionDefaults.method, binding.method),
eq(aiProviderDefaults.companyId, companyId),
eq(aiProviderDefaults.userId, userId!),
eq(aiProviderDefaults.provider, binding.provider),
),
)
.limit(1)
@ -281,7 +282,8 @@ export function aiConnectionService(db: Db) {
if (
!metadata.success ||
metadata.data.provider !== binding.provider ||
metadata.data.method !== binding.method
(binding.mode !== "responsible_user" && metadata.data.method !== binding.method) ||
!isAiConnectionCompatible(metadata.data, input.adapterType, input.model, input.runnerProvider, input.acpxAgent)
)
throw unprocessable("The selected AI connection is incompatible", {
code: "ai_connection_incompatible",
@ -365,7 +367,7 @@ export function aiConnectionService(db: Db) {
connectionId: connection.id,
grantId: grant.id,
provider: binding.provider,
method: binding.method,
method: metadata.data.method,
mode: binding.mode,
responsibleUserId: userId,
} satisfies AiConnectionAttribution,
@ -711,7 +713,7 @@ export function aiConnectionService(db: Db) {
.from(toolConnections)
.where(eq(toolConnections.id, id));
await syncConnectionCredentialBindings(tx, savedConnection, refs);
if (input.ownership === "personal")
if (input.ownership === "personal") {
await tx
.insert(aiConnectionDefaults)
.values({
@ -722,6 +724,8 @@ export function aiConnectionService(db: Db) {
grantId,
})
.onConflictDoNothing();
await tx.insert(aiProviderDefaults).values({ companyId, userId, provider: input.provider, grantId }).onConflictDoNothing();
}
if (!reconnect) {
const installs = input.allAgents
? [{ targetType: "company" as const, targetId: companyId }]

View File

@ -483,7 +483,7 @@ export function connectionIntentService(db: Db) {
}) : null;
const aiAccounts = managed ? await aiConnectionService(db).list(loaded.issue.companyId, loaded.interaction.addresseeUserId!) : [];
const selectedAiAccount = managed ? aiAccounts.find((account) =>
account.provider === managed.binding.provider && account.method === managed.binding.method
account.provider === managed.binding.provider && (managed.binding.mode === "responsible_user" || account.method === managed.binding.method)
&& (managed.binding.mode === "responsible_user" ? account.isDefault
: account.id === managed.binding.connectionId && account.grantId === managed.binding.grantId)
) : undefined;

View File

@ -4,7 +4,7 @@ import type { ConnectionGrant } from "@paperclipai/shared";
import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { aiMethodLabel, type AiConnectionSummary } from "./model";
import { AI_PROVIDERS, aiMethodLabel, type AiConnectionSummary } from "./model";
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
export function AiConnectionAccountControls({
@ -36,7 +36,7 @@ export function AiConnectionAccountControls({
<Star aria-hidden className={cn("size-5 shrink-0", activeDefault ? "fill-current text-(--status-task-icon-done)" : "text-muted-foreground")} />
<div>
<h3 className="text-sm font-semibold">Personal default</h3>
<p className="text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)}</p>
<p className="text-xs text-muted-foreground">For your {AI_PROVIDERS[account.provider].name} tasks</p>
</div>
</div>
{account.isDefault ? (

View File

@ -14,6 +14,7 @@ const requirement: AiConnectionRequirement = {
};
const account: AiConnectionSummary = {
...requirement,
method: "subscription",
id: "example",
grantId: "example-grant",
name: "My Claude subscription",

View File

@ -62,8 +62,6 @@ export function AiConnectionField({
const [adopting, setAdopting] = useState(false);
const [pendingAdoption, setPendingAdoption] = useState<AiConnectionBinding>();
const [connecting, setConnecting] = useState(false);
const method: AiAuthMethod =
value?.method ?? (provider === "openrouter" ? "api_key" : "subscription");
const changeBinding = (next: AiConnectionBinding) => {
if (legacy && !value) { if (!connecting) returnFocus.current = document.activeElement as HTMLElement; setPendingAdoption(next); }
else onChange(next);
@ -74,6 +72,9 @@ export function AiConnectionField({
queryFn: () => aiConnectionsApi.list(companyId, agentId),
enabled: Boolean(provider),
});
const method: AiAuthMethod = (value?.mode !== "responsible_user" ? value?.method : undefined)
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
?? (provider === "openrouter" ? "api_key" : "subscription");
if (!provider) return null;
if (legacy && !value && !adopting)
return (
@ -91,7 +92,7 @@ export function AiConnectionField({
</p>
)}
<AiConnectionPicker
requirement={{ companyId, provider, method }}
requirement={{ companyId, provider }}
connections={accounts.data?.connections ?? []}
value={value}
currentUserId={accounts.data?.currentUserId ?? ""}
@ -123,7 +124,7 @@ export function AiConnectionField({
</DialogHeader>
<p className="text-sm">
{pendingAdoption?.mode === "responsible_user"
? `Responsible users default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider && account.method === pendingAdoption.method)?.name ?? "Not connected"}. Other users use their own default.`
? `Responsible users default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider)?.name ?? "Not connected"}. Other users use their own default.`
: accounts.data?.connections.find(
(account) => account.id === pendingAdoption?.connectionId,
)?.name}
@ -165,12 +166,12 @@ export function AiConnectionField({
allAgents={false}
environmentId={environmentId}
onCancel={() => setConnecting(false)}
onComplete={({ method: connectedMethod }) => {
onComplete={() => {
void client.invalidateQueries({
queryKey: ["ai-connections", companyId],
});
setConnecting(false);
changeBinding({ provider, method: connectedMethod, mode: "responsible_user" });
changeBinding({ provider, method, mode: "responsible_user" });
}}
/>
</DialogContent>

View File

@ -46,14 +46,14 @@ export function AiConnectionPicker({
matchesAiRequirement(connection, requirement),
);
const personalDefault = personalAiDefault(
compatible,
connections,
requirement,
currentUserId,
);
const problem = value ? bindingProblem(
value,
requirement,
compatible,
connections,
currentUserId,
agentId,
) : undefined;
@ -63,7 +63,7 @@ export function AiConnectionPicker({
) =>
onChange({
provider: requirement.provider,
method: requirement.method,
method: connection.method,
mode,
connectionId: connection.id,
grantId: connection.grantId,
@ -81,8 +81,8 @@ export function AiConnectionPicker({
<div className="flex min-w-0 flex-col gap-1">
<h3 className="text-sm font-semibold">AI connection</h3>
<p className="text-xs text-muted-foreground">
{AI_PROVIDERS[requirement.provider].name} ·{" "}
{aiMethodLabel(requirement.provider, requirement.method)}
{AI_PROVIDERS[requirement.provider].name}
{value && value.mode !== "responsible_user" && ` · ${aiMethodLabel(value.provider, value.method)}`}
</p>
</div>
</div>
@ -109,16 +109,16 @@ export function AiConnectionPicker({
choices={[
{ id: "responsible_user", name: "Responsible users connection", description: <>
<span className="block">For you: {personalDefault?.name ?? "Not connected"}</span>
<span className="block">Other users tasks use their own {requirement.method === "api_key" ? `${AI_PROVIDERS[requirement.provider].name} API key` : aiMethodLabel(requirement.provider, requirement.method)}.</span>
<span className="block">Other users tasks use their own {AI_PROVIDERS[requirement.provider].name} connection.</span>
</> },
...compatible.filter((connection) => connection.ownership === "shared").map((connection) => ({
id: connection.id, name: connection.name,
disabled: Boolean(aiConnectionProblem(connection)),
description: <>Company shared{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}{aiConnectionProblem(connection) ? ` · ${aiConnectionProblem(connection)}` : ""}</>,
description: <>Company shared · {aiMethodLabel(connection.provider, connection.method)}{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}{aiConnectionProblem(connection) ? ` · ${aiConnectionProblem(connection)}` : ""}</>,
})),
]}
onSelect={(id) => {
if (id === "responsible_user") onChange({provider: requirement.provider, method: requirement.method, mode: "responsible_user"});
if (id === "responsible_user") onChange({provider: requirement.provider, method: personalDefault?.method ?? requirement.method ?? (requirement.provider === "openrouter" ? "api_key" : "subscription"), mode: "responsible_user"});
else { const connection = compatible.find((item) => item.id === id)!; select("shared", connection); }
}}
/>

View File

@ -16,6 +16,7 @@ const requirement: AiConnectionRequirement = {
};
const account: AiConnectionSummary = {
...requirement,
method: "subscription",
id: "connection",
grantId: "grant",
name: "Personal Claude",
@ -31,11 +32,10 @@ const binding: AiConnectionBinding = {
};
describe("AI connection selection presentation", () => {
it("scopes personal defaults to company, user, provider and method", () => {
it("scopes personal defaults to company, user and provider, independently of method", () => {
for (const change of [
{ companyId: "other" },
{ provider: "openai" as const },
{ method: "api_key" as const },
{ ownerUserId: "bob" },
{ ownership: "shared" as const },
]) {
@ -44,6 +44,9 @@ describe("AI connection selection presentation", () => {
).toBeUndefined();
}
expect(personalAiDefault([account], requirement, "alice")).toBe(account);
const apiDefault = { ...account, method: "api_key" as const };
expect(personalAiDefault([apiDefault], requirement, "alice")).toBe(apiDefault);
expect(bindingProblem(binding, requirement, [apiDefault], "alice", "agent")).toBeNull();
});
it("retains a revoked default instead of falling back to a healthy account", () => {
const revoked = { ...account, status: "revoked" as const };
@ -126,6 +129,7 @@ describe("AI connection selection presentation", () => {
bindingProblem(
{
...binding,
method: "subscription",
mode: "shared",
connectionId: account.id,
grantId: account.grantId,

View File

@ -30,7 +30,7 @@ export type AiConnectionSummary = Omit<AiManagedConnectionSummary, "isDefault">
export interface AiConnectionRequirement {
companyId: string;
provider: AiProvider;
method: AiAuthMethod;
method?: AiAuthMethod;
}
export const AI_CONNECTION_STATUS: Record<AiConnectionStatus, string> = {
@ -53,7 +53,7 @@ export function matchesAiRequirement(
return (
connection.companyId === requirement.companyId &&
connection.provider === requirement.provider &&
connection.method === requirement.method
(requirement.method === undefined || connection.method === requirement.method)
);
}
@ -65,7 +65,7 @@ export function personalAiDefault(
// Never choose another account because the declared default is unhealthy.
return connections.find(
(connection) =>
matchesAiRequirement(connection, requirement) &&
matchesAiRequirement(connection, { ...requirement, method: undefined }) &&
connection.ownership === "personal" &&
connection.ownerUserId === userId &&
connection.isDefault,
@ -92,7 +92,7 @@ export function bindingProblem(
) {
if (
binding.provider !== requirement.provider ||
binding.method !== requirement.method
(binding.mode !== "responsible_user" && requirement.method !== undefined && binding.method !== requirement.method)
)
return "Choose a connection compatible with this provider and sign-in method.";
if (binding.mode === "responsible_user")
@ -103,6 +103,7 @@ export function bindingProblem(
(item) =>
item.id === binding.connectionId &&
item.grantId === binding.grantId &&
item.method === binding.method &&
matchesAiRequirement(item, requirement),
);
if (!connection)

View File

@ -632,7 +632,7 @@ export function ConnectionSetupFlow({
const [curatedOAuthClientId, setCuratedOAuthClientId] = useState("");
const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState("");
const [vercelConnector, setVercelConnector] = useState("");
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection ? `ai-${aiConnection.method}` : "");
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection && aiConnection.mode !== "responsible_user" ? `ai-${aiConnection.method}` : "");
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
@ -919,8 +919,8 @@ export function ConnectionSetupFlow({
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
select: useCallback((data: Awaited<ReturnType<typeof toolsApi.listGallery>>) => connectionIntentId ? {
...data,
apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && method.ai.method === aiConnection.method : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0),
} : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method]),
apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && (aiConnection.mode === "responsible_user" || method.ai.method === aiConnection.method) : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0),
} : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method, aiConnection?.mode]),
enabled: !!selectedCompanyId,
});
// Use the same visible catalog for cards and every branded URL shortcut.
@ -1422,7 +1422,7 @@ export function ConnectionSetupFlow({
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
: undefined);
const explicitMethod = methods.find(candidate => requestedAi
? candidate.ai?.provider === requestedAi.provider && candidate.ai.method === requestedAi.method
? candidate.ai?.provider === requestedAi.provider && (!("mode" in requestedAi) || requestedAi.mode !== "responsible_user") && candidate.ai.method === requestedAi.method
: candidate.key === requestedMethodKey);
const initialMethod = explicitMethod ?? (
requestedDefinitionUsesManagedConnector
@ -2041,7 +2041,7 @@ export function ConnectionSetupFlow({
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection)} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection && aiConnection.mode !== "responsible_user")} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
ownership={(reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.credentialPolicy === "shared" : effectiveGrantKind === "organization") ? "shared" : "personal"}

View File

@ -443,7 +443,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// credential is entered.
// -------------------------------------------------------------------------
it("offers supported Anthropic AI authentication without the obsolete REST tool method", async () => {
it.each([false, true])("offers both Anthropic methods without the obsolete REST option (task repair: %s)", async (taskRepair) => {
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant",
});
@ -460,7 +460,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" });
client.setQueryData(queryKeys.instance.generalSettings, {});
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
await render(client);
await render(client, false, taskRepair ? <ConnectionSetupFlow host="dialog" serviceSlug="anthropic" interactionId="ai-intent" requestedAgentId="agent-1" aiConnection={{ provider: "anthropic", method: "subscription", mode: "responsible_user" }} /> : undefined);
await passAccessStep();
expect(container.textContent).toContain("Connect account");
expect(container.textContent).toContain("Connection name");
@ -478,7 +478,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key",
}));
expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions");
if (!taskRepair) expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions");
expect(connectAppMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Connect for tool access instead");
});

View File

@ -4,16 +4,16 @@ import type {
AiConnectionSummary,
} from "@/components/ai-connections/model";
export const AI_REVIEW_REQUIREMENT: AiConnectionRequirement = {
export const AI_REVIEW_REQUIREMENT = {
companyId: "ai-review-company",
provider: "anthropic",
method: "subscription",
};
export const AI_REVIEW_BINDING: AiConnectionBinding = {
} satisfies AiConnectionRequirement;
export const AI_REVIEW_BINDING = {
provider: "anthropic",
method: "subscription",
mode: "responsible_user",
};
} satisfies AiConnectionBinding;
export const AI_REVIEW_CONNECTIONS: AiConnectionSummary[] = [
{
id: "claude-dotta",

View File

@ -60,12 +60,13 @@ function AgentConnectionReview({
error,
}: AiConnectionsReviewProps) {
const requirement = initialRequirement;
const method = requirement.method ?? (requirement.provider === "openrouter" ? "api_key" : "subscription");
const [connections, setConnections] = useState(initialConnections);
const [binding, setBinding] = useState(
const [binding, setBinding] = useState<AiConnectionBinding>(
initialBinding ?? {
...AI_REVIEW_BINDING,
provider: requirement.provider,
method: requirement.method,
method,
},
);
const [stage, setStage] = useState<string>(
@ -73,7 +74,7 @@ function AgentConnectionReview({
);
const [auth, setAuth] = useState<AiAuthState>(initialAuthState);
const [name] = useState(
`My ${aiMethodLabel(requirement.provider, requirement.method) === "API key" ? `${AI_PROVIDERS[requirement.provider].name} API` : aiMethodLabel(requirement.provider, requirement.method)}`,
`My ${aiMethodLabel(requirement.provider, method) === "API key" ? `${AI_PROVIDERS[requirement.provider].name} API` : aiMethodLabel(requirement.provider, method)}`,
);
const [tested, setTested] = useState(false);
const [adopting, setAdopting] = useState(false);
@ -134,7 +135,7 @@ function AgentConnectionReview({
}
const id = `review-account-${connections.length + 1}`;
const connection: AiConnectionSummary = {
...requirement, id, grantId: `grant-${id}`, name: name.trim(),
...requirement, method, id, grantId: `grant-${id}`, name: name.trim(),
ownership: "personal", ownerUserId: currentUserId,
ownerName: currentUserId === "dotta" ? "Dotta" : "Sam",
isDefault: !connections.some((row) => matchesAiRequirement(row, requirement) && row.ownerUserId === currentUserId && row.isDefault),
@ -316,7 +317,7 @@ function AgentConnectionReview({
<AiReviewBoundary label="Simulated authentication controller: AiConnectionAuth · Reuses existing login cards">
<AiConnectionAuth
provider={requirement.provider}
method={requirement.method}
method={method}
state={auth}
onStart={() => {
if (!name.trim()) return;

View File

@ -38,6 +38,7 @@ const groups = [
"Choose an account",
[
["Responsible user", "responsible-user"],
["Same bot, another users API key", "responsible-user-api-key"],
["Company shared", "shared-selected"],
["Human access denied", "shared-audience-denied"],
["Another responsible user", "another-user-missing"],
@ -81,7 +82,7 @@ export const ReviewIndex: Story = {
viewports.
</p>
<p className="text-sm">
Personal defaults are per company, provider, and sign-in method.
Personal defaults are per company, user, and provider; subscription or API key.
Connection selection never changes harness or model. Unavailable
accounts block without fallback.
</p>
@ -145,6 +146,35 @@ export const IdentityMatrix: Story = {
render: () => <AiConnectorPages />,
};
export const ResponsibleUser: Story = {};
export const ResponsibleUserApiKey: Story = {
args: {
requirement: { companyId: AI_REVIEW_REQUIREMENT.companyId, provider: "anthropic" },
currentUserId: "sam",
initialBinding: AI_REVIEW_BINDING,
initialConnections: [...AI_REVIEW_CONNECTIONS, {
id: "sam-api", grantId: "sam-api-grant", companyId: AI_REVIEW_REQUIREMENT.companyId,
provider: "anthropic", method: "api_key", name: "My Claude API key",
ownership: "personal", ownerUserId: "sam", ownerName: "Sam", isDefault: true, status: "connected",
}, {
id: "shared-api", grantId: "shared-api-grant", companyId: AI_REVIEW_REQUIREMENT.companyId,
provider: "anthropic", method: "api_key", name: "Engineering Claude API",
ownership: "shared", status: "connected",
}],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const harness = canvas.getByTestId("ai-harness").textContent;
const model = canvas.getByTestId("ai-model").textContent;
await expect(canvas.getByText("For you: My Claude API key")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Engineering Claude API" }));
await expect(canvas.getByRole("button", { name: "Engineering Claude API" })).toHaveAttribute("aria-pressed", "true");
await userEvent.click(canvas.getByRole("button", { name: "Responsible users connection" }));
await expect(canvas.getByRole("button", { name: "Responsible users connection" })).toHaveAttribute("aria-pressed", "true");
await expect(canvas.getByTestId("ai-harness")).toHaveTextContent(harness!);
await expect(canvas.getByTestId("ai-model")).toHaveTextContent(model!);
await expect(canvas.queryByRole("status")).not.toBeInTheDocument();
},
};
export const SharedSelected: Story = {
args: {
initialBinding: {