feat: per-agent auth profile binding for rate limit isolation
Add optional `auth` field to agent config entries that maps provider names to specific auth profile IDs. This allows each agent to use a dedicated authentication profile per provider, enabling: - Rate limit isolation between agents - Per-agent billing visibility - Independent credential rotation Changes: - Schema: add `auth: Record<string, string>` to AgentEntrySchema + AgentConfig - Runner: look up per-agent auth binding and inject as preferredProfile - Doctor: validate per-agent auth profile references exist in store - Tests: 5 new test cases for schema validation + preferredProfile ordering - Help/labels: add user-facing documentation for the new field
This commit is contained in:
parent
936607ca22
commit
89ccc5fac1
@ -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,95 @@ 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"]);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -409,6 +409,17 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const preferredProfileId = params.authProfileId?.trim();
|
||||
|
||||
// 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" ? preferredProfileId : undefined;
|
||||
if (lockedProfileId) {
|
||||
const lockedProfile = authStore.profiles[lockedProfileId];
|
||||
@ -423,7 +434,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}.`);
|
||||
|
||||
@ -256,6 +256,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[] = [];
|
||||
|
||||
@ -200,6 +200,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@ -56,6 +56,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@ -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<string, string>;
|
||||
model?: AgentModelConfig;
|
||||
/** Optional allowlist of skills for this agent (omit = all skills; empty = none). */
|
||||
skills?: string[];
|
||||
|
||||
@ -719,6 +719,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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user