diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index f7df1c72bad..dff371f47dc 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -306,6 +306,7 @@ describe("anthropic provider replay hooks", () => { apiKey: "access-token", source: "Claude CLI native auth", mode: "oauth", + expiresAt: 123, }); expect(readClaudeCliCredentialsForRuntimeMock).toHaveBeenCalledTimes(1); }); @@ -329,6 +330,7 @@ describe("anthropic provider replay hooks", () => { apiKey: "bearer-token", source: "Claude CLI native auth", mode: "token", + expiresAt: 123, }); }); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 3e5b2074d4a..a2290fdd387 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -414,11 +414,13 @@ function resolveClaudeCliSyntheticAuth() { apiKey: credential.access, source: "Claude CLI native auth", mode: "oauth" as const, + expiresAt: credential.expires, } : { apiKey: credential.token, source: "Claude CLI native auth", mode: "token" as const, + expiresAt: credential.expires, }; } diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index 4fbbe62e245..ff2bdba5b60 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -1,16 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OAuthCredential } from "./auth-profiles/types.js"; -import type { ClaudeCliCredential } from "./cli-credentials.js"; -const { readClaudeCliCredentialsCachedMock, readCodexCliCredentialsCachedMock } = vi.hoisted( - () => ({ - readClaudeCliCredentialsCachedMock: vi.fn<() => ClaudeCliCredential | null>(() => null), - readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), - }), -); +const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ + readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), +})); vi.mock("./cli-credentials.js", () => ({ - readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock, + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, @@ -63,8 +59,6 @@ describe("buildAuthHealthSummary", () => { }); beforeEach(() => { - readClaudeCliCredentialsCachedMock.mockReset(); - readClaudeCliCredentialsCachedMock.mockReturnValue(null); readCodexCliCredentialsCachedMock.mockReset(); readCodexCliCredentialsCachedMock.mockReturnValue(null); }); @@ -144,15 +138,8 @@ describe("buildAuthHealthSummary", () => { expect(statuses["google:no-refresh"]).toBe("expired"); }); - it("uses fresh Claude CLI OAuth credentials for claude-cli profile health", () => { + it("uses runtime provider credentials for profile health", () => { vi.spyOn(Date, "now").mockReturnValue(now); - readClaudeCliCredentialsCachedMock.mockReturnValue({ - type: "oauth", - provider: "anthropic", - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - expires: now + DEFAULT_OAUTH_WARN_MS + 60_000, - }); const store = { version: 1, profiles: { @@ -169,6 +156,17 @@ describe("buildAuthHealthSummary", () => { const summary = buildAuthHealthSummary({ store, warnAfterMs: DEFAULT_OAUTH_WARN_MS, + runtimeCredentialsByProvider: new Map([ + [ + "claude-cli", + { + type: "token", + provider: "claude-cli", + token: "fresh-cli-access", + expires: now + DEFAULT_OAUTH_WARN_MS + 60_000, + }, + ], + ]), }); const profile = summary.profiles.find((entry) => entry.profileId === "anthropic:claude-cli"); diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index fbd7c581487..92241e493f0 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -1,5 +1,4 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { CLAUDE_CLI_PROFILE_ID } from "./auth-profiles/constants.js"; import { DEFAULT_OAUTH_REFRESH_MARGIN_MS, type AuthCredentialReasonCode, @@ -9,7 +8,6 @@ import { import { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js"; import { resolveEffectiveOAuthCredential } from "./auth-profiles/effective-oauth.js"; import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; -import { readClaudeCliCredentialsCached } from "./cli-credentials.js"; import { normalizeProviderId } from "./provider-id.js"; export type AuthProfileSource = "store"; @@ -103,46 +101,19 @@ function resolveOAuthStatus( return { status: "ok", remainingMs }; } -function resolveClaudeCliStatusCredential(params: { - profileId: string; - credential: AuthProfileCredential; -}): AuthProfileCredential { - if (params.profileId !== CLAUDE_CLI_PROFILE_ID) { - return params.credential; - } - const cliCredential = readClaudeCliCredentialsCached({ allowKeychainPrompt: false }); - if (!cliCredential) { - return params.credential; - } - if (cliCredential.type === "oauth") { - return { - type: "oauth", - provider: params.credential.provider, - access: cliCredential.access, - refresh: cliCredential.refresh, - expires: cliCredential.expires, - }; - } - return { - type: "token", - provider: params.credential.provider, - token: cliCredential.token, - expires: cliCredential.expires, - }; -} - function buildProfileHealth(params: { profileId: string; credential: AuthProfileCredential; + runtimeCredential?: AuthProfileCredential; store: AuthProfileStore; cfg?: OpenClawConfig; now: number; warnAfterMs: number; }): AuthProfileHealth { - const { profileId, credential, store, cfg, now, warnAfterMs } = params; + const { profileId, credential, runtimeCredential, store, cfg, now, warnAfterMs } = params; const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); const source = resolveAuthProfileSource(profileId); - const healthCredential = resolveClaudeCliStatusCredential({ profileId, credential }); + const healthCredential = runtimeCredential ?? credential; const provider = normalizeProviderId(healthCredential.provider); if (healthCredential.type === "api_key") { @@ -227,6 +198,7 @@ export function buildAuthHealthSummary(params: { cfg?: OpenClawConfig; warnAfterMs?: number; providers?: string[]; + runtimeCredentialsByProvider?: ReadonlyMap; }): AuthHealthSummary { const now = Date.now(); const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS; @@ -242,6 +214,9 @@ export function buildAuthHealthSummary(params: { buildProfileHealth({ profileId, credential, + runtimeCredential: params.runtimeCredentialsByProvider?.get( + normalizeProviderId(credential.provider), + ), store: params.store, cfg: params.cfg, now, diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index ed5ac8b1ad9..46e311fcb7b 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -12,6 +12,7 @@ import { } from "../../agents/auth-health.js"; import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js"; import { ensureAuthProfileStoreWithoutExternalProfiles as ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; +import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; import { resolveProviderEnvApiKeyCandidates } from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; @@ -29,6 +30,8 @@ import { resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import type { ProviderSyntheticAuthResult } from "../../plugins/provider-external-auth.types.js"; +import { resolveProviderSyntheticAuthWithPlugin } from "../../plugins/provider-runtime.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -57,6 +60,14 @@ let listProbeRuntimePromise: Promise | undefined; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; +type StatusSyntheticAuth = { + value: string; + source: string; + credential?: string; + mode?: ProviderSyntheticAuthResult["mode"]; + expiresAt?: number; +}; + function loadProviderUsageRuntime(): Promise { providerUsageRuntimePromise ??= import("../../infra/provider-usage.js"); return providerUsageRuntimePromise; @@ -77,6 +88,56 @@ function loadListProbeRuntime(): Promise { return listProbeRuntimePromise; } +function resolveProviderConfigForStatus( + cfg: Awaited>, + provider: string, +) { + const providers = cfg.models?.providers ?? {}; + const direct = providers[provider]; + if (direct) { + return direct; + } + const normalized = normalizeProviderId(provider); + return ( + providers[normalized] ?? + Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1] + ); +} + +function syntheticAuthCredential( + provider: string, + auth: StatusSyntheticAuth, +): AuthProfileCredential | undefined { + if (!auth.mode) { + return undefined; + } + if (auth.mode === "api-key") { + return { + type: "api_key", + provider, + key: auth.credential, + }; + } + if (auth.mode === "token") { + return { + type: "token", + provider, + token: auth.credential, + expires: auth.expiresAt, + }; + } + if (auth.expiresAt === undefined) { + return undefined; + } + return { + type: "oauth", + provider, + access: auth.credential ?? "", + refresh: "", + expires: auth.expiresAt, + }; +} + export async function modelsStatusCommand( opts: { json?: boolean; @@ -185,14 +246,30 @@ export async function modelsStatusCommand( providersFromEnv.add(provider); } } - const syntheticAuthByProvider = new Map( - resolveRuntimeSyntheticAuthProviderRefs().map((provider) => [ - normalizeProviderId(provider), - { - value: "plugin-owned", - source: "plugin synthetic auth", + const syntheticAuthByProvider = new Map(); + for (const provider of resolveRuntimeSyntheticAuthProviderRefs()) { + const normalized = normalizeProviderId(provider); + const resolved = resolveProviderSyntheticAuthWithPlugin({ + provider: normalized, + config: cfg, + context: { + config: cfg, + provider: normalized, + providerConfig: resolveProviderConfigForStatus(cfg, normalized), }, - ]), + }); + syntheticAuthByProvider.set(normalized, { + value: "plugin-owned", + source: resolved?.source ?? "plugin synthetic auth", + credential: resolved?.apiKey, + mode: resolved?.mode, + expiresAt: resolved?.expiresAt, + }); + } + const runtimeCredentialsByProvider = new Map( + Array.from(syntheticAuthByProvider.entries()) + .map(([provider, auth]) => [provider, syntheticAuthCredential(provider, auth)] as const) + .filter((entry): entry is readonly [string, AuthProfileCredential] => Boolean(entry[1])), ); const providers = Array.from( @@ -325,6 +402,7 @@ export async function modelsStatusCommand( store, cfg, warnAfterMs: DEFAULT_OAUTH_WARN_MS, + runtimeCredentialsByProvider, }); const oauthProfiles = authHealth.profiles.filter( (profile) => profile.type === "oauth" || profile.type === "token", diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index bcda666929e..72095daad9c 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -121,6 +121,7 @@ const mocks = vi.hoisted(() => { }), loadProviderUsageSummary: vi.fn().mockResolvedValue(undefined), resolveRuntimeSyntheticAuthProviderRefs: vi.fn().mockReturnValue([]), + resolveProviderSyntheticAuthWithPlugin: vi.fn().mockReturnValue(undefined), }; }); @@ -211,6 +212,9 @@ vi.mock("../../infra/provider-usage.js", () => ({ vi.mock("../../plugins/synthetic-auth.runtime.js", () => ({ resolveRuntimeSyntheticAuthProviderRefs: mocks.resolveRuntimeSyntheticAuthProviderRefs, })); +vi.mock("../../plugins/provider-runtime.js", () => ({ + resolveProviderSyntheticAuthWithPlugin: mocks.resolveProviderSyntheticAuthWithPlugin, +})); import { modelsStatusCommand } from "./list.status-command.js"; @@ -427,6 +431,8 @@ describe("modelsStatusCommand auth overview", () => { const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); const originalSyntheticImpl = mocks.resolveRuntimeSyntheticAuthProviderRefs.getMockImplementation(); + const originalResolveSyntheticAuthImpl = + mocks.resolveProviderSyntheticAuthWithPlugin.getMockImplementation(); mocks.loadConfig.mockReturnValue({ agents: { defaults: { @@ -439,6 +445,17 @@ describe("modelsStatusCommand auth overview", () => { }); mocks.resolveEnvApiKey.mockImplementation(() => null); mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue(["codex", "unused-synthetic"]); + mocks.resolveProviderSyntheticAuthWithPlugin.mockImplementation( + ({ provider }: { provider: string }) => + provider === "codex" + ? { + apiKey: "codex-runtime-token", + source: "codex-app-server", + mode: "token", + expiresAt: Date.now() + 60_000, + } + : undefined, + ); try { await modelsStatusCommand({ json: true }, localRuntime as never); @@ -453,13 +470,13 @@ describe("modelsStatusCommand auth overview", () => { expect.arrayContaining([ expect.objectContaining({ provider: "codex", - syntheticAuth: { + syntheticAuth: expect.objectContaining({ value: "plugin-owned", - source: "plugin synthetic auth", - }, + source: "codex-app-server", + }), effective: { kind: "synthetic", - detail: "plugin synthetic auth", + detail: "codex-app-server", }, }), ]), @@ -481,6 +498,13 @@ describe("modelsStatusCommand auth overview", () => { } else { mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue([]); } + if (originalResolveSyntheticAuthImpl) { + mocks.resolveProviderSyntheticAuthWithPlugin.mockImplementation( + originalResolveSyntheticAuthImpl, + ); + } else { + mocks.resolveProviderSyntheticAuthWithPlugin.mockReturnValue(undefined); + } } }); diff --git a/src/plugins/provider-external-auth.types.ts b/src/plugins/provider-external-auth.types.ts index 09bf5b4c8e4..677cf588d41 100644 --- a/src/plugins/provider-external-auth.types.ts +++ b/src/plugins/provider-external-auth.types.ts @@ -12,6 +12,7 @@ export type ProviderSyntheticAuthResult = { apiKey: string; source: string; mode: Exclude; + expiresAt?: number; }; export type ProviderResolveExternalOAuthProfilesContext = {