diff --git a/src/agents/auth-profiles/order.test.ts b/src/agents/auth-profiles/order.test.ts index a1b15192e16..d4bcd660162 100644 --- a/src/agents/auth-profiles/order.test.ts +++ b/src/agents/auth-profiles/order.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { AgentEntrySchema } from "../../config/zod-schema.agent-runtime.js"; import { resolveAuthProfileOrder } from "./order.js"; import type { AuthProfileStore } from "./types.js"; @@ -22,4 +23,130 @@ describe("resolveAuthProfileOrder", () => { expect(order).toEqual(["volcengine:default"]); }); + + it("places per-agent preferredProfile first in the order", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai:account1": { + type: "api_key", + provider: "openai", + key: "sk-1", + }, + "openai:account2": { + type: "api_key", + provider: "openai", + key: "sk-2", + }, + }, + }; + + const order = resolveAuthProfileOrder({ + store, + provider: "openai", + preferredProfile: "openai:account2", + }); + + // The preferred profile should be first in the ordering + expect(order[0]).toBe("openai:account2"); + expect(order).toContain("openai:account1"); + }); + + it("ignores preferredProfile that does not exist in store", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai:account1": { + type: "api_key", + provider: "openai", + key: "sk-1", + }, + }, + }; + + const order = resolveAuthProfileOrder({ + store, + provider: "openai", + preferredProfile: "openai:nonexistent", + }); + + expect(order).toEqual(["openai:account1"]); + }); + + it("per-agent binding overrides session-level profile via effectivePreferredProfile", () => { + // Simulates the integration scenario where a per-agent binding (account2) + // should take priority over a session-level preferred profile (account1). + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai:account1": { + type: "api_key", + provider: "openai", + key: "sk-1", + }, + "openai:account2": { + type: "api_key", + provider: "openai", + key: "sk-2", + }, + }, + }; + + // Session requests account1, but per-agent config binds to account2. + const sessionPreferred = "openai:account1"; + const agentAuthBinding = "openai:account2"; + const effectivePreferred = agentAuthBinding || sessionPreferred; + + const order = resolveAuthProfileOrder({ + store, + provider: "openai", + preferredProfile: effectivePreferred, + }); + + // Per-agent binding (account2) must come first, not the session override (account1) + expect(order[0]).toBe("openai:account2"); + expect(order).toContain("openai:account1"); + }); +}); + +describe("AgentEntrySchema auth field", () => { + it("accepts an agent entry with per-agent auth bindings", () => { + const result = AgentEntrySchema.safeParse({ + id: "my-agent", + auth: { + openai: "openai:account2", + anthropic: "anthropic:team-key", + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.auth).toEqual({ + openai: "openai:account2", + anthropic: "anthropic:team-key", + }); + } + }); + + it("accepts an agent entry without auth field (backward compat)", () => { + const result = AgentEntrySchema.safeParse({ + id: "my-agent", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.auth).toBeUndefined(); + } + }); + + it("rejects auth field with non-string values", () => { + const result = AgentEntrySchema.safeParse({ + id: "my-agent", + auth: { + openai: 123, + }, + }); + + expect(result.success).toBe(false); + }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0c66203992f..be611cce507 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -423,7 +423,19 @@ export async function runEmbeddedPiAgent( const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); const preferredProfileId = params.authProfileId?.trim(); - let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined; + + // Per-agent auth binding: if config specifies a dedicated profile for this + // agent+provider combination, use it as the preferred profile. This gives + // per-agent bindings priority over session-level overrides while + // maintaining full backward compatibility for agents without bindings. + const normalizedProvider = normalizeProviderId(provider); + const agentAuthBinding = params.config?.agents?.list?.find( + (a: { id: string }) => a.id === params.agentId, + )?.auth?.[normalizedProvider]; + const effectivePreferredProfile = agentAuthBinding?.trim() || preferredProfileId; + + let lockedProfileId = + params.authProfileIdSource === "user" ? effectivePreferredProfile : undefined; if (lockedProfileId) { const lockedProfile = authStore.profiles[lockedProfileId]; if ( @@ -437,7 +449,7 @@ export async function runEmbeddedPiAgent( cfg: params.config, store: authStore, provider, - preferredProfile: preferredProfileId, + preferredProfile: effectivePreferredProfile, }); if (lockedProfileId && !profileOrder.includes(lockedProfileId)) { throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 1f46ef28ba1..f9a62e714d4 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -269,6 +269,27 @@ export async function noteAuthProfileHealth(params: { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: params.allowKeychainPrompt, }); + + // Validate per-agent auth bindings reference existing profiles. + const agents = params.cfg.agents?.list ?? []; + const perAgentWarnings: string[] = []; + for (const agent of agents) { + if (agent.auth && typeof agent.auth === "object") { + for (const [provider, profileId] of Object.entries(agent.auth)) { + if (typeof profileId !== "string" || !profileId.trim()) { + continue; + } + if (!store.profiles[profileId]) { + perAgentWarnings.push( + `- agent "${agent.id}": auth profile "${profileId}" (provider: ${provider}) not found in auth store`, + ); + } + } + } + } + if (perAgentWarnings.length > 0) { + note(perAgentWarnings.join("\n"), "Per-agent auth binding warnings"); + } const unusable = (() => { const now = Date.now(); const out: string[] = []; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 947726bd7e8..ee7c116aad3 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -204,6 +204,8 @@ export const FIELD_HELP: Record = { "Idle runtime TTL in minutes for ACP session workers before eligible cleanup.", "acp.runtime.installCommand": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", + "agents.list[].auth": + 'Per-agent auth profile bindings. Maps provider names to specific auth profile IDs (e.g. {"openai": "openai:account2"}) for rate limit isolation and per-agent billing.', "agents.list.*.skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].skills": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 53317e2fcd2..b87909d4c18 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -56,6 +56,7 @@ export const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.skills": "Agent Skill Filter", + "agents.list[].auth": "Per-Agent Auth", "agents.list[].runtime": "Agent Runtime", "agents.list[].runtime.type": "Agent Runtime Type", "agents.list[].runtime.acp": "Agent ACP Runtime", diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..3b6e3cd7c84 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -64,6 +64,8 @@ export type AgentConfig = { name?: string; workspace?: string; agentDir?: string; + /** Per-agent auth profile bindings. Maps provider names to specific auth profile IDs. */ + auth?: Record; model?: AgentModelConfig; /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ skills?: string[]; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..024a3bb52fe 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -766,6 +766,7 @@ export const AgentEntrySchema = z name: z.string().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), + auth: z.record(z.string(), z.string()).optional(), model: AgentModelSchema.optional(), skills: z.array(z.string()).optional(), memorySearch: MemorySearchSchema,