fix(models): prevent plaintext apiKey writes to models state (#38889)

Land #38889 by @gambletan.

Co-authored-by: gambletan <ethanchang32@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 19:29:46 +00:00
parent de2ccffec1
commit 17ab46aedd
4 changed files with 86 additions and 0 deletions

View File

@@ -406,6 +406,39 @@ describe("models-config", () => {
});
});
it("does not persist resolved env var value as plaintext in models.json", async () => {
await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
openai: {
apiKey: "sk-plaintext-should-not-appear", // already resolved by loadConfig
api: "openai-completions",
models: [
{
id: "gpt-4.1",
name: "GPT-4.1",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
},
],
},
},
},
};
await ensureOpenClawModelsJson(cfg);
const result = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY");
});
});
});
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
await withTempHome(async () => {
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {

View File

@@ -74,6 +74,39 @@ describe("normalizeProviders", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("replaces resolved env var value with env var name to prevent plaintext persistence", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const original = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-test-secret-value-12345";
try {
const providers: NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]> = {
openai: {
apiKey: "sk-test-secret-value-12345", // simulates resolved ${OPENAI_API_KEY}
api: "openai-completions",
models: [
{
id: "gpt-4.1",
name: "GPT-4.1",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
},
],
},
};
const normalized = normalizeProviders({ providers, agentDir });
expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY");
} finally {
if (original === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = original;
}
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-"));

View File

@@ -392,6 +392,8 @@ async function discoverVllmModels(
}
}
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim();
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
@@ -655,6 +657,23 @@ export function normalizeProviders(params: {
}
}
// Reverse-lookup: if apiKey looks like a resolved secret value (not an env
// var name), check whether it matches the canonical env var for this provider.
// This prevents resolveConfigEnvVars()-resolved secrets from being persisted
// to models.json as plaintext. (Fixes #38757)
const currentApiKey = normalizedProvider.apiKey;
if (
typeof currentApiKey === "string" &&
currentApiKey.trim() &&
!ENV_VAR_NAME_RE.test(currentApiKey.trim())
) {
const envVarName = resolveEnvApiKeyVarName(normalizedKey);
if (envVarName && process.env[envVarName] === currentApiKey) {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
}
}
// If a provider defines models, pi's ModelRegistry requires apiKey to be set.
// Fill it from the environment or auth profiles when possible.
const hasModels =