Merge ea1d5a30dbebd4fa59e1d443aa138230c1b0a448 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
cyq 2026-03-21 04:17:19 +01:00 committed by GitHub
commit 04b5c33f52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 168 additions and 2 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,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);
});
});

View File

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

View File

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

View File

@ -204,6 +204,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

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