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:
超预期 2026-03-10 22:32:41 +01:00
parent 936607ca22
commit 89ccc5fac1
7 changed files with 131 additions and 1 deletions

View File

@ -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);
});
});

View File

@ -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}.`);

View File

@ -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[] = [];

View File

@ -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":

View File

@ -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",

View File

@ -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[];

View File

@ -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,