Skip to content

Provider auth and client identity

Written 2026-09-03 by Claude (Opus 5) at Daniel’s request, for the Enso project.

Why Enso treats provider authentication as a first-class design surface, and what the alternative looks like in practice. Grounded in the source of two harnesses read on this machine on 2026-09-03:

Package Version Read at
@earendil-works/pi-coding-agent (pi) 0.84.4 ~/.nvm/versions/node/v24.12.0/lib/node_modules/
@oh-my-pi/pi-ai (omp’s AI layer) 17.2.14 ~/.bun/install/global/node_modules/

Both are MIT-licensed and public. Line citations below are to the installed source at those versions; they will drift as the packages move.

Companion to pi-immediate-needs.md, which covers what pi lacks on the safety axis. This covers what it lacks on the auth axis, and what a fork did about it.


The problem this document exists to record

Section titled “The problem this document exists to record”

Enso is a multi-provider harness. The provider it will be used with most is Anthropic, and Anthropic’s consumer plans (Pro/Max) are not the same product as the API. The plan is sold for use through Anthropic’s own clients; the API is sold per token. A third-party harness that wants to run on the model has one honest route — API billing — and the plan is not it.

That gap is the whole subject. Every design decision below follows from choosing which side of it to stand on.

  • OAuth client id, authorize endpoint, and scopes: @earendil-works/pi-ai/dist/auth/oauth/anthropic.js:13-14. The client id is the one Claude Code uses, stored base64-obfuscated. The scope set includes user:inference and user:sessions:claude_code.
  • Token endpoint: platform.claude.com/v1/oauth/token.
  • Inference requests carry the bundled Anthropic SDK’s own X-Stainless-* headers with that SDK’s real values. There is no claude-cli user agent anywhere in pi’s bundle — checked by grep across dist/ and its vendored pi-ai.
  • ⚠ On the OAuth path, pi prepends a Claude Code identity system block. @earendil-works/pi-ai/dist/api/anthropic-messages.js:746-753: if (isOAuthToken) sets params.system to "You are Claude Code, Anthropic's official CLI for Claude." as the first block, ahead of the caller’s own system prompt, under the comment “For OAuth tokens, we MUST include Claude Code identity.” The user’s system prompt is appended after it.
  • pi does not construct the x-anthropic-billing-header block or any cch attestation — the string does not appear anywhere in its bundle.
  • When subscription auth is active, pi prints a warning that third-party harness usage draws from extra usage and is billed per token rather than against plan limits. ANTHROPIC_SUBSCRIPTION_AUTH_WARNING silences the warning; it does not change the billing.

⚠ Corrected 2026-09-03. This section previously opened “pi ships an Anthropic OAuth provider and identifies itself as pi” and concluded that pi “presents itself honestly at the transport layer.” The transport-layer half is accurate and verified. The framing was not: pi asserts Claude Code identity in the system prompt on every OAuth request, which the original text missed and which the layer table below therefore mis-attributed to omp alone.

The correct statement is narrower. pi borrows the client id and asserts the client identity in the prompt; what it does not do is forge the transport fingerprint or reproduce the billing attestation. That is still a real distinction from the next section, and a smaller one than this document first claimed.

The error was found from the outside: pi-multi-account’s source checks whether pi already marked a request with a Claude Code identity block, which is a claim about pi that this document contradicted. Reading a competitor’s assumptions about a dependency turned out to be a faster route to a defect than re-reading the dependency.

Credential storage. A flat auth.json under the agent directory, four fields per provider: {access, refresh, expires, type}. No account identity, no grant anchor, no failure record. This has practical consequences covered under Observability.

omp’s Anthropic subscription path reproduces the identity of Anthropic’s first-party client at four layers. This is a description of the shape, not a build guide; the constants are in the cited files.

1. OAuth identity. src/registry/oauth/anthropic.ts:13-25 — the same Claude Code client id as pi, the claude.ai authorize endpoint, the same scope set, but the token endpoint is api.anthropic.com/v1/oauth/token (line 15). Login additionally calls api.anthropic.com/api/claude_cli/bootstrap (line 16) with entrypoint=cli, a pinned model, and a claude-code/<version> user agent (lines 17-18, 142-149).

2. Transport headers. src/providers/anthropic.ts:307-326 — on the OAuth path, requests carry claude-cli/<version> (external, claude-desktop) as user agent, x-app: cli, anthropic-dangerous-direct-browser-access, an X-Claude-Code-Session-Id, a per-request x-client-request-id, and eight X-Stainless-* headers hardcoded at :528-537 to the values the first-party client’s build emits — including a runtime version that is not the Node version omp runs on and a package version that is not the SDK omp bundles. If a caller supplies a user agent that already starts with claude-cli, that one is preserved instead (:217-220, :308). An enforcedHeaderKeys set at :539-556 strips a caller’s own user agent and auth headers so the fingerprint cannot be displaced by accident.

3. A system-block billing header. :565-578 — the first system block is not part of the user’s prompt at all. It is a synthetic x-anthropic-billing-header: line carrying a client version, an entrypoint, and a short fingerprint derived by hashing a hardcoded salt together with selected characters of the first user message and the pinned client version. A comment states it matches the first-party client’s own fingerprint routine. The second system block is a hardcoded identity line asserting the agent is built on Anthropic’s Agent SDK (src/providers/claude-code-fingerprint.ts:18, injected at :2876), and the cache-breakpoint logic at :2857-2861 knows to skip both synthetic blocks when deciding what is cacheable.

4. A body attestation. :580-583 and the fetch wrapper that follows — the billing header ships with a placeholder cch value, which is replaced before the request goes on the wire by a seeded hash of the serialized request body, truncated to a few hex characters. The seed is a hardcoded 64-bit constant.

Layer 4 is the one that matters for classification. Layers 1-3 are impersonation: claiming an identity. Layer 4 is attestation defeat: the value exists so the server can verify a request was produced by the genuine client, and reproducing it requires having recovered the algorithm and its constants by reverse engineering. There is no reading of layer 4 on which it is anything other than deliberate circumvention of an integrity control.

⚠ Who does which layer — corrected 2026-09-03, because the original text implied all four were omp’s alone.

Layer pi omp pi-multi-account
1. Claude Code OAuth client id ✅ ✅ uses pi’s — imports loginAnthropic
2. Transport fingerprint (UA, X-Stainless-*, x-app) ✕ ✅ ✕
3a. Claude Code identity system block ✅ ✅ detects, does not add
3b. x-anthropic-billing-header + version fingerprint ✕ ✅ ✅
4. cch body attestation ✕ ✅ ✅ (computed differently)

⚠⚠ The two attestation implementations disagree, and that is the strongest available argument against depending on any of this. omp derives cch from a seeded hash over the serialized request body. pi-multi-account derives it from a SHA-256 of the first user message text, truncated to five hex characters. These are not the same value and cannot both be correct. Neither package can tell you which one is — the value is undocumented, so both are reverse-engineering guesses, and a wrong guess produces a request that is accepted or rejected for reasons no error message explains. Two independent reimplementations of one undocumented field, disagreeing, is what “pinned to a moving target with an invisible failure mode” looks like in practice.

⚠ There is also collision between packages: pi-multi-account checks whether the billing header is already present before adding one, with a comment naming pi-anthropic-auth as another package that injects it. So the layer-3b/4 behaviour is an ecosystem pattern, several packages implement it, they step on each other, and the mitigation is a string check on a header nobody documents.

Credential storage. SQLite at ~/.omp/agent/agent.db, table auth_credentials, via SqliteAuthCredentialStore. Eight columns; the credential blob carries accountId, email, orgId, orgName, and authorizedAt alongside the tokens, and a separate disabled_cause column holds the text of the failure that retired a credential. This part is straightforwardly better than pi’s and is the part worth copying.

Not primarily an ethics argument — though the ethics are not close, and obtaining a paid entitlement by defeating the check that gates it is the plain description of layers 3 and 4. Three engineering reasons stand on their own:

It is pinned to a moving target in four places at once. A client version string, an SDK version string, a runtime version string, a salt, a seed, a set of character offsets, a header set, and two synthetic system blocks. Every one of them is a copy of something Anthropic controls and ships on its own schedule. When any single one moves, this path breaks.

Its failure mode is invisible. None of those pins is version-checked, because there is nothing to check them against. A drifted constant does not produce a version-mismatch error; it produces authentication or inference failures at some later point, in a code path far from the header block that caused them. That is the same shape as the 12-day scan-worker outage on 715-109: both halves of a contract green, contradictory shapes, nothing raised. It is the failure class this project exists to design against.

The account risk lands on the operator, not the author. Circumventing a billing-integrity control is the category of term violation that ends accounts rather than generating a warning, and it would be Daniel’s work account, with 715-109’s AWS and ThingsBoard work sitting behind the same identity.

There is a fourth reason specific to this project: Enso is a multi-provider harness. An auth layer whose Anthropic path is a bundle of pinned impersonation constants is not a provider abstraction — it is a special case that leaks its shape into everything above it. The design goal is one credential model that holds for Anthropic, OpenAI-compatible endpoints, a local llama.cpp server, and whatever comes next. Forgery does not generalize.

What Enso does instead, and what it intends to

Section titled “What Enso does instead, and what it intends to”

Auth is provider-neutral and honest about identity. No per-provider identity forgery; Enso identifies as Enso. That part is built, in the sense that nothing here forges anything.

What exists today, in two places. Provider auth is pi’s, not ours: it lives in pi’s own auth.json under the scratch agent dir, written by /login and by pi-multi-account (#7) — stated at packages/core/src/secrets.ts:30-33. The harness extensions’ own keys are a separate channel: .enso/secrets.env, read per call by readSecret (secrets.ts:83-86) so a rotation needs no restart, and gated against the model’s read tool. Both are listed in reference/env-and-config.md.

Secrets do not live in config files, and they do not live in the environment either. ~/.pi/agent/models.json stores provider API keys in plaintext beside non-secret model configuration, which is how one got printed into a transcript on 2026-09-03 — so the harness reads values from the gated file instead, and quarantineSecrets (secrets.ts:144-155) deletes every secret-shaped variable from the environment before the first session and the first child process (#89). The environment is the channel this design closes, not a key source: a bullet that says “keys come from the OS keyring or the environment” would describe the opposite of what is built.

Designed, not built: the credential record. A record carrying the account identity, the grant anchor, and the reason a credential was retired — the shape omp has and pi does not, see Observability — is a requirement here, not a thing that exists. There is no credential store in packages/**, and no keyring integration anywhere in the repository: keyring/keychain/libsecret appear only in secrets.ts’s header, naming gh’s keyring as the way a bash child should get a token. The built half was #89 (closed); the record itself has no issue of its own, and until it has one this paragraph is the only place it is tracked — recorded as such by #248, which is what made this section say designed instead of does.

The provider set is the answer to cost, not the billing path. For Anthropic specifically that means API billing when Anthropic is required. For everything else it means the harness is genuinely multi-provider: OpenAI-compatible endpoints, a self-hosted llama.cpp or ollama server at zero marginal cost for loop-wiring and smoke work, and whatever else is authenticated. Most of Enso — the guard extension, @enso/core, persistence, the tool-result renderers, and every read-only pane over existing transcripts — needs no inference at all, so provider cost gates a much smaller slice of the build than it first appears to.

If plan-rate access from a custom harness is a requirement, it is a conversation with Anthropic. That is a real option and it is not this one.

Multi-account rotation, as pi-multi-account does it

Section titled “Multi-account rotation, as pi-multi-account does it”

Read 2026-09-03 from the installed v1.13.8 (~/.pi/agent/npm/node_modules/pi-multi-account, one 183 KB index.ts, zero runtime dependencies). Repo: github.com/Sarrius/pi-multi-account. Relevant here because Enso needs a multi-account credential model and this is the only worked example.

It implements no login flow of its own. It imports pi’s loginAnthropic and refreshAnthropicToken and wraps them. No client id, no authorize URL, no token endpoint appears in its source — the login is pi’s, with pi’s client id and pi’s endpoint.

Each account is a synthetic provider. registerAnthropicSlot calls pi.registerProvider("anthropic-account-N", …) with baseUrl: "https://api.anthropic.com", api: "anthropic-messages", and an oauth block of {login, refreshToken, getApiKey}. So an “account slot” is a first-class provider with its own entry in auth.json, and the base anthropic provider stays pi’s. The same pattern is applied to Codex slots.

Accounts are discovered, not configured. It reads ~/.pi/agent/auth.json directly and builds the rotation from whatever is authenticated. Config (provider-failover.json) and state (provider-failover-state.json) hold policy and cooldowns, not credentials.

Duplicate-login rejection via an identity function. rejectDuplicateLogin compares accountIdentity() against existing entries so the same account cannot occupy two slots. Precedence: a stored accountId, then a subject extracted from the access token (Codex, Cursor), then hash12(access), then hash12(key).

⚠ For Anthropic that identity is volatile. Its own comment concedes Anthropic accounts “are not deterministically identifiable from auth.json alone”, so Anthropic falls through to hashing the access token — which changes on every refresh. Duplicate detection therefore degrades for exactly the provider the rotation exists to serve. This is the same root cause as Observability: pi’s credential record carries no account identity, so anything downstream has to invent one.

Worth taking for Enso, and separable from the attestation layers above:

  • the provider-slot pattern — N synthetic providers over one base URL, each with its own credential row
  • discovery from the credential store rather than hand-maintained config
  • an explicit identity function with a documented precedence order, so duplicate detection is testable
  • cooldown state separate from config, and an anti-bounce guard (its comment records failover ping-ponging “every 1-9s forever” as an observed failure)
  • a decision log that states it excludes credentials, so a rotation can be explained after the fact

Not worth reimplementing: the volatile-token identity fallback — it is a symptom of pi’s credential shape, and Enso storing accountId removes the need for it.

⚠ On layers 3b and 4: Enso depends on them, by decision. pi-multi-account is vendored into the harness package (PR #6), and it supplies both. Daniel’s call, made 2026-09-03 with the tradeoff on the table: he is a reverse engineer, the risk is his account, and the alternative was no subscription access from Enso at all.

So state the position precisely rather than letting this document imply a purity it does not have:

  • Enso does not author the transport fingerprint or the attestation. That line holds.
  • Enso inherits layers 1 and 3a from pi itself, on every OAuth request, unavoidably — see § What pi does. There is no opt-out short of patching pi.
  • Enso depends on layers 3b and 4 through a vendored third-party package, deliberately.

The engineering objections in § Why Enso will not do this are unchanged and now apply to a dependency rather than to our own code: eight constants pinned to something Anthropic ships on its own schedule, no version-checkable pin, and a failure mode that surfaces as inference errors far from the cause. The disagreeing cch implementations above are the sharpest evidence. What changes is who is holding the liability — an upstream maintainer rather than us — which lowers the maintenance cost and does nothing about the account risk.

⚠ Note this is also the package that is fatal at startup against pi 0.84.4 while declaring peerDependencies: "*" — see pi-extension-survey.md. Read it for the shape; do not depend on it.

Independent of the identity question, omp’s credential record is better instrumented, and the gap has a concrete cost. Two fields:

authorizedAt. An Anthropic OAuth grant family has an absolute lifetime anchored at the interactive login — refresh rotation does not extend it. omp records the constant and its evidence in src/registry/oauth/anthropic-constants.ts: roughly 30 days, observed against production, with grants dying to the hour despite healthy 8-hour rotations. Because omp stores the anchor, it can warn before the deadline. pi stores only expires for the current access token, so it cannot compute the deadline at all and simply dies with a bare invalid_grant: "Refresh token expired" when it arrives.

disabled_cause. omp writes the failure text onto the credential row and marks it disabled. Observed on this machine: a work-account row carrying the full invalid_grant response from 2026-08-11, still readable three weeks later, which is what identified the cause immediately. pi’s equivalent failure produced a session that exited 0 with no output.

Both are Enso requirements. A session that cannot authenticate must exit non-zero and say why, and a credential that has been retired must carry the reason it was retired. Neither has anything to do with which billing path a harness takes — they are just the difference between a diagnosable failure and an afternoon of guessing.


Everything above was read from installed source on 2026-09-03, not from documentation or recollection. Line numbers are for @oh-my-pi/pi-ai 17.2.14 and @earendil-works/pi-coding-agent 0.84.4 and will drift. The ~30-day grant lifetime is omp’s own recorded observation, corroborated here by two independently expired grants (2026-07-23 and 2026-07-29 in pi’s store, 2026-08-10 in omp’s) — it is a display heuristic in omp’s words, not a documented wire contract.

The attestation constants in layers 3 and 4 are deliberately not reproduced here. The file and line citations are complete, the source is MIT-licensed and on disk, and an auditor needs to know the mechanism exists and what it is pinned to — not the values, which are the circumvention itself.