diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts new file mode 100644 index 00000000000..29af3ca45f9 --- /dev/null +++ b/src/agents/model-auth-markers.ts @@ -0,0 +1,44 @@ +import type { SecretRefSource } from "../config/types.secrets.js"; + +export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const QWEN_OAUTH_MARKER = "qwen-oauth"; +export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; +export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; + +const AWS_SDK_ENV_MARKERS = new Set([ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", +]); + +export function isEnvVarNameMarker(value: string): boolean { + return /^[A-Z][A-Z0-9_]*$/.test(value.trim()); +} + +export function isAwsSdkAuthMarker(value: string): boolean { + return AWS_SDK_ENV_MARKERS.has(value.trim()); +} + +export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function isNonSecretApiKeyMarker( + value: string, + opts?: { includeEnvVarName?: boolean }, +): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const isKnownMarker = + trimmed === MINIMAX_OAUTH_MARKER || + trimmed === QWEN_OAUTH_MARKER || + trimmed === OLLAMA_LOCAL_AUTH_MARKER || + trimmed === NON_ENV_SECRETREF_MARKER || + isAwsSdkAuthMarker(trimmed); + if (isKnownMarker) { + return true; + } + return opts?.includeEnvVarName === false ? false : isEnvVarNameMarker(trimmed); +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 68a117c96a9..257ee985c1c 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -16,6 +16,7 @@ import { resolveAuthProfileOrder, resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -90,7 +91,7 @@ function resolveSyntheticLocalProviderAuth(params: { } return { - apiKey: "ollama-local", // pragma: allowlist secret + apiKey: OLLAMA_LOCAL_AUTH_MARKER, source: "models.providers.ollama (synthetic local key)", mode: "api-key", }; diff --git a/src/agents/models-config.file-mode.test.ts b/src/agents/models-config.file-mode.test.ts new file mode 100644 index 00000000000..af5719082da --- /dev/null +++ b/src/agents/models-config.file-mode.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +describe("models-config file mode", () => { + it("writes models.json with mode 0600", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); + + it("repairs models.json mode to 0600 on no-content-change paths", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + await fs.chmod(modelsPath, 0o644); + + const result = await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + expect(result.wrote).toBe(false); + + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); +}); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index bb3ca7a7cbe..222de727cf9 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -220,6 +220,85 @@ describe("models-config", () => { }); }); + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "STALE_AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" }, + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + }); + }); + + it("replaces stale merged apiKey when provider is SecretRef-managed via auth-profiles", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "minimax:default": { + type: "api_key", + provider: "minimax", + keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeAgentModelsJson({ + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "STALE_AGENT_KEY", + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: {}, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); + }); + }); + it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts new file mode 100644 index 00000000000..0a606762d66 --- /dev/null +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + MINIMAX_OAUTH_MARKER, + NON_ENV_SECRETREF_MARKER, + QWEN_OAUTH_MARKER, +} from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("models-config provider auth provenance", () => { + it("persists env keyRef and tokenRef auth profiles as env var markers", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY", "TOGETHER_API_KEY"]); + delete process.env.VOLCANO_ENGINE_API_KEY; + delete process.env.TOGETHER_API_KEY; + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "volcengine:default": { + type: "api_key", + provider: "volcengine", + keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, + }, + "together:default": { + type: "token", + provider: "together", + tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for ref-managed profiles even when runtime plaintext is present", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "sk-runtime-resolved-byteplus", + keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" }, + }, + "together:default": { + type: "token", + provider: "together", + token: "tok-runtime-resolved-together", + tokenRef: { source: "exec", provider: "vault", id: "providers/together/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); + + it("keeps oauth compatibility markers for minimax-portal and qwen-portal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); + expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts new file mode 100644 index 00000000000..82a16dbcbee --- /dev/null +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("cloudflare-ai-gateway profile provenance", () => { + it("prefers env keyRef marker over runtime plaintext for persistence", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for non-env keyRef cloudflare profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "file", provider: "vault", id: "/cloudflare/apiKey" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts new file mode 100644 index 00000000000..3f81f33abe1 --- /dev/null +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("provider discovery auth marker guardrails", () => { + let originalVitest: string | undefined; + let originalNodeEnv: string | undefined; + let originalFetch: typeof globalThis.fetch | undefined; + + afterEach(() => { + if (originalVitest !== undefined) { + process.env.VITEST = originalVitest; + } else { + delete process.env.VITEST; + } + if (originalNodeEnv !== undefined) { + process.env.NODE_ENV = originalNodeEnv; + } else { + delete process.env.NODE_ENV; + } + if (originalFetch) { + globalThis.fetch = originalFetch; + } + }); + + function enableDiscovery() { + originalVitest = process.env.VITEST; + originalNodeEnv = process.env.NODE_ENV; + originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + } + + it("does not send marker value as vLLM bearer token during discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const request = fetchMock.mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(request?.headers?.Authorization).toBeUndefined(); + }); + + it("does not call Hugging Face discovery with marker-backed credentials", async () => { + enableDiscovery(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "huggingface:default": { + type: "api_key", + provider: "huggingface", + keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("router.huggingface.co"), + ); + expect(huggingfaceCalls).toHaveLength(0); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 5c4907bc279..5bee4f5293a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { coerceSecretRef } from "../config/types.secrets.js"; +import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_COPILOT_API_BASE_URL, @@ -40,6 +40,13 @@ import { HUGGINGFACE_MODEL_CATALOG, buildHuggingfaceModelDefinition, } from "./huggingface-models.js"; +import { + MINIMAX_OAUTH_MARKER, + OLLAMA_LOCAL_AUTH_MARKER, + QWEN_OAUTH_MARKER, + isNonSecretApiKeyMarker, + resolveNonEnvSecretRefApiKeyMarker, +} from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; import { @@ -62,7 +69,6 @@ const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth"; // Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price const MINIMAX_API_COST = { input: 0.3, @@ -132,7 +138,6 @@ const KIMI_CODING_DEFAULT_COST = { }; const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_OAUTH_PLACEHOLDER = "qwen-oauth"; const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; const QWEN_PORTAL_DEFAULT_COST = { @@ -403,35 +408,88 @@ function resolveAwsSdkApiKeyVarName(): string { return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; } +type ProfileApiKeyResolution = { + apiKey: string; + source: "plaintext" | "env-ref" | "non-env-ref"; + /** Optional secret value that may be used for provider discovery only. */ + discoveryApiKey?: string; +}; + +function toDiscoveryApiKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { + return undefined; + } + return trimmed; +} + +function resolveApiKeyFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): ProfileApiKeyResolution | undefined { + if (!cred) { + return undefined; + } + if (cred.type === "api_key") { + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + if (keyRef.source === "env") { + const envVar = keyRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), + source: "non-env-ref", + }; + } + if (cred.key?.trim()) { + return { + apiKey: cred.key, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.key), + }; + } + return undefined; + } + if (cred.type === "token") { + const tokenRef = coerceSecretRef(cred.tokenRef); + if (tokenRef && tokenRef.id.trim()) { + if (tokenRef.source === "env") { + const envVar = tokenRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), + source: "non-env-ref", + }; + } + if (cred.token?.trim()) { + return { + apiKey: cred.token, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.token), + }; + } + } + return undefined; +} + function resolveApiKeyFromProfiles(params: { provider: string; store: ReturnType; -}): string | undefined { +}): ProfileApiKeyResolution | undefined { const ids = listProfilesForProvider(params.store, params.provider); for (const id of ids) { - const cred = params.store.profiles[id]; - if (!cred) { - continue; - } - if (cred.type === "api_key") { - if (cred.key?.trim()) { - return cred.key; - } - const keyRef = coerceSecretRef(cred.keyRef); - if (keyRef?.source === "env" && keyRef.id.trim()) { - return keyRef.id.trim(); - } - continue; - } - if (cred.type === "token") { - if (cred.token?.trim()) { - return cred.token; - } - const tokenRef = coerceSecretRef(cred.tokenRef); - if (tokenRef?.source === "env" && tokenRef.id.trim()) { - return tokenRef.id.trim(); - } - continue; + const resolved = resolveApiKeyFromCredential(params.store.profiles[id]); + if (resolved) { + return resolved; } } return undefined; @@ -483,6 +541,12 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig export function normalizeProviders(params: { providers: ModelsConfig["providers"]; agentDir: string; + secretDefaults?: { + env?: string; + file?: string; + exec?: string; + }; + secretRefManagedProviders?: Set; }): ModelsConfig["providers"] { const { providers } = params; if (!providers) { @@ -505,17 +569,42 @@ export function normalizeProviders(params: { } let normalizedProvider = provider; const configuredApiKey = normalizedProvider.apiKey; + const configuredApiKeyRef = resolveSecretInputRef({ + value: configuredApiKey, + defaults: params.secretDefaults, + }).ref; + const profileApiKey = resolveApiKeyFromProfiles({ + provider: normalizedKey, + store: authStore, + }); - // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". - if ( - typeof configuredApiKey === "string" && - normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey - ) { - mutated = true; - normalizedProvider = { - ...normalizedProvider, - apiKey: normalizeApiKeyConfig(configuredApiKey), - }; + if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { + const marker = + configuredApiKeyRef.source === "env" + ? configuredApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); + if (normalizedProvider.apiKey !== marker) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey: marker }; + } + params.secretRefManagedProviders?.add(normalizedKey); + } else if (typeof configuredApiKey === "string") { + // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". + const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); + if (normalizedConfiguredApiKey !== configuredApiKey) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + apiKey: normalizedConfiguredApiKey, + }; + } + if ( + profileApiKey && + profileApiKey.source !== "plaintext" && + normalizedConfiguredApiKey === profileApiKey.apiKey + ) { + params.secretRefManagedProviders?.add(normalizedKey); + } } // If a provider defines models, pi's ModelRegistry requires apiKey to be set. @@ -533,12 +622,11 @@ export function normalizeProviders(params: { normalizedProvider = { ...normalizedProvider, apiKey }; } else { const fromEnv = resolveEnvApiKeyVarName(normalizedKey); - const fromProfiles = resolveApiKeyFromProfiles({ - provider: normalizedKey, - store: authStore, - }); - const apiKey = fromEnv ?? fromProfiles; + const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { + if (profileApiKey && profileApiKey.source !== "plaintext") { + params.secretRefManagedProviders?.add(normalizedKey); + } mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } @@ -777,14 +865,8 @@ async function buildOllamaProvider( }; } -async function buildHuggingfaceProvider(apiKey?: string): Promise { - // Resolve env var name to value for discovery (GET /v1/models requires Bearer token). - const resolvedSecret = - apiKey?.trim() !== "" - ? /^[A-Z][A-Z0-9_]*$/.test(apiKey!.trim()) - ? (process.env[apiKey!.trim()] ?? "").trim() - : apiKey!.trim() - : ""; +async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? ""; const models = resolvedSecret !== "" ? await discoverHuggingfaceModels(resolvedSecret) @@ -928,10 +1010,24 @@ export async function resolveImplicitProviders(params: { const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); + const resolveProviderApiKey = ( + provider: string, + ): { apiKey: string | undefined; discoveryApiKey?: string } => { + const envVar = resolveEnvApiKeyVarName(provider); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore }); + return { + apiKey: fromProfiles?.apiKey, + discoveryApiKey: fromProfiles?.discoveryApiKey, + }; + }; - const minimaxKey = - resolveEnvApiKeyVarName("minimax") ?? - resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + const minimaxKey = resolveProviderApiKey("minimax").apiKey; if (minimaxKey) { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } @@ -940,34 +1036,26 @@ export async function resolveImplicitProviders(params: { if (minimaxOauthProfile.length > 0) { providers["minimax-portal"] = { ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_PLACEHOLDER, + apiKey: MINIMAX_OAUTH_MARKER, }; } - const moonshotKey = - resolveEnvApiKeyVarName("moonshot") ?? - resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + const moonshotKey = resolveProviderApiKey("moonshot").apiKey; if (moonshotKey) { providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; } - const kimiCodingKey = - resolveEnvApiKeyVarName("kimi-coding") ?? - resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore }); + const kimiCodingKey = resolveProviderApiKey("kimi-coding").apiKey; if (kimiCodingKey) { providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey }; } - const syntheticKey = - resolveEnvApiKeyVarName("synthetic") ?? - resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore }); + const syntheticKey = resolveProviderApiKey("synthetic").apiKey; if (syntheticKey) { providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; } - const veniceKey = - resolveEnvApiKeyVarName("venice") ?? - resolveApiKeyFromProfiles({ provider: "venice", store: authStore }); + const veniceKey = resolveProviderApiKey("venice").apiKey; if (veniceKey) { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } @@ -976,13 +1064,11 @@ export async function resolveImplicitProviders(params: { if (qwenProfiles.length > 0) { providers["qwen-portal"] = { ...buildQwenPortalProvider(), - apiKey: QWEN_PORTAL_OAUTH_PLACEHOLDER, + apiKey: QWEN_OAUTH_MARKER, }; } - const volcengineKey = - resolveEnvApiKeyVarName("volcengine") ?? - resolveApiKeyFromProfiles({ provider: "volcengine", store: authStore }); + const volcengineKey = resolveProviderApiKey("volcengine").apiKey; if (volcengineKey) { providers.volcengine = { ...buildDoubaoProvider(), apiKey: volcengineKey }; providers["volcengine-plan"] = { @@ -991,9 +1077,7 @@ export async function resolveImplicitProviders(params: { }; } - const byteplusKey = - resolveEnvApiKeyVarName("byteplus") ?? - resolveApiKeyFromProfiles({ provider: "byteplus", store: authStore }); + const byteplusKey = resolveProviderApiKey("byteplus").apiKey; if (byteplusKey) { providers.byteplus = { ...buildBytePlusProvider(), apiKey: byteplusKey }; providers["byteplus-plan"] = { @@ -1002,9 +1086,7 @@ export async function resolveImplicitProviders(params: { }; } - const xiaomiKey = - resolveEnvApiKeyVarName("xiaomi") ?? - resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); + const xiaomiKey = resolveProviderApiKey("xiaomi").apiKey; if (xiaomiKey) { providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; } @@ -1024,7 +1106,9 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const apiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway") ?? cred.key?.trim() ?? ""; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway"); + const profileApiKey = resolveApiKeyFromCredential(cred)?.apiKey; + const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; } @@ -1041,9 +1125,7 @@ export async function resolveImplicitProviders(params: { // Use the user's configured baseUrl (from explicit providers) for model // discovery so that remote / non-default Ollama instances are reachable. // Skip discovery when explicit models are already defined. - const ollamaKey = - resolveEnvApiKeyVarName("ollama") ?? - resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); + const ollamaKey = resolveProviderApiKey("ollama").apiKey; const explicitOllama = params.explicitProviders?.ollama; const hasExplicitModels = Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0; @@ -1052,7 +1134,7 @@ export async function resolveImplicitProviders(params: { ...explicitOllama, baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), api: explicitOllama.api ?? "ollama", - apiKey: ollamaKey ?? explicitOllama.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } else { const ollamaBaseUrl = explicitOllama?.baseUrl; @@ -1065,7 +1147,7 @@ export async function resolveImplicitProviders(params: { if (ollamaProvider.models.length > 0 || ollamaKey || explicitOllama?.apiKey) { providers.ollama = { ...ollamaProvider, - apiKey: ollamaKey ?? explicitOllama?.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } } @@ -1073,23 +1155,16 @@ export async function resolveImplicitProviders(params: { // vLLM provider - OpenAI-compatible local server (opt-in via env/profile). // If explicitly configured, keep user-defined models/settings as-is. if (!params.explicitProviders?.vllm) { - const vllmEnvVar = resolveEnvApiKeyVarName("vllm"); - const vllmProfileKey = resolveApiKeyFromProfiles({ provider: "vllm", store: authStore }); - const vllmKey = vllmEnvVar ?? vllmProfileKey; + const { apiKey: vllmKey, discoveryApiKey } = resolveProviderApiKey("vllm"); if (vllmKey) { - const discoveryApiKey = vllmEnvVar - ? (process.env[vllmEnvVar]?.trim() ?? "") - : (vllmProfileKey ?? ""); providers.vllm = { - ...(await buildVllmProvider({ apiKey: discoveryApiKey || undefined })), + ...(await buildVllmProvider({ apiKey: discoveryApiKey })), apiKey: vllmKey, }; } } - const togetherKey = - resolveEnvApiKeyVarName("together") ?? - resolveApiKeyFromProfiles({ provider: "together", store: authStore }); + const togetherKey = resolveProviderApiKey("together").apiKey; if (togetherKey) { providers.together = { ...buildTogetherProvider(), @@ -1097,41 +1172,32 @@ export async function resolveImplicitProviders(params: { }; } - const huggingfaceKey = - resolveEnvApiKeyVarName("huggingface") ?? - resolveApiKeyFromProfiles({ provider: "huggingface", store: authStore }); + const { apiKey: huggingfaceKey, discoveryApiKey: huggingfaceDiscoveryApiKey } = + resolveProviderApiKey("huggingface"); if (huggingfaceKey) { - const hfProvider = await buildHuggingfaceProvider(huggingfaceKey); + const hfProvider = await buildHuggingfaceProvider(huggingfaceDiscoveryApiKey); providers.huggingface = { ...hfProvider, apiKey: huggingfaceKey, }; } - const qianfanKey = - resolveEnvApiKeyVarName("qianfan") ?? - resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore }); + const qianfanKey = resolveProviderApiKey("qianfan").apiKey; if (qianfanKey) { providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey }; } - const openrouterKey = - resolveEnvApiKeyVarName("openrouter") ?? - resolveApiKeyFromProfiles({ provider: "openrouter", store: authStore }); + const openrouterKey = resolveProviderApiKey("openrouter").apiKey; if (openrouterKey) { providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; } - const nvidiaKey = - resolveEnvApiKeyVarName("nvidia") ?? - resolveApiKeyFromProfiles({ provider: "nvidia", store: authStore }); + const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; if (nvidiaKey) { providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; } - const kilocodeKey = - resolveEnvApiKeyVarName("kilocode") ?? - resolveApiKeyFromProfiles({ provider: "kilocode", store: authStore }); + const kilocodeKey = resolveProviderApiKey("kilocode").apiKey; if (kilocodeKey) { providers.kilocode = { ...buildKilocodeProvider(), apiKey: kilocodeKey }; } diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts new file mode 100644 index 00000000000..ec6aefc6330 --- /dev/null +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + setRuntimeConfigSnapshot, +} from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config runtime source snapshot", () => { + it("uses runtime source snapshot markers when passed the active runtime config", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses non-env marker from runtime source snapshot for file refs", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-runtime-moonshot", + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); +}); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index e31d61044c3..78e15aafbb5 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + type OpenClawConfig, + loadConfig, +} from "../config/config.js"; import { applyConfigEnvVars } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; @@ -141,8 +146,9 @@ async function resolveProvidersForModelsJson(params: { function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record[string]>; + secretRefManagedProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders } = params; + const { nextProviders, existingProviders, secretRefManagedProviders } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -159,7 +165,11 @@ function mergeWithExistingProviderSecrets(params: { continue; } const preserved: Record = {}; - if (typeof existing.apiKey === "string" && existing.apiKey) { + if ( + !secretRefManagedProviders.has(key) && + typeof existing.apiKey === "string" && + existing.apiKey + ) { preserved.apiKey = existing.apiKey; } if (typeof existing.baseUrl === "string" && existing.baseUrl) { @@ -174,6 +184,7 @@ async function resolveProvidersForMode(params: { mode: NonNullable; targetPath: string; providers: Record; + secretRefManagedProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -189,6 +200,7 @@ async function resolveProvidersForMode(params: { return mergeWithExistingProviderSecrets({ nextProviders: params.providers, existingProviders, + secretRefManagedProviders: params.secretRefManagedProviders, }); } @@ -200,11 +212,32 @@ async function readRawFile(pathname: string): Promise { } } +async function ensureModelsFileMode(pathname: string): Promise { + await fs.chmod(pathname, 0o600).catch(() => { + // best-effort + }); +} + +function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { + const runtimeSource = getRuntimeConfigSourceSnapshot(); + if (!runtimeSource) { + return config ?? loadConfig(); + } + if (!config) { + return runtimeSource; + } + const runtimeResolved = getRuntimeConfigSnapshot(); + if (runtimeResolved && config === runtimeResolved) { + return runtimeSource; + } + return config; +} + export async function ensureOpenClawModelsJson( config?: OpenClawConfig, agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { - const cfg = config ?? loadConfig(); + const cfg = resolveModelsConfigInput(config); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are @@ -221,24 +254,31 @@ export async function ensureOpenClawModelsJson( const mode = cfg.models?.mode ?? DEFAULT_MODE; const targetPath = path.join(agentDir, "models.json"); + const secretRefManagedProviders = new Set(); + + const normalizedProviders = + normalizeProviders({ + providers, + agentDir, + secretDefaults: cfg.secrets?.defaults, + secretRefManagedProviders, + }) ?? providers; const mergedProviders = await resolveProvidersForMode({ mode, targetPath, - providers, + providers: normalizedProviders, + secretRefManagedProviders, }); - - const normalizedProviders = normalizeProviders({ - providers: mergedProviders, - agentDir, - }); - const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const existingRaw = await readRawFile(targetPath); if (existingRaw === next) { + await ensureModelsFileMode(targetPath); return { agentDir, wrote: false }; } await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); await fs.writeFile(targetPath, next, { mode: 0o600 }); + await ensureModelsFileMode(targetPath); return { agentDir, wrote: true }; } diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index bc23ff9351c..55817e28301 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; describe("resolveProviderAuthOverview", () => { @@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => { expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)"); }); + + it("renders marker-backed models.json auth as marker detail", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: NON_ENV_SECRETREF_MARKER, + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + }); + + it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).not.toContain("marker("); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 0fc2f9828c5..28880415eeb 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -6,12 +6,19 @@ import { resolveAuthStorePathForDisplay, resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; import type { ProviderAuthOverview } from "./list.types.js"; +function formatMarkerOrSecret(value: string): string { + return isNonSecretApiKeyMarker(value, { includeEnvVarName: false }) + ? `marker(${value.trim()})` + : maskApiKey(value); +} + function formatProfileSecretLabel(params: { value: string | undefined; ref: { source: string; id: string } | undefined; @@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: { }): string { const value = typeof params.value === "string" ? params.value.trim() : ""; if (value) { - return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value); + const display = formatMarkerOrSecret(value); + return params.kind === "token" ? `token:${display}` : display; } if (params.ref) { const refLabel = `ref(${params.ref.source}:${params.ref.id})`; @@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: { }; } if (customKey) { - return { kind: "models.json", detail: maskApiKey(customKey) }; + return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; } return { kind: "missing", detail: "missing" }; })(); @@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: { ...(customKey ? { modelsJson: { - value: maskApiKey(customKey), + value: formatMarkerOrSecret(customKey), source: `models.json: ${shortenHomePath(params.modelsPath)}`, }, } diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index c3e754199a2..6b2abe138b2 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; @@ -163,4 +164,53 @@ describe("buildProbeTargets reason codes", () => { expect(plan.results[0]?.error).toContain("[unresolved_ref]"); expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN"); }); + + it("skips marker-only models.json credentials when building probe targets", async () => { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + try { + const plan = await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey: OLLAMA_LOCAL_AUTH_MARKER, + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(plan.targets).toEqual([]); + expect(plan.results).toEqual([]); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } + }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 8a2ec87adcc..40eb6b99b9b 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,6 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { @@ -373,7 +374,8 @@ export async function buildProbeTargets(params: { const envKey = resolveEnvApiKey(providerKey); const customKey = getCustomProviderApiKey(cfg, providerKey); - if (!envKey && !customKey) { + const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/config/config.ts b/src/config/config.ts index dfe47d82f87..35fe656c666 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,7 @@ export { clearRuntimeConfigSnapshot, createConfigIO, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, parseConfigJson5, readConfigFileSnapshot, diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 0a37de08aaa..cca75174500 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -5,6 +5,7 @@ import { withTempHome } from "./home-env.test-harness.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, setRuntimeConfigSnapshot, writeConfigFile, @@ -12,6 +13,70 @@ import { import type { OpenClawConfig } from "./types.js"; describe("runtime config snapshot writes", () => { + it("returns the source snapshot when runtime snapshot is active", async () => { + await withTempHome("openclaw-config-runtime-source-", async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }; + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("clears runtime source snapshot when runtime snapshot is cleared", async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }; + + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + expect(getRuntimeConfigSourceSnapshot()).toBeNull(); + }); + it("preserves source secret refs when writeConfigFile receives runtime-resolved config", async () => { await withTempHome("openclaw-config-runtime-write-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/io.ts b/src/config/io.ts index a2a2af5d1b5..d8b90646d12 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1345,6 +1345,10 @@ export function getRuntimeConfigSnapshot(): OpenClawConfig | null { return runtimeConfigSnapshot; } +export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { + return runtimeConfigSourceSnapshot; +} + export function loadConfig(): OpenClawConfig { if (runtimeConfigSnapshot) { return runtimeConfigSnapshot; diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 2ef7d8aae3a..96b0f1c2754 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -774,6 +774,9 @@ describe("config help copy quality", () => { it("documents auth/model root semantics and provider secret handling", () => { const providerKey = FIELD_HELP["models.providers.*.apiKey"]; expect(/secret|env|credential/i.test(providerKey)).toBe(true); + const modelsMode = FIELD_HELP["models.mode"]; + expect(modelsMode.includes("SecretRef-managed")).toBe(true); + expect(modelsMode.includes("preserve")).toBe(true); const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"]; expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c97aa0408a4..0885aa0ea81 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -688,7 +688,7 @@ export const FIELD_HELP: Record = { models: "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "models.mode": - 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing; matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', + 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers while matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', "models.providers": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.providers.*.baseUrl": diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index bae5ae5a7d9..6a27ee7835a 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; import { resolveProviderAuths } from "./provider-usage.auth.js"; describe("resolveProviderAuths key normalization", () => { @@ -403,4 +404,40 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); }, {}); }); + + it("ignores marker-backed config keys for provider usage auth resolution", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: NON_ENV_SECRETREF_MARKER, + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["minimax"], + }); + expect(auths).toEqual([]); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index ff63c1570f1..6afa4bebaad 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -8,6 +8,7 @@ import { resolveApiKeyForProfile, resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { getCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; @@ -103,7 +104,7 @@ function resolveProviderApiKeyFromConfigAndStore(params: { const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, params.providerId); - if (key) { + if (key && !isNonSecretApiKeyMarker(key)) { return key; } @@ -122,9 +123,17 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } if (cred.type === "api_key") { - return normalizeSecretInput(cred.key); + const key = normalizeSecretInput(cred.key); + if (key && !isNonSecretApiKeyMarker(key)) { + return key; + } + return undefined; } - return normalizeSecretInput(cred.token); + const token = normalizeSecretInput(cred.token); + if (token && !isNonSecretApiKeyMarker(token)) { + return token; + } + return undefined; } async function resolveOAuthToken(params: { diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index cd85d84d3d8..effb5ed9298 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -10,6 +10,7 @@ type AuditFixture = { configPath: string; authStorePath: string; authJsonPath: string; + modelsPath: string; envPath: string; env: NodeJS.ProcessEnv; }; @@ -27,9 +28,11 @@ function resolveRuntimePathEnv(): string { function hasFinding( report: Awaited>, - predicate: (entry: { code: string; file: string }) => boolean, + predicate: (entry: { code: string; file: string; jsonPath?: string }) => boolean, ): boolean { - return report.findings.some((entry) => predicate(entry as { code: string; file: string })); + return report.findings.some((entry) => + predicate(entry as { code: string; file: string; jsonPath?: string }), + ); } async function createAuditFixture(): Promise { @@ -38,6 +41,7 @@ async function createAuditFixture(): Promise { const configPath = path.join(stateDir, "openclaw.json"); const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); const authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); + const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json"); const envPath = path.join(stateDir, ".env"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -49,6 +53,7 @@ async function createAuditFixture(): Promise { configPath, authStorePath, authJsonPath, + modelsPath, envPath, env: { OPENCLAW_STATE_DIR: stateDir, @@ -85,6 +90,16 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { version: 1, profiles: Object.fromEntries(seededProfiles), }); + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); await fs.writeFile(fixture.envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8"); } @@ -254,4 +269,64 @@ describe("secrets audit", () => { const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; expect(callCount).toBe(1); }); + + it("scans agent models.json files for plaintext provider apiKey values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "sk-models-plaintext", + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + expect(report.filesScanned).toContain(fixture.modelsPath); + }); + + it("does not flag models.json marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(false); + }); + + it("reports malformed models.json as unresolved findings", async () => { + await fs.writeFile(fixture.modelsPath, "{bad-json", "utf8"); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 132ea4ac431..170c4fedd4f 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -1,8 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveStateDir, type OpenClawConfig } from "../config/config.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; @@ -23,6 +25,7 @@ import { import { isNonEmptyString, isRecord } from "./shared.js"; import { describeUnknownError } from "./shared.js"; import { + listAgentModelsJsonPaths, listAuthProfileStorePaths, listLegacyAuthJsonPaths, parseEnvAssignmentValue, @@ -315,6 +318,62 @@ function collectAuthJsonResidue(params: { stateDir: string; collector: AuditColl } } +function collectModelsJsonSecrets(params: { + modelsJsonPath: string; + collector: AuditCollector; +}): void { + if (!fs.existsSync(params.modelsJsonPath)) { + return; + } + params.collector.filesScanned.add(params.modelsJsonPath); + const parsedResult = readJsonObjectIfExists(params.modelsJsonPath); + if (parsedResult.error) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: "", + message: `Invalid JSON in models.json: ${parsedResult.error}`, + }); + return; + } + const parsed = parsedResult.value; + if (!parsed || !isRecord(parsed.providers)) { + return; + } + for (const [providerId, providerValue] of Object.entries(parsed.providers)) { + if (!isRecord(providerValue)) { + continue; + } + const apiKey = providerValue.apiKey; + if (coerceSecretRef(apiKey)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json contains an unresolved SecretRef object; regenerate models.json.", + provider: providerId, + }); + continue; + } + if (!isNonEmptyString(apiKey)) { + continue; + } + if (isNonSecretApiKeyMarker(apiKey)) { + continue; + } + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json provider apiKey is stored as plaintext.", + provider: providerId, + }); + } +} + async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenClawConfig; @@ -497,6 +556,12 @@ export async function runSecretsAudit( defaults, }); } + for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) { + collectModelsJsonSecrets({ + modelsJsonPath, + collector, + }); + } await collectUnresolvedRefFindings({ collector, config, diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index ccbfc544f6d..a868e9e898a 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -31,6 +31,32 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { return out; } +export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "models.json")); + } + } + + for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + continue; + } + const agentDir = resolveAgentDir(config, agentId); + paths.add(path.join(resolveUserPath(agentDir), "models.json")); + } + + return [...paths]; +} + export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string;