From 22117ee72fd2de4606cd55387e588c728f0481ed Mon Sep 17 00:00:00 2001 From: meilleur-art <340670015+meilleur-art@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:27:18 +0900 Subject: [PATCH 1/3] feat(auth): ordered credential preferences with per-account usage caps auth.credentialPreferences now takes an ordered list per provider. Each entry is an account, optionally with a usage cap in percent: auth: credentialPreferences: anthropic: - low-cost@example.com - shared@example.com: 75 Selection uses the first listed account that is usable and under its cap. When every listed account is rate-limited or capped, the remaining accounts are ranked by balanced/earliest-reset ranking, and capped listed accounts are used only after those. Caps are read from the windows each provider reports, so providers without a ranking strategy and providers without usage reporting behave predictably. A session keeps its account after crossing a cap; the list is consulted when an account is selected. The single global auth.credentialPreferenceMaxUsedPercent is replaced by the per-account cap. Neither key has been released, so no migration is needed. gjc accounts prefer [=]... writes the list without editing YAML. It resolves every account against the stored OAuth rows, stores a portable email or account id, and writes nothing when an account is missing, ambiguous, duplicated, or has an invalid cap. Bare emails are accepted alongside email:/account: selectors, matching gjc accounts pin. An account listed twice fails startup closed. --- docs/custom-providers-and-multi-account.md | 17 +- .../configured-credential-preference.md | 2 +- packages/ai/src/auth-storage.ts | 90 ++++-- ...auth-storage-configured-preference.test.ts | 275 +++++++++++------- .../configured-credential-preference.md | 3 +- packages/coding-agent/src/cli/accounts-cli.ts | 146 +++++++++- packages/coding-agent/src/cli/fast-help.ts | 2 +- .../coding-agent/src/commands/accounts.ts | 12 +- .../src/config/settings-schema.ts | 23 +- packages/coding-agent/src/config/settings.ts | 1 - .../src/session/startup-auth-config.ts | 93 ++++-- .../test/accounts-logout-org.test.ts | 2 +- .../coding-agent/test/accounts-prefer.test.ts | 163 +++++++++++ .../test/startup-auth-config.test.ts | 107 ++++--- schemas/config.schema.json | 39 ++- scripts/generate-json-schemas.test.ts | 39 +++ scripts/generate-json-schemas.ts | 21 +- 17 files changed, 773 insertions(+), 262 deletions(-) create mode 100644 packages/coding-agent/test/accounts-prefer.test.ts diff --git a/docs/custom-providers-and-multi-account.md b/docs/custom-providers-and-multi-account.md index 5324b5557f9..f0aadc5802e 100644 --- a/docs/custom-providers-and-multi-account.md +++ b/docs/custom-providers-and-multi-account.md @@ -104,6 +104,8 @@ gjc accounts check anthropic --json gjc accounts pin anthropic me@example.com --persistent gjc accounts pin anthropic id:42 --persistent gjc accounts pin anthropic --clear --persistent +gjc accounts prefer anthropic low-cost@example.com shared@example.com=75 +gjc accounts prefer anthropic --clear gjc accounts logout anthropic --account me@example.com gjc accounts logout anthropic --all ``` @@ -111,6 +113,7 @@ gjc accounts logout anthropic --all - `list` shows stored OAuth rows and configured API-key sources, with safe identity/source/health/usage-freshness fields. It does not probe. - `check` performs the explicit sequential checker. `--json` emits safe machine-readable rows; a failed probe sets a non-zero exit status. - `pin` requires `--persistent`. `` is a bare email, `id:`, `email:`, or `account:`. The command validates an active OAuth row and writes the canonical `id:` selector to global configuration. `--clear` removes the provider's persistent pin. +- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email, or its account id when another row shares the email, so the file never holds a store-bound row id. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. - `logout` requires exactly one of `--account ` or `--all`; it removes only OAuth rows from the local store. It never removes API-key source rows, and it refuses to mutate a broker-backed store. API-key rows remain visible and checkable so operators can see which source is selected, but they are not part of OAuth account pooling, cannot be pinned by `gjc accounts pin`, and cannot be removed by `gjc accounts logout`. @@ -134,8 +137,10 @@ auth: openai-codex: email:me@example.com credentialPinStoreIdentity: broker:https://broker.example.test:8765 credentialPreferences: - anthropic: email:low-cost-plan@example.com - credentialPreferenceMaxUsedPercent: 90 + anthropic: + - low-cost@example.com + - shared@example.com: 75 + openai-codex: me@example.com ``` - `auth.broker.url` and `auth.broker.token` select direct-broker mode. `GJC_AUTH_BROKER_URL` and `GJC_AUTH_BROKER_TOKEN` take precedence over the nested values; nested values may be literal strings or trusted `$ENV_NAME` references. Resolution order remains explicit env → nested value (after indirection) → the owner-only `/auth-broker.token` file, so an unresolved nested token does not displace the token-file authority. A configured URL without a resolvable token is a hard error; GJC does not silently fall back to local SQLite. An absent config file leaves broker mode disabled, while malformed or unreadable global startup-auth config fails closed with a typed `StartupAuthConfigError`. @@ -143,8 +148,8 @@ auth: - `auth.credentialPins` is a global-only record of provider → selector. Project-scoped pins are ignored; do not place this record in project settings. - Numeric `id:` pins are bound to the credential-store authority fingerprint (`broker:` or `local:`). Changing broker URL or local database invalidates those numeric pins instead of retargeting a row with the same number; `email:` and `account:` selectors remain portable. - `auth.credentialPinStoreIdentity` is managed alongside persistent pins and contains no credential material. Numeric pins are applied only when this value exactly matches the current store authority; missing or mismatched metadata invalidates the numeric pin rather than retargeting a same-number row. Email/account selectors remain portable across store changes. -- `auth.credentialPreferences` is a global-only record of provider → soft preferred account. Unlike a pin, a preference never blocks the provider: the preferred account is tried first while it is usable, and the provider falls back to normal ranking when the account is blocked by a quota or rate-limit failure, is no longer stored, or has reached the usage threshold. Only portable `email:` and `account:` selectors are accepted; numeric `id:` selectors are rejected because a preference has no store-identity binding. -- `auth.credentialPreferenceMaxUsedPercent` (greater than 0 and at most 100, default 100) applies to every preference. The preferred account yields to normal ranking once its most-used known usage window (for example Claude 5h or 7d) reaches this percent. Unknown usage never counts as over the threshold. At 100 the account yields only once it is blocked. +- `auth.credentialPreferences` is a global-only record of provider → ordered soft preference list. `gjc accounts prefer` writes it; hand edits use the same shape. A value is one account or a list. Each entry is an account (`me@example.com`, `email:me@example.com`, or `account:`) or an account with a usage cap in percent (`me@example.com: 75`, greater than 0 and at most 100). Numeric `id:` selectors are rejected because a preference has no store-identity binding. An invalid entry or an account listed twice fails startup closed rather than silently changing the order. +- Selection walks the list in order and uses the first account that is usable and under its cap. An uncapped account stays first until a quota or rate-limit failure blocks it. A capped account counts as full once its most-used known usage window (for example Claude 5h or 7d, or Codex primary/secondary) reaches the cap; unknown usage never counts as full. When every listed account is blocked or full, the unlisted accounts are ranked normally, and full listed accounts are used only after those. Unlike a pin, a preference never blocks the provider. - Literal dotted root keys such as `auth.broker.url: ...`, `auth.credentialRankingMode: ...`, or `auth.credentialPins: ...` are rejected. Startup reports the keys and prints manual rewrite guidance. Rewrite `config.yml` by hand using the nested shape above; there is no automatic migration, and secret values must not be copied into command output. ### Pin and credential precedence @@ -155,10 +160,10 @@ API-key overrides are outside OAuth pinning: 2. A `models.yml` provider `apiKey` config override comes next and beats stored/broker OAuth. 3. If no API-key override is active, an explicit session selector wins: `--credential ...` or an account selected in `/login `. 4. A global `auth.credentialPins` entry seeds that provider's selector when a new session starts. Session **AUTO** masks it for that session. -5. With no selector, a runtime `--prefer-credential` or, failing that, a global `auth.credentialPreferences` entry moves its account ahead of automatic ranking while that account is usable. +5. With no selector, a runtime `--prefer-credential` moves its account ahead of automatic ranking while that account is usable. Without one, the global `auth.credentialPreferences` list orders the accounts as described above. 6. Otherwise OAuth accounts use automatic ranking (`balanced` or `earliest-reset`). -Ranking is performed at session start, or when the session's preferred account is blocked; a running session keeps its selected credential. The preference usage threshold is therefore evaluated when ranking runs; a session already using the preferred account keeps it until a quota or rate-limit failure rotates it away. Blocked/exhausted accounts sort last. Persistent pins and session selectors are valid only for active OAuth credentials. An API-key override (including a configured environment API-key source when creating a pin) makes OAuth pinning unavailable for that provider. +Ranking is performed at session start, or when the session's preferred account is blocked; a running session keeps its selected credential. Preference caps are therefore evaluated when an account is selected; a session keeps its account after that account crosses its cap, until a quota or rate-limit failure moves the session to the next listed account. Caps are read from the shared usage cache, so concurrent sessions that start before the cache refreshes can each select the same account and carry it somewhat past its cap. Blocked/exhausted accounts sort last. Persistent pins and session selectors are valid only for active OAuth credentials. An API-key override (including a configured environment API-key source when creating a pin) makes OAuth pinning unavailable for that provider. ## Local, direct-broker, and gateway capabilities diff --git a/packages/ai/changelog.d/configured-credential-preference.md b/packages/ai/changelog.d/configured-credential-preference.md index 398735b805a..2ba0a28d26a 100644 --- a/packages/ai/changelog.d/configured-credential-preference.md +++ b/packages/ai/changelog.d/configured-credential-preference.md @@ -1,6 +1,6 @@ ### Added -- `AuthStorageOptions.credentialPreferences` accepts a persisted soft OAuth account preference per provider (`ConfiguredCredentialPreference`: selector plus optional `maxUsedFraction`). The preferred row is moved ahead of balanced/earliest-reset ranking while it is usable, and yields to the normal ranking when it is blocked, no longer stored, or its most-used known usage window has reached `maxUsedFraction`. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. +- `AuthStorageOptions.credentialPreferences` accepts an ordered, persisted soft OAuth account preference list per provider (`ConfiguredCredentialPreference[]`: selector plus optional `maxUsedFraction` cap per entry). Selection uses the first listed account that is usable and under its cap; when every listed account is blocked or capped, the unlisted accounts are ranked by balanced/earliest-reset ranking, and capped listed accounts are used only after those. Caps read the windows each provider reports, so they also apply to providers without a ranking strategy. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 83a219bda72..407828ef387 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -699,19 +699,22 @@ export interface CredentialDisabledEvent { export type CredentialRankingMode = "balanced" | "earliest-reset"; /** - * Persisted soft preference for one provider's OAuth pool. + * One entry of a provider's ordered, persisted soft preference list. * - * The matching credential is moved ahead of the normal ranking while it is - * usable. It yields to the normal ranking when it is blocked, when it cannot be - * found, or when its most-used known usage window has reached - * `maxUsedFraction`. Unknown usage never counts as over the threshold. + * Entries are consulted in list order when a credential is selected. Usable + * entries under their cap are tried first, in list order; the remaining + * credentials follow in the normal ranking order. An entry whose most-used + * known usage window has reached `maxUsedFraction` is demoted behind every + * other credential, so it is used only when nothing else is available. A + * blocked or missing entry is skipped. Unknown usage never counts as over the + * cap. * * Runtime selectors (`--credential`, `--prefer-credential`) and session - * stickiness take precedence over a configured preference. + * stickiness take precedence over configured preferences. */ export interface ConfiguredCredentialPreference { selector: AuthCredentialSelector; - /** Used fraction in (0, 1] at which the preference stops applying. Absent means only blocking rotates away. */ + /** Used fraction in (0, 1] at which this entry stops being preferred. Absent means only blocking moves past it. */ maxUsedFraction?: number; } @@ -731,8 +734,8 @@ export type AuthStorageOptions = { usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; credentialRankingMode?: CredentialRankingMode; - /** Persisted soft preferences keyed by provider id. */ - credentialPreferences?: Readonly>; + /** Persisted ordered soft preferences keyed by provider id. */ + credentialPreferences?: Readonly>; usageFetch?: typeof fetch; usageRequestTimeoutMs?: number; usageLogger?: UsageLogger; @@ -1546,8 +1549,8 @@ export class AuthStorage { #runtimeCredentialSelectors: Map = new Map(); /** Soft runtime credential preference per provider; quota failures may rotate away from it. */ #runtimePreferredCredentialSelectors: Map = new Map(); - /** Persisted soft credential preference per provider; usage threshold and quota failures may rotate away from it. */ - #configuredCredentialPreferences: Map = new Map(); + /** Persisted ordered soft credential preferences per provider; usage caps and quota failures move past entries. */ + #configuredCredentialPreferences: Map = new Map(); /** Credential selectors explicitly attached to a credential session scope. */ #sessionCredentialSelectors: Map> = new Map(); /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */ @@ -1638,8 +1641,10 @@ export class AuthStorage { : DEFAULT_USAGE_REQUEST_TIMEOUT_MS; this.#usageFetchLeaseMs = usageTimeoutForLease + USAGE_FETCH_LEASE_GRACE_MS; this.#credentialRankingMode = options.credentialRankingMode ?? "balanced"; - for (const [provider, preference] of Object.entries(options.credentialPreferences ?? {})) { - this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preference); + for (const [provider, preferences] of Object.entries(options.credentialPreferences ?? {})) { + if (preferences.length > 0) { + this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preferences); + } } this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential; this.#fetchUsageReportsOverride = options.fetchUsageReports; @@ -3222,10 +3227,12 @@ export class AuthStorage { } /** - * Move the configured preferred credential to the front of `candidates` while it is usable and under its usage - * threshold. A missing row, a blocked row, or a row at its threshold leaves the normal ranking untouched. + * Reorder `candidates` by the provider's configured preference list: usable entries under their cap first, in list + * order; then the remaining candidates in their ranked order; then entries at or over their cap, in list order. + * Missing and blocked entries are skipped and keep their ranked position. A row listed twice counts once, at its + * first entry. */ - async #applyConfiguredCredentialPreference( + async #applyConfiguredCredentialPreferences( provider: string, providerKey: string, candidates: Array<{ @@ -3236,28 +3243,45 @@ export class AuthStorage { strategy: CredentialRankingStrategy | undefined, options?: AuthApiKeyOptions, ): Promise { - const preference = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider)); - if (!preference) return; - const preferredCandidate = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector); - if (preferredCandidate < 0) return; - const { maxUsedFraction } = preference; - if (maxUsedFraction !== undefined) { - const candidate = candidates[preferredCandidate]!; - if (!candidate.usageChecked) { + const preferences = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider)); + if (!preferences) return; + type Candidate = (typeof candidates)[number]; + const matched: Array<{ candidate: Candidate; maxUsedFraction: number | undefined }> = []; + const claimed = new Set(); + for (const preference of preferences) { + const position = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector); + if (position < 0) continue; + const candidate = candidates[position]!; + if (claimed.has(candidate)) continue; + claimed.add(candidate); + matched.push({ candidate, maxUsedFraction: preference.maxUsedFraction }); + } + if (matched.length === 0) return; + // Ranking already fetched usage for strategy-backed providers; fetch only what a cap still needs. + await Promise.all( + matched.map(async ({ candidate, maxUsedFraction }) => { + if (maxUsedFraction === undefined || candidate.usageChecked) return; candidate.usage = await this.#getUsageReport(provider, candidate.selection.credential, { baseUrl: options?.baseUrl, timeoutMs: this.#usageRequestTimeoutMs, signal: options?.signal, }); candidate.usageChecked = true; + }), + ); + const preferred: Candidate[] = []; + const capped: Candidate[] = []; + for (const { candidate, maxUsedFraction } of matched) { + const usedFraction = + maxUsedFraction === undefined ? undefined : this.#maxKnownUsedFraction(candidate.usage, strategy); + if (maxUsedFraction !== undefined && usedFraction !== undefined && usedFraction >= maxUsedFraction) { + capped.push(candidate); + } else { + preferred.push(candidate); } - const usedFraction = this.#maxKnownUsedFraction(candidate.usage, strategy); - if (usedFraction !== undefined && usedFraction >= maxUsedFraction) return; - } - if (preferredCandidate > 0) { - const [preferred] = candidates.splice(preferredCandidate, 1); - candidates.unshift(preferred); } + const rest = candidates.filter(candidate => !claimed.has(candidate)); + candidates.splice(0, candidates.length, ...preferred, ...rest, ...capped); } /** @@ -6001,8 +6025,10 @@ export class AuthStorage { const [preferred] = candidates.splice(preferredCandidate, 1); candidates.unshift(preferred); } - } else if (!preferredSelector) { - await this.#applyConfiguredCredentialPreference(provider, providerKey, candidates, strategy, options); + } else if (!preferredSelector && !sessionPreferredIsAvailable) { + // Configured preferences choose a credential; a session that already sticks to a usable one keeps it, so + // the list (and any usage fetch its caps need) is consulted only at selection time. + await this.#applyConfiguredCredentialPreferences(provider, providerKey, candidates, strategy, options); } } diff --git a/packages/ai/test/auth-storage-configured-preference.test.ts b/packages/ai/test/auth-storage-configured-preference.test.ts index 99dbd947984..2feb1745afe 100644 --- a/packages/ai/test/auth-storage-configured-preference.test.ts +++ b/packages/ai/test/auth-storage-configured-preference.test.ts @@ -10,24 +10,30 @@ import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const CHEAP = "cheap@example.test"; +const SHARED = "shared@example.test"; +const PERSONAL = "personal@example.test"; + const openStores: SqliteAuthCredentialStore[] = []; const usageByEmail = new Map(); -const usageProvider: UsageProvider = { - id: "anthropic", - async fetchUsage(params) { - const email = params.credential.email; - return email ? (usageByEmail.get(email) ?? null) : null; - }, +/** + * Usage-report shapes by provider type. Each provider names its windows differently; the cap must read the windows + * each provider actually reports rather than assume Anthropic's ids. + */ +type ProviderCase = "anthropic" | "openai-codex" | "kimi-code"; +const WINDOW_IDS: Record = { + anthropic: { short: "anthropic:5h", long: "anthropic:7d" }, + "openai-codex": { short: "openai-codex:primary", long: "openai-codex:secondary" }, + "kimi-code": { short: "kimi-code:0", long: "kimi-code:1" }, }; -function limit(provider: string, windowId: "5h" | "7d", usedFraction: number): UsageLimit { - const durationMs = windowId === "5h" ? 5 * HOUR_MS : 7 * DAY_MS; +function limit(id: string, provider: string, durationMs: number, usedFraction: number): UsageLimit { return { - id: `${provider}:${windowId}`, - label: windowId, - scope: { provider, windowId, shared: true }, - window: { id: windowId, label: windowId, durationMs, resetsAt: Date.now() + durationMs / 2 }, + id, + label: id, + scope: { provider, windowId: id, shared: true }, + window: { id, label: id, durationMs, resetsAt: Date.now() + durationMs / 2 }, amount: { unit: "percent", used: usedFraction * 100, @@ -40,45 +46,56 @@ function limit(provider: string, windowId: "5h" | "7d", usedFraction: number): U }; } -function report(email: string, fiveHourUsed: number, sevenDayUsed: number, provider = "anthropic"): UsageReport { - return { +function setUsage(email: string, shortUsed: number, longUsed: number, provider: ProviderCase = "anthropic"): void { + const ids = WINDOW_IDS[provider]; + usageByEmail.set(email, { provider, fetchedAt: Date.now(), - limits: [limit(provider, "5h", fiveHourUsed), limit(provider, "7d", sevenDayUsed)], + limits: [limit(ids.short, provider, 5 * HOUR_MS, shortUsed), limit(ids.long, provider, 7 * DAY_MS, longUsed)], metadata: { email }, + }); +} + +function oauth(email: string): OAuthCredential { + return { + type: "oauth", + access: `token-${email}`, + refresh: `refresh-${email}`, + expires: Date.now() + HOUR_MS, + email, }; } -function oauth(access: string, email: string): OAuthCredential { - return { type: "oauth", access, refresh: `refresh-${access}`, expires: Date.now() + HOUR_MS, email }; +function token(email: string): string { + return `token-${email}`; +} + +function prefer(email: string, maxUsedPercent?: number): ConfiguredCredentialPreference { + return maxUsedPercent === undefined + ? { selector: { kind: "email", value: email } } + : { selector: { kind: "email", value: email }, maxUsedFraction: maxUsedPercent / 100 }; } async function createStorage( - preference?: ConfiguredCredentialPreference, - rankingStrategyResolver?: () => undefined, - provider: "anthropic" | "kimi-code" = "anthropic", + preferences: readonly ConfiguredCredentialPreference[] | undefined, + options: { provider?: ProviderCase; reportsUsage?: boolean; emails?: readonly string[] } = {}, ): Promise { + const provider = options.provider ?? "anthropic"; const store = await SqliteAuthCredentialStore.open(":memory:"); openStores.push(store); - const usageProviderForStorage: UsageProvider = - provider === "anthropic" - ? usageProvider - : { - id: provider, - async fetchUsage(params) { - const email = params.credential.email; - return email ? (usageByEmail.get(email) ?? null) : null; - }, - }; + const usageProvider: UsageProvider = { + id: provider, + async fetchUsage(params) { + const email = params.credential.email; + return email ? (usageByEmail.get(email) ?? null) : null; + }, + }; const storage = new AuthStorage(store, { - usageProviderResolver: resolvedProvider => (resolvedProvider === provider ? usageProviderForStorage : undefined), - ...(rankingStrategyResolver ? { rankingStrategyResolver } : {}), - ...(preference ? { credentialPreferences: { [provider]: preference } } : {}), - }); - await storage.set(provider, [ - oauth("token-personal", "personal@example.test"), - oauth("token-cheap", "cheap@example.test"), - ]); + usageProviderResolver: resolved => + options.reportsUsage !== false && resolved === provider ? usageProvider : undefined, + ...(preferences ? { credentialPreferences: { [provider]: preferences } } : {}), + }); + await storage.set(provider, (options.emails ?? [PERSONAL, CHEAP, SHARED]).map(oauth)); return storage; } @@ -87,97 +104,133 @@ afterEach(() => { for (const store of openStores.splice(0)) store.close(); }); -describe("configured credential preference", () => { - test("balanced ranking picks the less-used account without a preference", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.5, 0.5)); - const storage = await createStorage(); +describe("ordered credential preferences", () => { + test("balanced ranking picks the least-used account without preferences", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage(undefined); - expect(await storage.getApiKey("anthropic", "no-preference")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "no-preference")).toBe(token(PERSONAL)); }); - test("the preferred account is used ahead of the balanced ranking", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.5, 0.5)); - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); + test("the first listed account is used ahead of balanced ranking", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "preferred")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "first")).toBe(token(CHEAP)); }); - test("the preference yields to balanced ranking once any window reaches maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.9, 0.2)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test("a rate-limited first account moves selection to the second listed account, not to balanced ranking", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 1, 0.4); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "over-threshold")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "second")).toBe(token(SHARED)); }); - test("the long window alone can push the preference over maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.05, 0.95)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test.each([ + [74, SHARED], + [75, PERSONAL], + ])("a listed account at %i%% of a 75%% cap resolves to %s", async (usedPercent, expected) => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(CHEAP, 1, 0.4); + setUsage(SHARED, usedPercent / 100, 0.1); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "long-window-over-threshold")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", `cap-${usedPercent}`)).toBe(token(expected)); }); - test("the threshold applies when usage is available without a ranking strategy", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1, "kimi-code")); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.95, 0.2, "kimi-code")); - const storage = await createStorage( - { - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }, - () => undefined, - "kimi-code", - ); + test("the long window alone can put a listed account over its cap", async () => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(CHEAP, 0.05, 0.95); + const storage = await createStorage([prefer(CHEAP, 90)], { emails: [PERSONAL, CHEAP] }); - expect(await storage.getApiKey("kimi-code")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "long-window")).toBe(token(PERSONAL)); }); - test("the preference still applies just below maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.89, 0.2)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test("once every listed account is past its cap the unlisted accounts are ranked normally", async () => { + setUsage(PERSONAL, 0.6, 0.6); + setUsage(CHEAP, 0.8, 0.2); + setUsage(SHARED, 0.9, 0.2); + const storage = await createStorage([prefer(CHEAP, 50), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "under-threshold")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "all-capped")).toBe(token(PERSONAL)); }); - test("unknown usage does not count as over the threshold", async () => { - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.5, - }); + test("an account past its cap is still used when every other account is rate-limited", async () => { + setUsage(PERSONAL, 1, 0.9); + setUsage(CHEAP, 1, 0.5); + setUsage(SHARED, 0.9, 0.2); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "unknown-usage")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "capped-last-resort")).toBe(token(SHARED)); }); - test("a quota failure on the preferred account rotates away for the rest of the session", async () => { - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); + test.each([ + "openai-codex", + "kimi-code", + ] as const)("%s caps read the windows that provider reports", async provider => { + setUsage(PERSONAL, 0.5, 0.5, provider); + setUsage(CHEAP, 0.2, 0.2, provider); + setUsage(SHARED, 0.8, 0.1, provider); + const storage = await createStorage([prefer(SHARED, 75), prefer(CHEAP)], { provider }); + + expect(await storage.getApiKey(provider, `${provider}-cap`)).toBe(token(CHEAP)); + }); + + test("a provider without usage reporting follows list order and treats caps as not reached", async () => { + const storage = await createStorage([prefer(SHARED, 10), prefer(CHEAP)], { reportsUsage: false }); + + expect(await storage.getApiKey("anthropic", "no-usage")).toBe(token(SHARED)); + }); + + test("unknown usage for one account does not count as over its cap", async () => { + setUsage(PERSONAL, 0.1, 0.1); + const storage = await createStorage([prefer(CHEAP, 50)], { emails: [PERSONAL, CHEAP] }); + + expect(await storage.getApiKey("anthropic", "unknown-usage")).toBe(token(CHEAP)); + }); + + test("a quota failure during a session moves to the next listed account", async () => { + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "quota")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "quota")).toBe(token(CHEAP)); expect(await storage.markUsageLimitReached("anthropic", "quota", { retryAfterMs: 60_000 })).toMatchObject({ state: "marked", }); - expect(await storage.getApiKey("anthropic", "quota")).toBe("token-personal"); - expect(await storage.getApiKey("anthropic", "quota-next-session")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "quota")).toBe(token(SHARED)); + }); + + test("a session keeps its account after crossing the cap instead of switching mid-session", async () => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(SHARED, 0.5, 0.1); + const storage = await createStorage([prefer(SHARED, 75)], { emails: [PERSONAL, SHARED] }); + + expect(await storage.getApiKey("anthropic", "sticky")).toBe(token(SHARED)); + setUsage(SHARED, 0.9, 0.1); + expect(await storage.getApiKey("anthropic", "sticky")).toBe(token(SHARED)); + }); + + test("a listed account that is not stored is skipped", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer("removed@example.test"), prefer(CHEAP)]); + + expect(await storage.getApiKey("anthropic", "missing")).toBe(token(CHEAP)); }); - test("a preference that matches no stored account leaves ranking untouched", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.5, 0.5)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.1, 0.1)); - const storage = await createStorage({ selector: { kind: "email", value: "removed@example.test" } }); + test("an account listed twice keeps its first position and cap", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.6, 0.1); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer(CHEAP, 50), prefer(SHARED), prefer(CHEAP)]); - expect(await storage.getApiKey("anthropic", "missing")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "duplicate")).toBe(token(SHARED)); }); test.each([ @@ -186,26 +239,22 @@ describe("configured credential preference", () => { ] as const)("%s selects the designated OAuth row in a pool that also stores an API key", async (_label, source) => { const store = await SqliteAuthCredentialStore.open(":memory:"); openStores.push(store); - const selector = { kind: "email", value: "cheap@example.test" } as const; + const selector = { kind: "email", value: CHEAP } as const; const storage = new AuthStorage(store, { rankingStrategyResolver: () => undefined, configValueResolver: async value => (value === "unusable-api-key" ? undefined : value), - ...(source === "configured" ? { credentialPreferences: { anthropic: { selector } } } : {}), + ...(source === "configured" ? { credentialPreferences: { anthropic: [{ selector }] } } : {}), }); - await storage.set("anthropic", [ - { type: "api_key", key: "unusable-api-key" }, - oauth("token-cheap", "cheap@example.test"), - oauth("token-personal", "personal@example.test"), - ]); + await storage.set("anthropic", [{ type: "api_key", key: "unusable-api-key" }, oauth(CHEAP), oauth(PERSONAL)]); if (source === "runtime") storage.setRuntimePreferredCredentialSelector("anthropic", selector); - expect(await storage.getApiKey("anthropic", `mixed-${source}`)).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", `mixed-${source}`)).toBe(token(CHEAP)); }); - test("a runtime --prefer-credential selector outranks the configured preference", async () => { - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); - storage.setRuntimePreferredCredentialSelector("anthropic", { kind: "email", value: "personal@example.test" }); + test("a runtime --prefer-credential selector outranks configured preferences", async () => { + const storage = await createStorage([prefer(CHEAP)]); + storage.setRuntimePreferredCredentialSelector("anthropic", { kind: "email", value: PERSONAL }); - expect(await storage.getApiKey("anthropic", "runtime-wins")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "runtime-wins")).toBe(token(PERSONAL)); }); }); diff --git a/packages/coding-agent/changelog.d/configured-credential-preference.md b/packages/coding-agent/changelog.d/configured-credential-preference.md index 3ed7c805fcf..a621c230e8b 100644 --- a/packages/coding-agent/changelog.d/configured-credential-preference.md +++ b/packages/coding-agent/changelog.d/configured-credential-preference.md @@ -1,3 +1,4 @@ ### Added -- Added global `auth.credentialPreferences` (provider → `email:`/`account:` selector) and `auth.credentialPreferenceMaxUsedPercent` (greater than 0 and at most 100, default 100) config. A preferred account, such as a lower-cost plan, is used first and the provider falls back to normal ranking once that account reaches the usage threshold or hits a quota/rate-limit failure, without the session-blocking behavior of `auth.credentialPins`. Both keys are global-only and ignored in project settings. +- Added global `auth.credentialPreferences`: an ordered list of preferred OAuth accounts per provider, each optionally capped by a usage percent (`- low-cost@example.com` then `- shared@example.com: 75`). The first listed account that is usable and under its cap is used; once every listed account is rate-limited or at its cap, the remaining accounts are ranked normally. Unlike `auth.credentialPins`, a preference never blocks the provider. The key is global-only and ignored in project settings. +- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors, and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. diff --git a/packages/coding-agent/src/cli/accounts-cli.ts b/packages/coding-agent/src/cli/accounts-cli.ts index 090e1106b70..652ef387ba7 100644 --- a/packages/coding-agent/src/cli/accounts-cli.ts +++ b/packages/coding-agent/src/cli/accounts-cli.ts @@ -26,15 +26,20 @@ import { clearPersistentPinForRemovedRows, } from "../session/account-inventory"; import { CREDENTIAL_STORE_UNREADABLE_MESSAGE } from "../session/credential-store-errors"; -import { resolveStartupAuthConfig, type StartupAuthConfigSnapshot } from "../session/startup-auth-config"; +import { + type CredentialPreferenceEntryValue, + isValidCredentialPreferenceCap, + resolveStartupAuthConfig, + type StartupAuthConfigSnapshot, +} from "../session/startup-auth-config"; -export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "logout"] as const; +export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "prefer", "logout"] as const; export type AccountsAction = (typeof ACCOUNTS_ACTIONS)[number]; export interface AccountsCommandArgs { action: AccountsAction; provider?: string; - selector?: string; + selectors?: readonly string[]; flags: { json?: boolean; persistent?: boolean; @@ -288,6 +293,133 @@ async function runPin( }); } +/** + * Split `=` into the account and its usage cap. A suffix that does not start with a digit is part of + * the account (account ids may end in `=`); a digit-led suffix must be a valid cap. + */ +function parsePreferArgument(raw: string): { account: string; cap: number | undefined } { + const separator = raw.lastIndexOf("="); + const rawCap = separator < 0 ? "" : raw.slice(separator + 1).trim(); + if (!/^\d/.test(rawCap)) return { account: raw.trim(), cap: undefined }; + const cap = /^\d+(\.\d+)?$/.test(rawCap) ? Number(rawCap) : Number.NaN; + if (!isValidCredentialPreferenceCap(cap)) { + throw new AccountsCommandError( + `Usage cap in ${JSON.stringify(raw)} must be a percent above 0 and at most 100, e.g. me@example.com=75; no preference was written.`, + ); + } + return { account: raw.slice(0, separator).trim(), cap }; +} + +/** + * Portable config text for a resolved OAuth row: its email, else its account id, whichever no other OAuth row of the + * provider shares. Numeric row ids are never persisted as preferences; a row with neither unique value is refused. + */ +function portablePreferenceAccount( + row: CredentialInventoryRecord, + providerRows: readonly CredentialInventoryRecord[], +): string | undefined { + const oauthRows = providerRows.filter(other => other.credentialKind === "oauth"); + const email = row.email?.trim().toLowerCase(); + if (email && oauthRows.filter(other => other.email?.trim().toLowerCase() === email).length === 1) { + return row.email!.trim(); + } + const accountId = row.accountId?.trim(); + if (accountId && oauthRows.filter(other => other.accountId?.trim() === accountId).length === 1) { + return `account:${accountId}`; + } + return undefined; +} + +async function writePersistentPreferences( + provider: string, + entries: readonly CredentialPreferenceEntryValue[] | undefined, +): Promise { + const settings = await Settings.init(); + await settings.commitAtomicBatchWithCurrent(current => { + const currentAuth = current.auth === undefined ? {} : asRecord(current.auth); + const rawPreferences = currentAuth?.credentialPreferences; + const preferences = rawPreferences === undefined ? {} : asRecord(rawPreferences); + if (!currentAuth || !preferences) { + throw new AccountsCommandError("Global auth.credentialPreferences is malformed; no preference was written."); + } + const next: Record = { ...preferences }; + if (entries) next[provider] = entries; + else delete next[provider]; + return [{ path: "auth.credentialPreferences", op: "set", value: next }]; + }); +} + +async function runPrefer( + providerArg: string | undefined, + accountArgs: readonly string[] | undefined, + flags: AccountsCommandArgs["flags"], +): Promise { + const provider = canonicalProvider(providerArg); + const accounts = accountArgs ?? []; + if (flags.clear && accounts.length > 0) { + throw new AccountsCommandError("Use either preferred accounts or --clear, not both."); + } + if (!flags.clear && accounts.length === 0) { + throw new AccountsCommandError( + "Prefer requires one or more accounts in order (e.g. me@example.com shared@example.com=75), or --clear.", + ); + } + if (flags.clear) { + await writePersistentPreferences(provider, undefined); + if (flags.json) writeJson({ ok: true, provider, cleared: true }); + else writeText([`Cleared preferred accounts for ${provider}; selection uses normal ranking.`]); + return; + } + + const requested = accounts.map(parsePreferArgument); + await withAuthStorageAndModels(async (storage, modelRegistry, startupAuth) => { + if ( + !modelRegistry.getConfiguredProviderIds().includes(provider) && + modelRegistry.getAll().every(model => model.provider !== provider) + ) { + throw new AccountsCommandError(`Provider ${provider} is not configured; no preference was written.`); + } + const providerRows = storage.listCredentialInventory(provider); + const seenIds = new Set(); + const resolved = requested.map(({ account, cap }) => { + let target: OAuthPinTarget; + try { + target = storage.resolveOAuthPinTarget(provider, selectorFromInput(account)); + } catch (error) { + throw toAccountsCommandError(error) ?? error; + } + if (seenIds.has(target.credentialId)) { + throw new AccountsCommandError(`${account} is listed more than once; no preference was written.`); + } + seenIds.add(target.credentialId); + const row = providerRows.find(candidate => candidate.id === target.credentialId); + const portable = row ? portablePreferenceAccount(row, providerRows) : undefined; + if (!portable) { + throw new AccountsCommandError( + `${account} has no unique email or account id to store as a preference; no preference was written.`, + ); + } + return { account: portable, cap: cap === 100 ? undefined : cap }; + }); + const entries: CredentialPreferenceEntryValue[] = resolved.map(({ account, cap }) => + cap === undefined ? account : { [account]: cap }, + ); + await writePersistentPreferences(provider, entries); + if (flags.json) { + writeJson({ ok: true, provider, preferences: entries }); + return; + } + writeText([ + `Preferred accounts for ${provider}, tried in order:`, + ...resolved.map( + ({ account, cap }, index) => + ` ${index + 1}. ${account}${cap === undefined ? " (until rate-limited)" : ` (until ${cap}% used)`}`, + ), + `Then: remaining accounts by ${startupAuth.credentialRankingMode} ranking. Takes effect for new sessions.`, + ]); + }); +} + function accountMatches(row: CredentialInventoryRecord, rawAccount: string): boolean { const value = rawAccount.trim(); if (value.length === 0) return false; @@ -398,7 +530,13 @@ export async function runAccountsCommand(cmd: AccountsCommandArgs): Promise 1) { + throw new AccountsCommandError("Pin takes one selector; use `gjc accounts prefer` for an ordered list."); + } + await runPin(cmd.provider, cmd.selectors?.[0], cmd.flags); + return; + case "prefer": + await runPrefer(cmd.provider, cmd.selectors, cmd.flags); return; case "logout": await runLogout(cmd.provider, cmd.flags); diff --git a/packages/coding-agent/src/cli/fast-help.ts b/packages/coding-agent/src/cli/fast-help.ts index 14abab7610d..d6abf4ea44e 100644 --- a/packages/coding-agent/src/cli/fast-help.ts +++ b/packages/coding-agent/src/cli/fast-help.ts @@ -8,7 +8,7 @@ export function getExtraHelpText(): string { ${APP_NAME} session - List, inspect, create, remove, or attach sessions ${APP_NAME} state - Inspect or manage persisted GJC state ${APP_NAME} auth-broker - Manage the auth-broker (credential vault) - ${APP_NAME} accounts - List, check, pin, and remove stored OAuth accounts + ${APP_NAME} accounts - List, check, pin, prefer, and remove stored OAuth accounts ${APP_NAME} models presets - Inspect, refresh, pin, rollback, or disable the signed model preset registry ${APP_NAME} auth-gateway - Run an auth-gateway forward proxy ${APP_NAME} harness - Run harness control-plane commands diff --git a/packages/coding-agent/src/commands/accounts.ts b/packages/coding-agent/src/commands/accounts.ts index 39573f14fef..367f83d7c5c 100644 --- a/packages/coding-agent/src/commands/accounts.ts +++ b/packages/coding-agent/src/commands/accounts.ts @@ -8,7 +8,7 @@ import { } from "../cli/accounts-cli"; export default class Accounts extends Command { - static description = "List, check, pin, and remove stored OAuth accounts"; + static description = "List, check, pin, prefer, and remove stored OAuth accounts"; static strict = true; static args = { @@ -22,15 +22,17 @@ export default class Accounts extends Command { required: false, }), selector: Args.string({ - description: "Account selector (bare email, id:, email:, or account:)", + description: + "Account selector (bare email, id:, email:, or account:); prefer takes several in order, each optionally =", required: false, + multiple: true, }), }; static flags = { json: Flags.boolean({ description: "Output safe machine-readable JSON" }), persistent: Flags.boolean({ description: "Required for persistent pin changes" }), - clear: Flags.boolean({ description: "Clear the persistent pin" }), + clear: Flags.boolean({ description: "Clear the persistent pin or preferred accounts" }), account: Flags.string({ description: "Logout one account by row id, email, or org:" }), all: Flags.boolean({ description: "Logout every OAuth account for the provider" }), }; @@ -44,6 +46,8 @@ export default class Accounts extends Command { "gjc accounts pin anthropic id:42 --persistent", "gjc accounts pin anthropic 'org:Acme Team' --persistent", "gjc accounts pin anthropic --clear --persistent", + "gjc accounts prefer anthropic cheap@example.com shared@example.com=75", + "gjc accounts prefer anthropic --clear", "gjc accounts logout anthropic --account me@example.com", "gjc accounts logout anthropic --all", ]; @@ -58,7 +62,7 @@ export default class Accounts extends Command { const cmd: AccountsCommandArgs = { action, provider: args.provider, - selector: args.selector, + selectors: Array.isArray(args.selector) ? args.selector : args.selector ? [args.selector] : undefined, flags: { json: flags.json, persistent: flags.persistent, diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 645e676f242..0bb4f9eb71f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1,5 +1,6 @@ import type { Effort } from "@gajae-code/ai/model-thinking"; import { PET_MODE_IDS, PET_SKIN_IDS, PET_SKINS } from "@gajae-code/tui/components/gajae-pet"; +import { type CredentialPreferenceEntryValue, parseCredentialPreferenceList } from "../session/startup-auth-config"; import { TASK_SIMPLE_MODES } from "../task/simple-mode"; import { getThinkingLevelMetadata } from "../thinking-metadata"; import { DEFAULT_EDIT_MODE_SETTING, EDIT_MODE_SETTINGS, EDIT_MODES, type EditMode } from "../utils/edit-mode"; @@ -215,7 +216,10 @@ type RecordValueDef = | { type: "model-selector-value" } | { type: "string-enum"; values: readonly string[] } | { type: "credential-selector" } - | { type: "portable-credential-selector" }; + | { type: "credential-preference-list" }; + +/** A provider's `auth.credentialPreferences` value: one account or a non-empty ordered list of entries. */ +export type CredentialPreferenceSettingValue = string | CredentialPreferenceEntryValue[]; interface ConstrainedRecordValueDef { type: "autorouting-selector-value"; @@ -453,17 +457,12 @@ export const SETTINGS_SCHEMA = { valueSchema: { type: "credential-selector" } as const, }, "auth.credentialPinStoreIdentity": { type: "string", default: undefined }, - // Soft per-provider OAuth account preference. Global-only, like pins; read at startup. + // Ordered soft per-provider OAuth account preferences with optional per-entry usage caps. Global-only, like pins; + // read at startup. "auth.credentialPreferences": { type: "record", - default: {} as Record, - valueSchema: { type: "portable-credential-selector" } as const, - }, - // Usage percent at which a preferred account yields to the normal ranking; 100 yields only once it is blocked. - "auth.credentialPreferenceMaxUsedPercent": { - type: "number", - default: 100, - validate: (value: number) => Number.isFinite(value) && value > 0 && value <= 100, + default: {} as Record, + valueSchema: { type: "credential-preference-list" } as const, }, "session.directoryMigration": { type: "enum", @@ -4395,8 +4394,8 @@ function validRecordValue(valueSchema: RecordValueDef, value: unknown): boolean /^account:\S+$/.test(value)) ); } - if (valueSchema.type === "portable-credential-selector") { - return typeof value === "string" && (/^email:[^@\s]+@[^@\s]+$/.test(value) || /^account:\S+$/.test(value)); + if (valueSchema.type === "credential-preference-list") { + return parseCredentialPreferenceList(value) !== undefined; } return typeof value === "string" && valueSchema.values.includes(value); } diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 2cde2a8bd6a..40d1ca8d8ac 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -316,7 +316,6 @@ const GLOBAL_ONLY_AUTH_SETTING_KEYS = new Set([ "credentialPins", "credentialPinStoreIdentity", "credentialPreferences", - "credentialPreferenceMaxUsedPercent", ]); const LOCAL_NOTIFICATION_SETTING_PATHS = new Set( [...LOCAL_NOTIFICATION_SETTING_KEYS].map(key => `notifications.${key}`), diff --git a/packages/coding-agent/src/session/startup-auth-config.ts b/packages/coding-agent/src/session/startup-auth-config.ts index 48608682338..69276e15302 100644 --- a/packages/coding-agent/src/session/startup-auth-config.ts +++ b/packages/coding-agent/src/session/startup-auth-config.ts @@ -31,8 +31,8 @@ export interface StartupAuthConfigSnapshot { credentialPinStoreIdentity?: string; credentialRankingMode: CredentialRankingMode; credentialPins: Readonly>; - /** Soft per-provider preferences; only portable `email:`/`account:` selectors are accepted. */ - credentialPreferences: Readonly>; + /** Ordered soft per-provider preferences; only portable email/`account:` selectors are accepted. */ + credentialPreferences: Readonly>; } export type StartupAuthConfigErrorKind = @@ -72,7 +72,6 @@ const LEGACY_LITERAL_AUTH_KEYS = new Set([ "auth.credentialRankingMode", "auth.credentialPins", "auth.credentialPreferences", - "auth.credentialPreferenceMaxUsedPercent", ]); function configPathForAgentDir(agentDir: string): string { @@ -109,20 +108,69 @@ export function isValidPersistedCredentialSelector(value: string): boolean { return parsePersistedCredentialSelector(value) !== undefined; } +/** JSON-schema pattern for a preference account; shared with {@link parsePreferenceAccount}. */ +export const PREFERENCE_ACCOUNT_PATTERN = "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$"; + /** - * Validate the selector grammar used by `auth.credentialPreferences`. Numeric row ids are excluded: they are bound - * to one credential store, and a preference has no store-identity binding to detect a retargeted row. + * Parse one `auth.credentialPreferences` account: a bare email, `email:
`, or `account:`. Numeric row ids + * are excluded: they are bound to one credential store, and a preference has no store-identity binding to detect a + * retargeted row. */ -export function parsePortableCredentialSelector(value: string): AuthCredentialSelector | undefined { - const selector = parsePersistedCredentialSelector(value); - return selector?.kind === "id" ? undefined : selector; +export function parsePreferenceAccount(value: string): AuthCredentialSelector | undefined { + const trimmed = value.trim(); + const selector = parsePersistedCredentialSelector(trimmed); + if (selector) return selector.kind === "id" ? undefined : selector; + return /^[^@\s:]+@[^@\s]+$/.test(trimmed) ? { kind: "email", value: trimmed } : undefined; } -/** `auth.credentialPreferenceMaxUsedPercent` accepts (0, 100]; 100 rotates away only once the account is blocked. */ -export function isValidCredentialPreferenceMaxUsedPercent(value: unknown): value is number { +/** A preference cap accepts (0, 100]; 100 moves past the account only once it is blocked. */ +export function isValidCredentialPreferenceCap(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100; } +/** Canonical config text for one preference entry: the account, or a one-key `{ account: cap }` mapping. */ +export type CredentialPreferenceEntryValue = string | Readonly>; + +function parseCredentialPreferenceEntry(value: unknown): ConfiguredCredentialPreference | undefined { + if (typeof value === "string") { + const selector = parsePreferenceAccount(value); + return selector ? { selector } : undefined; + } + const entry = asRecord(value); + const fields = entry ? Object.entries(entry) : []; + if (fields.length !== 1) return undefined; + const [account, cap] = fields[0]!; + const selector = parsePreferenceAccount(account); + if (!selector || !isValidCredentialPreferenceCap(cap)) return undefined; + return cap === 100 ? { selector } : { selector, maxUsedFraction: cap / 100 }; +} + +function preferenceSelectorKey(selector: AuthCredentialSelector): string { + return `${selector.kind}:${selector.kind === "email" ? selector.value.toLowerCase() : selector.value}`; +} + +/** + * Parse one provider's `auth.credentialPreferences` value: a single account, or a non-empty ordered list whose + * entries are an account (`me@example.com`) or an account with a usage cap in percent (`me@example.com: 75`). + * Returns undefined for any invalid value, including an account listed twice, so a typo fails closed instead of + * silently changing the order. + */ +export function parseCredentialPreferenceList(value: unknown): ConfiguredCredentialPreference[] | undefined { + const rawEntries = Array.isArray(value) ? value : [value]; + if (rawEntries.length === 0) return undefined; + const entries: ConfiguredCredentialPreference[] = []; + const seen = new Set(); + for (const rawEntry of rawEntries) { + const entry = parseCredentialPreferenceEntry(rawEntry); + if (!entry) return undefined; + const key = preferenceSelectorKey(entry.selector); + if (seen.has(key)) return undefined; + seen.add(key); + entries.push(entry); + } + return entries; +} + function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -169,8 +217,9 @@ function throwLegacyLiteralKeyError(keys: string[]): never { " credentialPins:\n" + " : id:\n" + " credentialPreferences:\n" + - " : email:
\n" + - " credentialPreferenceMaxUsedPercent: <0-100, exclusive of 0>\n" + + " :\n" + + " - \n" + + " - : \n" + "Do not copy secret values into command output; rewrite the file by hand.", ); } @@ -201,27 +250,21 @@ function readCredentialPins( function readCredentialPreferences( auth: Record | undefined, configPath: string, -): Readonly> { - const rawMaxUsedPercent = auth?.credentialPreferenceMaxUsedPercent; - if (rawMaxUsedPercent !== undefined && !isValidCredentialPreferenceMaxUsedPercent(rawMaxUsedPercent)) { - throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - } +): Readonly> { const rawPreferences = auth?.credentialPreferences; if (rawPreferences === undefined) return {}; const preferences = asRecord(rawPreferences); if (!preferences) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - const maxUsedFraction = - rawMaxUsedPercent === undefined || rawMaxUsedPercent === 100 ? undefined : rawMaxUsedPercent / 100; - const result: Record = {}; - for (const [provider, rawSelector] of Object.entries(preferences)) { + const result: Record = {}; + for (const [provider, rawList] of Object.entries(preferences)) { const normalizedProvider = provider.trim(); if (normalizedProvider.length === 0 || /[\u0000-\u001f\u007f]/u.test(normalizedProvider)) { throw new StartupAuthConfigError("invalid-credential-preferences", configPath); } - const selector = typeof rawSelector === "string" ? parsePortableCredentialSelector(rawSelector) : undefined; - if (!selector) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - result[normalizedProvider] = maxUsedFraction === undefined ? { selector } : { selector, maxUsedFraction }; + const entries = parseCredentialPreferenceList(rawList); + if (!entries) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); + result[normalizedProvider] = entries; } return result; } @@ -230,7 +273,7 @@ interface GlobalStartupAuthYaml { auth: Record | undefined; credentialPinStoreIdentity: string | undefined; credentialPins: Readonly>; - credentialPreferences: Readonly>; + credentialPreferences: Readonly>; } const EMPTY_GLOBAL_STARTUP_AUTH: GlobalStartupAuthYaml = { diff --git a/packages/coding-agent/test/accounts-logout-org.test.ts b/packages/coding-agent/test/accounts-logout-org.test.ts index 4be7ffdaff6..c4b5f8e99b1 100644 --- a/packages/coding-agent/test/accounts-logout-org.test.ts +++ b/packages/coding-agent/test/accounts-logout-org.test.ts @@ -103,7 +103,7 @@ describe("gjc accounts with org: selectors", () => { const result = await accountsJson({ action: "pin", provider: "anthropic", - selector: "org:Acme", + selectors: ["org:Acme"], flags: { persistent: true, json: true }, }); diff --git a/packages/coding-agent/test/accounts-prefer.test.ts b/packages/coding-agent/test/accounts-prefer.test.ts new file mode 100644 index 00000000000..d50fe28e05f --- /dev/null +++ b/packages/coding-agent/test/accounts-prefer.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { SqliteAuthCredentialStore } from "@gajae-code/ai"; +import { getAgentDbPath, resetAgentDirFromEnvironment, setAgentDir } from "@gajae-code/utils"; +import { YAML } from "bun"; +import { runAccountsCommand } from "../src/cli/accounts-cli"; +import { resetSettingsForTest } from "../src/config/settings"; +import { resolveStartupAuthConfig } from "../src/session/startup-auth-config"; + +// Each command run opens the credential store and refreshes the offline model registry, which exceeds the default +// timeout when the suite runs alongside other files. +const CLI_TIMEOUT_MS = 30_000; + +type PreferJson = { ok: boolean; preferences?: unknown[]; cleared?: boolean; error?: { message: string } }; + +function oauth(email: string, accountId: string, organizationId?: string) { + return { + type: "oauth" as const, + access: `access-${email}-${organizationId ?? "personal"}`, + refresh: `refresh-${email}-${organizationId ?? "personal"}`, + expires: Date.now() + 3_600_000, + email, + accountId, + ...(organizationId ? { organizationId, organizationName: organizationId } : {}), + }; +} + +async function preferJson(selectors: string[] | undefined, clear = false): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: Uint8Array | string): boolean => { + chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stdout.write; + const priorExitCode = process.exitCode; + try { + await runAccountsCommand({ action: "prefer", provider: "anthropic", selectors, flags: { json: true, clear } }); + } finally { + process.stdout.write = original as typeof process.stdout.write; + process.exitCode = priorExitCode ?? 0; + } + return JSON.parse(chunks.join("")); +} + +describe("gjc accounts prefer", () => { + let agentDir = ""; + let originalAgentDir: string | undefined; + let ids: number[] = []; + + beforeEach(async () => { + originalAgentDir = process.env.GJC_CODING_AGENT_DIR; + agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-accounts-prefer-")); + setAgentDir(agentDir); + resetSettingsForTest(); + const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); + try { + for (const credential of [ + oauth("cheap@example.com", "acct-cheap"), + oauth("shared@example.com", "acct-shared"), + // One login that belongs to two organizations: neither its email nor its account id is unique. + oauth("team@example.com", "acct-team", "org-a"), + oauth("team@example.com", "acct-team", "org-b"), + ]) { + store.upsertAuthCredentialForProvider("anthropic", credential); + } + ids = store.listAuthCredentials("anthropic").map(entry => entry.id); + expect(ids).toHaveLength(4); + } finally { + store.close(); + } + }); + + afterEach(async () => { + if (originalAgentDir === undefined) delete process.env.GJC_CODING_AGENT_DIR; + else process.env.GJC_CODING_AGENT_DIR = originalAgentDir; + resetAgentDirFromEnvironment(); + resetSettingsForTest(); + await fs.rm(agentDir, { recursive: true, force: true }); + }); + + async function writtenPreferences(): Promise { + const file = Bun.file(path.join(agentDir, "config.yml")); + if (!(await file.exists())) return undefined; + const parsed = YAML.parse(await file.text()) as { auth?: { credentialPreferences?: unknown } } | null; + return parsed?.auth?.credentialPreferences; + } + + it( + "writes the ordered list in the short config form that startup reads back", + async () => { + const result = await preferJson(["cheap@example.com", `id:${ids[1]}=75`]); + + expect(result).toEqual({ + ok: true, + provider: "anthropic", + preferences: ["cheap@example.com", { "shared@example.com": 75 }], + } as never); + expect(await writtenPreferences()).toEqual({ + anthropic: ["cheap@example.com", { "shared@example.com": 75 }], + }); + expect((await resolveStartupAuthConfig(agentDir)).credentialPreferences).toEqual({ + anthropic: [ + { selector: { kind: "email", value: "cheap@example.com" } }, + { selector: { kind: "email", value: "shared@example.com" }, maxUsedFraction: 0.75 }, + ], + }); + }, + CLI_TIMEOUT_MS, + ); + + it( + "stores a 100 percent cap as an uncapped account", + async () => { + await preferJson(["cheap@example.com=100"]); + + expect(await writtenPreferences()).toEqual({ anthropic: ["cheap@example.com"] }); + }, + CLI_TIMEOUT_MS, + ); + + it.each([ + ["an account that is not stored", ["missing@example.com"], "No active OAuth credential"], + ["a zero cap", ["cheap@example.com=0"], "above 0 and at most 100"], + ["a cap over 100", ["cheap@example.com=101"], "above 0 and at most 100"], + ["a malformed cap", ["cheap@example.com=7o"], "above 0 and at most 100"], + ["the same account twice", ["cheap@example.com", "id:" /* filled below */], "listed more than once"], + ["an account with no unique email or account id", ["org:org-a"], "no unique email or account id"], + ])( + "refuses %s and writes nothing", + async (_label, selectors, message) => { + const args = selectors.map(value => (value === "id:" ? `id:${ids[0]}` : value)); + const result = await preferJson(args); + + expect(result.ok).toBe(false); + expect(result.error?.message).toContain(message); + expect(await writtenPreferences()).toBeUndefined(); + }, + CLI_TIMEOUT_MS, + ); + + it( + "clears only the named provider", + async () => { + await preferJson(["cheap@example.com"]); + const result = await preferJson(undefined, true); + + expect(result).toEqual({ ok: true, provider: "anthropic", cleared: true } as never); + expect(await writtenPreferences()).toEqual({}); + }, + CLI_TIMEOUT_MS, + ); + + it( + "requires accounts or --clear, not both", + async () => { + expect((await preferJson(undefined)).error?.message).toContain("one or more accounts"); + expect((await preferJson(["cheap@example.com"], true)).error?.message).toContain("not both"); + }, + CLI_TIMEOUT_MS, + ); +}); diff --git a/packages/coding-agent/test/startup-auth-config.test.ts b/packages/coding-agent/test/startup-auth-config.test.ts index 5d0354c6b91..b9ff3da851d 100644 --- a/packages/coding-agent/test/startup-auth-config.test.ts +++ b/packages/coding-agent/test/startup-auth-config.test.ts @@ -169,51 +169,60 @@ describe("startup auth config", () => { }); }); - it("reads soft credential preferences with the shared usage threshold", async () => { + it("reads ordered credential preferences with per-account usage caps", async () => { clearAuthEnv(); const agentDir = await makeAgentDir( [ "auth:", " credentialPreferences:", - " anthropic: email:cheap@example.com", + " anthropic:", + " - cheap@example.com", + " - shared@example.com: 75", + " - email:prefixed@example.com: 40.5", + " - account:acct-9: 100", " openai-codex: account:acct-1", - " credentialPreferenceMaxUsedPercent: 90", + " kimi-code: solo@example.com", ].join("\n"), ); - await expect(resolveStartupAuthConfig(agentDir)).resolves.toMatchObject({ - credentialPreferences: { - anthropic: { selector: { kind: "email", value: "cheap@example.com" }, maxUsedFraction: 0.9 }, - "openai-codex": { selector: { kind: "account", value: "acct-1" }, maxUsedFraction: 0.9 }, - }, - }); - - const noThreshold = await makeAgentDir( - "auth:\n credentialPreferences:\n anthropic: email:cheap@example.com\n", - ); - const resolved = await resolveStartupAuthConfig(noThreshold); + const resolved = await resolveStartupAuthConfig(agentDir); expect(resolved.credentialPreferences).toEqual({ - anthropic: { selector: { kind: "email", value: "cheap@example.com" } }, + anthropic: [ + { selector: { kind: "email", value: "cheap@example.com" } }, + { selector: { kind: "email", value: "shared@example.com" }, maxUsedFraction: 0.75 }, + { selector: { kind: "email", value: "prefixed@example.com" }, maxUsedFraction: 0.405 }, + // A 100 percent cap is the same as no cap: only blocking moves past the account. + { selector: { kind: "account", value: "acct-9" } }, + ], + "openai-codex": [{ selector: { kind: "account", value: "acct-1" } }], + "kimi-code": [{ selector: { kind: "email", value: "solo@example.com" } }], }); }); - it("fails closed for numeric, malformed, or out-of-range credential preferences", async () => { + it.each([ + ["a numeric row id", " anthropic: id:42\n"], + ["a non-mapping section", null], + ["a value that is not an account", " anthropic: cheap\n"], + ["an empty list", " anthropic: []\n"], + ["a numeric id inside a list", " anthropic:\n - cheap@example.com\n - id:42\n"], + ["the same account twice", " anthropic:\n - cheap@example.com\n - Cheap@Example.com: 50\n"], + ["two accounts in one entry", " anthropic:\n - cheap@example.com: 50\n other@example.com: 50\n"], + ["a zero cap", " anthropic:\n - cheap@example.com: 0\n"], + ["a cap over 100", " anthropic:\n - cheap@example.com: 101\n"], + ["a string cap", ' anthropic:\n - cheap@example.com: "75%"\n'], + ["a nested list", " anthropic:\n - - cheap@example.com\n"], + ])("fails closed for credential preferences with %s", async (_label, body) => { clearAuthEnv(); - for (const config of [ - "auth:\n credentialPreferences:\n anthropic: id:42\n", - "auth:\n credentialPreferences: preference-section-secret\n", - "auth:\n credentialPreferences:\n anthropic: cheap@example.com\n", - "auth:\n credentialPreferenceMaxUsedPercent: 0\n", - "auth:\n credentialPreferenceMaxUsedPercent: 101\n", - 'auth:\n credentialPreferenceMaxUsedPercent: "90"\n', - ]) { - const agentDir = await makeAgentDir(config); - await expectStartupAuthConfigError( - resolveStartupAuthConfig(agentDir), - "invalid-credential-preferences", - "preference-section-secret", - ); - } + const agentDir = await makeAgentDir( + body === null + ? "auth:\n credentialPreferences: preference-section-secret\n" + : `auth:\n credentialPreferences:\n${body}`, + ); + await expectStartupAuthConfigError( + resolveStartupAuthConfig(agentDir), + "invalid-credential-preferences", + "preference-section-secret", + ); }); it("keeps malformed or absent pin-store metadata from authorizing numeric pins", async () => { @@ -317,33 +326,43 @@ describe("startup auth config", () => { ]); }); - it("validates preference records as portable selectors with a bounded threshold", () => { - expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: "email:cheap@example.com" } })).toEqual( + it("validates preference records with the startup parser grammar", () => { + for (const value of [ + "email:cheap@example.com", + "cheap@example.com", + ["cheap@example.com", { "account:acct-1": 75 }], + ]) { + expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: value } })).toEqual([]); + } + for (const value of [ + "id:27", [], - ); - expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: "id:27" } })).toEqual([ - { path: "auth.credentialPreferences.anthropic", detail: "Expected portable-credential-selector." }, - ]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 90 })).toEqual([]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 0 })).not.toEqual([]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 101 })).not.toEqual([]); + [{ "cheap@example.com": 0 }], + [{ "cheap@example.com": 50, "other@example.com": 50 }], + ["cheap@example.com", "cheap@example.com"], + ]) { + expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: value } })).toEqual([ + { path: "auth.credentialPreferences.anthropic", detail: "Expected credential-preference-list." }, + ]); + } }); it("ignores project-scoped credential preferences", async () => { clearAuthEnv(); const agentDir = await makeAgentDir( - "auth:\n credentialPreferences:\n anthropic: email:global@example.com\n credentialPreferenceMaxUsedPercent: 80\n", + "auth:\n credentialPreferences:\n anthropic:\n - global@example.com: 80\n", ); const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-startup-auth-project-")); tempDirs.push(projectDir); await fs.mkdir(path.join(projectDir, ".gjc"), { recursive: true }); await fs.writeFile( path.join(projectDir, ".gjc", "config.yml"), - "auth:\n credentialPreferences:\n anthropic: email:project@example.com\n credentialPreferenceMaxUsedPercent: 10\n", + "auth:\n credentialPreferences:\n anthropic:\n - project@example.com: 10\n", ); const settings = await Settings.loadForScope({ cwd: projectDir, agentDir }); - expect(settings.get("auth.credentialPreferences")).toEqual({ anthropic: "email:global@example.com" }); - expect(settings.get("auth.credentialPreferenceMaxUsedPercent")).toBe(80); + expect(settings.get("auth.credentialPreferences")).toEqual({ + anthropic: [{ "global@example.com": 80 }], + }); }); }); diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 75ea61df228..4bba80001e0 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -46,16 +46,39 @@ "credentialPreferences": { "type": "object", "additionalProperties": { - "type": "string", - "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+)$" + "anyOf": [ + { + "type": "string", + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "type": "string", + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + } + } + ] + } + } + ] }, "default": {} - }, - "credentialPreferenceMaxUsedPercent": { - "type": "number", - "default": 100, - "exclusiveMinimum": 0, - "maximum": 100 } }, "additionalProperties": false diff --git a/scripts/generate-json-schemas.test.ts b/scripts/generate-json-schemas.test.ts index b7a390b1dce..91a2e1fa5c9 100644 --- a/scripts/generate-json-schemas.test.ts +++ b/scripts/generate-json-schemas.test.ts @@ -3,6 +3,11 @@ import * as path from "node:path"; import { JSON_SCHEMA_OUTPUTS, stableJson } from "./generate-json-schemas"; import { ProfileModelSelectorPattern } from "../packages/coding-agent/src/config/models-config-schema"; import { SETTINGS_SCHEMA } from "../packages/coding-agent/src/config/settings-schema"; +import { + PREFERENCE_ACCOUNT_PATTERN, + parseCredentialPreferenceList, + parsePreferenceAccount, +} from "../packages/coding-agent/src/session/startup-auth-config"; function acceptsJsonSchemaFixture(schema: unknown, value: unknown): boolean { if (schema === true) return true; @@ -127,6 +132,40 @@ describe("generated JSON Schemas", () => { })).toBe(false); }); + it("emits the credential preference grammar the startup parser accepts", () => { + const schema = configSchema() as any; + const value = schema.properties.auth.properties.credentialPreferences.additionalProperties; + const [single, list] = value.anyOf; + const [account, capped] = list.items.anyOf; + expect(single).toEqual({ type: "string", pattern: PREFERENCE_ACCOUNT_PATTERN }); + expect(account).toEqual(single); + expect(list.minItems).toBe(1); + expect(capped).toMatchObject({ + minProperties: 1, + maxProperties: 1, + propertyNames: { pattern: PREFERENCE_ACCOUNT_PATTERN }, + additionalProperties: { type: "number", exclusiveMinimum: 0, maximum: 100 }, + }); + + const pattern = new RegExp(PREFERENCE_ACCOUNT_PATTERN); + for (const candidate of [ + "me@example.com", + "email:me@example.com", + "account:acct-1", + "id:42", + "me", + "me@@example.com", + "project:p-1", + "me @example.com", + ]) { + expect(pattern.test(candidate)).toBe(parsePreferenceAccount(candidate) !== undefined); + } + for (const cap of [0, 0.5, 75, 100, 101]) { + const inSchema = cap > 0 && cap <= 100; + expect(parseCredentialPreferenceList([{ "me@example.com": cap }]) !== undefined).toBe(inSchema); + } + }); + it("emits web search fallback item enum and provider webSearch enum", () => { const configSchema = JSON_SCHEMA_OUTPUTS.find(output => output.path === "schemas/config.schema.json")?.schema as any; const fallbackItems = configSchema.properties.web_search.properties.fallback.items; diff --git a/scripts/generate-json-schemas.ts b/scripts/generate-json-schemas.ts index d0a22aaf31f..12663938956 100644 --- a/scripts/generate-json-schemas.ts +++ b/scripts/generate-json-schemas.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { zodToWireSchema } from "../packages/ai/src/utils/schema/wire"; import { AUTOROUTING_SELECTOR_MAX_LENGTH } from "../packages/coding-agent/src/config/autorouting-contract"; import { SETTINGS_SCHEMA } from "../packages/coding-agent/src/config/settings-schema"; +import { PREFERENCE_ACCOUNT_PATTERN } from "../packages/coding-agent/src/session/startup-auth-config"; import { ModelsConfigSchema } from "../packages/coding-agent/src/config/models-config-schema"; type JsonSchema = boolean | JsonSchemaObject; @@ -33,7 +34,6 @@ type SettingDefinition = SettingsSchema[keyof SettingsSchema]; const DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; const PERSISTED_CREDENTIAL_SELECTOR_PATTERN = "^(id:[1-9][0-9]*|email:[^@\\s]+@[^@\\s]+|account:\\S+)$"; -const PORTABLE_CREDENTIAL_SELECTOR_PATTERN = "^(email:[^@\\s]+@[^@\\s]+|account:\\S+)$"; export const JSON_SCHEMA_OUTPUTS = [ { path: "schemas/config.schema.json", @@ -135,10 +135,6 @@ function settingDefinitionToJsonSchema(settingPath: string, definition: SettingD schema.exclusiveMinimum = 0; schema.maximum = 1; } - if (settingPath === "auth.credentialPreferenceMaxUsedPercent") { - schema.exclusiveMinimum = 0; - schema.maximum = 100; - } if (settingPath === "sdk.promptDeadlineMs" || settingPath === "sdk.promptMaxRuntimeMs") { schema.type = "integer"; schema.minimum = 60_000; @@ -219,7 +215,7 @@ function recordValueSchema( valueSchema?: | { readonly type: "model-selector-value" } | { readonly type: "credential-selector" } - | { readonly type: "portable-credential-selector" } + | { readonly type: "credential-preference-list" } | { readonly type: "string-enum"; readonly values: readonly string[] }, ): JsonSchema { if (valueSchema?.type === "string-enum") return { type: "string", enum: valueSchema.values }; @@ -229,10 +225,17 @@ function recordValueSchema( pattern: PERSISTED_CREDENTIAL_SELECTOR_PATTERN, }; } - if (valueSchema?.type === "portable-credential-selector") { + if (valueSchema?.type === "credential-preference-list") { + const account = { type: "string", pattern: PREFERENCE_ACCOUNT_PATTERN }; + const cappedAccount = { + type: "object", + minProperties: 1, + maxProperties: 1, + propertyNames: { pattern: PREFERENCE_ACCOUNT_PATTERN }, + additionalProperties: { type: "number", exclusiveMinimum: 0, maximum: 100 }, + }; return { - type: "string", - pattern: PORTABLE_CREDENTIAL_SELECTOR_PATTERN, + anyOf: [account, { type: "array", minItems: 1, items: { anyOf: [account, cappedAccount] } }], }; } if (valueSchema?.type !== "model-selector-value") return true; From 74373c38aef3b0ec30987332ca354776b776d723 Mon Sep 17 00:00:00 2001 From: meilleur-art <340670015+meilleur-art@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:54:16 +0900 Subject: [PATCH 2/3] fix(auth): keep written credential preferences startup-valid and reject aliased duplicates gjc accounts prefer stored a unique email verbatim, so an address with a colon in its local part was written in the bare form that the startup parser rejects: the command succeeded and the next startup failed with invalid-credential-preferences. The writer now emits the first form parsePreferenceAccount reads back (bare, else email:
, else account:). A hand-edited list could name one stored row through two selectors (email and account:). Parsing compared selector text only, and AuthStorage silently skipped the later entry, dropping its position and cap despite the documented fail-closed contract. AuthStorage now resolves every entry to its stored row: discoverAuthStorage fails startup with invalid-credential-preferences when two entries share a row, and selection ignores such a list as a whole if a later login creates the overlap mid-session. Also: the config schema marks preference lists uniqueItems, and the prefer CLI treats an = before the last @ as part of the email, so user=2@example.com is no longer misread as a cap. Constraint: duplicate aliases are only detectable against loaded credential rows, not in the config parser Rejected: fail the provider at request time | a preference must never block the provider Rejected: keep first-entry-wins | silently drops the later entry's cap Confidence: high Scope-risk: narrow Tested: new regressions fail on 22117ee72 and pass here; focused preference/startup/prefer/schema tests; ai and coding-agent check; check:schemas; check:tools; 52 auth/account/credential test files Not-tested: broker-backed startup with aliased preferences (same code path after reload) --- docs/custom-providers-and-multi-account.md | 4 +- .../configured-credential-preference.md | 2 +- packages/ai/src/auth-storage.ts | 48 +++++++++++- ...auth-storage-configured-preference.test.ts | 77 +++++++++++++++++-- .../configured-credential-preference.md | 4 +- packages/coding-agent/src/cli/accounts-cli.ts | 17 ++-- packages/coding-agent/src/sdk/session.ts | 25 +++++- .../src/session/startup-auth-config.ts | 2 +- .../coding-agent/test/accounts-prefer.test.ts | 27 +++++++ .../test/startup-auth-config.test.ts | 37 +++++++++ schemas/config.schema.json | 1 + scripts/generate-json-schemas.test.ts | 4 + scripts/generate-json-schemas.ts | 2 +- 13 files changed, 225 insertions(+), 25 deletions(-) diff --git a/docs/custom-providers-and-multi-account.md b/docs/custom-providers-and-multi-account.md index f0aadc5802e..fce22d32e41 100644 --- a/docs/custom-providers-and-multi-account.md +++ b/docs/custom-providers-and-multi-account.md @@ -113,7 +113,7 @@ gjc accounts logout anthropic --all - `list` shows stored OAuth rows and configured API-key sources, with safe identity/source/health/usage-freshness fields. It does not probe. - `check` performs the explicit sequential checker. `--json` emits safe machine-readable rows; a failed probe sets a non-zero exit status. - `pin` requires `--persistent`. `` is a bare email, `id:`, `email:`, or `account:`. The command validates an active OAuth row and writes the canonical `id:` selector to global configuration. `--clear` removes the provider's persistent pin. -- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email, or its account id when another row shares the email, so the file never holds a store-bound row id. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. +- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email (as `email:
` when the address is not a valid bare entry, for example one with `:` in its local part), or its account id when another row shares the email, so the file never holds a store-bound row id and every written list is read back by startup. A cap is the text after the last `=` that follows the account, so `user=2@example.com` is an account and `user=2@example.com=75` caps it at 75%. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. - `logout` requires exactly one of `--account ` or `--all`; it removes only OAuth rows from the local store. It never removes API-key source rows, and it refuses to mutate a broker-backed store. API-key rows remain visible and checkable so operators can see which source is selected, but they are not part of OAuth account pooling, cannot be pinned by `gjc accounts pin`, and cannot be removed by `gjc accounts logout`. @@ -148,7 +148,7 @@ auth: - `auth.credentialPins` is a global-only record of provider → selector. Project-scoped pins are ignored; do not place this record in project settings. - Numeric `id:` pins are bound to the credential-store authority fingerprint (`broker:` or `local:`). Changing broker URL or local database invalidates those numeric pins instead of retargeting a row with the same number; `email:` and `account:` selectors remain portable. - `auth.credentialPinStoreIdentity` is managed alongside persistent pins and contains no credential material. Numeric pins are applied only when this value exactly matches the current store authority; missing or mismatched metadata invalidates the numeric pin rather than retargeting a same-number row. Email/account selectors remain portable across store changes. -- `auth.credentialPreferences` is a global-only record of provider → ordered soft preference list. `gjc accounts prefer` writes it; hand edits use the same shape. A value is one account or a list. Each entry is an account (`me@example.com`, `email:me@example.com`, or `account:`) or an account with a usage cap in percent (`me@example.com: 75`, greater than 0 and at most 100). Numeric `id:` selectors are rejected because a preference has no store-identity binding. An invalid entry or an account listed twice fails startup closed rather than silently changing the order. +- `auth.credentialPreferences` is a global-only record of provider → ordered soft preference list. `gjc accounts prefer` writes it; hand edits use the same shape. A value is one account or a list. Each entry is an account (`me@example.com`, `email:me@example.com`, or `account:`) or an account with a usage cap in percent (`me@example.com: 75`, greater than 0 and at most 100). Numeric `id:` selectors are rejected because a preference has no store-identity binding. An invalid entry or an account listed twice fails startup closed rather than silently changing the order; this includes two entries that name the same stored account through different selectors (for example `me@example.com` and `account:`), which startup detects once the credential store is loaded. If a later login makes two entries name one account during a running session, that provider's list is ignored and its accounts are ranked normally until the list is fixed. - Selection walks the list in order and uses the first account that is usable and under its cap. An uncapped account stays first until a quota or rate-limit failure blocks it. A capped account counts as full once its most-used known usage window (for example Claude 5h or 7d, or Codex primary/secondary) reaches the cap; unknown usage never counts as full. When every listed account is blocked or full, the unlisted accounts are ranked normally, and full listed accounts are used only after those. Unlike a pin, a preference never blocks the provider. - Literal dotted root keys such as `auth.broker.url: ...`, `auth.credentialRankingMode: ...`, or `auth.credentialPins: ...` are rejected. Startup reports the keys and prints manual rewrite guidance. Rewrite `config.yml` by hand using the nested shape above; there is no automatic migration, and secret values must not be copied into command output. diff --git a/packages/ai/changelog.d/configured-credential-preference.md b/packages/ai/changelog.d/configured-credential-preference.md index 2ba0a28d26a..5f13b498afd 100644 --- a/packages/ai/changelog.d/configured-credential-preference.md +++ b/packages/ai/changelog.d/configured-credential-preference.md @@ -1,6 +1,6 @@ ### Added -- `AuthStorageOptions.credentialPreferences` accepts an ordered, persisted soft OAuth account preference list per provider (`ConfiguredCredentialPreference[]`: selector plus optional `maxUsedFraction` cap per entry). Selection uses the first listed account that is usable and under its cap; when every listed account is blocked or capped, the unlisted accounts are ranked by balanced/earliest-reset ranking, and capped listed accounts are used only after those. Caps read the windows each provider reports, so they also apply to providers without a ranking strategy. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. +- `AuthStorageOptions.credentialPreferences` accepts an ordered, persisted soft OAuth account preference list per provider (`ConfiguredCredentialPreference[]`: selector plus optional `maxUsedFraction` cap per entry). Selection uses the first listed account that is usable and under its cap; when every listed account is blocked or capped, the unlisted accounts are ranked by balanced/earliest-reset ranking, and capped listed accounts are used only after those. Caps read the windows each provider reports, so they also apply to providers without a ranking strategy. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. A list whose entries name one stored row twice (for example an email and `account:` for the same login) is ignored as a whole and reported by `AuthStorage.findDuplicateCredentialPreferences()`. ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 407828ef387..851194f2a7d 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -1551,6 +1551,8 @@ export class AuthStorage { #runtimePreferredCredentialSelectors: Map = new Map(); /** Persisted ordered soft credential preferences per provider; usage caps and quota failures move past entries. */ #configuredCredentialPreferences: Map = new Map(); + /** Providers already warned that their preference list names one stored row twice. */ + #warnedDuplicateCredentialPreferences = new Set(); /** Credential selectors explicitly attached to a credential session scope. */ #sessionCredentialSelectors: Map> = new Map(); /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */ @@ -3226,11 +3228,40 @@ export class AuthStorage { ); } + /** + * Providers whose configured preference list names one stored credential row through more than one entry, for + * example `email:` and `account:` selectors for the same login. Startup rejects such a list. + */ + findDuplicateCredentialPreferences(): Array<{ provider: string; credentialId: number }> { + const duplicates: Array<{ provider: string; credentialId: number }> = []; + for (const [provider, preferences] of this.#configuredCredentialPreferences) { + const credentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences); + if (credentialId !== undefined) duplicates.push({ provider, credentialId }); + } + return duplicates; + } + + /** Id of the first stored row that two entries of `preferences` both designate; undefined when every entry differs. */ + #duplicateConfiguredPreferenceRow( + provider: string, + preferences: readonly ConfiguredCredentialPreference[], + ): number | undefined { + const designatedIds = new Set(); + for (const { selector } of preferences) { + const credentialId = this.#findCredentialBySelector(provider, selector)?.id; + if (credentialId === undefined) continue; + if (designatedIds.has(credentialId)) return credentialId; + designatedIds.add(credentialId); + } + return undefined; + } + /** * Reorder `candidates` by the provider's configured preference list: usable entries under their cap first, in list * order; then the remaining candidates in their ranked order; then entries at or over their cap, in list order. - * Missing and blocked entries are skipped and keep their ranked position. A row listed twice counts once, at its - * first entry. + * Missing and blocked entries are skipped and keep their ranked position. A list that names one stored row twice + * (startup rejects it, but a later login can make two entries resolve to one row) is ignored as a whole rather than + * applying one entry's position and cap and dropping the other's. */ async #applyConfiguredCredentialPreferences( provider: string, @@ -3245,6 +3276,18 @@ export class AuthStorage { ): Promise { const preferences = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider)); if (!preferences) return; + const duplicateCredentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences); + if (duplicateCredentialId !== undefined) { + const storageProvider = resolveOAuthStorageProvider(provider); + if (!this.#warnedDuplicateCredentialPreferences.has(storageProvider)) { + this.#warnedDuplicateCredentialPreferences.add(storageProvider); + logger.warn("Configured credential preferences name one account twice; using normal ranking", { + provider: storageProvider, + credentialId: duplicateCredentialId, + }); + } + return; + } type Candidate = (typeof candidates)[number]; const matched: Array<{ candidate: Candidate; maxUsedFraction: number | undefined }> = []; const claimed = new Set(); @@ -3252,7 +3295,6 @@ export class AuthStorage { const position = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector); if (position < 0) continue; const candidate = candidates[position]!; - if (claimed.has(candidate)) continue; claimed.add(candidate); matched.push({ candidate, maxUsedFraction: preference.maxUsedFraction }); } diff --git a/packages/ai/test/auth-storage-configured-preference.test.ts b/packages/ai/test/auth-storage-configured-preference.test.ts index 2feb1745afe..871ba2da714 100644 --- a/packages/ai/test/auth-storage-configured-preference.test.ts +++ b/packages/ai/test/auth-storage-configured-preference.test.ts @@ -56,13 +56,14 @@ function setUsage(email: string, shortUsed: number, longUsed: number, provider: }); } -function oauth(email: string): OAuthCredential { +function oauth(email: string, accountId?: string): OAuthCredential { return { type: "oauth", access: `token-${email}`, refresh: `refresh-${email}`, expires: Date.now() + HOUR_MS, email, + ...(accountId ? { accountId } : {}), }; } @@ -95,7 +96,10 @@ async function createStorage( options.reportsUsage !== false && resolved === provider ? usageProvider : undefined, ...(preferences ? { credentialPreferences: { [provider]: preferences } } : {}), }); - await storage.set(provider, (options.emails ?? [PERSONAL, CHEAP, SHARED]).map(oauth)); + await storage.set( + provider, + (options.emails ?? [PERSONAL, CHEAP, SHARED]).map(email => oauth(email)), + ); return storage; } @@ -224,13 +228,70 @@ describe("ordered credential preferences", () => { expect(await storage.getApiKey("anthropic", "missing")).toBe(token(CHEAP)); }); - test("an account listed twice keeps its first position and cap", async () => { - setUsage(PERSONAL, 0.1, 0.1); - setUsage(CHEAP, 0.6, 0.1); - setUsage(SHARED, 0.3, 0.3); - const storage = await createStorage([prefer(CHEAP, 50), prefer(SHARED), prefer(CHEAP)]); + describe("a list that names one stored account twice", () => { + const CHEAP_ACCOUNT = "acct-cheap"; + const aliasedPreferences: ConfiguredCredentialPreference[] = [ + { selector: { kind: "email", value: CHEAP }, maxUsedFraction: 0.5 }, + prefer(SHARED), + { selector: { kind: "account", value: CHEAP_ACCOUNT } }, + ]; + + async function createAliasedStorage( + preferences: readonly ConfiguredCredentialPreference[], + credentials: readonly OAuthCredential[], + ): Promise { + const store = await SqliteAuthCredentialStore.open(":memory:"); + openStores.push(store); + const storage = new AuthStorage(store, { + usageProviderResolver: () => ({ + id: "anthropic", + async fetchUsage(params) { + const email = params.credential.email; + return email ? (usageByEmail.get(email) ?? null) : null; + }, + }), + credentialPreferences: { anthropic: preferences }, + }); + await storage.set("anthropic", [...credentials]); + return storage; + } + + test("through email and account selectors is reported with the shared row", async () => { + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL), + oauth(CHEAP, CHEAP_ACCOUNT), + oauth(SHARED), + ]); + const cheapRow = storage.listCredentialInventory("anthropic").find(row => row.email === CHEAP); + + expect(storage.findDuplicateCredentialPreferences()).toEqual([ + { provider: "anthropic", credentialId: cheapRow!.id }, + ]); + }); - expect(await storage.getApiKey("anthropic", "duplicate")).toBe(token(SHARED)); + test("is ignored as a whole instead of honoring only the first entry's cap", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.6, 0.1); + setUsage(SHARED, 0.3, 0.3); + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL), + oauth(CHEAP, CHEAP_ACCOUNT), + oauth(SHARED), + ]); + + // Applied, the list would pick SHARED (CHEAP is over its 50% cap); ignored, balanced ranking picks PERSONAL. + expect(await storage.getApiKey("anthropic", "aliased")).toBe(token(PERSONAL)); + }); + + test("is not reported when the aliases designate different rows", async () => { + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL, CHEAP_ACCOUNT), + oauth(CHEAP), + oauth(SHARED), + ]); + + expect(storage.findDuplicateCredentialPreferences()).toEqual([]); + }); }); test.each([ diff --git a/packages/coding-agent/changelog.d/configured-credential-preference.md b/packages/coding-agent/changelog.d/configured-credential-preference.md index a621c230e8b..38407bd2465 100644 --- a/packages/coding-agent/changelog.d/configured-credential-preference.md +++ b/packages/coding-agent/changelog.d/configured-credential-preference.md @@ -1,4 +1,4 @@ ### Added -- Added global `auth.credentialPreferences`: an ordered list of preferred OAuth accounts per provider, each optionally capped by a usage percent (`- low-cost@example.com` then `- shared@example.com: 75`). The first listed account that is usable and under its cap is used; once every listed account is rate-limited or at its cap, the remaining accounts are ranked normally. Unlike `auth.credentialPins`, a preference never blocks the provider. The key is global-only and ignored in project settings. -- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors, and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. +- Added global `auth.credentialPreferences`: an ordered list of preferred OAuth accounts per provider, each optionally capped by a usage percent (`- low-cost@example.com` then `- shared@example.com: 75`). The first listed account that is usable and under its cap is used; once every listed account is rate-limited or at its cap, the remaining accounts are ranked normally. Unlike `auth.credentialPins`, a preference never blocks the provider. The key is global-only and ignored in project settings. Startup fails closed when two entries name the same stored account, including through different selectors such as an email and `account:`. +- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors in a form startup reads back (`email:
` when the address is not a valid bare entry), and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. diff --git a/packages/coding-agent/src/cli/accounts-cli.ts b/packages/coding-agent/src/cli/accounts-cli.ts index 652ef387ba7..53b7386f8fe 100644 --- a/packages/coding-agent/src/cli/accounts-cli.ts +++ b/packages/coding-agent/src/cli/accounts-cli.ts @@ -29,6 +29,7 @@ import { CREDENTIAL_STORE_UNREADABLE_MESSAGE } from "../session/credential-store import { type CredentialPreferenceEntryValue, isValidCredentialPreferenceCap, + parsePreferenceAccount, resolveStartupAuthConfig, type StartupAuthConfigSnapshot, } from "../session/startup-auth-config"; @@ -294,12 +295,13 @@ async function runPin( } /** - * Split `=` into the account and its usage cap. A suffix that does not start with a digit is part of - * the account (account ids may end in `=`); a digit-led suffix must be a valid cap. + * Split `=` into the account and its usage cap. The cap follows the whole account, so an `=` before + * the last `@` belongs to an email local part (`user=2@example.com`), and a suffix that does not start with a digit is + * part of the account (account ids may end in `=`); a digit-led suffix must be a valid cap. */ function parsePreferArgument(raw: string): { account: string; cap: number | undefined } { const separator = raw.lastIndexOf("="); - const rawCap = separator < 0 ? "" : raw.slice(separator + 1).trim(); + const rawCap = separator < 0 || separator < raw.lastIndexOf("@") ? "" : raw.slice(separator + 1).trim(); if (!/^\d/.test(rawCap)) return { account: raw.trim(), cap: undefined }; const cap = /^\d+(\.\d+)?$/.test(rawCap) ? Number(rawCap) : Number.NaN; if (!isValidCredentialPreferenceCap(cap)) { @@ -313,6 +315,8 @@ function parsePreferArgument(raw: string): { account: string; cap: number | unde /** * Portable config text for a resolved OAuth row: its email, else its account id, whichever no other OAuth row of the * provider shares. Numeric row ids are never persisted as preferences; a row with neither unique value is refused. + * The email is written bare when the startup grammar reads it back that way, else as `email:
`; every form + * written is one {@link parsePreferenceAccount} accepts, so a successful write never fails the next startup. */ function portablePreferenceAccount( row: CredentialInventoryRecord, @@ -321,11 +325,14 @@ function portablePreferenceAccount( const oauthRows = providerRows.filter(other => other.credentialKind === "oauth"); const email = row.email?.trim().toLowerCase(); if (email && oauthRows.filter(other => other.email?.trim().toLowerCase() === email).length === 1) { - return row.email!.trim(); + const address = row.email!.trim(); + const written = [address, `email:${address}`].find(text => parsePreferenceAccount(text)); + if (written) return written; } const accountId = row.accountId?.trim(); if (accountId && oauthRows.filter(other => other.accountId?.trim() === accountId).length === 1) { - return `account:${accountId}`; + const written = `account:${accountId}`; + if (parsePreferenceAccount(written)) return written; } return undefined; } diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 44c93af14be..e13e85144e9 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -189,6 +189,7 @@ import { parsePersistedCredentialSelector, resolveStartupAuthConfig, type StartupAuthConfigSnapshot, + startupAuthConfigError, } from "../session/startup-auth-config"; import { isOwnedCompletionEnvelopeAllowed, @@ -795,7 +796,7 @@ export async function discoverAuthStorage( credentialRankingMode, credentialPreferences, }); - return await reloadDiscoveredAuthStorage(storage); + return rejectDuplicateCredentialPreferences(await reloadDiscoveredAuthStorage(storage), agentDir); } const dbPath = getAgentDbPath(agentDir); const storage = await AuthStorage.create(dbPath, { @@ -804,7 +805,7 @@ export async function discoverAuthStorage( credentialRankingMode, credentialPreferences, }); - return await reloadDiscoveredAuthStorage(storage); + return rejectDuplicateCredentialPreferences(await reloadDiscoveredAuthStorage(storage), agentDir); } async function reloadDiscoveredAuthStorage(storage: AuthStorage): Promise { @@ -821,6 +822,26 @@ async function reloadDiscoveredAuthStorage(storage: AuthStorage): Promise sanitizeProviderForLog(provider)), + }); + const error = startupAuthConfigError(agentDir, "invalid-credential-preferences"); + try { + storage.close(); + } catch (cleanupError) { + throw attachStartupCleanupDiagnostic(error, cleanupError); + } + throw error; +} + /** Ranking is resolved as part of the typed startup-auth snapshot. */ /** diff --git a/packages/coding-agent/src/session/startup-auth-config.ts b/packages/coding-agent/src/session/startup-auth-config.ts index 69276e15302..20d2ac2bb73 100644 --- a/packages/coding-agent/src/session/startup-auth-config.ts +++ b/packages/coding-agent/src/session/startup-auth-config.ts @@ -86,7 +86,7 @@ function shortenConfigPath(configPath: string): string { return absolutePath.startsWith(homePrefix) ? `~${absolutePath.slice(homePath.length)}` : absolutePath; } -function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError { +export function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError { return new StartupAuthConfigError(kind, configPathForAgentDir(agentDir)); } diff --git a/packages/coding-agent/test/accounts-prefer.test.ts b/packages/coding-agent/test/accounts-prefer.test.ts index d50fe28e05f..fe58827d573 100644 --- a/packages/coding-agent/test/accounts-prefer.test.ts +++ b/packages/coding-agent/test/accounts-prefer.test.ts @@ -110,6 +110,33 @@ describe("gjc accounts prefer", () => { CLI_TIMEOUT_MS, ); + it.each([ + ["a colon in its local part, in the email: form", "odd:name@example.com", "email:odd:name@example.com", "75"], + ["an equals sign in its local part, bare", "user=2@example.com", "user=2@example.com", undefined], + ])( + "stores an email with %s that startup reads back", + async (_label, email, written, cap) => { + const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); + let rowId: number; + try { + store.upsertAuthCredentialForProvider("anthropic", oauth(email, `acct-${ids.length}`)); + rowId = store.listAuthCredentials("anthropic").find(entry => !ids.includes(entry.id))!.id; + } finally { + store.close(); + } + const argument = cap === undefined ? email : `id:${rowId}=${cap}`; + + const result = await preferJson([argument]); + + const entry = cap === undefined ? written : { [written]: Number(cap) }; + expect(result.preferences).toEqual([entry]); + expect(await writtenPreferences()).toEqual({ anthropic: [entry] }); + const [preference] = (await resolveStartupAuthConfig(agentDir)).credentialPreferences.anthropic ?? []; + expect(preference?.selector).toEqual({ kind: "email", value: email }); + }, + CLI_TIMEOUT_MS, + ); + it( "stores a 100 percent cap as an uncapped account", async () => { diff --git a/packages/coding-agent/test/startup-auth-config.test.ts b/packages/coding-agent/test/startup-auth-config.test.ts index b9ff3da851d..8c69a07f9d2 100644 --- a/packages/coding-agent/test/startup-auth-config.test.ts +++ b/packages/coding-agent/test/startup-auth-config.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { SqliteAuthCredentialStore } from "@gajae-code/ai"; import { getAgentDbPath } from "@gajae-code/utils"; import { Settings } from "../src/config/settings"; import { validateSettingPatch } from "../src/config/settings-schema"; +import { discoverAuthStorage } from "../src/sdk/session"; import { isValidPersistedCredentialSelector, resolveStartupAuthConfig, @@ -225,6 +227,41 @@ describe("startup auth config", () => { ); }); + it.each([ + ["fails closed when two entries name one stored account", "acct-cheap", true], + ["opens the store when the entries name different stored accounts", "acct-other", false], + ])("%s through different selectors", async (_label, cheapAccountId, rejected) => { + clearAuthEnv(); + const agentDir = await makeAgentDir( + "auth:\n credentialPreferences:\n anthropic:\n - cheap@example.com: 50\n - account:acct-cheap\n", + ); + const store = await SqliteAuthCredentialStore.open(getAgentDbPath(agentDir)); + try { + store.upsertAuthCredentialForProvider("anthropic", { + type: "oauth", + access: "access-cheap", + refresh: "refresh-cheap", + expires: Date.now() + 3_600_000, + email: "cheap@example.com", + accountId: cheapAccountId, + }); + } finally { + store.close(); + } + + const discovery = discoverAuthStorage(agentDir); + if (rejected) { + await expectStartupAuthConfigError(discovery, "invalid-credential-preferences", "cheap@example.com"); + return; + } + const storage = await discovery; + try { + expect(storage.findDuplicateCredentialPreferences()).toEqual([]); + } finally { + storage.close(); + } + }); + it("keeps malformed or absent pin-store metadata from authorizing numeric pins", async () => { clearAuthEnv(); const absent = await makeAgentDir("auth:\n credentialPins:\n anthropic: id:42\n"); diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 4bba80001e0..e483de5eca7 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -54,6 +54,7 @@ { "type": "array", "minItems": 1, + "uniqueItems": true, "items": { "anyOf": [ { diff --git a/scripts/generate-json-schemas.test.ts b/scripts/generate-json-schemas.test.ts index 91a2e1fa5c9..4ca76c869ea 100644 --- a/scripts/generate-json-schemas.test.ts +++ b/scripts/generate-json-schemas.test.ts @@ -140,6 +140,10 @@ describe("generated JSON Schemas", () => { expect(single).toEqual({ type: "string", pattern: PREFERENCE_ACCOUNT_PATTERN }); expect(account).toEqual(single); expect(list.minItems).toBe(1); + // The runtime rejects an account listed twice; the schema rejects the exact repeats it can express. + expect(list.uniqueItems).toBe(true); + expect(parseCredentialPreferenceList(["me@example.com", "me@example.com"])).toBeUndefined(); + expect(parseCredentialPreferenceList([{ "me@example.com": 75 }, { "me@example.com": 75 }])).toBeUndefined(); expect(capped).toMatchObject({ minProperties: 1, maxProperties: 1, diff --git a/scripts/generate-json-schemas.ts b/scripts/generate-json-schemas.ts index 12663938956..12688708529 100644 --- a/scripts/generate-json-schemas.ts +++ b/scripts/generate-json-schemas.ts @@ -235,7 +235,7 @@ function recordValueSchema( additionalProperties: { type: "number", exclusiveMinimum: 0, maximum: 100 }, }; return { - anyOf: [account, { type: "array", minItems: 1, items: { anyOf: [account, cappedAccount] } }], + anyOf: [account, { type: "array", minItems: 1, uniqueItems: true, items: { anyOf: [account, cappedAccount] } }], }; } if (valueSchema?.type !== "model-selector-value") return true; From d40c38a94ea0aec3b25d3d98ac48e9dfdc668580 Mon Sep 17 00:00:00 2001 From: meilleur-art <340670015+meilleur-art@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:34:14 +0900 Subject: [PATCH 3/3] fix(auth): write preference emails only in a form that reads back as the same selector The serializer accepted any candidate text startup could parse, without checking what it parsed to. A stored email such as account:team@example.com was written bare and read back as an account-id selector, and email:team@example.com lost its leading prefix, so the preference vanished or targeted another row. Each candidate is now written only when parsePreferenceAccount returns the same kind and value, which sends both addresses to the email:
form. Constraint: the explicit email: grammar already accepts prefix-bearing addresses Confidence: high Scope-risk: narrow Tested: new account:/email: prefix cases fail on 74373c38a and pass here, asserting the written selector resolves to the selected row id; accounts-prefer, startup-auth-config, configured-preference, schema, logout-org tests (76/76); coding-agent check; check:tools Not-tested: broker-backed startup --- docs/custom-providers-and-multi-account.md | 2 +- .../configured-credential-preference.md | 2 +- packages/coding-agent/src/cli/accounts-cli.ts | 18 +++++++++++++----- .../coding-agent/test/accounts-prefer.test.ts | 11 ++++++++++- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/custom-providers-and-multi-account.md b/docs/custom-providers-and-multi-account.md index fce22d32e41..b68170fa62d 100644 --- a/docs/custom-providers-and-multi-account.md +++ b/docs/custom-providers-and-multi-account.md @@ -113,7 +113,7 @@ gjc accounts logout anthropic --all - `list` shows stored OAuth rows and configured API-key sources, with safe identity/source/health/usage-freshness fields. It does not probe. - `check` performs the explicit sequential checker. `--json` emits safe machine-readable rows; a failed probe sets a non-zero exit status. - `pin` requires `--persistent`. `` is a bare email, `id:`, `email:`, or `account:`. The command validates an active OAuth row and writes the canonical `id:` selector to global configuration. `--clear` removes the provider's persistent pin. -- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email (as `email:
` when the address is not a valid bare entry, for example one with `:` in its local part), or its account id when another row shares the email, so the file never holds a store-bound row id and every written list is read back by startup. A cap is the text after the last `=` that follows the account, so `user=2@example.com` is an account and `user=2@example.com=75` caps it at 75%. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. +- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email (as `email:
` when the bare text would not read back as that address, for example one with `:` in its local part or one starting with `account:` or `email:`), or its account id when another row shares the email, so the file never holds a store-bound row id and every written list is read back by startup. A cap is the text after the last `=` that follows the account, so `user=2@example.com` is an account and `user=2@example.com=75` caps it at 75%. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. - `logout` requires exactly one of `--account ` or `--all`; it removes only OAuth rows from the local store. It never removes API-key source rows, and it refuses to mutate a broker-backed store. API-key rows remain visible and checkable so operators can see which source is selected, but they are not part of OAuth account pooling, cannot be pinned by `gjc accounts pin`, and cannot be removed by `gjc accounts logout`. diff --git a/packages/coding-agent/changelog.d/configured-credential-preference.md b/packages/coding-agent/changelog.d/configured-credential-preference.md index 38407bd2465..3b9cad1bb9d 100644 --- a/packages/coding-agent/changelog.d/configured-credential-preference.md +++ b/packages/coding-agent/changelog.d/configured-credential-preference.md @@ -1,4 +1,4 @@ ### Added - Added global `auth.credentialPreferences`: an ordered list of preferred OAuth accounts per provider, each optionally capped by a usage percent (`- low-cost@example.com` then `- shared@example.com: 75`). The first listed account that is usable and under its cap is used; once every listed account is rate-limited or at its cap, the remaining accounts are ranked normally. Unlike `auth.credentialPins`, a preference never blocks the provider. The key is global-only and ignored in project settings. Startup fails closed when two entries name the same stored account, including through different selectors such as an email and `account:`. -- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors in a form startup reads back (`email:
` when the address is not a valid bare entry), and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. +- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors in a form startup reads back (`email:
` when the bare text would not read back as the same address), and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. diff --git a/packages/coding-agent/src/cli/accounts-cli.ts b/packages/coding-agent/src/cli/accounts-cli.ts index 53b7386f8fe..9796641b32f 100644 --- a/packages/coding-agent/src/cli/accounts-cli.ts +++ b/packages/coding-agent/src/cli/accounts-cli.ts @@ -315,8 +315,10 @@ function parsePreferArgument(raw: string): { account: string; cap: number | unde /** * Portable config text for a resolved OAuth row: its email, else its account id, whichever no other OAuth row of the * provider shares. Numeric row ids are never persisted as preferences; a row with neither unique value is refused. - * The email is written bare when the startup grammar reads it back that way, else as `email:
`; every form - * written is one {@link parsePreferenceAccount} accepts, so a successful write never fails the next startup. + * Each candidate text is written only when {@link parsePreferenceAccount} reads it back as the same selector, so a + * successful write neither fails the next startup nor retargets the row: the email is written bare when that + * round-trips, else as `email:
` (an address such as `account:team@example.com` would otherwise parse as an + * account id, and `email:team@example.com` would lose its prefix). */ function portablePreferenceAccount( row: CredentialInventoryRecord, @@ -325,18 +327,24 @@ function portablePreferenceAccount( const oauthRows = providerRows.filter(other => other.credentialKind === "oauth"); const email = row.email?.trim().toLowerCase(); if (email && oauthRows.filter(other => other.email?.trim().toLowerCase() === email).length === 1) { - const address = row.email!.trim(); - const written = [address, `email:${address}`].find(text => parsePreferenceAccount(text)); + const selector: AuthCredentialSelector = { kind: "email", value: row.email!.trim() }; + const written = [selector.value, `email:${selector.value}`].find(text => readsBackAs(text, selector)); if (written) return written; } const accountId = row.accountId?.trim(); if (accountId && oauthRows.filter(other => other.accountId?.trim() === accountId).length === 1) { const written = `account:${accountId}`; - if (parsePreferenceAccount(written)) return written; + if (readsBackAs(written, { kind: "account", value: accountId })) return written; } return undefined; } +/** Whether startup parses `text` as exactly `selector`, kind and value. */ +function readsBackAs(text: string, selector: AuthCredentialSelector): boolean { + const parsed = parsePreferenceAccount(text); + return parsed?.kind === selector.kind && parsed.value === selector.value; +} + async function writePersistentPreferences( provider: string, entries: readonly CredentialPreferenceEntryValue[] | undefined, diff --git a/packages/coding-agent/test/accounts-prefer.test.ts b/packages/coding-agent/test/accounts-prefer.test.ts index fe58827d573..ba7e9dc0ced 100644 --- a/packages/coding-agent/test/accounts-prefer.test.ts +++ b/packages/coding-agent/test/accounts-prefer.test.ts @@ -7,6 +7,7 @@ import { getAgentDbPath, resetAgentDirFromEnvironment, setAgentDir } from "@gaja import { YAML } from "bun"; import { runAccountsCommand } from "../src/cli/accounts-cli"; import { resetSettingsForTest } from "../src/config/settings"; +import { discoverAuthStorage } from "../src/sdk/session"; import { resolveStartupAuthConfig } from "../src/session/startup-auth-config"; // Each command run opens the credential store and refreshes the offline model registry, which exceeds the default @@ -112,9 +113,11 @@ describe("gjc accounts prefer", () => { it.each([ ["a colon in its local part, in the email: form", "odd:name@example.com", "email:odd:name@example.com", "75"], + ["an account: prefix, in the email: form", "account:team@example.com", "email:account:team@example.com", "75"], + ["an email: prefix, in the email: form", "email:team@example.com", "email:email:team@example.com", "75"], ["an equals sign in its local part, bare", "user=2@example.com", "user=2@example.com", undefined], ])( - "stores an email with %s that startup reads back", + "stores an email with %s that startup reads back as the same row", async (_label, email, written, cap) => { const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); let rowId: number; @@ -133,6 +136,12 @@ describe("gjc accounts prefer", () => { expect(await writtenPreferences()).toEqual({ anthropic: [entry] }); const [preference] = (await resolveStartupAuthConfig(agentDir)).credentialPreferences.anthropic ?? []; expect(preference?.selector).toEqual({ kind: "email", value: email }); + const storage = await discoverAuthStorage(agentDir); + try { + expect(storage.resolveOAuthPinTarget("anthropic", preference!.selector).credentialId).toBe(rowId); + } finally { + storage.close(); + } }, CLI_TIMEOUT_MS, );