Secrets: harden SecretRef-safe models.json persistence (#38955)

This commit is contained in:
Josh Avant
2026-03-07 11:28:39 -06:00
committed by GitHub
parent b08337b902
commit 8e20dd22d8
66 changed files with 2713 additions and 299 deletions

View File

@@ -179,6 +179,28 @@ describe("buildInlineProviderModels", () => {
expect(result).toHaveLength(1);
expect(result[0].headers).toBeUndefined();
});
it("preserves literal marker-shaped headers in 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({
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
"X-Managed": "secretref-managed",
"X-Static": "tenant-a",
});
});
});
describe("resolveModel", () => {
@@ -223,6 +245,56 @@ describe("resolveModel", () => {
});
});
it("preserves literal marker-shaped 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({
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
"X-Managed": "secretref-managed",
"X-Custom-Auth": "token-123",
});
});
it("drops marker headers from discovered models.json entries", () => {
mockDiscoveredModel({
provider: "custom",
modelId: "listed-model",
templateModel: {
...makeModel("listed-model"),
provider: "custom",
headers: {
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
"X-Managed": "secretref-managed",
"X-Static": "tenant-a",
},
},
});
const result = resolveModel("custom", "listed-model", "/tmp/agent");
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"X-Static": "tenant-a",
});
});
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,29 @@ type InlineProviderConfig = {
baseUrl?: string;
api?: ModelDefinitionConfig["api"];
models?: ModelDefinitionConfig[];
headers?: Record<string, string>;
headers?: unknown;
};
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
): 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") {
continue;
}
if (opts?.stripSecretRefMarkers && isSecretRefHeaderValueMarker(headerValue)) {
continue;
}
next[headerName] = headerValue;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export { buildModelAliasLines };
function resolveConfiguredProviderConfig(
@@ -46,16 +67,23 @@ function applyConfiguredProviderOverrides(params: {
}): Model<Api> {
const { discoveredModel, providerConfig, modelId } = params;
if (!providerConfig) {
return discoveredModel;
return {
...discoveredModel,
// Discovered models originate from models.json and may contain persistence markers.
headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }),
};
}
const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId);
if (
!configuredModel &&
!providerConfig.baseUrl &&
!providerConfig.api &&
!providerConfig.headers
) {
return discoveredModel;
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
stripSecretRefMarkers: true,
});
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 +95,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 +114,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 +196,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 +217,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>);
}