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>
This commit is contained in:
Peter Steinberger
2026-08-25 14:42:08 -07:00
committed by GitHub
parent fab76d4842
commit fee4e77d91
24 changed files with 537 additions and 326 deletions
@@ -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" } }),
);
});
+6 -1
View File
@@ -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,
+55
View File
@@ -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. */
+1 -54
View File
@@ -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";
+61 -12
View File
@@ -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 () => {
+17 -5
View File
@@ -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<object, boolean>();
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;
+2 -2
View File
@@ -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(),
@@ -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",
@@ -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);
@@ -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 },
+3 -1
View File
@@ -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),
+20 -37
View File
@@ -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<string>;
provider: string;
store: AuthProfileStore;
}): boolean {
}): void {
if (!params.deprecatedProfileIds.has(params.profileId)) {
return false;
}
return true;
}
function assertAuthProfileNotRetired(params: Parameters<typeof isAuthProfileRetired>[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,
+30 -2
View File
@@ -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,
+13 -16
View File
@@ -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,
};
}
+15
View File
@@ -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();
+53 -1
View File
@@ -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: {
+2
View File
@@ -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);
@@ -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["']/);
});
});
@@ -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,
@@ -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 })],
});
});
});
@@ -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<string, "refreshable" | "runtime" | "static">;
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,
}),
@@ -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<ModelAuthAvailabilityEvaluation> {
const resolvePreparedSyntheticCliRuntime = createPreparedSyntheticCliRuntimeResolver({
cfg: params.cfg,
agentId: params.agentId,
metadataSnapshot: params.metadataSnapshot,
});
const pending = new Map<string, Promise<ModelAuthAvailabilityEvaluation>>();
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;
+72 -56
View File
@@ -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" }];
+34
View File
@@ -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,