From fee4e77d91e013eb28525e178af730f631e6dc6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 14:42:08 -0700 Subject: [PATCH] fix(anthropic): native Claude models show incorrect availability (#129346) * fix(anthropic): verify native Claude login before publishing runtime auth * fix(status): preserve native Claude CLI authentication labels * refactor(gateway): remove retired native Claude auth projection * fix(plugin-sdk): retain Claude compatibility types Keep the released Claude CLI credential reader source-compatible through its documented v2026.10 retirement window and explicitly account for both retained type exports in the public SDK budget. Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * fix(plugin-sdk): preserve only shipped Claude compatibility exports * fix(ci): align Claude native-auth contracts Retire Doctor fixtures for credentials now owned by Claude CLI and remove the two never-shipped compatibility type exports while retaining the shipped reader through its documented v2026.10 window. Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * test(onboard): remove retired Claude credential file fixture * fix(auth): skip incompatible profiles before plugin discovery * fix(auth): avoid cold plugin activation for retired profiles * test: repair upstream doctor and learning regressions * fix(onboard): validate authored aliases without plugin discovery * fix(agents): resolve compaction aliases without plugin discovery * test(ui): register pairing sidebar before lifecycle teardown * fix(anthropic): keep provider policy imports runtime-free * test(vitest): cover codex startup retry test family * fix(agents): avoid plugin discovery for internal sessions --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- extensions/anthropic/cli-auth-seam.test.ts | 20 +++ extensions/anthropic/cli-auth-seam.ts | 7 +- extensions/anthropic/cli-constants.ts | 55 ++++++++ extensions/anthropic/cli-shared.ts | 55 +------- extensions/anthropic/index.test.ts | 73 ++++++++-- extensions/anthropic/provider-discovery.ts | 22 ++- extensions/anthropic/register.runtime.ts | 4 +- .../compaction-runtime-context.test.ts | 39 +++++- .../compaction-runtime-context.ts | 12 +- src/agents/model-auth-availability.test.ts | 27 ++++ src/agents/model-auth-availability.ts | 4 +- src/agents/model-auth-provider.ts | 57 +++----- src/agents/model-auth.profiles.test.ts | 32 ++++- src/agents/tools/message-tool-discovery.ts | 29 ++-- src/agents/tools/message-tool.test.ts | 15 ++ src/commands/onboard-custom-config.test.ts | 54 +++++++- src/commands/onboard-custom-config.ts | 2 + .../provider-setup-cold-imports.test.ts | 22 +++ .../models-list-auth-resolver.ts | 60 ++------ ...st-result.cli-runtime-availability.test.ts | 78 +++-------- ...-list-result.openai-routes.test-support.ts | 4 +- .../server-methods/models-list-result.ts | 30 ++-- src/gateway/server-methods/models.test.ts | 128 ++++++++++-------- src/plugins/provider-runtime.test.ts | 34 +++++ 24 files changed, 537 insertions(+), 326 deletions(-) diff --git a/extensions/anthropic/cli-auth-seam.test.ts b/extensions/anthropic/cli-auth-seam.test.ts index c0d6e799bd61..d07381ecb405 100644 --- a/extensions/anthropic/cli-auth-seam.test.ts +++ b/extensions/anthropic/cli-auth-seam.test.ts @@ -32,3 +32,23 @@ it("does not inspect Claude token storage when the CLI reports logout", () => { expect(probeClaudeCliAuthStatus()).toEqual({ status: "missing" }); }); + +it("keeps the selected native-login root while removing inherited provider credentials", () => { + spawnSync.mockReturnValue({ status: 0, stdout: JSON.stringify({ loggedIn: true }) }); + + expect( + probeClaudeCliAuthStatus({ + command: "/custom/claude", + env: { + ANTHROPIC_API_KEY: "synthetic-ignored-api-key", + CLAUDE_CODE_OAUTH_TOKEN: "synthetic-ignored-token", + CLAUDE_CONFIG_DIR: "/tmp/selected-claude-account", + }, + }), + ).toEqual({ status: "available" }); + expect(spawnSync).toHaveBeenCalledWith( + "/custom/claude", + ["auth", "status", "--json"], + expect.objectContaining({ env: { CLAUDE_CONFIG_DIR: "/tmp/selected-claude-account" } }), + ); +}); diff --git a/extensions/anthropic/cli-auth-seam.ts b/extensions/anthropic/cli-auth-seam.ts index 753d7599be01..2c5d43b34505 100644 --- a/extensions/anthropic/cli-auth-seam.ts +++ b/extensions/anthropic/cli-auth-seam.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { CLAUDE_CLI_CLEAR_ENV } from "./cli-constants.js"; type ClaudeCliAuthStatus = { status: "available" } | { status: "missing" | "unreadable" }; @@ -8,9 +9,13 @@ export function probeClaudeCliAuthStatus(params?: { command?: string; env?: NodeJS.ProcessEnv; }): ClaudeCliAuthStatus { + const env = { ...(params?.env ?? process.env) }; + for (const name of CLAUDE_CLI_CLEAR_ENV) { + delete env[name]; + } const result = spawnSync(params?.command ?? "claude", ["auth", "status", "--json"], { encoding: "utf8", - env: params?.env ?? process.env, + env, maxBuffer: 64 * 1024, timeout: 3_000, windowsHide: true, diff --git a/extensions/anthropic/cli-constants.ts b/extensions/anthropic/cli-constants.ts index e93e85374e1b..850cbdd6a356 100644 --- a/extensions/anthropic/cli-constants.ts +++ b/extensions/anthropic/cli-constants.ts @@ -13,6 +13,61 @@ export const CLAUDE_CLI_OFF_THINKING_PROFILE = { } as const; /** Non-secret marker telling OpenClaw that the installed Claude CLI owns auth. */ export const CLAUDE_CLI_NATIVE_AUTH_MARKER = ["openclaw", "claude-cli-native-auth"].join(":"); + +// Claude Code honors provider-routing, auth, and config-root env before +// consulting its local login state, so inherited shell overrides must not +// steer OpenClaw-managed Claude CLI runs toward a different provider, +// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's +// config directory remains inherited because it owns the selected native login. +/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */ +export const CLAUDE_CLI_CLEAR_ENV = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEY_OLD", + "ANTHROPIC_API_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", + "ANTHROPIC_OAUTH_TOKEN", + "ANTHROPIC_UNIX_SOCKET", + // Re-injected per run from OpenClaw's canonical context budget. + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + // Re-injected only for 200K runs. Claude's user settings `env` block has + // higher precedence than the spawned process environment by design. + "CLAUDE_CODE_DISABLE_1M_CONTEXT", + "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING", + // Re-injected per run from OpenClaw's effective thinking level. + "MAX_THINKING_TOKENS", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", + "CLAUDE_CODE_OAUTH_SCOPES", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_PLUGIN_CACHE_DIR", + "CLAUDE_CODE_PLUGIN_SEED_DIR", + "CLAUDE_CODE_REMOTE", + "CLAUDE_CODE_USE_COWORK_PLUGINS", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_VERTEX", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_LOGS_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_SDK_DISABLED", + "OTEL_TRACES_EXPORTER", +] as const; + /** Default Claude CLI model ref for agent defaults and live tests. */ export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-5`; /** Provider-relative model id for Anthropic runtime-policy resolution. */ diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 3e823de61fe1..f745209a1226 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -13,66 +13,13 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; export { CLAUDE_CLI_BACKEND_ID, + CLAUDE_CLI_CLEAR_ENV, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS, CLAUDE_CLI_DEFAULT_MODEL_REF, CLAUDE_CLI_MODEL_ALIASES, CLAUDE_CLI_SESSION_ID_FIELDS, } from "./cli-constants.js"; -// Claude Code honors provider-routing, auth, and config-root env before -// consulting its local login state, so inherited shell overrides must not -// steer OpenClaw-managed Claude CLI runs toward a different provider, -// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's -// config directory remains inherited because it owns the selected native login. -/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */ -export const CLAUDE_CLI_CLEAR_ENV = [ - "ANTHROPIC_API_KEY", - "ANTHROPIC_API_KEY_OLD", - "ANTHROPIC_API_TOKEN", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_CUSTOM_HEADERS", - "ANTHROPIC_OAUTH_TOKEN", - "ANTHROPIC_UNIX_SOCKET", - // Re-injected per run from OpenClaw's canonical context budget. - "CLAUDE_CODE_AUTO_COMPACT_WINDOW", - // Re-injected only for 200K runs. Claude's user settings `env` block has - // higher precedence than the spawned process environment by design. - "CLAUDE_CODE_DISABLE_1M_CONTEXT", - "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING", - // Re-injected per run from OpenClaw's effective thinking level. - "MAX_THINKING_TOKENS", - "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", - "CLAUDE_CODE_ENTRYPOINT", - "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", - "CLAUDE_CODE_OAUTH_SCOPES", - "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", - "CLAUDE_CODE_PLUGIN_CACHE_DIR", - "CLAUDE_CODE_PLUGIN_SEED_DIR", - "CLAUDE_CODE_REMOTE", - "CLAUDE_CODE_USE_COWORK_PLUGINS", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_FOUNDRY", - "CLAUDE_CODE_USE_VERTEX", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", - "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", - "OTEL_EXPORTER_OTLP_LOGS_HEADERS", - "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", - "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", - "OTEL_EXPORTER_OTLP_METRICS_HEADERS", - "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", - "OTEL_EXPORTER_OTLP_PROTOCOL", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", - "OTEL_EXPORTER_OTLP_TRACES_HEADERS", - "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", - "OTEL_LOGS_EXPORTER", - "OTEL_METRICS_EXPORTER", - "OTEL_SDK_DISABLED", - "OTEL_TRACES_EXPORTER", -] as const; - const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions"; const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode"; const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources"; diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 9db9e17f0c0b..d65636e621e8 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -1404,20 +1404,69 @@ describe("anthropic provider replay hooks", () => { } }); - it("resolves claude-cli with a non-secret native auth marker", async () => { - const provider = await registerSingleProviderPlugin(anthropicPlugin); + it.each([ + { status: "available", authenticated: true }, + { status: "missing", authenticated: false }, + { status: "unreadable", authenticated: false }, + ] as const)( + "publishes native Claude auth only when its CLI reports $status", + async ({ status, authenticated }) => { + probeClaudeCliAuthStatusMock.mockReturnValue({ status }); + const provider = await registerSingleProviderPlugin(anthropicPlugin); + const config = {}; - const runtimeAuth = provider.resolveSyntheticAuth?.({ - provider: "claude-cli", - } as never); - const discoveryAuth = anthropicProviderDiscovery.resolveSyntheticAuth?.({ - provider: "claude-cli", - } as never); - for (const auth of [runtimeAuth, discoveryAuth]) { - expect(auth?.apiKey).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER); - expect(auth?.source).toBe("Claude CLI native auth"); - expect(auth?.mode).toBe("oauth"); + const runtimeAuth = provider.resolveSyntheticAuth?.({ + config, + provider: "claude-cli", + } as never); + const discoveryAuth = anthropicProviderDiscovery.resolveSyntheticAuth?.({ + config, + provider: "claude-cli", + } as never); + for (const auth of [runtimeAuth, discoveryAuth]) { + expect(auth).toEqual( + authenticated + ? { + apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER, + source: "Claude CLI native auth", + mode: "oauth", + } + : undefined, + ); + } + expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledOnce(); + }, + ); + + it("reuses native login facts within one config generation and reprobes its replacement", async () => { + probeClaudeCliAuthStatusMock.mockReturnValue({ status: "available" }); + const provider = await registerSingleProviderPlugin(anthropicPlugin); + const firstConfig = {}; + + for (let request = 0; request < 4; request += 1) { + expect( + anthropicProviderDiscovery.resolveSyntheticAuth?.({ + config: firstConfig, + provider: "claude-cli", + } as never)?.apiKey, + ).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER); + expect( + provider.resolveSyntheticAuth?.({ config: firstConfig, provider: "claude-cli" } as never) + ?.apiKey, + ).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER); } + expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledOnce(); + + probeClaudeCliAuthStatusMock.mockReturnValue({ status: "missing" }); + expect( + anthropicProviderDiscovery.resolveSyntheticAuth?.({ + config: {}, + provider: "claude-cli", + } as never), + ).toBeUndefined(); + expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledTimes(2); + expect(provider.resolveSyntheticAuth?.({ provider: "claude-cli" } as never)).toBeUndefined(); + expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledTimes(2); }); it("does not copy native Claude auth during anthropic cli migration", async () => { diff --git a/extensions/anthropic/provider-discovery.ts b/extensions/anthropic/provider-discovery.ts index 537e1219edf0..eb6c553932ed 100644 --- a/extensions/anthropic/provider-discovery.ts +++ b/extensions/anthropic/provider-discovery.ts @@ -3,11 +3,23 @@ * synthetic auth for catalog/runtime discovery without full Anthropic registration. */ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js"; +import { probeClaudeCliAuthStatus } from "./cli-auth-seam.js"; +import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js"; -const CLAUDE_CLI_BACKEND_ID = "claude-cli"; +const nativeLoginAvailabilityByConfig = new WeakMap(); -export function resolveClaudeCliSyntheticAuth() { +export function resolveClaudeCliSyntheticAuth(config: object | undefined) { + if (!config) { + return undefined; + } + let available = nativeLoginAvailabilityByConfig.get(config); + if (available === undefined) { + available = probeClaudeCliAuthStatus().status === "available"; + nativeLoginAvailabilityByConfig.set(config, available); + } + if (!available) { + return undefined; + } return { apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER, source: "Claude CLI native auth", @@ -20,8 +32,8 @@ const anthropicProviderDiscovery: ProviderPlugin = { label: "Claude CLI", docsPath: "/providers/models", auth: [], - resolveSyntheticAuth: ({ provider }) => - provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth() : undefined, + resolveSyntheticAuth: ({ config, provider }) => + provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth(config) : undefined, }; export default anthropicProviderDiscovery; diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index ce8e0b660709..dc588b2bc802 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -1179,9 +1179,9 @@ export function buildAnthropicProvider(): ProviderPlugin { ); }, normalizeResolvedModel: (ctx) => normalizeAnthropicResolvedModel(ctx), - resolveSyntheticAuth: ({ provider }) => + resolveSyntheticAuth: ({ config, provider }) => normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID - ? resolveClaudeCliSyntheticAuth() + ? resolveClaudeCliSyntheticAuth(config) : undefined, // Publish Claude CLI rows through the provider catalog hook. augmentModelCatalog: () => buildClaudeCliCatalogEntries(), diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index bc1063f575ac..9ef60fd34f9b 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -1,11 +1,13 @@ // Coverage for building compaction runtime context from active runner state. import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/config.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; +import * as manifestModelIdNormalization from "../../plugins/manifest-model-id-normalization.js"; import { addSession, deleteSession } from "../bash-process-registry.js"; import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js"; +import * as providerModelNormalizationRuntime from "../provider-model-normalization.runtime.js"; import { buildEmbeddedCompactionRuntimeContext, resolveCompactionContextTokenBudget, @@ -219,6 +221,41 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.authProfileId).toBe("openai:p1"); }); + it("resolves literal compaction overrides without discovering provider plugins", () => { + const manifestNormalization = vi + .spyOn(manifestModelIdNormalization, "normalizeProviderModelIdWithManifest") + .mockImplementation(() => { + throw new Error("literal compaction overrides must not discover plugin manifests"); + }); + const runtimeNormalization = vi + .spyOn(providerModelNormalizationRuntime, "normalizeProviderModelIdWithRuntime") + .mockImplementation(() => { + throw new Error("literal compaction overrides must not activate provider plugins"); + }); + + try { + const result = buildEmbeddedCompactionRuntimeContext({ + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + config: { + agents: { defaults: { compaction: { model: "gpt-4o" } } }, + } as OpenClawConfig, + provider: "openai", + modelId: "gpt-3.5-turbo", + authProfileId: "openai:p1", + }); + + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-4o"); + expect(result.authProfileId).toBe("openai:p1"); + expect(manifestNormalization).not.toHaveBeenCalled(); + expect(runtimeNormalization).not.toHaveBeenCalled(); + } finally { + runtimeNormalization.mockRestore(); + manifestNormalization.mockRestore(); + } + }); + it("uses session model when no compaction.model override configured", () => { const result = buildEmbeddedCompactionRuntimeContext({ workspaceDir: "/tmp/workspace", diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index e9c01c865b21..9e021eb3e7fd 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -15,7 +15,6 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_PROVIDER } from "../defaults.js"; import { buildModelAliasIndex, inferUniqueProviderFromConfiguredModels, - resolveModelRefFromString, } from "../model-selection-shared.js"; import { resolveSelectedOpenAIRuntimeProvider } from "../openai-routing.js"; import { agentRuntimeAuthPlanMatchesTarget } from "../runtime-plan/prepare-auth.js"; @@ -191,16 +190,11 @@ export function resolveEmbeddedCompactionTarget(params: { return assembleTarget(inferredLiteralProvider, override); } const defaultProvider = provider || DEFAULT_PROVIDER; - const aliasResolution = resolveModelRefFromString({ + const aliasResolution = buildModelAliasIndex({ cfg: config, - raw: override, defaultProvider, - aliasIndex: buildModelAliasIndex({ - cfg: config, - defaultProvider, - }), - }); - if (aliasResolution?.alias) { + }).byAlias.get(normalizeCompactionConfigKey(override)); + if (aliasResolution) { return assembleTarget(aliasResolution.ref.provider, aliasResolution.ref.model); } return assembleTarget(provider, override); diff --git a/src/agents/model-auth-availability.test.ts b/src/agents/model-auth-availability.test.ts index 3b25baadfb5d..f8ec997860e3 100644 --- a/src/agents/model-auth-availability.test.ts +++ b/src/agents/model-auth-availability.test.ts @@ -146,6 +146,33 @@ describe("createModelAuthAvailabilityResolver", () => { }); }); + it("keeps prepared native-runtime authentication scoped to its exact owner", () => { + const metadataSnapshot = { + index: { plugins: [] }, + plugins: [ + { + id: "anthropic", + origin: "bundled", + providerAuthAliases: { "claude-cli": "anthropic" }, + }, + ], + } as unknown as PluginMetadataSnapshot; + const resolver = createModelAuthAvailabilityResolver({ + cfg: {}, + authStore: authStore(), + env: {}, + metadataSnapshot, + preparedRuntimeAuthModes: { "claude-cli": "api_key" }, + }); + + expect(resolver.evaluateModelAuth("claude-cli")).toMatchObject({ + availability: true, + evidence: "runtime", + selectedAuthMode: "api_key", + }); + expect(resolver.evaluateModelAuth("anthropic").availability).not.toBe(true); + }); + it.each([ { mode: "api_key" as const, selectedRoute: platformRoute }, { mode: "oauth" as const, selectedRoute: subscriptionRoute }, diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index d0023f77330a..1edd0e9438d7 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -622,7 +622,9 @@ export function createModelAuthAvailabilityResolver( evidence: "aws-sdk", }; } - const preparedRuntimeAuthMode = params.preparedRuntimeAuthModes?.[normalizeProvider(provider)]; + const preparedRuntimeAuthMode = + params.preparedRuntimeAuthModes?.[normalizeProviderIdForAuth(provider)] ?? + params.preparedRuntimeAuthModes?.[normalizeProvider(provider)]; if (preparedRuntimeAuthMode) { return { availability: modeAllowed(provider, target, preparedRuntimeAuthMode), diff --git a/src/agents/model-auth-provider.ts b/src/agents/model-auth-provider.ts index 796c4f731754..8ea61a0edbd8 100644 --- a/src/agents/model-auth-provider.ts +++ b/src/agents/model-auth-provider.ts @@ -39,20 +39,11 @@ export type ProviderCredentialPrecedence = "profile-first" | "env-first"; const log = createSubsystemLogger("model-auth"); -function isAuthProfileRetired(params: { +function assertAuthProfileNotRetired(params: { profileId: string; deprecatedProfileIds: ReadonlySet; - provider: string; - store: AuthProfileStore; -}): boolean { +}): void { if (!params.deprecatedProfileIds.has(params.profileId)) { - return false; - } - return true; -} - -function assertAuthProfileNotRetired(params: Parameters[0]): void { - if (!isAuthProfileRetired(params)) { return; } throw new Error( @@ -153,8 +144,6 @@ export async function resolveApiKeyForProviderCore(params: { assertAuthProfileNotRetired({ profileId, deprecatedProfileIds: getDeprecatedProfileIds(), - provider, - store, }); const configuredProfileType = store.profiles[profileId]?.type; if (configuredProfileType) { @@ -311,8 +300,6 @@ export async function resolveApiKeyForProviderCore(params: { assertAuthProfileNotRetired({ profileId: providerEntryReference.profileId, deprecatedProfileIds: getDeprecatedProfileIds(), - provider, - store: providerEntryStore, }); } const providerEntryBinding = await authConfig.resolveProviderEntryApiKeyBinding({ @@ -417,19 +404,27 @@ export async function resolveApiKeyForProviderCore(params: { provider, preferredProfile, forModel: params.modelId, - }).filter( - (candidateProfileId) => - !isAuthProfileRetired({ - profileId: candidateProfileId, - deprecatedProfileIds: getDeprecatedProfileIds(), - provider, - store, - }), - ); + }); let deferredAuthProfileResult: ResolvedProviderAuth | null = null; let refreshFailure: OAuthRefreshFailureError | undefined; for (const candidate of order) { - let candidateMode: ResolvedProviderAuth["mode"] | undefined; + const candidateType = store.profiles[candidate]?.type; + const candidateMode = candidateType + ? authConfig.profileTypeToAuthMode(candidateType) + : undefined; + if ( + candidateMode && + !isAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + mode: candidateMode, + }) + ) { + continue; + } + if (getDeprecatedProfileIds().has(candidate)) { + continue; + } try { const awsSdkProfileAuth = authConfig.resolveConfiguredAwsSdkProfileAuth({ cfg, @@ -439,18 +434,6 @@ export async function resolveApiKeyForProviderCore(params: { if (awsSdkProfileAuth) { return awsSdkProfileAuth; } - const candidateType = store.profiles[candidate]?.type; - candidateMode = candidateType ? authConfig.profileTypeToAuthMode(candidateType) : undefined; - if ( - candidateMode && - !isAuthModeAllowedForModel({ - provider, - modelApi: params.modelApi, - mode: candidateMode, - }) - ) { - continue; - } const resolved = await resolveApiKeyForProfile({ cfg, store, diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index c8d25baa24b5..94b798d90059 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -206,6 +206,12 @@ vi.mock("./model-auth-env-vars.js", () => { }; }); +const resolveProviderDeprecatedAuthProfileIdsMock = vi.hoisted(() => + vi.fn(({ provider }: { provider: string }) => + provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [], + ), +); + vi.mock("../plugins/provider-runtime.js", () => ({ buildProviderMissingAuthMessageWithPlugin: (params: { provider: string; @@ -218,8 +224,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ }, formatProviderAuthProfileApiKeyWithPlugin: async () => undefined, refreshProviderOAuthCredentialWithPlugin: async () => null, - resolveProviderDeprecatedAuthProfileIds: ({ provider }: { provider: string }) => - provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [], + resolveProviderDeprecatedAuthProfileIds: resolveProviderDeprecatedAuthProfileIdsMock, resolveProviderSyntheticAuthWithPlugin: (params: { provider: string; context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } }; @@ -267,6 +272,7 @@ vi.mock("./cli-credentials.js", () => cliCredentialMocks); beforeEach(() => { clearRuntimeAuthProfileStoreSnapshots(); + resolveProviderDeprecatedAuthProfileIdsMock.mockClear(); cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); }); @@ -520,6 +526,28 @@ describe("getApiKeyForModelCore", () => { ).rejects.toThrow(/requires an OpenAI API key profile/); }); + it("skips incompatible OpenAI OAuth profiles before loading provider retirement policy", async () => { + await withEnvAsync({ OPENAI_API_KEY: "direct-openai-audio-key" }, async () => { + const resolved = await resolveApiKeyForProviderCore({ + provider: "openai", + modelApi: "openai-audio-transcriptions", + store: { + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + ...oauthFixture, + }, + }, + }, + }); + + expect(resolved).toMatchObject({ apiKey: "direct-openai-audio-key", mode: "api-key" }); + expect(resolveProviderDeprecatedAuthProfileIdsMock).not.toHaveBeenCalled(); + }); + }); + it("rejects an explicit OpenAI API-key profile for the Codex transport", async () => { const store = { version: 1 as const, diff --git a/src/agents/tools/message-tool-discovery.ts b/src/agents/tools/message-tool-discovery.ts index afd5023a4158..7465e6352326 100644 --- a/src/agents/tools/message-tool-discovery.ts +++ b/src/agents/tools/message-tool-discovery.ts @@ -20,7 +20,7 @@ import type { ChannelMessageActionName } from "../../channels/plugins/types.publ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveAllowedMessageActions } from "../../infra/outbound/outbound-policy.js"; import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js"; import { appendMessageToolReadHint, @@ -89,7 +89,7 @@ function inferDeliveryFromSessionKey( return null; } const channel = normalizeMessageChannel(route.channel); - if (!channel) { + if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) { return null; } const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined; @@ -112,15 +112,12 @@ export function resolveEffectiveCurrentChannelContext(options?: MessageToolCurre } { const currentChannelProvider = options?.currentChannelProvider; const currentChannelId = options?.currentChannelId; - const sessionDelivery = inferDeliveryFromSessionKey(options?.agentSessionKey); - const sessionDeliveryChannel = normalizeMessageChannel(sessionDelivery?.channel); - const preferSessionDeliveryContext = - normalizeMessageChannel(currentChannelProvider) === "webchat" && - sessionDeliveryChannel !== undefined && - sessionDeliveryChannel !== "webchat" && - Boolean(sessionDelivery?.to); + const sessionDelivery = + normalizeMessageChannel(currentChannelProvider) === INTERNAL_MESSAGE_CHANNEL + ? inferDeliveryFromSessionKey(options?.agentSessionKey) + : null; - if (!preferSessionDeliveryContext) { + if (!sessionDelivery?.to) { return { currentChannelProvider, currentChannelId, @@ -129,12 +126,12 @@ export function resolveEffectiveCurrentChannelContext(options?: MessageToolCurre }; } return { - accountId: sessionDelivery?.accountId, - currentChannelProvider: sessionDeliveryChannel, - currentChannelId: sessionDelivery?.to, - currentChatType: sessionDelivery?.chatType, - currentMessagingTarget: sessionDelivery?.to, - currentThreadTs: sessionDelivery?.threadId, + accountId: sessionDelivery.accountId, + currentChannelProvider: sessionDelivery.channel, + currentChannelId: sessionDelivery.to, + currentChatType: sessionDelivery.chatType, + currentMessagingTarget: sessionDelivery.to, + currentThreadTs: sessionDelivery.threadId, }; } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 2b245d081333..aa2dc67ce007 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1174,6 +1174,21 @@ describe("message tool secret scoping", () => { expect(input?.params).toMatchObject({ action: "send", message: "hi" }); }); + it("does not discover bundled plugins for an internal WebChat session", async () => { + const { getBundledChannelPlugin } = await import("../../channels/plugins/bundled.js"); + const bundledPluginLookup = vi.mocked(getBundledChannelPlugin); + bundledPluginLookup.mockClear(); + + createMessageTool({ + config: { agents: { entries: { main: { default: true } } } }, + preparedMessageToolCatalog: EMPTY_PREPARED_MESSAGE_TOOL_CATALOG, + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:webchat:dm:dashboard", + }); + + expect(bundledPluginLookup).not.toHaveBeenCalled(); + }); + it("keeps automatic WebChat final-answer guidance while selecting the tool-local sink", async () => { mockSendResult(); diff --git a/src/commands/onboard-custom-config.test.ts b/src/commands/onboard-custom-config.test.ts index 0aa0486fcf1f..c3150035cc5e 100644 --- a/src/commands/onboard-custom-config.test.ts +++ b/src/commands/onboard-custom-config.test.ts @@ -1,12 +1,15 @@ // Onboard custom config tests cover provider-specific config merging and context-window bounds. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; import type { OpenClawConfig } from "../config/config.js"; +import * as currentPluginMetadataSnapshot from "../plugins/current-plugin-metadata-snapshot.js"; +import * as manifestContractEligibility from "../plugins/manifest-contract-eligibility.js"; import { applyCustomApiConfig, buildAnthropicVerificationProbeRequest, buildOpenAiVerificationProbeRequest, parseNonInteractiveCustomApiFlags, + resolveCustomModelAliasError, resolveCustomModelImageInputInference, } from "./onboard-custom-config.js"; @@ -120,6 +123,55 @@ it("rejects custom aliases already used by the selected agent", () => { ).toThrow("Alias Operations already points to openai/ops."); }); +it("validates authored and inherited aliases without discovering plugin metadata", () => { + const currentSnapshot = vi + .spyOn(currentPluginMetadataSnapshot, "getCurrentPluginMetadataSnapshot") + .mockImplementation(() => { + throw new Error("authored alias validation must not inspect plugin metadata"); + }); + const loadedSnapshot = vi + .spyOn(manifestContractEligibility, "loadManifestMetadataSnapshot") + .mockImplementation(() => { + throw new Error("authored alias validation must not load plugin metadata"); + }); + const cfg = { + agents: { + defaults: { models: { "anthropic/global": { alias: "Global" } } }, + entries: { ops: { models: { "openai/ops": { alias: "Operations" } } } }, + }, + } as OpenClawConfig; + + try { + expect( + resolveCustomModelAliasError({ + raw: "Operations", + cfg, + agentId: "ops", + modelRef: "custom/new-model", + }), + ).toBe("Alias Operations already points to openai/ops."); + expect( + resolveCustomModelAliasError({ + raw: "Global", + cfg, + agentId: "ops", + modelRef: "custom/new-model", + }), + ).toBe("Alias Global already points to anthropic/global."); + expect( + resolveCustomModelAliasError({ + raw: "Operations", + cfg, + agentId: "ops", + modelRef: "openai/ops", + }), + ).toBeUndefined(); + } finally { + currentSnapshot.mockRestore(); + loadedSnapshot.mockRestore(); + } +}); + it("preserves a list-form roster when applying custom-provider model state", () => { const result = applyCustomApiConfig({ config: { diff --git a/src/commands/onboard-custom-config.ts b/src/commands/onboard-custom-config.ts index 38c2d1a1c0e0..2c8dd044d6fa 100644 --- a/src/commands/onboard-custom-config.ts +++ b/src/commands/onboard-custom-config.ts @@ -317,6 +317,8 @@ export function resolveCustomModelAliasError(params: { cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER, agentId: params.agentId, + allowManifestNormalization: false, + allowPluginNormalization: false, }); const aliasKey = normalizeLowercaseStringOrEmpty(normalized); const existing = aliasIndex.byAlias.get(aliasKey); diff --git a/src/commands/provider-setup-cold-imports.test.ts b/src/commands/provider-setup-cold-imports.test.ts index 8fc3e2593652..b6c969f79f1e 100644 --- a/src/commands/provider-setup-cold-imports.test.ts +++ b/src/commands/provider-setup-cold-imports.test.ts @@ -37,4 +37,26 @@ describe("provider setup cold imports", () => { } } }); + + it("keeps bundled provider policy and config defaults off credential and execution runtime", () => { + for (const file of [ + "extensions/anthropic/config-defaults.ts", + "extensions/anthropic/provider-policy-api.ts", + ]) { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + expect( + source, + `${file} must not load credential runtime for a provider-owned constant`, + ).not.toMatch(/from\s+["']openclaw\/plugin-sdk\/provider-auth["']/); + } + + const policySource = fs.readFileSync( + path.join(repoRoot, "extensions/anthropic/provider-policy-api.ts"), + "utf8", + ); + expect( + policySource, + "lightweight provider policy must not load CLI execution runtime", + ).not.toMatch(/from\s+["']\.\/cli-shared\.js["']/); + }); }); diff --git a/src/gateway/server-methods/models-list-auth-resolver.ts b/src/gateway/server-methods/models-list-auth-resolver.ts index bbddd44d6421..bf8edfbcd863 100644 --- a/src/gateway/server-methods/models-list-auth-resolver.ts +++ b/src/gateway/server-methods/models-list-auth-resolver.ts @@ -1,4 +1,3 @@ -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credential-modes.js"; import { resolveAgentDir } from "../../agents/agent-scope.js"; import { resolveExternalCliAuthScopeFromConfig } from "../../agents/auth-profiles/external-cli-scope.js"; @@ -8,64 +7,22 @@ import { createModelAuthAvailabilityResolver, type ModelAuthAvailabilityResolver, } from "../../agents/model-auth-availability.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { normalizePluginsConfig } from "../../plugins/config-state.js"; -import { isActivatedManifestOwner } from "../../plugins/manifest-owner-policy.js"; +import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest-contract-eligibility.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import { resolveModelChoiceAgentRuntime } from "./models-list-public-projection.js"; function listEnabledSyntheticAuthProviderRefs( metadataSnapshot: PluginMetadataSnapshot, + config: OpenClawConfig, ): readonly string[] { - return metadataSnapshot.index.plugins - .filter((plugin) => plugin.enabled) + return metadataSnapshot.plugins + .filter((plugin) => + isManifestPluginAvailableForControlPlane({ snapshot: metadataSnapshot, plugin, config }), + ) .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } -export function createPreparedSyntheticCliRuntimeResolver(params: { - cfg: OpenClawConfig; - agentId: string; - metadataSnapshot: PluginMetadataSnapshot; -}): (entry: ModelCatalogEntry) => string | undefined { - const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins); - const activatedPluginIds = new Set( - params.metadataSnapshot.plugins - .filter((plugin) => - isActivatedManifestOwner({ - plugin, - normalizedConfig: normalizedPluginConfig, - rootConfig: params.cfg, - }), - ) - .map((plugin) => plugin.id), - ); - return (entry) => { - const runtime = normalizeProviderId( - resolveModelChoiceAgentRuntime({ - cfg: params.cfg, - agentId: params.agentId, - entry, - })?.id ?? "", - ); - if (!runtime || runtime === "openclaw") { - return undefined; - } - const provider = normalizeProviderId(entry.provider); - const providerOwners = new Set(params.metadataSnapshot.owners.providers.get(provider) ?? []); - const owners = (params.metadataSnapshot.owners.cliBackends.get(runtime) ?? []).filter( - (pluginId) => - providerOwners.has(pluginId) && - activatedPluginIds.has(pluginId) && - params.metadataSnapshot.byPluginId - .get(pluginId) - ?.syntheticAuthRefs?.some((candidate) => normalizeProviderId(candidate) === runtime), - ); - return owners.length === 1 ? runtime : undefined; - }; -} - export function createModelsListAuthResolver(params: { cfg: OpenClawConfig; agentId: string; @@ -87,7 +44,10 @@ export function createModelsListAuthResolver(params: { preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, skipSetupProviderFallback: true, - syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params.metadataSnapshot), + syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs( + params.metadataSnapshot, + params.cfg, + ), externalCliProviderIds: resolveExternalCliAuthScopeFromConfig(params.cfg)?.providerIds ?? [], preparedRuntimeAuthStore: params.preparedAuthStore, routeResolverFactory: params.routeResolverFactory, diff --git a/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts b/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts index 21a31d4cd2a4..7cbb5019a928 100644 --- a/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts +++ b/src/gateway/server-methods/models-list-result.cli-runtime-availability.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; -import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { listModels, providerCatalogEntry, @@ -24,15 +22,21 @@ const config = { async function listClaudeCliModel( params: { + authenticated?: boolean; + pluginDisabled?: boolean; cfg?: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; } = {}, ) { return await listModels({ catalog: [], staticEntries: [providerCatalogEntry("anthropic", "claude-opus-5")], - cfg: params.cfg ?? config, - ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), + cfg: + params.cfg ?? + (params.pluginDisabled + ? { ...config, plugins: { entries: { anthropic: { enabled: false } } } } + : config), + preparedAuthModes: + params.authenticated && !params.pluginDisabled ? { "claude-cli": "api_key" } : {}, view: "configured", }); } @@ -46,25 +50,18 @@ describe("models.list CLI runtime availability", () => { vi.unstubAllEnvs(); }); - it("marks a Claude CLI runtime model available through bundled synthetic auth", async () => { - await expect(listClaudeCliModel()).resolves.toEqual({ - models: [expect.objectContaining({ id: "claude-opus-5", available: true })], - }); - }); - - it("does not use synthetic auth from an explicitly disabled Anthropic plugin", async () => { - await expect( - listClaudeCliModel({ - cfg: { - ...config, - plugins: { entries: { anthropic: { enabled: false } } }, - }, - }), - ).resolves.toEqual({ - models: [expect.objectContaining({ id: "claude-opus-5", available: false })], - }); - }); - + it.each([ + { authenticated: true, pluginDisabled: false, available: true }, + { authenticated: false, pluginDisabled: false, available: false }, + { authenticated: true, pluginDisabled: true, available: false }, + ])( + "reports native login=$authenticated and plugin disabled=$pluginDisabled", + async (scenario) => { + await expect(listClaudeCliModel(scenario)).resolves.toEqual({ + models: [expect.objectContaining({ id: "claude-opus-5", available: scenario.available })], + }); + }, + ); it("does not use synthetic auth when plugins are globally disabled", async () => { await expect( listClaudeCliModel({ @@ -77,37 +74,4 @@ describe("models.list CLI runtime availability", () => { models: [expect.objectContaining({ id: "claude-opus-5", available: false })], }); }); - - it("does not choose between multiple active runtime owners", async () => { - const metadataSnapshot = loadManifestMetadataSnapshot({ config, env: process.env }); - const anthropic = metadataSnapshot.plugins.find((plugin) => plugin.id === "anthropic"); - if (!anthropic) { - throw new Error("Anthropic manifest missing from model availability fixture"); - } - const duplicate = { ...anthropic, id: "anthropic-duplicate" }; - const providerOwners = new Map(metadataSnapshot.owners.providers); - providerOwners.set("anthropic", [...(providerOwners.get("anthropic") ?? []), duplicate.id]); - const cliBackendOwners = new Map(metadataSnapshot.owners.cliBackends); - cliBackendOwners.set("claude-cli", [ - ...(cliBackendOwners.get("claude-cli") ?? []), - duplicate.id, - ]); - - await expect( - listClaudeCliModel({ - metadataSnapshot: { - ...metadataSnapshot, - plugins: [...metadataSnapshot.plugins, duplicate], - byPluginId: new Map([...metadataSnapshot.byPluginId, [duplicate.id, duplicate]]), - owners: { - ...metadataSnapshot.owners, - providers: providerOwners, - cliBackends: cliBackendOwners, - }, - }, - }), - ).resolves.toEqual({ - models: [expect.objectContaining({ id: "claude-opus-5", available: false })], - }); - }); }); diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts index d74495b39476..3afc10c7411b 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts @@ -1,3 +1,4 @@ +import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credential-modes.js"; import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; @@ -47,6 +48,7 @@ export async function listModels(params: { staticEntries?: ModelCatalogEntry[]; cfg?: OpenClawConfig; discoveryModes?: Record; + preparedAuthModes?: PreparedAgentCredentialModes; metadataSnapshot?: PluginMetadataSnapshot; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; view?: "all" | "configured" | "provider-config" | "default"; @@ -60,7 +62,7 @@ export async function listModels(params: { catalogComplete: false, workspaceDir: "/tmp/models-list-openai-workspace", config, - authModes: {}, + authModes: params.preparedAuthModes ?? {}, authStore: loadAuthProfileStoreWithoutExternalProfiles("/tmp/models-list-openai-agent", { allowKeychainPrompt: false, }), diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 363caf08cabc..d8ac0af5a9dd 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -51,10 +51,7 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import { loadDeferredCatalog, readPreparedCatalog } from "../server-model-catalog-auth.js"; import { resolveGatewayModelThinkingProfile } from "../session-utils-model.js"; import { resolveModelProviderCapabilities } from "./model-provider-capabilities.js"; -import { - createModelsListAuthResolver, - createPreparedSyntheticCliRuntimeResolver, -} from "./models-list-auth-resolver.js"; +import { createModelsListAuthResolver } from "./models-list-auth-resolver.js"; import { prepareModelsListHarnessCatalog } from "./models-list-harness-catalog.js"; import { buildPublicModelProjection, @@ -86,27 +83,24 @@ function resolveLegacyEntryAvailability(params: { cfg: OpenClawConfig; agentId: string; metadataSnapshot: PluginMetadataSnapshot; - resolvePreparedSyntheticCliRuntime: (entry: ModelCatalogEntry) => string | undefined; }): ModelAuthAvailability { if (params.primaryAvailability === true) { return true; } let available = params.primaryAvailability; - const preparedSyntheticRuntime = params.resolvePreparedSyntheticCliRuntime(params.entry); - const runtimeProvider = - resolveCliRuntimeExecutionProvider({ - provider: params.entry.provider, - cfg: params.cfg, - agentId: params.agentId, - modelId: params.entry.id, - metadataSnapshot: params.metadataSnapshot, - }) ?? preparedSyntheticRuntime; + const runtimeProvider = resolveCliRuntimeExecutionProvider({ + provider: params.entry.provider, + cfg: params.cfg, + agentId: params.agentId, + modelId: params.entry.id, + metadataSnapshot: params.metadataSnapshot, + }); if ( runtimeProvider && normalizeProviderId(runtimeProvider) !== normalizeProviderId(params.entry.provider) ) { const runtimeAvailable = params.authResolver.resolveProviderAuthAvailability(runtimeProvider); - if (runtimeAvailable === true || preparedSyntheticRuntime === runtimeProvider) { + if (runtimeAvailable === true) { return true; } if (available === false && runtimeAvailable === undefined) { @@ -128,11 +122,6 @@ function createModelsListEntryEvaluator(params: { entry: ModelCatalogEntry, routeVariants?: readonly ModelCatalogEntry[], ) => Promise { - const resolvePreparedSyntheticCliRuntime = createPreparedSyntheticCliRuntimeResolver({ - cfg: params.cfg, - agentId: params.agentId, - metadataSnapshot: params.metadataSnapshot, - }); const pending = new Map>(); return (entry, routeVariants = [entry]) => { const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry); @@ -162,7 +151,6 @@ function createModelsListEntryEvaluator(params: { cfg: params.cfg, agentId: params.agentId, metadataSnapshot: params.metadataSnapshot, - resolvePreparedSyntheticCliRuntime, }), } : evaluation; diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index fff113de6e0c..fb9d8708e579 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -11,6 +11,7 @@ import { loadAuthProfileStoreWithoutExternalProfiles, replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles.js"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js"; import type { PreparedModelRuntimeAuth } from "../../agents/prepared-model-runtime-auth.js"; import { materializeRuntimeCapabilities } from "../../agents/prepared-model-runtime.configured-catalog.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; @@ -1633,67 +1634,82 @@ describe("models.list", () => { }); }); - it("keeps catalog models available through a native CLI runtime", async () => { - await withoutAnthropicEnvAuth(async () => { - await withModelsTestState( - { - layout: "state-only", - prefix: "openclaw-models-list-cli-runtime-", - agentEnv: "main", - }, - async () => { - const runtimeConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-8": { - agentRuntime: { id: "claude-cli" }, + it.each([ + { authenticated: true, available: true }, + { authenticated: false, available: false }, + ])( + "projects native Claude runtime availability when authenticated=$authenticated", + async ({ authenticated, available }) => { + await withoutAnthropicEnvAuth(async () => { + await withModelsTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-cli-runtime-", + agentEnv: "main", + }, + async () => { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => + [{ id: "claude-cli", modelProvider: "anthropic", pluginId: "anthropic" }] as never, + }); + try { + const runtimeConfig = { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-8": { + agentRuntime: { id: "claude-cli" }, + }, + }, }, }, - }, - }, - } as unknown as OpenClawConfig; - const { request, respond } = requestModelsList({ - view: "all", - runtimeConfig, - loadGatewayModelCatalog: vi.fn(() => - Promise.resolve([ - { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - provider: "anthropic", - }, - ]), - ), - reqId: "req-models-list-cli-runtime", - }); - await request; + } as unknown as OpenClawConfig; + const { request, respond } = requestModelsList({ + view: "all", + runtimeConfig, + preparedAuthModes: authenticated ? { "claude-cli": "api_key" } : {}, + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + provider: "anthropic", + }, + ]), + ), + reqId: "req-models-list-cli-runtime", + }); + await request; - expect(respond).toHaveBeenCalledWith( - true, - { - models: [ + expect(respond).toHaveBeenCalledWith( + true, { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - provider: "anthropic", - agentRuntime: { - id: "claude-cli", - cloudPlacementSupported: false, - devicePlacementSupported: false, - source: "model", - }, - available: true, - tags: ["configured"], + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + provider: "anthropic", + agentRuntime: { + id: "claude-cli", + cloudPlacementSupported: false, + devicePlacementSupported: false, + source: "model", + }, + available, + tags: ["configured"], + }, + ], }, - ], - }, - undefined, - ); - }, - ); - }); - }); + undefined, + ); + } finally { + cliBackendsTesting.resetDepsForTest(); + } + }, + ); + }); + }, + ); it("keeps file SecretRef provider availability unknown when read-only auth cannot resolve it", async () => { const catalog = [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 4108ae0e5755..db374f90cf6b 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -625,6 +625,40 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); }); + it("does not activate provider runtime to inspect retired auth profiles", () => { + resolvePluginProvidersMock.mockReturnValue([ + { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + deprecatedProfileIds: ["demo:retired"], + }, + ]); + + expect(resolveProviderDeprecatedAuthProfileIds({ provider: DEMO_PROVIDER_ID })).toEqual([]); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); + + it("honors retired auth profiles declared by an active provider", () => { + const provider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + deprecatedProfileIds: ["demo:retired"], + }; + const registry = createEmptyPluginRegistry(); + registry.providers.push({ pluginId: DEMO_PROVIDER_ID, provider, source: "test" }); + setActivePluginRegistry(registry, "startup-registry", "gateway-bindable", "/tmp/workspace"); + + expect( + resolveProviderDeprecatedAuthProfileIds({ + provider: DEMO_PROVIDER_ID, + workspaceDir: "/tmp/workspace", + }), + ).toEqual(["demo:retired"]); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); + it("uses the prepared run registry without repeating provider discovery", () => { const provider: ProviderPlugin = { id: DEMO_PROVIDER_ID,