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:
parent
422287eecd
commit
8d1f0c20af
|
|
@ -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 account’s 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 user’s subscription and another user’s 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 user’s 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,
|
||||
|
|
|
|||
|
|
@ -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"`;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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')`),
|
||||
]);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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/")))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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 }), {
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const requirement: AiConnectionRequirement = {
|
|||
};
|
||||
const account: AiConnectionSummary = {
|
||||
...requirement,
|
||||
method: "subscription",
|
||||
id: "example",
|
||||
grantId: "example-grant",
|
||||
name: "My Claude subscription",
|
||||
|
|
|
|||
|
|
@ -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 user’s 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 user’s 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>
|
||||
|
|
|
|||
|
|
@ -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 user’s 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); }
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const groups = [
|
|||
"Choose an account",
|
||||
[
|
||||
["Responsible user", "responsible-user"],
|
||||
["Same bot, another user’s 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 user’s connection" }));
|
||||
await expect(canvas.getByRole("button", { name: "Responsible user’s 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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue