diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index d356e4f809e..d3d56c032bc 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -23,6 +23,7 @@ Scope intent: [//]: # "secretref-supported-list-start" - `models.providers.*.apiKey` +- `models.providers.*.headers.*` - `skills.entries.*.apiKey` - `agents.defaults.memorySearch.remote.apiKey` - `agents.list[].memorySearch.remote.apiKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ac454a605a6..773ef8ab162 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -426,6 +426,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "models.providers.*.headers.*", + "configFile": "openclaw.json", + "path": "models.providers.*.headers.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "skills.entries.*.apiKey", "configFile": "openclaw.json", diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 29af3ca45f9..2647c53ed9b 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -4,6 +4,7 @@ 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"; +export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; const AWS_SDK_ENV_MARKERS = new Set([ "AWS_BEARER_TOKEN_BEDROCK", @@ -23,6 +24,21 @@ export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): st return NON_ENV_SECRETREF_MARKER; } +export function resolveNonEnvSecretRefHeaderValueMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveEnvSecretRefHeaderValueMarker(envVarName: string): string { + return `${SECRETREF_ENV_HEADER_MARKER_PREFIX}${envVarName.trim()}`; +} + +export function isSecretRefHeaderValueMarker(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed === NON_ENV_SECRETREF_MARKER || trimmed.startsWith(SECRETREF_ENV_HEADER_MARKER_PREFIX) + ); +} + export function isNonSecretApiKeyMarker( value: string, opts?: { includeEnvVarName?: boolean }, diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index cccd54851d8..1271b30faed 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.js"; describe("normalizeProviders", () => { @@ -73,4 +74,30 @@ describe("normalizeProviders", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + try { + const providers: NonNullable["providers"]> = { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { source: "env", provider: "default", id: "OPENAI_HEADER_TOKEN" }, + "X-Tenant-Token": { source: "file", provider: "vault", id: "/openai/token" }, + }, + models: [], + }, + }; + + const normalized = normalizeProviders({ + providers, + agentDir, + }); + expect(normalized?.openai?.headers?.Authorization).toBe("secretref-env:OPENAI_HEADER_TOKEN"); + expect(normalized?.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 5bee4f5293a..64bcc72010c 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -46,6 +46,8 @@ import { QWEN_OAUTH_MARKER, isNonSecretApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, + resolveNonEnvSecretRefHeaderValueMarker, + resolveEnvSecretRefHeaderValueMarker, } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; @@ -408,6 +410,43 @@ function resolveAwsSdkApiKeyVarName(): string { return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; } +function normalizeHeaderValues(params: { + headers: ProviderConfig["headers"] | undefined; + secretDefaults: + | { + env?: string; + file?: string; + exec?: string; + } + | undefined; +}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { + const { headers } = params; + if (!headers) { + return { headers, mutated: false }; + } + let mutated = false; + const nextHeaders: Record[string]> = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + const resolvedRef = resolveSecretInputRef({ + value: headerValue, + defaults: params.secretDefaults, + }).ref; + if (!resolvedRef || !resolvedRef.id.trim()) { + nextHeaders[headerName] = headerValue; + continue; + } + mutated = true; + nextHeaders[headerName] = + resolvedRef.source === "env" + ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) + : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); + } + if (!mutated) { + return { headers, mutated: false }; + } + return { headers: nextHeaders, mutated: true }; +} + type ProfileApiKeyResolution = { apiKey: string; source: "plaintext" | "env-ref" | "non-env-ref"; @@ -568,6 +607,14 @@ export function normalizeProviders(params: { mutated = true; } let normalizedProvider = provider; + const normalizedHeaders = normalizeHeaderValues({ + headers: normalizedProvider.headers, + secretDefaults: params.secretDefaults, + }); + if (normalizedHeaders.mutated) { + mutated = true; + normalizedProvider = { ...normalizedProvider, headers: normalizedHeaders.headers }; + } const configuredApiKey = normalizedProvider.apiKey; const configuredApiKeyRef = resolveSecretInputRef({ value: configuredApiKey, diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts index ec6aefc6330..5f2e39b7c63 100644 --- a/src/agents/models-config.runtime-source-snapshot.test.ts +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -100,4 +100,63 @@ describe("models-config runtime source snapshot", () => { } }); }); + + it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }, + "X-Tenant-Token": { + source: "file", + provider: "vault", + id: "/providers/openai/tenantToken", + }, + }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: "Bearer runtime-openai-token", + "X-Tenant-Token": "runtime-tenant-token", + }, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record }>; + }>(); + expect(parsed.providers.openai?.headers?.Authorization).toBe( + "secretref-env:OPENAI_HEADER_TOKEN", + ); + expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); }); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d23b68d32b6..52c943f7c54 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -179,6 +179,24 @@ describe("buildInlineProviderModels", () => { expect(result).toHaveLength(1); expect(result[0].headers).toBeUndefined(); }); + + it("drops SecretRef marker headers when building inline provider models", () => { + const providers: Parameters[0] = { + custom: { + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].headers).toEqual({ "X-Static": "tenant-a" }); + }); }); describe("resolveModel", () => { @@ -223,6 +241,31 @@ describe("resolveModel", () => { }); }); + it("drops marker-backed provider headers in fallback models", () => { + const cfg = { + models: { + providers: { + custom: { + baseUrl: "http://localhost:9000", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }, + models: [makeModel("listed-model")], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "X-Custom-Auth": "token-123", + }); + }); + it("prefers matching configured model metadata for fallback token limits", () => { const cfg = { models: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index b846895d029..082ff8a0c70 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -5,6 +5,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; +import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -19,9 +20,23 @@ type InlineProviderConfig = { baseUrl?: string; api?: ModelDefinitionConfig["api"]; models?: ModelDefinitionConfig[]; - headers?: Record; + headers?: unknown; }; +function sanitizeModelHeaders(headers: unknown): Record | undefined { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return undefined; + } + const next: Record = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string" || isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + next[headerName] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -46,16 +61,20 @@ function applyConfiguredProviderOverrides(params: { }): Model { const { discoveredModel, providerConfig, modelId } = params; if (!providerConfig) { - return discoveredModel; + return { + ...discoveredModel, + headers: sanitizeModelHeaders(discoveredModel.headers), + }; } const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId); - if ( - !configuredModel && - !providerConfig.baseUrl && - !providerConfig.api && - !providerConfig.headers - ) { - return discoveredModel; + const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers); + const providerHeaders = sanitizeModelHeaders(providerConfig.headers); + const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers); + if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) { + return { + ...discoveredModel, + headers: discoveredHeaders, + }; } return { ...discoveredModel, @@ -67,13 +86,13 @@ function applyConfiguredProviderOverrides(params: { contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens, headers: - providerConfig.headers || configuredModel?.headers + discoveredHeaders || providerHeaders || configuredHeaders ? { - ...discoveredModel.headers, - ...providerConfig.headers, - ...configuredModel?.headers, + ...discoveredHeaders, + ...providerHeaders, + ...configuredHeaders, } - : discoveredModel.headers, + : undefined, compat: configuredModel?.compat ?? discoveredModel.compat, }; } @@ -86,15 +105,22 @@ export function buildInlineProviderModels( if (!trimmed) { return []; } + const providerHeaders = sanitizeModelHeaders(entry?.headers); return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed, baseUrl: entry?.baseUrl, api: model.api ?? entry?.api, - headers: - entry?.headers || (model as InlineModelEntry).headers - ? { ...entry?.headers, ...(model as InlineModelEntry).headers } - : undefined, + headers: (() => { + const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers); + if (!providerHeaders && !modelHeaders) { + return undefined; + } + return { + ...providerHeaders, + ...modelHeaders, + }; + })(), })); }); } @@ -161,6 +187,8 @@ export function resolveModelWithRegistry(params: { } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); + const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { return normalizeModelCompat({ id: modelId, @@ -180,9 +208,7 @@ export function resolveModelWithRegistry(params: { providerConfig?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, headers: - providerConfig?.headers || configuredModel?.headers - ? { ...providerConfig?.headers, ...configuredModel?.headers } - : undefined, + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, } as Model); } diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 6d25e4c6d16..92a4769c1fd 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -154,6 +154,35 @@ describe("config identity defaults", () => { }); }); + it("accepts SecretRef values in model provider headers", async () => { + await withTempHome("openclaw-config-identity-", async (home) => { + const cfg = await writeAndLoadConfig(home, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }, + }, + models: [], + }, + }, + }, + }); + + expect(cfg.models?.providers?.openai?.headers?.Authorization).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }); + }); + }); + it("respects empty responsePrefix to disable identity defaults", async () => { await withTempHome("openclaw-config-identity-", async (home) => { const cfg = await writeAndLoadConfig(home, configWithDefaultIdentity({ responsePrefix: "" })); diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 41ac8b1aa5d..e21a330f2e6 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -135,6 +135,7 @@ describe("mapSensitivePaths", () => { expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true); expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); }); diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 4ef646cc48a..b881269d961 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -54,7 +54,7 @@ export type ModelProviderConfig = { auth?: ModelProviderAuthMode; api?: ModelApi; injectNumCtxForOpenAICompat?: boolean; - headers?: Record; + headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 733917e4dac..7ddef789282 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -234,7 +234,7 @@ export const ModelProviderSchema = z .optional(), api: ModelApiSchema.optional(), injectNumCtxForOpenAICompat: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), }) diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 38df19b7432..a2e52aaec3e 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -29,7 +29,10 @@ describe("runCapability deepgram provider options", () => { deepgram: { baseUrl: "https://provider.example", apiKey: "test-key", - headers: { "X-Provider": "1" }, + headers: { + "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", + }, models: [], }, }, @@ -39,7 +42,10 @@ describe("runCapability deepgram provider options", () => { audio: { enabled: true, baseUrl: "https://config.example", - headers: { "X-Config": "2" }, + headers: { + "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", + }, providerOptions: { deepgram: { detect_language: true, @@ -52,7 +58,10 @@ describe("runCapability deepgram provider options", () => { provider: "deepgram", model: "nova-3", baseUrl: "https://entry.example", - headers: { "X-Entry": "3" }, + headers: { + "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", + }, providerOptions: { deepgram: { detectLanguage: false, @@ -82,6 +91,9 @@ describe("runCapability deepgram provider options", () => { "X-Config": "2", "X-Entry": "3", }); + expect((seenHeaders as Record)["X-Provider-Managed"]).toBeUndefined(); + expect((seenHeaders as Record)["X-Config-Managed"]).toBeUndefined(); + expect((seenHeaders as Record)["X-Entry-Managed"]).toBeUndefined(); expect(seenQuery).toMatchObject({ detect_language: false, punctuate: false, diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8423ece464d..a07d2a9b2b6 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -4,6 +4,7 @@ import { collectProviderApiKeysForExecution, executeWithApiKeyRotation, } from "../agents/api-key-rotation.js"; +import { isSecretRefHeaderValueMarker } from "../agents/model-auth-markers.js"; import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js"; @@ -40,6 +41,22 @@ import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; export type ProviderRegistry = Map; +function sanitizeProviderHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value !== "string" || isSecretRefHeaderValueMarker(value)) { + continue; + } + next[key] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +} + function trimOutput(text: string, maxChars?: number): string { const trimmed = text.trim(); if (!maxChars || trimmed.length <= maxChars) { @@ -352,9 +369,9 @@ async function resolveProviderExecutionContext(params: { }); const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl; const mergedHeaders = { - ...providerConfig?.headers, - ...params.config?.headers, - ...params.entry.headers, + ...sanitizeProviderHeaders(providerConfig?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.config?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.entry.headers as Record | undefined), }; const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined; return { apiKeys, baseUrl, headers }; diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index a8e5ecd0cf8..7f097ef5d43 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -149,6 +149,18 @@ function createOpenAiProviderTarget(params?: { }; } +function createOpenAiProviderHeaderTarget(params?: { + path?: string; + pathSegments?: string[]; +}): SecretsApplyPlan["targets"][number] { + return { + type: "models.providers.headers", + path: params?.path ?? "models.providers.openai.headers.x-api-key", + ...(params?.pathSegments ? { pathSegments: params.pathSegments } : {}), + ref: OPENAI_API_KEY_ENV_REF, + }; +} + function createOneWayScrubOptions(): NonNullable { return { scrubEnv: true, @@ -436,6 +448,47 @@ describe("secrets apply", () => { }); }); + it("applies model provider header targets", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + ...createOpenAiProviderConfig(), + headers: { + "x-api-key": "sk-header-plaintext", + }, + }, + }, + }, + }); + + const plan = createPlan({ + targets: [ + createOpenAiProviderHeaderTarget({ + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + }), + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const nextConfig = await applyPlanAndReadConfig<{ + models?: { + providers?: { + openai?: { + headers?: Record; + }; + }; + }; + }>(fixture, plan); + expect(nextConfig.models?.providers?.openai?.headers?.["x-api-key"]).toEqual( + OPENAI_API_KEY_ENV_REF, + ); + }); + it("applies array-indexed targets for agent memory search", async () => { await fs.writeFile( fixture.configPath, diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index effb5ed9298..28172d7c770 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -295,6 +295,33 @@ describe("secrets audit", () => { expect(report.filesScanned).toContain(fixture.modelsPath); }); + it("scans agent models.json files for plaintext provider header values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + headers: { + Authorization: "Bearer sk-header-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.headers.Authorization", + ), + ).toBe(true); + }); + it("does not flag models.json marker values as plaintext", async () => { await writeJsonFile(fixture.modelsPath, { providers: { @@ -319,6 +346,70 @@ describe("secrets audit", () => { ).toBe(false); }); + it("does not flag models.json header marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "x-managed-token": "secretref-managed", + }, + 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.headers.Authorization", + ), + ).toBe(false); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.x-managed-token", + ), + ).toBe(false); + }); + + it("reports unresolved models.json SecretRef objects in provider headers", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", + headers: { + Authorization: { source: "env", provider: "default", id: "OPENAI_HEADER_TOKEN" }, + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "REF_UNRESOLVED" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + it("reports malformed models.json as unresolved findings", async () => { await fs.writeFile(fixture.modelsPath, "{bad-json", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 170c4fedd4f..41509a61fb9 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -1,7 +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 { + isNonSecretApiKeyMarker, + isSecretRefHeaderValueMarker, +} 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"; @@ -355,22 +358,50 @@ function collectModelsJsonSecrets(params: { message: "models.json contains an unresolved SecretRef object; regenerate models.json.", provider: providerId, }); + } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) { + 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, + }); + } + + const headers = isRecord(providerValue.headers) ? providerValue.headers : undefined; + if (!headers) { continue; } - if (!isNonEmptyString(apiKey)) { - continue; + for (const [headerKey, headerValue] of Object.entries(headers)) { + const headerPath = `providers.${providerId}.headers.${headerKey}`; + if (coerceSecretRef(headerValue)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: + "models.json contains an unresolved SecretRef object for provider headers; regenerate models.json.", + provider: providerId, + }); + continue; + } + if (!isNonEmptyString(headerValue)) { + continue; + } + if (isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: "models.json provider header value is stored as plaintext.", + provider: providerId, + }); } - 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, - }); } } diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 95071d549e1..01ee81ea551 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -21,6 +21,22 @@ describe("secrets plan validation", () => { expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); }); + it("accepts model provider header targets with wildcard-backed paths", () => { + const resolved = resolveValidatedPlanTarget({ + type: "models.providers.headers", + path: "models.providers.openai.headers.x-api-key", + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + providerId: "openai", + }); + expect(resolved?.pathSegments).toEqual([ + "models", + "providers", + "openai", + "headers", + "x-api-key", + ]); + }); + it("rejects target paths that do not match the registered shape", () => { const resolved = resolveValidatedPlanTarget({ type: "channels.telegram.botToken", diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 085573173cc..504331f0a96 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -10,6 +10,7 @@ import { isRecord } from "./shared.js"; type ProviderLike = { apiKey?: unknown; + headers?: unknown; enabled?: unknown; }; @@ -24,18 +25,37 @@ function collectModelProviderAssignments(params: { context: ResolverContext; }): void { for (const [providerId, provider] of Object.entries(params.providers)) { + const providerIsActive = provider.enabled !== false; collectSecretInputAssignment({ value: provider.apiKey, path: `models.providers.${providerId}.apiKey`, expected: "string", defaults: params.defaults, context: params.context, - active: provider.enabled !== false, + active: providerIsActive, inactiveReason: "provider is disabled.", apply: (value) => { provider.apiKey = value; }, }); + const headers = isRecord(provider.headers) ? provider.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + collectSecretInputAssignment({ + value: headerValue, + path: `models.providers.${providerId}.headers.${headerKey}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: providerIsActive, + inactiveReason: "provider is disabled.", + apply: (value) => { + headers[headerKey] = value; + }, + }); + } } } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index e1ca5774a75..1d9189f843c 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -56,6 +56,13 @@ describe("secrets runtime snapshot", () => { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_PROVIDER_AUTH_HEADER", + }, + }, models: [], }, }, @@ -123,6 +130,7 @@ describe("secrets runtime snapshot", () => { config, env: { OPENAI_API_KEY: "sk-env-openai", // pragma: allowlist secret + OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", // pragma: allowlist secret GITHUB_TOKEN: "ghp-env-token", // pragma: allowlist secret REVIEW_SKILL_API_KEY: "sk-skill-ref", // pragma: allowlist secret MEMORY_REMOTE_API_KEY: "mem-ref-key", // pragma: allowlist secret @@ -162,6 +170,9 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( + "Bearer sk-env-header", + ); expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); expect(snapshot.config.talk?.apiKey).toBe("talk-ref-key"); diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 61ccb1f9b66..0e738f91c2d 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -642,6 +642,19 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ providerIdPathSegmentIndex: 2, trackProviderShadowing: true, }, + { + id: "models.providers.*.headers.*", + targetType: "models.providers.headers", + targetTypeAliases: ["models.providers.*.headers.*"], + configFile: "openclaw.json", + pathPattern: "models.providers.*.headers.*", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 2, + }, { id: "skills.entries.*.apiKey", targetType: "skills.entries.apiKey", diff --git a/src/secrets/target-registry-pattern.test.ts b/src/secrets/target-registry-pattern.test.ts index 4739ca5776d..2cd3537fb53 100644 --- a/src/secrets/target-registry-pattern.test.ts +++ b/src/secrets/target-registry-pattern.test.ts @@ -39,6 +39,17 @@ describe("target registry pattern helpers", () => { expect(materializePathTokens(refTokens, ["anthropic"])).toBeNull(); }); + it("matches two wildcard captures in five-segment header paths", () => { + const tokens = parsePathPattern("models.providers.*.headers.*"); + const match = matchPathTokens( + ["models", "providers", "openai", "headers", "x-api-key"], + tokens, + ); + expect(match).toEqual({ + captures: ["openai", "x-api-key"], + }); + }); + it("expands wildcard and array patterns over config objects", () => { const root = { agents: {