Files
openclaw/docs/auth-credential-semantics.md
zachisfineandAyaan Zaidi 56760137fc fix(auth): keep session account selection after OAuth re-login (#149591)
Closes #149502

## What Problem This Solves

Fixes: an explicitly pinned conversation loses its credential when OAuth re-login replaces the same account's generated profile ID.

## User Impact

Signing back into one unambiguously matched account preserves its existing profile ID and session selection. Different or ambiguous accounts do not take over an explicit pin. A missing pin stays strict and now logs actionable recovery guidance.

There is no new session permission, protocol field, SQLite migration, schema version, or stored field. Upgrade does not rewrite credentials or session rows. If a credential was already deleted before upgrading, the operator must deliberately select a configured `model@profile` or reconnect the intended account with `models auth login --provider <provider> --profile-id <selected-profile-id>`; the missing identity is never guessed.

## Why This Change Was Made

Keep only same-account continuity at the login owner, with the pre-purge snapshot, provider identity matcher, and write-transaction revalidation. Runtime fallback cannot establish the identity of a deleted credential and could silently switch accounts. A separate reset API adds protocol and permission surface without preventing the failure. The existing explicit selection/reconnect paths cover deliberate recovery, so the proposed reset API and unrelated Codex import changes were removed. The large mocked identity suite was replaced by focused ambiguity/error/override cases; the real persistence cases retain continuity and concurrent-reassignment coverage.

[Maintainer decision (Ayaan): approved](https://github.com/openclaw/openclaw/pull/149591#issuecomment-5855670538). Verified same-account sign-in may restore the former profile ID after an explicit force purge; different, ambiguous, or unverifiable accounts do not reuse an ID. There is no new session-reset permission or schema change. Missing pre-upgrade identities continue to require deliberate operator recovery.

No overlap with Pash/Sarah changes.

## Evidence

- Reproduced on pinned main `ea3c488f4fb6`: the production `models auth login` flow (`runModelsAuthLoginFlowCore`) with an isolated scripted OAuth plugin wrote a new ID after forced re-login; the unchanged user-pinned session failed with `No credentials found for profile`.
- The smaller change kept the original ID after same-account re-login and resolved OAuth for the same session written by main. Different-account forced re-login left the explicit pin unavailable, logged the recovery diagnostic, and did not select the new account. Automatic pins resolved the new profile on both main and the candidate.
- The already-broken main-written row recovered through the existing explicit `--profile-id` reconnect path. The user-pinned row, including its internal session ID and timestamps, was unchanged by re-login and auth resolution.
- Before/after SQLite checks were identical: shared `user_version=19`, agent `user_version=23`; ordered `sqlite_schema` SHA-256 values were `42e3cb1d5e4a44081e99a2991c43a8fa77675c6d8ebc74886a7aab38700df36a` (shared) and `d6596c675858b8fdbccced25f9626164634b772ea200830f03c99586ded62b1f` (agent).
- 36 focused tests passed across identity boundaries, production persistence, and session selection after merging main. Typechecking rooted at the touched files and type-aware lint passed. The three-file test command took 46.56 seconds wall time including worker preparation; the five persistence cases took about 1.03 seconds of test execution. Hosted CI timing is pending.
- After the approved main refresh to `32dcd02906ef`, head `d69620a27208` passed all 36 focused tests again (33.35 seconds wall time). The ten PR files are unchanged from the fully exercised `6b25647966e4` revision, and the Pash/Sarah intent check still has no overlap.
- The contributor's previously recorded real OpenAI device-code/two-turn/restart evidence applies to the retained identity-preserving login mechanism. The initial simplification proof above used synthetic OAuth credentials and auth resolution; the full network proof below additionally exercises the production CLI and real Gateway model turns without paid services.

### Full CLI/Gateway network proof

On `6b25647966e4`, a separate loopback HTTP provider served real device-code, authorization, token, and OpenAI-compatible model endpoints. The same driver on both refs used `pnpm openclaw`, a real PTY for `models auth login`, isolated HOME/state/ports, Gateway-backed agent turns, and the dashboard account picker's `sessions.patch` request (`model@profile`). After forced same-account login without `--profile-id` and Gateway restart, the candidate returned the scripted account-A response in the same session (`8f7ec68c-b05f-4c0b-b12b-6d01ced4bd71`) with its explicit user pin intact. Main failed the equivalent next turn with `selected_auth_profile_unavailable`.

Both refs rejected a pinned account-A turn after login as account B; the error was visible in CLI output and no account-B answer was substituted. A separate real `chat.send` control created persisted `authProfileOverrideSource: "auto"` sessions on both refs. After re-login as B and restart, the same sessions completed another turn, `chat.history` contained the account-B response, and each automatic pin rotated to the new credential while retaining source `auto`. Both schema hashes were unchanged across this control. Explicit `--profile-id` reconnect plus restart also recovered the original user-pinned conversation on both refs.

The candidate's full schema hashes and versions remained unchanged throughout: shared version 19/hash `b691989fd451644ad9c0233eb04359bde42744456b1532a94ecd32e1ca429859`, agent version 23/hash `d6596c675858b8fdbccced25f9626164634b772ea200830f03c99586ded62b1f`. A separate before/after CLI re-login check found both complete session rows deeply equal as parsed JSON. Turns themselves appropriately update transcript/activity data.

**Baseline/build limitations:** `9ae6e1853062` and `ea3c488f4fb6` fail their supported runtime wrapper on the known `INEFFECTIVE_DYNAMIC_IMPORT` guard for `transcript-visible-record.ts`. The full network baseline is `a4656d4eea79`, immediately before the commit introducing that import; its fresh runtime build succeeded. OAuth login, credential writer/persistence, and account-picker model-selection behavior are unchanged between that baseline and the PR merge base; intervening relevant changes factor session-selection helpers and clean up manual API-key input handling. Broader OAuth refresh-peer changes are not exercised by nonexpiring fixture credentials. The candidate's full build completed; it does not change the offending import, and the reason the warning differs with its bundle graph is not established.

The older main baseline's optimizer created SQLite-owned `sqlite_stat1` during the flow, changing its full agent-schema hash; excluding that single statistics-table definition exactly recovers its initial hash. Its application schema and versions did not change. The older baseline also lacks an unrelated upstream `worktrees.gc_protection_json` column, so cross-ref shared hashes differ; the earlier current-main existing-state proof above supplies the unchanged-schema upgrade comparison. No schema changes belong to this PR.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-27 18:07:54 +05:30

25 KiB
Raw Permalink Blame History

summary, title, read_when
summary title read_when
Canonical credential eligibility and resolution semantics for auth profiles Auth credential semantics
Working on auth profile resolution or credential routing
Debugging model auth failures or profile order

These semantics keep selection-time and runtime auth behavior aligned. They are shared by:

  • resolveAuthProfileOrder (profile ordering)
  • resolveApiKeyForProfile (runtime credential resolution)
  • openclaw models status --probe
  • openclaw doctor auth checks (doctor-auth)

Stable probe reason codes

Probe results carry a status bucket (ok, auth, rate_limit, billing, timeout, format, unknown, no_model) plus a stable reasonCode when the probe never reached a model call:

reasonCode Meaning
excluded_by_auth_order Profile omitted from the explicit auth order for its provider.
missing_credential No inline credential or SecretRef is configured.
expired Token expires is in the past.
invalid_expires expires is not a valid positive Unix ms timestamp.
unresolved_ref Configured SecretRef could not be resolved.
ineligible_profile Profile is incompatible with provider config (includes malformed key input).
no_model Credentials exist but no probeable model candidate resolved.

Eligibility checks report ok as the reason code for usable credentials.

Token credentials

Token credentials (type: "token") support inline token and/or tokenRef.

Eligibility rules

  1. A token profile is ineligible when both token and tokenRef are absent (missing_credential).
  2. expires is optional. When present it must be a finite number of Unix epoch milliseconds greater than 0 and no larger than the maximum JavaScript Date timestamp (8640000000000000).
  3. If expires is invalid (wrong type, NaN, 0, negative, non-finite, or beyond that maximum), the profile is ineligible with invalid_expires.
  4. If expires is in the past, the profile is ineligible with expired.
  5. tokenRef does not bypass expires validation.

Resolution rules

  1. Resolver semantics match eligibility semantics for expires.
  2. For eligible profiles, token material may be resolved from the inline value or tokenRef.
  3. Unresolvable refs produce unresolved_ref in models status --probe output.

Manual API keys

Saving a manual API key in Models waits for the Gateway to apply any changed provider binding before refreshing model authentication. Replacing a key whose binding is unchanged needs only the authentication refresh. If the Gateway cannot confirm application, the key remains saved and the response includes a restart warning. This preserves the configured reload policy, including disabled reloads. Removing a key still rejects a binding or credential that changed concurrently.

Setup replacements

Setup replacement credentials are saved under separate profile IDs with an internal setup descriptor in the existing credential payload. They cannot enter normal rotation, resolve through an explicit profile pin, or be copied to another agent. Only the owning setup operation can test the selected credential. After one successful tool-free turn, setup asks whether to activate it. Declining or failing the test keeps the saved credential inactive and preserves the current connection. Model Setup offers the same saved sign-in for a fresh test without another login. Gateway activation waits for config application; a required restart keeps the replacement inactive until setup is retried. Ordinary login remains immediate. The descriptor retains the selected model and connection settings for retry after restart, without caching a verification result. This adds no database schema or migration; older runtimes do not enforce the inactive state. Before downgrading, remove saved inactive replacements or restore the state from before setup.

Noninteractive setup saves replacement credentials and prints a openclaw models auth activate <profileId> --agent <id> command to test and activate the saved sign-in. Model Setup offers the same operation. Interactive setup defaults to activation after a successful test. Reusing an existing credential and first-run noninteractive setup retain their existing behavior.

Agent copy portability

Agent auth inheritance is read-through. When an agent has no local profile, it resolves profiles from the shared auth store at runtime without copying secret material into its own credential store (agents/<agentId>/agent/openclaw-agent.sqlite). The shared store lives in state/openclaw.sqlite after openclaw doctor --fix performs the one-time relocation. Until then, doctor reports the legacy agents/main/agent/openclaw-agent.sqlite owner and leaves that agent undeletable.

Auth usage and cooldown updates wait for write admission on their actual agent database owner, including the legacy shared store. Relocated shared-state auth uses its own coordinator. Queued updates retain their selected state root and shared owner, then read the current profile after admission. Runtime snapshots publish after the durable commit and before the next admitted writer; removing a profile while its health update waits does not recreate its health state. Cold agent opens validate integrity asynchronously and recheck ownership before writing. OAuth upserts recheck the current local or inherited credential after admission, before applying the existing generation-replacement rules.

Inline API-key failure bookkeeping reads and updates the selected agent's auth state through its existing SQLite worker. It preserves credential bytes and other profiles' health state. Runtime snapshot publication reads canonical local and shared rows off-thread, then retains the current host's resolved secrets and external profile overlays. A publication failure does not replay a committed health update. The synchronous SDK store APIs retain their existing contracts.

Gateway model metadata refreshes when credentials, profile ordering or ownership, or model availability changes, including cooldown and blocked-state transitions. Usage timestamps, success history, and failure counters remain recorded without invalidating chat metadata or broadcasting a change to connected clients.

Gateway model-auth status reads share provider preparation across clients for the same agent and published config/auth generation. Credential warning and expiry boundaries also invalidate preparation; explicit refresh bypasses it. Each reply still computes current expiry durations, reads auxiliary usage from its existing cache, and applies the requesting client's profile-identity visibility. File-backed external CLI bootstrap health stays with its reader's freshness lifecycle because external login can change without a Gateway publication.

Repeated model resolution reuses persisted auth rows while the owning database's write generation and file identity remain unchanged. Committed auth writes and runtime snapshot reloads invalidate those rows immediately. Database, WAL, and journal identities are probed at most once per 100 ms on warm cache hits; the first read at or after that interval detects changes from other processes. Hits do not extend this freshness window. Cache misses still check identity before and after reading rows. Scoped overlays, migration refusals, and personal-account selection still run on each request. Isolated agent scopes and private database snapshots do not share this cache. Gateway cache misses reuse a read-only child whose lifetime ends at shutdown; each read reacquires its source admission and closes its SQLite handles before returning. Detached connection, cron, heartbeat, and hook callbacks retain that Gateway's read-only worker scope without inheriting startup or request authority. Shutdown refuses late callbacks before they can create another reader. Usage bookkeeping invalidates later cache reuse while admitted reads can finish their snapshots. Credential, selection, ownership, and lifecycle changes still invalidate in-flight preparation. Model selection retries that stale read once after its readers finish cleanup, preserving the selected agent and any explicit profile pin. If an in-process OAuth refresh invalidated the read, selection first observes that owner's durable settlement, including inherited credentials and fenced peers. This wait uses the existing refresh timeout and neither reads credentials nor starts another refresh. Reconnects release waits for the replaced claim; readers of still-fenced peers continue to wait for the owner's cleanup. Pending refresh profiles remain candidates for model id/mode selection; the OAuth owner still settles the refresh before credentials can be used. A caller timeout does not retire its durable settlement from observation, and a waiting model read cannot cancel it. Canceling a model request ends only its settlement wait; the refresh owner and other waiting requests continue independently. Continued changes, admission refusals, and cleanup failures remain errors.

Credential lookups through resolveApiKeyForProvider and resolveApiKeyForProfile also accept an optional abort signal. Cancellation ends the caller's wait for queued admission, a profile lock, or refresh. Queued tasks recheck cancellation before claiming credentials. Started lock acquisition retains its cleanup owner, and claimed refreshes keep their independent durable settlement. Canceled callers cannot start a later queued refresh or return its credentials. Callers that omit the signal retain the existing wait behavior.

Workers certify committed SQLite visibility before rows enter the cache. Reads with unpublished or trailing WAL frames return normally without being retained.

Explicit copy flows, such as openclaw agents add, use this portability policy:

  • api_key and token profiles are portable unless copyToAgents: false.
  • oauth profiles are not portable by default because refresh tokens can be single-use or rotation-sensitive.
  • Provider-owned OAuth flows may opt in with copyToAgents: true only when copying refresh material across agents is known safe; the opt-in only applies when the profile carries inline access/refresh material.

Non-portable profiles remain available through the shared read-through base unless the target agent signs in separately and creates its own local profile.

During OAuth refresh, the current credential generation is replaced by an inert durable marker. Pending markers remain ineligible by default and are ordered only by runtime paths that immediately pass them to the settlement-aware resolver. Failed markers are terminal and require the operator to authenticate again.

Agent-local peers never receive copied rotated refresh material. A peer removes its marker and inherits the shared credential only after verifying that the shared credential belongs to the same account. If that identity cannot be verified, the peer remains terminally fenced instead of inheriting another account.

Plugin SDK OAuth validation

resolveApiKeyForProfile, exported from openclaw/plugin-sdk/agent-runtime, accepts an optional validateOAuthCredential callback. The resolver calls it before returning an OAuth credential and before persisting or adopting a refreshed credential. The callback also applies when a legacy provider:default profile falls back to a replacement OAuth profile.

Throwing from the callback rejects that credential. A rejected fallback is not returned or refreshed, and the original selected-profile refresh failure remains the operator-facing error. Rejecting an active refresh or settlement generation fails closed and can leave that generation and its peers terminally fenced, so the operator must authenticate again. Callers that omit the callback retain the existing resolution and fallback behavior.

openclaw agent exec preserves the original shared-store root when switching to temporary run state. Its bounded credential scope reads portable api_key and token profiles from that shared store without persisting copies; the configured agent's local profiles still win. Shared OAuth profiles are excluded from this temporary scope, even with copyToAgents: true, so the run does not acquire another refresh owner. --auth-env-only disables stored credential access entirely.

Auth writes that explicitly select a state directory, including isolated QA staging, use that directory's shared store for ownership and OAuth deduplication. Their runtime publication and rollback retain the same owner; another process-local state root is not an inherited base. An unrelated outer database may be older, newer, or unreadable without blocking an isolated write, but an unreadable or newer database in the selected target still fails closed. Writes without an explicit state directory retain the normal ambient state and agent-directory configuration.

Personal model accounts

Accounts connected from Settings → Profile → Connected accounts have an identity-scoped owner in the shared state database. Their credentials and usage state never enter shared or agent-local auth stores, external CLI mirrors, or global runtime snapshots. A runtime loads at most the one personal credential selected by its session. Unlinked personal accounts remain usable by existing session pins, not by automatic selection for new sessions.

Personal pins keep the existing same-provider failover policy: ordered shared accounts can be tried after a pinned account fails. They do not make another person's personal account a fallback. Reconnecting can replace only the connecting person's own credential; shared credentials referenced by an administrator-created link are not personal property. See Per-person model accounts.

Config-only auth routes

auth.profiles entries with mode: "aws-sdk" are routing metadata, not stored credentials. They are valid when the target provider uses models.providers.<id>.auth: "aws-sdk", the route the plugin-owned Amazon Bedrock setup writes. These profile ids may appear in auth.order and session overrides even when no matching entry exists in the credential store.

Do not write type: "aws-sdk" into the credential store; stored credentials are only api_key, token, or oauth. If a legacy auth-profiles.json has such a marker, openclaw doctor --fix moves it to auth.profiles and removes the marker from the store.

When a selected stored profile is removed, credential-scoped model discovery reports selected_auth_profile_unavailable before consulting dynamic model metadata. Restore the credential or select another configured profile; registering the model does not repair missing authentication. Config-only AWS SDK profiles remain valid without a stored credential. Chat admission and agent commands retain an explicit same-provider selection when its credential disappears so authentication can report recovery. Stale automatic selections and selections for incompatible providers are still cleared.

OAuth re-authentication preserves an existing profile id only when the provider's account-identity matcher proves that the new credential belongs to the same account. A different or ambiguous account keeps the provider's new profile id, so explicit session pins do not cross account boundaries. Unavailable pins remain strict and emit a session-scoped warning. For a credential already removed before re-login, deliberately select a configured account with model@profile, or reconnect the intended account with openclaw models auth login --provider <provider> --profile-id <selected-profile-id>. No session rows or stored credentials are rewritten during upgrade.

Explicit auth order filtering

  • When auth.order.<provider> or the auth-store order override is set for a provider, models status --probe only probes profile ids that remain in the resolved auth order for that provider. The stored override wins over auth.order config.
  • A stored profile for that provider that is omitted from the explicit order is not silently tried later. Probe output reports it with reasonCode: excluded_by_auth_order and the detail Excluded by auth.order for this provider.
  • A valid session user pin is an explicit per-session exception: OpenClaw tries that profile first even when it is omitted from the provider order, then uses the ordered same-provider profiles as retry candidates. A cooldown or disabled window applies only to the affected profile; it does not suppress its eligible siblings.

Prepared agent requests use their selected plugin metadata, configuration, workspace, and environment for auth profile eligibility, ordering, and environment credential evidence. An empty selected plugin set remains authoritative; another request’s plugin aliases cannot add profiles or change the credential owner.

Model catalog discovery

Stored-profile selection for model discovery follows the canonical auth order and eligibility rules. A cooldown limited to one model does not suppress account-wide catalog discovery. Configured subscription modes remain attached to direct credentials, and successful OAuth preparation supplies the resolved current token to its catalog consumer rather than the captured store's older token.

Environment-backed profiles keep usable values from the discovery environment, including cold command and worker paths. When that material is missing, only the selected profile's activated snapshot may supply it; otherwise discovery reports unavailable before catalog HTTP. Reference names are never sent as credentials or replaced with another profile's credential. On a Gateway, restore the secret and run openclaw secrets reload before retrying discovery.

When every eligible OAuth candidate fails preparation, discovery reports unavailable with the attempted profile identities instead of treating the provider as unconfigured. Compatible prior inventory remains available. A usable fallback credential still supplies its own catalog result.

When a catalog deadline expires, late provider results are discarded before finalization. An already-started hook or OAuth refresh may finish, including persisting a rotated credential, but cannot publish to the expired catalog run.

API-key-oriented and full-auth catalog callbacks retain their existing source priorities. Plugins must keep credential bytes and their authentication mode from the same selection. Catalog failure and recovery preserve the model inventory contract; they do not change message-execution profile rotation or session pins.

Probe target resolution

  • Probe targets can come from auth profiles, environment credentials, or models.json (result source: profile, env, models.json).
  • If a provider has credentials but OpenClaw cannot resolve a probeable model candidate for it, models status --probe reports status: no_model with reasonCode: no_model.

External CLI credential discovery

  • Supported external CLI credentials are discovered only when the provider, runtime, or auth profile is in scope for the current operation, or when a stored local profile for that external source already exists.
  • Auth-store callers choose an explicit external-CLI discovery mode: none for persisted/plugin auth only, existing for refreshing already stored external CLI profiles, or scoped for a concrete provider/profile set.
  • Read-only/status paths pass allowKeychainPrompt: false; they use file-backed external CLI credentials only and do not read or reuse macOS Keychain results.
  • /models reuses external login evidence already prepared with its catalog, so those providers remain visible without a second OpenClaw login. Opening the default menu does not repeat external CLI discovery; explicit auth order and route compatibility still apply.

Codex owns its native login. Ordinary status and model reads do not import its credentials into OpenClaw profiles. To retain a configured CLI-backed openai:default profile, explicitly import the current Codex login with openclaw models auth login --provider openai --method device-code. When that OAuth profile is declared in auth.profiles, the source is the current native Codex home, and no other managed OpenAI OAuth profile exists, import preserves the profile ID and its existing model and session pins. The configured model and native credential file stay unchanged. An explicitly isolated agent home continues to use the imported OpenClaw profile through its isolated runtime.

Since 2026.9.5, native Codex login no longer supplies the runtime-only openai:default profile. If that OAuth profile is still declared but absent from an agent's canonical credential store, openclaw doctor --fix, Doctor lint, and Gateway startup warn with the import command above. The warning does not copy credentials or block the update. Missing-profile errors identify local store absence without reporting a provider HTTP 401; the error records a local lookup failure, not a provider rejection. For multiple agents, add --agent <id> to the login command to select the affected agent.

Fresh imports keep account-scoped profile IDs. A matching existing account and user reuse their stored profile. Import from another home, missing account/user identity, or an existing managed account does not claim the legacy pin. Use the reported imported profile explicitly in those cases. Source changes and conflicting profiles detected before persistence stop only the selected import.

OAuth SecretRef Policy Guard

SecretRef input is for static credentials only. OAuth credentials are runtime-mutable (refresh flows persist rotated tokens), so SecretRef-backed OAuth material would split mutable state across stores.

  • If a profile credential is type: "oauth", SecretRef objects are rejected for any credential material field on that profile.
  • If auth.profiles.<id>.mode is "oauth", SecretRef-backed keyRef/tokenRef input for that profile is rejected.
  • Violations are hard failures (thrown errors) in startup/reload secret preparation and profile resolution paths.

Legacy-Compatible Messaging

When an empty SQLite auth store has a retired auth-profiles.json beside it, runtime inspects provider metadata without importing or resolving its credentials. AUTH_PROFILE_MIGRATION_REQUIRED blocks only those providers, including their auth aliases; unrelated provider auth remains available. Unreadable or unrecognized legacy data retains the owner-wide refusal. A populated SQLite store retains its warning-only behavior. Recorded refusals remain until the lifecycle explicitly clears them; changing or removing a legacy file does not release them. Doctor lists the affected providers, and openclaw doctor --fix performs the supported verified import and archive.

Session readers retain their local and shared auth-store owners and check each owner's current refusal before returning credentials. A shared-provider refusal does not replace an unrelated local credential with environment or config auth, and unresolved local SecretRefs still fail closed. Only recognized credential entries can narrow a legacy refusal; metadata-only objects and unknown layouts remain owner-wide.

Credential writes check migration readiness owner-wide for their destination database only. A shared-store refusal does not block refreshing an unrelated agent-local OAuth credential; a refusal on the write destination still blocks it.

Session migration guards use the same pinned runtime config as model discovery and the requested model's endpoint to resolve endpoint-dependent provider aliases. Prepared session views retain canonical profiles from both owners and validate their SecretRefs; migration metadata does not filter these profiles. The endpoint-aware request guards decide admission. Each selected credential also retains its physical source owner through merges and async resolution. A refusal held by that owner continues to fence matching credentials imported by another process until an explicit lifecycle clear/reload. The other owner's credentials remain independent. This provenance is runtime-only and is never stored in SQLite. If the requested provider needs an endpoint to identify its credential realm and that context is missing, any pending migration refusal blocks it. An explicitly configured unrelated endpoint remains usable.

For script compatibility, probe errors keep this first line unchanged:

Auth profile credentials are missing or expired.

Human-friendly detail and the stable reason code follow on subsequent lines in the form ↳ Auth reason [code]: ....