mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
refactor(auth): split auth state from auth store
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
108
src/agents/auth-profiles/state.ts
Normal file
108
src/agents/auth-profiles/state.ts
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user