/** * The credential-class fence (#852). * * On 2026-08-21 an Anthropic OAuth credential's refresh failed with * `invalid_grant`. The exact selector `anthropic/claude-opus-5` still resolved — * correct provider, correct model — but through the *other* Anthropic * credential, an API key. One run then spent an estimated $25.16 against * API-key billing while the operator expected subscription billing. * * ## Why conductor has to own this * * The harness cannot express it. `AuthStorage.getApiKey` * (`pi-ai/src/auth-storage.ts:5290-5356`, installed 17.2.10) resolves * first-match-wins across runtime override → config override → stored OAuth → * login-sourced API key → provider env var → other stored API key → fallback * resolver, and **an exact `provider/model` selector does not stop that * cascade**. A definitive OAuth refresh failure disables that row * (`auth-storage.ts:5162-5232`) and the same call descends into the API-key * legs. Three surfaces that look like levers are not: * * - `models.yml` `auth: "oauth"` forces OAuth-style request *shaping* and sets * `isOAuth` metadata (`model-registry.ts:636-690`); it selects nothing, and a * custom model still requires an `apiKey` unless `auth: none`. * - A provider-id variant is not a class pin: `xai-oauth` is a real distinct * provider whose own descriptor also accepts `XAI_API_KEY` * (`pi-catalog/src/provider-models/descriptors.ts:485-493`), and * `anthropic-oauth` does not exist — Anthropic's id is `anthropic`, whose env * resolver accepts both sources (`pi-ai/src/registry/anthropic.ts:7-26`). * - No setting, env var, selector suffix or request option expresses it: * `AuthApiKeyOptions` is `{ baseUrl, modelId, signal, forceRefresh }` * (`auth-storage.ts:796-813`). * * ## Why this is a pre-launch fence and not an audit * * There is no after-the-fact answer to "which class served that request". * `getApiKey` returns `string | undefined` and never names the class it chose * (`auth-storage.ts:5302`); `AssistantMessage` carries no credential field * (`pi-ai/src/types.ts:858-912`); and session `credential_pin` entries are * OAuth-only *and* change-only (`session-entries.ts:185-202`), so steady OAuth * writes nothing, API-key use writes nothing, and neither a stale pin nor a * missing one proves anything. A transcript audit of billing class would be a * fabrication, so this module does not attempt one. * * What the harness *does* expose, token-free, is its current state — and that is * enough to refuse before spending anything. * * ## The residue, stated rather than papered over * * This is a state check, not an atomic guarantee. If OAuth becomes invalid * *during* a request, the installed resolver may still disable it and fall * through to a same-provider API key, and nothing conductor can pass prevents * that. The only hard lever is omp's own `disabledProviders`, which removes the * provider before credential checks — and also removes the subscription route, * which is why it stays an operator action rather than conductor's automatic * response. The honest fix is upstream: an `AuthApiKeyOptions` that can require a * credential class and refuse rather than descend. * * ## Why the requirement is per provider, never per run * * A credential belongs to a provider, not to a model — a model has none of its * own. And a run's provider is not knowable in advance: `workerModel` is an * opaque omp selector (it may be a role alias), and the provider-fault fallback * chain (#286) can move a live run onto another provider mid-flight. So a * per-run provider inference would be a guess dressed as a fact. The declared * unit is therefore "this provider must bill to its subscription", and the * verdict is the same for every candidate while that is untrue. */ import { join } from "node:path"; /** * The harness's own answer about one provider, as read through its exported * API. Deliberately narrow: an origin kind and disabled-credential *causes*, and * nothing that could carry credential material. */ export interface CredentialClassProbe { provider: string; /** * `getCredentialOrigin(provider)` — the currently winning source * (`auth-storage.ts:2689-2714`). Absent means the provider resolves to nothing * at all, which is a different refusal from "resolves to the wrong class". */ origin?: { kind: string; envVar?: string }; /** * `listDisabledCredentials(provider)` (`auth-storage.ts:6235-6247`), reduced to * class and cause. The cause is what makes a refusal actionable — an operator * reading `invalid_grant` knows to re-authenticate — and it is not secret. */ disabled: readonly { type: string; cause: string }[]; } /** Why a fence refused, or that it did not. */ export type CredentialClassVerdict = | { ok: true } | { ok: false; reason: string }; /** The runnable probe's own output: one provider per request, or the failure. */ export type CredentialClassProbeResult = | { ok: true; probe: CredentialClassProbe } | { ok: false; reason: string }; /** * Does `provider` currently bill to its subscription? * * Pure, so the whole decision is testable without a credential store, a * subprocess or a harness — which matters because this is the function whose * wrong answer costs either a fleet stall or a surprise invoice. * * The gate is `origin.kind === "oauth"` and nothing looser. In particular * `env` is refused even when the environment variable is an OAuth *token* * (`ANTHROPIC_OAUTH_TOKEN` is one): conductor cannot see whether that value is a * subscription token or a key, and a fence that guesses is not a fence. */ export function oauthRequirementVerdict(probe: CredentialClassProbe): CredentialClassVerdict { const { provider, origin } = probe; // The disabled row is not the verdict — it is the remediation. Reported for // any refusal, because "OAuth is disabled because the refresh returned // invalid_grant" is the difference between an operator re-authenticating in a // minute and an operator reading source for an hour. const disabledOauth = probe.disabled.filter((row) => row.type === "oauth"); const because = disabledOauth.length === 0 ? "" : ` The ${provider} OAuth credential is disabled: ${disabledOauth .map((row) => row.cause) .join("; ")}.`; const remedy = ` Re-authenticate ${provider} (\`omp auth login ${provider}\`), or take the whole provider out of` + ` play with omp's own disabledProviders — conductor will not dispatch onto an API key it was told` + " not to use."; if (origin === undefined) { return { ok: false, reason: `${provider} requires subscription (OAuth) credentials, and no ${provider} credential resolves at` + ` all.${because}${remedy}`, }; } if (origin.kind === "oauth") return { ok: true }; const named = origin.kind === "env" && origin.envVar !== undefined ? `an environment variable (${origin.envVar})` : `a ${origin.kind} credential`; return { ok: false, reason: `${provider} requires subscription (OAuth) credentials, but it currently resolves to ${named}, so a` + ` dispatch would bill to that instead.${because}${remedy}`, }; } /** * Every declared provider's verdict, in declaration order, refusing on the first * that fails. * * One refusal at a time on purpose: a hold names the one thing to fix, and a * concatenation of provider problems reads as a configuration essay rather than * an action. */ export async function oauthFenceVerdict( providers: readonly string[], probe: (provider: string) => Promise, ): Promise { for (const provider of providers) { const result = await probe(provider); if (!result.ok) { // A probe that cannot answer is a refusal, not a pass. The whole point of // the fence is that the expensive outcome is dispatching blind, and // "we could not check" is indistinguishable from "it is wrong" in cost. return { ok: false, reason: `${provider} requires subscription (OAuth) credentials and that could not be verified: ` + `${result.reason}. Refusing rather than dispatching unverified.`, }; } const verdict = oauthRequirementVerdict(result.probe); if (!verdict.ok) return verdict; } return { ok: true }; } /** How the probe subprocess is run, injected so tests need no harness. */ export interface CredentialProbeDeps { run?: (args: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>; /** This module's own path, so the child runs the same file it was built with. */ modulePath?: string; } const REAL_RUN = async ( args: readonly string[], ): Promise<{ code: number; stdout: string; stderr: string }> => { const child = Bun.spawn([...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: process.env, }); const stdout = new Response(child.stdout).text(); const stderr = new Response(child.stderr).text(); const code = await child.exited; return { code, stdout: await stdout, stderr: await stderr }; }; /** * Ask the harness about one provider, out of process. * * Out of process for two reasons, and the second is the load-bearing one. It * keeps the harness's module graph and its credential database out of the * long-lived daemon; and it means every read is taken *now*, against whatever * the harness currently believes, rather than against a pool this process * loaded once and cached. A fence reading a stale in-memory pool would pass a * credential that was disabled an hour ago. * * This mirrors `harness-loader.ts` exactly — a runnable module, spawned with * `--no-install`, whose stdout is one JSON line — because that is already how * this package asks the installed harness a question it must not answer itself. */ export async function probeCredentialClass( provider: string, deps: CredentialProbeDeps = {}, ): Promise { const run = deps.run ?? REAL_RUN; const modulePath = deps.modulePath ?? join(import.meta.dir, "credential-class.ts"); let result: { code: number; stdout: string; stderr: string }; try { result = await run(["bun", "--no-install", modulePath, provider]); } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : String(err) }; } if (result.code !== 0) { const detail = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? `exit ${result.code}`; return { ok: false, reason: detail }; } return parseProbeOutput(provider, result.stdout); } /** * The child's stdout, validated rather than trusted. * * Exported because the parse is where a malformed answer either becomes a clean * refusal or becomes a fence that silently passes. Anything unexpected — not * JSON, wrong provider, missing `disabled` array — is a refusal. */ export function parseProbeOutput(provider: string, stdout: string): CredentialClassProbeResult { let parsed: unknown; try { parsed = JSON.parse(stdout.trim()); } catch { return { ok: false, reason: "the credential probe did not answer with JSON" }; } if (parsed === null || typeof parsed !== "object") { return { ok: false, reason: "the credential probe answered with no object" }; } const answeredFor = Reflect.get(parsed, "provider"); if (answeredFor !== provider) { // Never accept an answer about a provider nobody asked about: that is how a // fence comes to approve the wrong route. return { ok: false, reason: `the credential probe answered for ${String(answeredFor)}, not ${provider}`, }; } const rawDisabled = Reflect.get(parsed, "disabled"); if (!Array.isArray(rawDisabled)) { return { ok: false, reason: "the credential probe reported no disabled-credential list" }; } const disabled: { type: string; cause: string }[] = []; for (const row of rawDisabled) { if (row === null || typeof row !== "object") continue; const type = Reflect.get(row, "type"); const cause = Reflect.get(row, "cause"); if (typeof type !== "string") continue; disabled.push({ type, cause: typeof cause === "string" ? cause : "no cause recorded" }); } const rawOrigin = Reflect.get(parsed, "origin"); let origin: { kind: string; envVar?: string } | undefined; if (rawOrigin !== null && typeof rawOrigin === "object") { const kind = Reflect.get(rawOrigin, "kind"); const envVar = Reflect.get(rawOrigin, "envVar"); if (typeof kind === "string") { origin = { kind, ...(typeof envVar === "string" ? { envVar } : {}) }; } } return { ok: true, probe: { provider, ...(origin === undefined ? {} : { origin }), disabled } }; } /** * The two harness shapes this module needs, declared here rather than inlined. * * The harness is a *peer* dependency: it is absent from this package's own * install and resolves from wherever the operator installed omp. Its types are * therefore not available to `tsc` here, so the surface conductor relies on is * written down — which is better documentation than an inferred type anyway, * because it is exactly the contract that would break on a harness upgrade. */ interface HarnessAuthStorage { reload?: () => Promise; getCredentialOrigin: (provider: string) => { kind: string; envVar?: string } | undefined; listDisabledCredentials: (provider?: string) => Promise; } interface HarnessAuthModule { AuthStorage: new (store: unknown) => HarnessAuthStorage; SqliteAuthCredentialStore: new (db: unknown) => { close: () => void }; } interface HarnessPathsModule { getAgentDbPath: (agentDir?: string) => string; } /** * The child half: read the harness's state for one provider and print it. * * Everything here is the harness's own exported API — never a query against * `agent.db`, whose schema is not conductor's to know. Nothing printed can carry * credential material: an origin kind, an optional environment-variable *name*, * and disabled classes with their causes. * * The imports are dynamic and their specifiers resolved at runtime because they * genuinely cannot be static: the harness is an absent peer at build time (a * literal specifier would fail `tsc`), it must resolve from *this package's* * install root rather than Bun's ambient cache — the same anchoring * `harness-loader.ts` exists to guarantee — and loading it at module scope would * pull the whole harness and its credential database into the daemon, which is * the thing running the probe out of process avoids. */ if (import.meta.main) { const provider = process.argv[2] ?? ""; try { if (provider === "") throw new Error("usage: credential-class.ts "); const { Database } = await import("bun:sqlite"); const auth = (await import( Bun.resolveSync("@oh-my-pi/pi-coding-agent/session/auth-storage", import.meta.dir) )) as HarnessAuthModule; // The agent database's location is the harness's convention, taken from the // harness — hardcoding `~/.omp/agent/agent.db` here would be a second // opinion about where credentials live. const paths = (await import( Bun.resolveSync("@oh-my-pi/pi-utils", import.meta.dir) )) as HarnessPathsModule; const store = new auth.SqliteAuthCredentialStore(new Database(paths.getAgentDbPath())); try { const storage = new auth.AuthStorage(store); // `reload` is what makes this read current rather than constructor-fresh: // it rebuilds the active pool from the store, so a credential disabled // since the row was written is absent here exactly as it is for the // resolver that would have used it. await storage.reload?.(); const origin = storage.getCredentialOrigin(provider); const disabled = (await storage.listDisabledCredentials(provider)).map((row) => ({ type: row.type, cause: row.cause, })); process.stdout.write( `${JSON.stringify({ provider, ...(origin === undefined ? {} : { origin }), disabled })}\n`, ); } finally { store.close(); } } catch (cause) { process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`); process.exitCode = 1; } }