Secrets: support SecretRef model provider headers
This commit is contained in:
parent
4dd006a6a3
commit
5a3284ef9a
@ -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`
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>);
|
||||
}
|
||||
|
||||
|
||||
@ -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: "" }));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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[];
|
||||
};
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user