refactor(auth): split auth state from auth store

This commit is contained in:
Peter Steinberger
2026-04-06 13:41:44 +01:00
parent 35af6cc49c
commit 1c41987876
23 changed files with 459 additions and 195 deletions

View File

@@ -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/<agentId>/agent/auth-profiles.json` (legacy: `~/.openclaw/agent/auth-profiles.json`).
- Runtime auth-routing state lives in `~/.openclaw/agents/<agentId>/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
{

View File

@@ -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 agents `auth-profiles.json`):
Set an explicit auth profile order override for an agent (stored in that agents `auth-state.json`):
```bash
openclaw models auth order get --provider anthropic

View File

@@ -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.<id>` 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.<id>` 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

View File

@@ -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());

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<string, unknown>;
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<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, { lastUsed?: number }>;
};
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 });
}
});
});

View File

@@ -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,

View File

@@ -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";

View File

@@ -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<OAuthCredential> = {}): 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" }),

View File

@@ -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<string, ProviderExternalOAuthProfile>;
type ExternalOAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
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
);
}

View File

@@ -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({

View File

@@ -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: {},
};

View File

@@ -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<string, unknown>).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<string, string[]>,
);
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<string, unknown>;
return {
order: normalizeAuthProfileOrder(record.order),
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
: undefined,
usageStats:
record.usageStats && typeof record.usageStats === "object"
? (record.usageStats as Record<string, ProfileUsageStats>)
: undefined,
};
}
export function mergeAuthProfileState(
base: AuthProfileState,
override: AuthProfileState,
): AuthProfileState {
const mergeRecord = <T>(left?: Record<string, T>, right?: Record<string, T>) => {
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;
}

View File

@@ -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<string, AuthProfileCredential>;
@@ -35,7 +46,12 @@ const AUTH_PROFILE_TYPES = new Set<AuthProfileCredential["type"]>(["api_key", "o
const runtimeAuthStoreSnapshots = new Map<string, AuthProfileStore>();
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<string, unknown>).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<string, string[]>,
)
: 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<string, string>)
: undefined,
usageStats:
record.usageStats && typeof record.usageStats === "object"
? (record.usageStats as Record<string, ProfileUsageStats>)
: 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));
}

View File

@@ -82,9 +82,7 @@ export type ProfileUsageStats = {
lastFailureAt?: number;
};
export type AuthProfileStore = {
version: number;
profiles: Record<string, AuthProfileCredential>;
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<string, ProfileUsageStats>;
};
export type AuthProfileSecretsStore = {
version: number;
profiles: Record<string, AuthProfileCredential>;
};
export type AuthProfileStateStore = {
version: number;
} & AuthProfileState;
export type AuthProfileStore = AuthProfileSecretsStore & AuthProfileState;
export type AuthProfileIdRepairResult = {
config: OpenClawConfig;
changes: string[];

View File

@@ -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 <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "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 <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")

View File

@@ -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}`);

View File

@@ -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",
},
},
],

View File

@@ -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;

View File

@@ -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<ProviderExternalAuthProfile>
| ReadonlyArray<ProviderExternalAuthProfile>
| null
| undefined;
/**
* @deprecated Use `resolveExternalAuthProfiles`.
*
* Kept for compatibility with existing provider plugins.
*/
resolveExternalOAuthProfiles?: (
ctx: ProviderResolveExternalOAuthProfilesContext,

View File

@@ -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<ConfigureCandidate> {