diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 448c8e75ff8..0219b9322b9 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -59,6 +59,7 @@ happened while the attempt was running. OpenClaw uses **auth profiles** for both API keys and OAuth tokens. - Secrets live in `~/.openclaw/agents//agent/auth-profiles.json` (legacy: `~/.openclaw/agent/auth-profiles.json`). +- Runtime auth-routing state lives in `~/.openclaw/agents//agent/auth-state.json`. - Config `auth.profiles` / `auth.order` are **metadata + routing only** (no secrets). - Legacy import-only OAuth file: `~/.openclaw/credentials/oauth.json` (imported into `auth-profiles.json` on first use). @@ -155,7 +156,7 @@ Cooldowns use exponential backoff: - 25 minutes - 1 hour (cap) -State is stored in `auth-profiles.json` under `usageStats`: +State is stored in `auth-state.json` under `usageStats`: ```json { @@ -184,7 +185,7 @@ limit reached, resets tomorrow`, or `organization spending limit exceeded`). Those stay on the short cooldown/failover path instead of the long billing-disable path. -State is stored in `auth-profiles.json`: +State is stored in `auth-state.json`: ```json { diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index b71cd67bf3c..05a5842aedc 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -158,7 +158,7 @@ Use `/model` (or `/model list`) for a compact picker; use `/model status` for th ### Per-agent (CLI override) -Set an explicit auth profile order override for an agent (stored in that agent’s `auth-profiles.json`): +Set an explicit auth profile order override for an agent (stored in that agent’s `auth-state.json`): ```bash openclaw models auth order get --provider anthropic diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 673da4a5f85..fbac3ec0c9f 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -616,7 +616,7 @@ Provider plugins now have two layers: - runtime hooks: `normalizeModelId`, `normalizeTransport`, `normalizeConfig`, `applyNativeStreamingUsageCompat`, `resolveConfigApiKey`, - `resolveSyntheticAuth`, `resolveExternalOAuthProfiles`, + `resolveSyntheticAuth`, `resolveExternalAuthProfiles`, `shouldDeferSyntheticProfileAuth`, `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `contributeResolvedModelCompat`, `capabilities`, @@ -650,53 +650,53 @@ client-id/client-secret setup vars. For model/provider plugins, OpenClaw calls hooks in this rough order. The "When to use" column is the quick decision guide. -| # | Hook | What it does | When to use | -| --- | --------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults | -| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics | -| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ | -| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution | -| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family | -| 5 | `normalizeConfig` | Normalize `models.providers.` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries | -| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes | -| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here | -| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker | -| 9 | `resolveExternalOAuthProfiles` | Overlay external OAuth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external OAuth credentials without persisting copied refresh tokens | -| 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence | -| 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids | -| 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids | -| 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport | -| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider | -| 15 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks | -| 16 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup | -| 17 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules | -| 18 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields | -| 19 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup | -| 20 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper | -| 21 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport | -| 22 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity | -| 23 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy | -| 24 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape | -| 25 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers | -| 26 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure | -| 27 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss | -| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc | -| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating | -| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint | -| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint | -| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers | -| 33 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off | -| 34 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models | -| 35 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family | -| 36 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching | -| 37 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential | -| 38 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential | -| 39 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser | -| 40 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin | -| 41 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) | -| 42 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers | -| 43 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation | -| 44 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active | +| # | Hook | What it does | When to use | +| --- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults | +| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics | +| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ | +| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution | +| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family | +| 5 | `normalizeConfig` | Normalize `models.providers.` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries | +| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes | +| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here | +| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker | +| 9 | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external auth credentials without persisting copied refresh tokens | +| 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence | +| 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids | +| 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids | +| 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport | +| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider | +| 15 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks | +| 16 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup | +| 17 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules | +| 18 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields | +| 19 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup | +| 20 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper | +| 21 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport | +| 22 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity | +| 23 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy | +| 24 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape | +| 25 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers | +| 26 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure | +| 27 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss | +| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc | +| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating | +| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint | +| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint | +| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers | +| 33 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off | +| 34 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models | +| 35 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family | +| 36 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching | +| 37 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential | +| 38 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential | +| 39 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser | +| 40 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin | +| 41 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) | +| 42 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers | +| 43 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation | +| 44 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active | `normalizeModelId`, `normalizeTransport`, and `normalizeConfig` first check the matched provider plugin, then fall through other hook-capable provider plugins diff --git a/extensions/openai/openai-codex-cli-auth.test.ts b/extensions/openai/openai-codex-cli-auth.test.ts index 7ff29fa2986..b941ca2121a 100644 --- a/extensions/openai/openai-codex-cli-auth.test.ts +++ b/extensions/openai/openai-codex-cli-auth.test.ts @@ -47,7 +47,6 @@ describe("readOpenAICodexCliOAuthProfile", () => { refresh: "refresh-token", accountId: "acct_123", email: "codex@example.com", - managedBy: "codex-cli", }, }); expect(parsed?.credential.expires).toBeGreaterThan(Date.now()); diff --git a/extensions/openai/openai-codex-cli-auth.ts b/extensions/openai/openai-codex-cli-auth.ts index aaef03cbfd6..e47dc51a9a3 100644 --- a/extensions/openai/openai-codex-cli-auth.ts +++ b/extensions/openai/openai-codex-cli-auth.ts @@ -8,7 +8,6 @@ import { } from "./openai-codex-auth-identity.js"; const PROVIDER_ID = "openai-codex"; -const CODEX_CLI_MANAGED_BY = "codex-cli"; export const CODEX_CLI_PROFILE_ID = `${PROVIDER_ID}:codex-cli`; export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`; @@ -55,19 +54,26 @@ function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null { } } +function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean { + return ( + a.type === b.type && + a.provider === b.provider && + a.access === b.access && + a.refresh === b.refresh && + a.expires === b.expires && + a.clientId === b.clientId && + a.email === b.email && + a.displayName === b.displayName && + a.enterpriseUrl === b.enterpriseUrl && + a.projectId === b.projectId && + a.accountId === b.accountId + ); +} + export function readOpenAICodexCliOAuthProfile(params: { env?: NodeJS.ProcessEnv; store: AuthProfileStore; }): { profileId: string; credential: OAuthCredential } | null { - const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]; - if ( - existing?.type === "oauth" && - existing.provider === PROVIDER_ID && - existing.managedBy !== CODEX_CLI_MANAGED_BY - ) { - return null; - } - const authFile = readCodexCliAuthFile(params.env ?? process.env); if (!authFile || authFile.auth_mode !== "chatgpt") { return null; @@ -81,19 +87,23 @@ export function readOpenAICodexCliOAuthProfile(params: { const accountId = trimNonEmptyString(authFile.tokens?.account_id); const identity = resolveCodexAuthIdentity({ accessToken: access }); + const credential: OAuthCredential = { + type: "oauth", + provider: PROVIDER_ID, + access, + refresh, + expires: resolveCodexAccessTokenExpiry(access) ?? 0, + ...(accountId ? { accountId } : {}), + ...(identity.email ? { email: identity.email } : {}), + ...(identity.profileName ? { displayName: identity.profileName } : {}), + }; + const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]; + if (existing && (existing.type !== "oauth" || !oauthCredentialMatches(existing, credential))) { + return null; + } return { profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: { - type: "oauth", - provider: PROVIDER_ID, - access, - refresh, - expires: resolveCodexAccessTokenExpiry(access) ?? 0, - ...(accountId ? { accountId } : {}), - ...(identity.email ? { email: identity.email } : {}), - ...(identity.profileName ? { displayName: identity.profileName } : {}), - managedBy: CODEX_CLI_MANAGED_BY, - }, + credential, }; } diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 2a7ba07f276..2b13d7b35c9 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -303,7 +303,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { }, resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx), - resolveExternalOAuthProfiles: (ctx) => { + resolveExternalAuthProfiles: (ctx) => { const profile = readOpenAICodexCliOAuthProfile({ env: ctx.env, store: ctx.store, diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 0b0f0f3bec7..c4acb686028 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -380,7 +380,6 @@ describe("ensureAuthProfileStore", () => { provider: "openai-codex", access: "codex-access-token", refresh: "codex-refresh-token", - managedBy: "codex-cli", }); expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index d7e36d310ff..85f6da5803e 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveAuthStorePath } from "./auth-profiles/paths.js"; +import { resolveAuthStatePath, resolveAuthStorePath } from "./auth-profiles/paths.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, @@ -125,4 +125,57 @@ describe("saveAuthProfileStore", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("writes runtime scheduling state to auth-state.json only", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-")); + try { + const store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-anthropic-plain", + }, + }, + order: { + anthropic: ["anthropic:default"], + }, + lastGood: { + anthropic: "anthropic:default", + }, + usageStats: { + "anthropic:default": { + lastUsed: 123, + }, + }, + }; + + saveAuthProfileStore(store, agentDir); + + const authProfiles = JSON.parse( + await fs.readFile(resolveAuthStorePath(agentDir), "utf8"), + ) as { + profiles: Record; + order?: unknown; + lastGood?: unknown; + usageStats?: unknown; + }; + expect(authProfiles.profiles["anthropic:default"]).toBeDefined(); + expect(authProfiles.order).toBeUndefined(); + expect(authProfiles.lastGood).toBeUndefined(); + expect(authProfiles.usageStats).toBeUndefined(); + + const authState = JSON.parse(await fs.readFile(resolveAuthStatePath(agentDir), "utf8")) as { + order?: Record; + lastGood?: Record; + usageStats?: Record; + }; + expect(authState.order?.anthropic).toEqual(["anthropic:default"]); + expect(authState.lastGood?.anthropic).toBe("anthropic:default"); + expect(authState.usageStats?.["anthropic:default"]?.lastUsed).toBe(123); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index b2822ca9690..dda2e41256f 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -8,7 +8,10 @@ export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js"; export { formatAuthDoctorHint } from "./auth-profiles/doctor.js"; export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js"; export { resolveAuthProfileEligibility, resolveAuthProfileOrder } from "./auth-profiles/order.js"; -export { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js"; +export { + resolveAuthStatePathForDisplay, + resolveAuthStorePathForDisplay, +} from "./auth-profiles/paths.js"; export { dedupeProfileIds, listProfilesForProvider, @@ -35,6 +38,7 @@ export type { AuthProfileCredential, AuthProfileFailureReason, AuthProfileIdRepairResult, + AuthProfileState, AuthProfileStore, OAuthCredential, ProfileUsageStats, diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index b01067e30da..83f1e4ba584 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -2,6 +2,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; export const AUTH_STORE_VERSION = 1; export const AUTH_PROFILE_FILENAME = "auth-profiles.json"; +export const AUTH_STATE_FILENAME = "auth-state.json"; export const LEGACY_AUTH_FILENAME = "auth.json"; export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 670a9b83f1f..9b9922484b9 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProviderExternalOAuthProfile } from "../../plugins/types.js"; +import type { ProviderExternalAuthProfile } from "../../plugins/types.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -const resolveExternalOAuthProfilesWithPluginsMock = vi.fn< - (params: unknown) => ProviderExternalOAuthProfile[] +const resolveExternalAuthProfilesWithPluginsMock = vi.fn< + (params: unknown) => ProviderExternalAuthProfile[] >(() => []); vi.mock("../../plugins/provider-runtime.js", () => ({ - resolveExternalOAuthProfilesWithPlugins: (params: unknown) => - resolveExternalOAuthProfilesWithPluginsMock(params), + resolveExternalAuthProfilesWithPlugins: (params: unknown) => + resolveExternalAuthProfilesWithPluginsMock(params), })); function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore { @@ -22,18 +22,17 @@ function createCredential(overrides: Partial = {}): OAuthCreden access: "access-token", refresh: "refresh-token", expires: 123, - managedBy: "codex-cli", ...overrides, }; } describe("auth external oauth helpers", () => { beforeEach(() => { - resolveExternalOAuthProfilesWithPluginsMock.mockReset(); + resolveExternalAuthProfilesWithPluginsMock.mockReset(); }); it("overlays provider-managed runtime oauth profiles onto the store", async () => { - resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([ + resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([ { profileId: "openai-codex:default", credential: createCredential(), @@ -52,7 +51,7 @@ describe("auth external oauth helpers", () => { it("omits exact runtime-only overlays from persisted store writes", async () => { const credential = createCredential(); - resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([ + resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([ { profileId: "openai-codex:default", credential, @@ -71,7 +70,7 @@ describe("auth external oauth helpers", () => { it("keeps persisted copies when the external overlay is marked persisted", async () => { const credential = createCredential(); - resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([ + resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([ { profileId: "openai-codex:default", credential, @@ -91,7 +90,7 @@ describe("auth external oauth helpers", () => { it("keeps stale local copies when runtime overlay no longer matches", async () => { const credential = createCredential(); - resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([ + resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([ { profileId: "openai-codex:default", credential: createCredential({ access: "fresh-access-token" }), diff --git a/src/agents/auth-profiles/external-oauth.ts b/src/agents/auth-profiles/external-oauth.ts index 332b608469c..40bb01af6e9 100644 --- a/src/agents/auth-profiles/external-oauth.ts +++ b/src/agents/auth-profiles/external-oauth.ts @@ -1,12 +1,12 @@ -import { resolveExternalOAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; -import type { ProviderExternalOAuthProfile } from "../../plugins/types.js"; +import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; +import type { ProviderExternalAuthProfile } from "../../plugins/types.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -type ExternalOAuthProfileMap = Map; +type ExternalOAuthProfileMap = Map; function normalizeExternalOAuthProfile( - profile: ProviderExternalOAuthProfile, -): ProviderExternalOAuthProfile | null { + profile: ProviderExternalAuthProfile, +): ProviderExternalAuthProfile | null { if (!profile?.profileId || !profile.credential) { return null; } @@ -22,7 +22,7 @@ function resolveExternalOAuthProfileMap(params: { env?: NodeJS.ProcessEnv; }): ExternalOAuthProfileMap { const env = params.env ?? process.env; - const profiles = resolveExternalOAuthProfilesWithPlugins({ + const profiles = resolveExternalAuthProfilesWithPlugins({ env, context: { config: undefined, @@ -56,8 +56,7 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean a.displayName === b.displayName && a.enterpriseUrl === b.enterpriseUrl && a.projectId === b.projectId && - a.accountId === b.accountId && - a.managedBy === b.managedBy + a.accountId === b.accountId ); } diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 0fa9e2f139a..8df1e810bc2 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -214,7 +214,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { access: "expired-access-token", refresh: "expired-refresh-token", expires: Date.now() - 60_000, - managedBy: "codex-cli", }, }, }, @@ -253,7 +252,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { access: "expired-access-token", refresh: "expired-refresh-token", expires: Date.now() - 60_000, - managedBy: "codex-cli", }, }, }, @@ -296,7 +294,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { access: "rotated-cli-access-token", refresh: "rotated-cli-refresh-token", accountId: "acct-rotated", - managedBy: "codex-cli", }); expect(persisted.profiles[profileId]).not.toEqual( expect.objectContaining({ diff --git a/src/agents/auth-profiles/paths.ts b/src/agents/auth-profiles/paths.ts index 78167334f92..5430fc631cf 100644 --- a/src/agents/auth-profiles/paths.ts +++ b/src/agents/auth-profiles/paths.ts @@ -3,8 +3,13 @@ import path from "node:path"; import { saveJsonFile } from "../../infra/json-file.js"; import { resolveUserPath } from "../../utils.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; -import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js"; -import type { AuthProfileStore } from "./types.js"; +import { + AUTH_PROFILE_FILENAME, + AUTH_STATE_FILENAME, + AUTH_STORE_VERSION, + LEGACY_AUTH_FILENAME, +} from "./constants.js"; +import type { AuthProfileSecretsStore } from "./types.js"; export function resolveAuthStorePath(agentDir?: string): string { const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir()); @@ -16,16 +21,26 @@ export function resolveLegacyAuthStorePath(agentDir?: string): string { return path.join(resolved, LEGACY_AUTH_FILENAME); } +export function resolveAuthStatePath(agentDir?: string): string { + const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir()); + return path.join(resolved, AUTH_STATE_FILENAME); +} + export function resolveAuthStorePathForDisplay(agentDir?: string): string { const pathname = resolveAuthStorePath(agentDir); return pathname.startsWith("~") ? pathname : resolveUserPath(pathname); } +export function resolveAuthStatePathForDisplay(agentDir?: string): string { + const pathname = resolveAuthStatePath(agentDir); + return pathname.startsWith("~") ? pathname : resolveUserPath(pathname); +} + export function ensureAuthStoreFile(pathname: string) { if (fs.existsSync(pathname)) { return; } - const payload: AuthProfileStore = { + const payload: AuthProfileSecretsStore = { version: AUTH_STORE_VERSION, profiles: {}, }; diff --git a/src/agents/auth-profiles/state.ts b/src/agents/auth-profiles/state.ts new file mode 100644 index 00000000000..823d64264c2 --- /dev/null +++ b/src/agents/auth-profiles/state.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; +import { AUTH_STORE_VERSION } from "./constants.js"; +import { resolveAuthStatePath } from "./paths.js"; +import type { AuthProfileState, AuthProfileStateStore, ProfileUsageStats } from "./types.js"; + +function normalizeAuthProfileOrder(raw: unknown): AuthProfileState["order"] { + if (!raw || typeof raw !== "object") { + return undefined; + } + const normalized = Object.entries(raw as Record).reduce( + (acc, [provider, value]) => { + if (!Array.isArray(value)) { + return acc; + } + const list = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (list.length > 0) { + acc[provider] = list; + } + return acc; + }, + {} as Record, + ); + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function coerceAuthProfileState(raw: unknown): AuthProfileState { + if (!raw || typeof raw !== "object") { + return {}; + } + const record = raw as Record; + return { + order: normalizeAuthProfileOrder(record.order), + lastGood: + record.lastGood && typeof record.lastGood === "object" + ? (record.lastGood as Record) + : undefined, + usageStats: + record.usageStats && typeof record.usageStats === "object" + ? (record.usageStats as Record) + : undefined, + }; +} + +export function mergeAuthProfileState( + base: AuthProfileState, + override: AuthProfileState, +): AuthProfileState { + const mergeRecord = (left?: Record, right?: Record) => { + if (!left && !right) { + return undefined; + } + if (!left) { + return { ...right }; + } + if (!right) { + return { ...left }; + } + return { ...left, ...right }; + }; + + return { + order: mergeRecord(base.order, override.order), + lastGood: mergeRecord(base.lastGood, override.lastGood), + usageStats: mergeRecord(base.usageStats, override.usageStats), + }; +} + +export function loadPersistedAuthProfileState(agentDir?: string): AuthProfileState { + return coerceAuthProfileState(loadJsonFile(resolveAuthStatePath(agentDir))); +} + +export function buildPersistedAuthProfileState( + store: AuthProfileState, +): AuthProfileStateStore | null { + const state = coerceAuthProfileState(store); + if (!state.order && !state.lastGood && !state.usageStats) { + return null; + } + return { + version: AUTH_STORE_VERSION, + ...(state.order ? { order: state.order } : {}), + ...(state.lastGood ? { lastGood: state.lastGood } : {}), + ...(state.usageStats ? { usageStats: state.usageStats } : {}), + }; +} + +export function savePersistedAuthProfileState( + store: AuthProfileState, + agentDir?: string, +): AuthProfileStateStore | null { + const payload = buildPersistedAuthProfileState(store); + const statePath = resolveAuthStatePath(agentDir); + if (!payload) { + try { + fs.unlinkSync(statePath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + return null; + } + saveJsonFile(statePath, payload); + return payload; +} diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 612fd9649b1..3e890f5678b 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -14,12 +14,23 @@ import { overlayExternalOAuthProfiles, shouldPersistExternalOAuthProfile, } from "./external-oauth.js"; -import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; +import { + ensureAuthStoreFile, + resolveAuthStatePath, + resolveAuthStorePath, + resolveLegacyAuthStorePath, +} from "./paths.js"; +import { + coerceAuthProfileState, + mergeAuthProfileState, + loadPersistedAuthProfileState, + savePersistedAuthProfileState, +} from "./state.js"; import type { AuthProfileCredential, + AuthProfileSecretsStore, AuthProfileStore, OAuthCredentials, - ProfileUsageStats, } from "./types.js"; type LegacyAuthStore = Record; @@ -35,7 +46,12 @@ const AUTH_PROFILE_TYPES = new Set(["api_key", "o const runtimeAuthStoreSnapshots = new Map(); const loadedAuthStoreCache = new Map< string, - { mtimeMs: number | null; syncedAtMs: number; store: AuthProfileStore } + { + authMtimeMs: number | null; + stateMtimeMs: number | null; + syncedAtMs: number; + store: AuthProfileStore; + } >(); function resolveRuntimeStoreKey(agentDir?: string): string { @@ -104,12 +120,17 @@ function readAuthStoreMtimeMs(authPath: string): number | null { } } -function readCachedAuthProfileStore( - authPath: string, - mtimeMs: number | null, -): AuthProfileStore | null { - const cached = loadedAuthStoreCache.get(authPath); - if (!cached || cached.mtimeMs !== mtimeMs) { +function readCachedAuthProfileStore(params: { + authPath: string; + authMtimeMs: number | null; + stateMtimeMs: number | null; +}): AuthProfileStore | null { + const cached = loadedAuthStoreCache.get(params.authPath); + if ( + !cached || + cached.authMtimeMs !== params.authMtimeMs || + cached.stateMtimeMs !== params.stateMtimeMs + ) { return null; } if (Date.now() - cached.syncedAtMs >= EXTERNAL_CLI_SYNC_TTL_MS) { @@ -118,15 +139,17 @@ function readCachedAuthProfileStore( return cloneAuthProfileStore(cached.store); } -function writeCachedAuthProfileStore( - authPath: string, - mtimeMs: number | null, - store: AuthProfileStore, -): void { - loadedAuthStoreCache.set(authPath, { - mtimeMs, +function writeCachedAuthProfileStore(params: { + authPath: string; + authMtimeMs: number | null; + stateMtimeMs: number | null; + store: AuthProfileStore; +}): void { + loadedAuthStoreCache.set(params.authPath, { + authMtimeMs: params.authMtimeMs, + stateMtimeMs: params.stateMtimeMs, syncedAtMs: Date.now(), - store: cloneAuthProfileStore(store), + store: cloneAuthProfileStore(params.store), }); } @@ -286,37 +309,11 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null { normalized[key] = parsed.credential; } warnRejectedCredentialEntries("auth-profiles.json", rejected); - const order = - record.order && typeof record.order === "object" - ? Object.entries(record.order as Record).reduce( - (acc, [provider, value]) => { - if (!Array.isArray(value)) { - return acc; - } - const list = value - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean); - if (list.length === 0) { - return acc; - } - acc[provider] = list; - return acc; - }, - {} as Record, - ) - : undefined; + const legacyState = coerceAuthProfileState(record); return { version: Number(record.version ?? AUTH_STORE_VERSION), profiles: normalized, - order, - lastGood: - record.lastGood && typeof record.lastGood === "object" - ? (record.lastGood as Record) - : undefined, - usageStats: - record.usageStats && typeof record.usageStats === "object" - ? (record.usageStats as Record) - : undefined, + ...legacyState, }; } @@ -360,7 +357,7 @@ function mergeAuthProfileStores( function buildPersistedAuthProfileStore( store: AuthProfileStore, params?: { agentDir?: string }, -): AuthProfileStore { +): AuthProfileSecretsStore { const profiles = Object.fromEntries( Object.entries(store.profiles).flatMap(([profileId, credential]) => { if ( @@ -387,14 +384,11 @@ function buildPersistedAuthProfileStore( } return [[profileId, credential]]; }), - ) as AuthProfileStore["profiles"]; + ) as AuthProfileSecretsStore["profiles"]; return { version: AUTH_STORE_VERSION, profiles, - order: store.order ?? undefined, - lastGood: store.lastGood ?? undefined, - usageStats: store.usageStats ?? undefined, }; } @@ -460,9 +454,18 @@ function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): voi } } -function loadCoercedStore(authPath: string): AuthProfileStore | null { +function loadCoercedStore(authPath: string, agentDir?: string): AuthProfileStore | null { const raw = loadJsonFile(authPath); - return coerceAuthStore(raw); + const store = coerceAuthStore(raw); + if (!store) { + return null; + } + const persistedState = loadPersistedAuthProfileState(agentDir); + const embeddedState = coerceAuthProfileState(raw); + return { + ...store, + ...mergeAuthProfileState(embeddedState, persistedState), + }; } function shouldLogAuthStoreTiming(): boolean { @@ -515,19 +518,31 @@ function loadAuthProfileStoreForAgent( ): AuthProfileStore { const readOnly = options?.readOnly === true; const authPath = resolveAuthStorePath(agentDir); + const statePath = resolveAuthStatePath(agentDir); + const authMtimeMs = readAuthStoreMtimeMs(authPath); + const stateMtimeMs = readAuthStoreMtimeMs(statePath); if (!readOnly) { - const cached = readCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath)); + const cached = readCachedAuthProfileStore({ + authPath, + authMtimeMs, + stateMtimeMs, + }); if (cached) { return cached; } } - const asStore = loadCoercedStore(authPath); + const asStore = loadCoercedStore(authPath, agentDir); if (asStore) { // Runtime secret activation must remain read-only: // sync external CLI credentials in-memory, but never persist while readOnly. syncExternalCliCredentialsTimed(asStore, { log: !readOnly }); if (!readOnly) { - writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), asStore); + writeCachedAuthProfileStore({ + authPath, + authMtimeMs: readAuthStoreMtimeMs(authPath), + stateMtimeMs: readAuthStoreMtimeMs(statePath), + store: asStore, + }); } return asStore; } @@ -538,11 +553,20 @@ function loadAuthProfileStoreForAgent( const mainRaw = loadJsonFile(mainAuthPath); const mainStore = coerceAuthStore(mainRaw); if (mainStore && Object.keys(mainStore.profiles).length > 0) { - // Clone main store to subagent directory for auth inheritance - saveJsonFile(authPath, mainStore); + // Clone only secret-bearing profiles to subagent directory for auth inheritance. + saveJsonFile(authPath, { + version: AUTH_STORE_VERSION, + profiles: mainStore.profiles, + } satisfies AuthProfileSecretsStore); log.info("inherited auth-profiles from main agent", { agentDir }); - writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), mainStore); - return mainStore; + const inherited = { version: mainStore.version, profiles: { ...mainStore.profiles } }; + writeCachedAuthProfileStore({ + authPath, + authMtimeMs: readAuthStoreMtimeMs(authPath), + stateMtimeMs: readAuthStoreMtimeMs(statePath), + store: inherited, + }); + return inherited; } } @@ -583,7 +607,12 @@ function loadAuthProfileStoreForAgent( } if (!readOnly) { - writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), store); + writeCachedAuthProfileStore({ + authPath, + authMtimeMs: readAuthStoreMtimeMs(authPath), + stateMtimeMs: readAuthStoreMtimeMs(statePath), + store, + }); } return store; } @@ -633,12 +662,19 @@ export function ensureAuthProfileStore( export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); + const statePath = resolveAuthStatePath(agentDir); const runtimeKey = resolveRuntimeStoreKey(agentDir); const payload = buildPersistedAuthProfileStore(store, { agentDir }); saveJsonFile(authPath, payload); + savePersistedAuthProfileState(store, agentDir); const runtimeStore = cloneAuthProfileStore(store); syncExternalCliCredentialsTimed(runtimeStore, { log: false }); - writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), runtimeStore); + writeCachedAuthProfileStore({ + authPath, + authMtimeMs: readAuthStoreMtimeMs(authPath), + stateMtimeMs: readAuthStoreMtimeMs(statePath), + store: runtimeStore, + }); if (runtimeAuthStoreSnapshots.has(runtimeKey)) { runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(runtimeStore)); } diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 6ad04395eb2..5158a1c45ef 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -82,9 +82,7 @@ export type ProfileUsageStats = { lastFailureAt?: number; }; -export type AuthProfileStore = { - version: number; - profiles: Record; +export type AuthProfileState = { /** * Optional per-agent preferred profile order overrides. * This lets you lock/override auth rotation for a specific agent without @@ -96,6 +94,17 @@ export type AuthProfileStore = { usageStats?: Record; }; +export type AuthProfileSecretsStore = { + version: number; + profiles: Record; +}; + +export type AuthProfileStateStore = { + version: number; +} & AuthProfileState; + +export type AuthProfileStore = AuthProfileSecretsStore & AuthProfileState; + export type AuthProfileIdRepairResult = { config: OpenClawConfig; changes: string[]; diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 4337fa75d68..c8fd0b0676a 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -381,7 +381,7 @@ export function registerModelsCli(program: Command) { order .command("get") - .description("Show per-agent auth order override (from auth-profiles.json)") + .description("Show per-agent auth order override (from auth-state.json)") .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Output JSON", false) @@ -402,7 +402,7 @@ export function registerModelsCli(program: Command) { order .command("set") - .description("Set per-agent auth order override (locks rotation to this list)") + .description("Set per-agent auth order override (writes auth-state.json)") .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .argument("", "Auth profile ids (e.g. anthropic:default)") diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index 7f90117b762..889110f9616 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -2,6 +2,7 @@ import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope import { type AuthProfileStore, ensureAuthProfileStore, + resolveAuthStatePathForDisplay, setAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; @@ -58,7 +59,7 @@ export async function modelsAuthOrderGetCommand( agentId, agentDir, provider, - authStorePath: shortenHomePath(`${agentDir}/auth-profiles.json`), + authStatePath: shortenHomePath(resolveAuthStatePathForDisplay(agentDir)), order: order.length > 0 ? order : null, }); return; @@ -66,7 +67,7 @@ export async function modelsAuthOrderGetCommand( runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); - runtime.log(`Auth file: ${shortenHomePath(`${agentDir}/auth-profiles.json`)}`); + runtime.log(`Auth state file: ${shortenHomePath(resolveAuthStatePathForDisplay(agentDir))}`); runtime.log(order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)"); } @@ -81,7 +82,7 @@ export async function modelsAuthOrderClearCommand( order: null, }); if (!updated) { - throw new Error("Failed to update auth-profiles.json (lock busy?)."); + throw new Error("Failed to update auth-state.json (lock busy?)."); } runtime.log(`Agent: ${agentId}`); @@ -120,7 +121,7 @@ export async function modelsAuthOrderSetCommand( order: requested, }); if (!updated) { - throw new Error("Failed to update auth-profiles.json (lock busy?)."); + throw new Error("Failed to update auth-state.json (lock busy?)."); } runtime.log(`Agent: ${agentId}`); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index fc63f3664b2..ecfd7ee731c 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -49,7 +49,7 @@ let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js"). let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef; let resolveProviderReasoningOutputModeWithPlugin: typeof import("./provider-runtime.js").resolveProviderReasoningOutputModeWithPlugin; let resolveProviderReplayPolicyWithPlugin: typeof import("./provider-runtime.js").resolveProviderReplayPolicyWithPlugin; -let resolveExternalOAuthProfilesWithPlugins: typeof import("./provider-runtime.js").resolveExternalOAuthProfilesWithPlugins; +let resolveExternalAuthProfilesWithPlugins: typeof import("./provider-runtime.js").resolveExternalAuthProfilesWithPlugins; let resolveProviderSyntheticAuthWithPlugin: typeof import("./provider-runtime.js").resolveProviderSyntheticAuthWithPlugin; let shouldDeferProviderSyntheticProfileAuthWithPlugin: typeof import("./provider-runtime.js").shouldDeferProviderSyntheticProfileAuthWithPlugin; let sanitizeProviderReplayHistoryWithPlugin: typeof import("./provider-runtime.js").sanitizeProviderReplayHistoryWithPlugin; @@ -257,7 +257,7 @@ describe("provider-runtime", () => { resolveProviderModernModelRef, resolveProviderReasoningOutputModeWithPlugin, resolveProviderReplayPolicyWithPlugin, - resolveExternalOAuthProfilesWithPlugins, + resolveExternalAuthProfilesWithPlugins, resolveProviderSyntheticAuthWithPlugin, shouldDeferProviderSyntheticProfileAuthWithPlugin, sanitizeProviderReplayHistoryWithPlugin, @@ -646,7 +646,7 @@ describe("provider-runtime", () => { }, createEmbeddingProvider, resolveSyntheticAuth, - resolveExternalOAuthProfiles: ({ store }) => + resolveExternalAuthProfiles: ({ store }) => store.profiles["demo:managed"] ? [] : [ @@ -659,7 +659,6 @@ describe("provider-runtime", () => { access: "external-access", refresh: "external-refresh", expires: Date.now() + 60_000, - managedBy: "demo-cli", }, }, ], @@ -1056,7 +1055,7 @@ describe("provider-runtime", () => { }, { actual: () => - resolveExternalOAuthProfilesWithPlugins({ + resolveExternalAuthProfilesWithPlugins({ env: process.env, context: { env: process.env, @@ -1073,7 +1072,6 @@ describe("provider-runtime", () => { access: "external-access", refresh: "external-refresh", expires: expect.any(Number), - managedBy: "demo-cli", }, }, ], diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 5432ad3123e..965ed8bfb3b 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -10,13 +10,13 @@ import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, + ProviderExternalAuthProfile, ProviderBuildMissingAuthMessageContext, ProviderBuildUnknownModelHintContext, ProviderBuiltInModelSuppressionContext, ProviderCacheTtlEligibilityContext, ProviderCreateEmbeddingProviderContext, ProviderDeferSyntheticProfileAuthContext, - ProviderExternalOAuthProfile, ProviderResolveSyntheticAuthContext, ProviderCreateStreamFnContext, ProviderDefaultThinkingPolicyContext, @@ -34,6 +34,7 @@ import type { ProviderModernModelPolicyContext, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, + ProviderResolveExternalAuthProfilesContext, ProviderResolveExternalOAuthProfilesContext, ProviderPrepareRuntimeAuthContext, ProviderApplyConfigDefaultsContext, @@ -791,15 +792,17 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { return resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context) ?? undefined; } -export function resolveExternalOAuthProfilesWithPlugins(params: { +export function resolveExternalAuthProfilesWithPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; - context: ProviderResolveExternalOAuthProfilesContext; -}): ProviderExternalOAuthProfile[] { - const matches: ProviderExternalOAuthProfile[] = []; + context: ProviderResolveExternalAuthProfilesContext; +}): ProviderExternalAuthProfile[] { + const matches: ProviderExternalAuthProfile[] = []; for (const plugin of resolveProviderPluginsForHooks(params)) { - const profiles = plugin.resolveExternalOAuthProfiles?.(params.context); + const profiles = + plugin.resolveExternalAuthProfiles?.(params.context) ?? + plugin.resolveExternalOAuthProfiles?.(params.context); if (!profiles || profiles.length === 0) { continue; } @@ -808,6 +811,15 @@ export function resolveExternalOAuthProfilesWithPlugins(params: { return matches; } +export function resolveExternalOAuthProfilesWithPlugins(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderResolveExternalOAuthProfilesContext; +}): ProviderExternalAuthProfile[] { + return resolveExternalAuthProfilesWithPlugins(params); +} + export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a817cf1c08c..51f641aaa22 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1051,12 +1051,15 @@ export type ProviderResolveExternalOAuthProfilesContext = { env: NodeJS.ProcessEnv; store: AuthProfileStore; }; +export type ProviderResolveExternalAuthProfilesContext = + ProviderResolveExternalOAuthProfilesContext; export type ProviderExternalOAuthProfile = { profileId: string; credential: OAuthCredential; persistence?: "runtime-only" | "persisted"; }; +export type ProviderExternalAuthProfile = ProviderExternalOAuthProfile; export type ProviderDeferSyntheticProfileAuthContext = { config?: OpenClawConfig; @@ -1536,11 +1539,23 @@ export type ProviderPlugin = { ctx: ProviderResolveSyntheticAuthContext, ) => ProviderSyntheticAuthResult | null | undefined; /** - * Provider-owned external OAuth profile discovery. + * Provider-owned external auth profile discovery. * - * Use this when credentials are managed by an external tool and should be - * visible to runtime auth resolution without being written back into - * `auth-profiles.json` by core. + * Use this when credentials are managed by an external tool and should be visible + * to runtime auth resolution without being written back into `auth-profiles.json` + * by core. + */ + resolveExternalAuthProfiles?: ( + ctx: ProviderResolveExternalAuthProfilesContext, + ) => + | Array + | ReadonlyArray + | null + | undefined; + /** + * @deprecated Use `resolveExternalAuthProfiles`. + * + * Kept for compatibility with existing provider plugins. */ resolveExternalOAuthProfiles?: ( ctx: ProviderResolveExternalOAuthProfilesContext, diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 95b54d9786d..e154ab27e59 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -4,7 +4,8 @@ import { confirm, select, text } from "@clack/prompts"; import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; -import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; +import { resolveAuthStatePath, resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; +import { coerceAuthProfileState, mergeAuthProfileState } from "../agents/auth-profiles/state.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; @@ -289,11 +290,7 @@ function normalizeAuthStoreForConfigure( return { version, profiles: raw.profiles as AuthProfileStore["profiles"], - ...(isRecord(raw.order) ? { order: raw.order as AuthProfileStore["order"] } : {}), - ...(isRecord(raw.lastGood) ? { lastGood: raw.lastGood as AuthProfileStore["lastGood"] } : {}), - ...(isRecord(raw.usageStats) - ? { usageStats: raw.usageStats as AuthProfileStore["usageStats"] } - : {}), + ...coerceAuthProfileState(raw), }; } @@ -309,7 +306,18 @@ function loadAuthProfileStoreForConfigure(params: { `Cannot run interactive secrets configure because ${storePath} could not be read: ${parsed.error}`, ); } - return normalizeAuthStoreForConfigure(parsed.value, storePath); + const store = normalizeAuthStoreForConfigure(parsed.value, storePath); + const statePath = resolveAuthStatePath(agentDir); + const parsedState = readJsonObjectIfExists(statePath); + if (parsedState.error) { + throw new Error( + `Cannot run interactive secrets configure because ${statePath} could not be read: ${parsedState.error}`, + ); + } + return { + ...store, + ...mergeAuthProfileState(store, coerceAuthProfileState(parsedState.value)), + }; } async function promptNewAuthProfileCandidate(agentId: string): Promise {