Secrets: support SecretRef model provider headers

This commit is contained in:
joshavant 2026-03-06 17:04:01 -06:00
parent 4dd006a6a3
commit 5a3284ef9a
No known key found for this signature in database
GPG Key ID: 4463B60B0DD49BC4
22 changed files with 575 additions and 44 deletions

View File

@ -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`

View File

@ -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",

View File

@ -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 },

View File

@ -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<NonNullable<OpenClawConfig["models"]>["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 });
}
});
});

View File

@ -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, NonNullable<ProviderConfig["headers"]>[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,

View File

@ -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<string, { headers?: Record<string, string> }>;
}>();
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();
}
});
});
});

View File

@ -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<typeof buildInlineProviderModels>[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<string, string> }).headers).toEqual({
"X-Custom-Auth": "token-123",
});
});
it("prefers matching configured model metadata for fallback token limits", () => {
const cfg = {
models: {

View File

@ -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<string, string>;
headers?: unknown;
};
function sanitizeModelHeaders(headers: unknown): Record<string, string> | undefined {
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
return undefined;
}
const next: Record<string, string> = {};
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<Api> {
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<Api>);
}

View File

@ -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: "" }));

View File

@ -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);
});
});

View File

@ -54,7 +54,7 @@ export type ModelProviderConfig = {
auth?: ModelProviderAuthMode;
api?: ModelApi;
injectNumCtxForOpenAICompat?: boolean;
headers?: Record<string, string>;
headers?: Record<string, SecretInput>;
authHeader?: boolean;
models: ModelDefinitionConfig[];
};

View File

@ -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),
})

View File

@ -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<string, string>)["X-Provider-Managed"]).toBeUndefined();
expect((seenHeaders as Record<string, string>)["X-Config-Managed"]).toBeUndefined();
expect((seenHeaders as Record<string, string>)["X-Entry-Managed"]).toBeUndefined();
expect(seenQuery).toMatchObject({
detect_language: false,
punctuate: false,

View File

@ -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<string, MediaUnderstandingProvider>;
function sanitizeProviderHeaders(
headers: Record<string, unknown> | undefined,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const next: Record<string, string> = {};
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<string, unknown> | undefined),
...sanitizeProviderHeaders(params.config?.headers as Record<string, unknown> | undefined),
...sanitizeProviderHeaders(params.entry.headers as Record<string, unknown> | undefined),
};
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
return { apiKeys, baseUrl, headers };

View File

@ -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<SecretsApplyPlan["options"]> {
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<string, unknown>;
};
};
};
}>(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,

View File

@ -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 });

View File

@ -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,
});
}
}

View File

@ -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",

View File

@ -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;
},
});
}
}
}

View File

@ -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");

View File

@ -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",

View File

@ -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: {