mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
60040d86b2
* fix: normalize provider keys during model config merge * fix: normalize source-managed provider keys when reapplying secret markers Make enforceSourceManagedProviderSecrets canonicalize provider keys with normalizeProviderId so mixed-case (e.g. "OpenAI") source config still matches the canonical "openai" generated provider. Without this, the trim-only source lookup misses, the source SecretRef marker is never reapplied, and resolved runtime secret values leak into generated models.json. Adds plan-level regressions in models-config.runtime-source-snapshot.test.ts covering mixed-case apiKey and header source configs. * fix: use Array#toSorted in mixed-case provider regression tests oxlint(no-array-sort) flagged the new test assertions for using .sort() on Object.keys(...). Switch to .toSorted() to keep the check-lint shard green. * style(agents): oxfmt models-config provider merge files * fix(agents): define provider key collision precedence --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Peter Steinberger <peter@steipete.me>
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
// Verifies generated models.json preserves source secret markers from runtime snapshots.
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
|
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
|
import {
|
|
installModelsConfigTestHooks,
|
|
MODELS_CONFIG_IMPLICIT_ENV_VARS,
|
|
unsetEnv,
|
|
withTempEnv,
|
|
} from "./models-config.e2e-harness.js";
|
|
import { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js";
|
|
|
|
vi.mock("../plugins/manifest-registry.js", () => ({
|
|
loadPluginManifestRegistry: () => ({ plugins: [] }),
|
|
}));
|
|
|
|
vi.mock("./model-auth-env-vars.js", () => ({
|
|
listKnownProviderEnvApiKeyNames: () => ["OPENAI_API_KEY"],
|
|
resolveProviderEnvAuthLookupMaps: () => ({
|
|
aliasMap: {},
|
|
envCandidateMap: { openai: ["OPENAI_API_KEY"] },
|
|
authEvidenceMap: {},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../plugins/provider-runtime.js", () => ({
|
|
applyProviderConfigDefaultsWithPlugin: (config: OpenClawConfig) => config,
|
|
applyProviderNativeStreamingUsageCompatWithPlugin: () => undefined,
|
|
normalizeProviderConfigWithPlugin: () => undefined,
|
|
resolveProviderConfigApiKeyWithPlugin: () => undefined,
|
|
resolveProviderSyntheticAuthWithPlugin: () => undefined,
|
|
}));
|
|
|
|
vi.mock("./models-config.providers.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./models-config.providers.js")>(
|
|
"./models-config.providers.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveImplicitProviders: async () => ({}),
|
|
};
|
|
});
|
|
|
|
installModelsConfigTestHooks();
|
|
|
|
let clearConfigCache: typeof import("../config/io.js").clearConfigCache;
|
|
let clearRuntimeConfigSnapshot: typeof import("../config/io.js").clearRuntimeConfigSnapshot;
|
|
let setRuntimeConfigSnapshot: typeof import("../config/io.js").setRuntimeConfigSnapshot;
|
|
let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson;
|
|
let resetModelsJsonReadyCacheForTest: typeof import("./models-config-state.test-support.js").resetModelsJsonReadyCacheForTest;
|
|
let planOpenClawModelsJsonWithDeps: typeof import("./models-config.plan.test-support.js").planOpenClawModelsJsonWithDeps;
|
|
let readGeneratedModelsJson: typeof import("./models-config.test-utils.js").readGeneratedModelsJson;
|
|
const fixtureSuite = createFixtureSuite("openclaw-models-runtime-source-");
|
|
|
|
beforeAll(async () => {
|
|
await fixtureSuite.setup();
|
|
({ clearConfigCache, clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
|
|
await import("../config/io.js"));
|
|
({ ensureOpenClawModelsJson } = await import("./models-config.js"));
|
|
({ resetModelsJsonReadyCacheForTest } = await import("./models-config-state.test-support.js"));
|
|
({ planOpenClawModelsJsonWithDeps } = await import("./models-config.plan.test-support.js"));
|
|
({ readGeneratedModelsJson } = await import("./models-config.test-utils.js"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearRuntimeConfigSnapshot();
|
|
clearConfigCache();
|
|
resetModelsJsonReadyCacheForTest();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fixtureSuite.cleanup();
|
|
});
|
|
|
|
function createOpenAiApiKeySourceConfig(): OpenClawConfig {
|
|
return {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOpenAiApiKeyRuntimeConfig(): OpenClawConfig {
|
|
// Runtime config simulates already-resolved secrets that must not be persisted.
|
|
return {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createCustomProviderApiKeySourceConfig(): OpenClawConfig {
|
|
return {
|
|
models: {
|
|
providers: {
|
|
litellm: {
|
|
baseUrl: "https://litellm.example/v1",
|
|
apiKey: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENCLAW_MODEL_LITELLM_API_KEY", // pragma: allowlist secret
|
|
},
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createCustomProviderApiKeyRuntimeConfig(): OpenClawConfig {
|
|
return {
|
|
models: {
|
|
providers: {
|
|
litellm: {
|
|
baseUrl: "https://litellm.example/v1",
|
|
apiKey: "sk-litellm-runtime-secret", // pragma: allowlist secret
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOpenAiHeaderSourceConfig(): OpenClawConfig {
|
|
return {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
api: "openai-completions" as const,
|
|
headers: {
|
|
Authorization: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret
|
|
},
|
|
"X-Tenant-Token": {
|
|
source: "file",
|
|
provider: "vault",
|
|
id: "/providers/openai/tenantToken",
|
|
},
|
|
},
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOpenAiHeaderRuntimeConfig(): OpenClawConfig {
|
|
return {
|
|
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: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getOpenAiProvider(config: OpenClawConfig) {
|
|
return expectDefined(config.models?.providers?.openai, "OpenAI provider config");
|
|
}
|
|
|
|
function createOpenAiSourceConfigWithHeadersAndApiKey(): OpenClawConfig {
|
|
const config = createOpenAiHeaderSourceConfig();
|
|
getOpenAiProvider(config).apiKey = {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENAI_API_KEY", // pragma: allowlist secret
|
|
};
|
|
return config;
|
|
}
|
|
|
|
function createOpenAiRuntimeConfigWithHeadersAndApiKey(): OpenClawConfig {
|
|
const config = createOpenAiHeaderRuntimeConfig();
|
|
getOpenAiProvider(config).apiKey = "sk-runtime-resolved"; // pragma: allowlist secret
|
|
return config;
|
|
}
|
|
|
|
function withGatewayTokenMode(config: OpenClawConfig): OpenClawConfig {
|
|
return {
|
|
...config,
|
|
gateway: {
|
|
auth: {
|
|
mode: "token",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function expectGeneratedProviderApiKey(
|
|
agentDir: string,
|
|
providerId: string,
|
|
expected: string,
|
|
) {
|
|
const parsed = await readGeneratedModelsJson<{
|
|
providers: Record<string, { apiKey?: string }>;
|
|
}>(agentDir);
|
|
expect(parsed.providers[providerId]?.apiKey).toBe(expected);
|
|
}
|
|
|
|
async function planGeneratedProviders(params: {
|
|
config: OpenClawConfig;
|
|
sourceConfigForSecrets: OpenClawConfig;
|
|
}) {
|
|
// Planner assertions avoid filesystem noise for marker-projection cases.
|
|
const plan = await planOpenClawModelsJsonWithDeps(
|
|
{
|
|
cfg: params.config,
|
|
sourceConfigForSecrets: params.sourceConfigForSecrets,
|
|
agentDir: "/tmp/openclaw-models-plan",
|
|
env: {},
|
|
existingRaw: "",
|
|
existingParsed: null,
|
|
},
|
|
{
|
|
resolveImplicitProviders: async () => ({}),
|
|
},
|
|
);
|
|
expect(plan.action).toBe("write");
|
|
if (plan.action !== "write") {
|
|
throw new Error(`expected models.json write plan, got ${plan.action}`);
|
|
}
|
|
return JSON.parse(plan.contents).providers as Record<
|
|
string,
|
|
{ apiKey?: string; headers?: Record<string, string> }
|
|
>;
|
|
}
|
|
|
|
function expectOpenAiHeaderMarkers(
|
|
providers: Record<string, { headers?: Record<string, string> }>,
|
|
) {
|
|
// Env header refs keep their id; non-env refs collapse to the shared sentinel.
|
|
expect(providers.openai?.headers?.Authorization).toBe(
|
|
"secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret
|
|
);
|
|
expect(providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER);
|
|
}
|
|
|
|
describe("models-config runtime source snapshot", () => {
|
|
it("uses runtime source snapshot markers when passed the active runtime config", () => {
|
|
const sourceConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
openai: getOpenAiProvider(createOpenAiApiKeySourceConfig()),
|
|
moonshot: {
|
|
baseUrl: "https://api.moonshot.ai/v1",
|
|
apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" },
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const runtimeConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
openai: getOpenAiProvider(createOpenAiApiKeyRuntimeConfig()),
|
|
moonshot: {
|
|
baseUrl: "https://api.moonshot.ai/v1",
|
|
apiKey: "sk-runtime-moonshot", // pragma: allowlist secret
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const providers = enforceSourceManagedProviderSecrets({
|
|
providers: runtimeConfig.models!.providers!,
|
|
sourceProviders: sourceConfig.models!.providers,
|
|
})!;
|
|
expect(providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
expect(providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
|
});
|
|
|
|
it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => {
|
|
const agentDir = await fixtureSuite.createCaseDir("agent");
|
|
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
|
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
|
const sourceConfig = createOpenAiApiKeySourceConfig();
|
|
const runtimeConfig = createOpenAiApiKeyRuntimeConfig();
|
|
const clonedRuntimeConfig: OpenClawConfig = {
|
|
...runtimeConfig,
|
|
agents: {
|
|
defaults: {
|
|
imageModel: "openai/gpt-image-1",
|
|
},
|
|
},
|
|
};
|
|
|
|
try {
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
await ensureOpenClawModelsJson(clonedRuntimeConfig, agentDir);
|
|
await expectGeneratedProviderApiKey(agentDir, "openai", "OPENAI_API_KEY"); // pragma: allowlist secret
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
clearConfigCache();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("preserves source markers for custom-provider api keys after models status secret resolution", async () => {
|
|
const agentDir = await fixtureSuite.createCaseDir("agent");
|
|
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
|
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
|
const sourceConfig = createCustomProviderApiKeySourceConfig();
|
|
const runtimeConfig = createCustomProviderApiKeyRuntimeConfig();
|
|
|
|
try {
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
await ensureOpenClawModelsJson(runtimeConfig, agentDir);
|
|
await expectGeneratedProviderApiKey(agentDir, "litellm", "OPENCLAW_MODEL_LITELLM_API_KEY"); // pragma: allowlist secret
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
clearConfigCache();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("invalidates cached readiness when projected config changes under the same runtime snapshot", async () => {
|
|
const agentDir = await fixtureSuite.createCaseDir("agent");
|
|
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
|
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
|
const sourceConfig = createOpenAiApiKeySourceConfig();
|
|
const runtimeConfig = createOpenAiApiKeyRuntimeConfig();
|
|
const firstCandidate: OpenClawConfig = {
|
|
...runtimeConfig,
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
...getOpenAiProvider(runtimeConfig),
|
|
baseUrl: "https://api.openai.com/v1",
|
|
headers: {
|
|
"X-OpenClaw-Test": "one",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const secondCandidate: OpenClawConfig = {
|
|
...runtimeConfig,
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
...getOpenAiProvider(runtimeConfig),
|
|
baseUrl: "https://mirror.example/v1",
|
|
headers: {
|
|
"X-OpenClaw-Test": "two",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
try {
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
await ensureOpenClawModelsJson(firstCandidate, agentDir);
|
|
let parsed = await readGeneratedModelsJson<{
|
|
providers: Record<
|
|
string,
|
|
{ baseUrl?: string; apiKey?: string; headers?: Record<string, string> }
|
|
>;
|
|
}>(agentDir);
|
|
expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1");
|
|
expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
expect(parsed.providers.openai?.headers?.["X-OpenClaw-Test"]).toBe("one");
|
|
|
|
// Header changes still rewrite models.json, but merge mode preserves the existing baseUrl.
|
|
await ensureOpenClawModelsJson(secondCandidate, agentDir);
|
|
parsed = await readGeneratedModelsJson<{
|
|
providers: Record<
|
|
string,
|
|
{ baseUrl?: string; apiKey?: string; headers?: Record<string, string> }
|
|
>;
|
|
}>(agentDir);
|
|
expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1");
|
|
expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
expect(parsed.providers.openai?.headers?.["X-OpenClaw-Test"]).toBe("two");
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
clearConfigCache();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => {
|
|
const providers = await planGeneratedProviders({
|
|
config: createOpenAiHeaderRuntimeConfig(),
|
|
sourceConfigForSecrets: createOpenAiHeaderSourceConfig(),
|
|
});
|
|
expectOpenAiHeaderMarkers(providers);
|
|
});
|
|
|
|
it("keeps source markers when runtime projection is skipped for incompatible top-level shape", async () => {
|
|
const providers = await planGeneratedProviders({
|
|
config: createOpenAiRuntimeConfigWithHeadersAndApiKey(),
|
|
sourceConfigForSecrets: withGatewayTokenMode(createOpenAiSourceConfigWithHeadersAndApiKey()),
|
|
});
|
|
expect(providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
expectOpenAiHeaderMarkers(providers);
|
|
});
|
|
|
|
it("reapplies source markers when sourceConfigForSecrets uses mixed-case provider keys", async () => {
|
|
// Regression: provider keys in sourceConfigForSecrets may arrive as "OpenAI" while the
|
|
// merge boundary canonicalizes to "openai". The source-managed marker lookup must use the
|
|
// same provider-id normalizer, otherwise the resolved runtime apiKey leaks into models.json.
|
|
const mixedCaseSourceConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
OpenAI: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
|
api: "openai-completions" as const,
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const providers = await planGeneratedProviders({
|
|
config: createOpenAiApiKeyRuntimeConfig(),
|
|
sourceConfigForSecrets: mixedCaseSourceConfig,
|
|
});
|
|
expect(Object.keys(providers).toSorted()).toEqual(["openai"]);
|
|
expect(providers.OpenAI).toBeUndefined();
|
|
expect(providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
});
|
|
|
|
it("reapplies source header markers when sourceConfigForSecrets uses mixed-case provider keys", async () => {
|
|
const sourceConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
" OpenAI ": {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
api: "openai-completions" as const,
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
|
headers: {
|
|
Authorization: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret
|
|
},
|
|
"X-Tenant-Token": {
|
|
source: "file",
|
|
provider: "vault",
|
|
id: "/providers/openai/tenantToken",
|
|
},
|
|
},
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const providers = await planGeneratedProviders({
|
|
config: createOpenAiRuntimeConfigWithHeadersAndApiKey(),
|
|
sourceConfigForSecrets: sourceConfig,
|
|
});
|
|
expect(Object.keys(providers).toSorted()).toEqual(["openai"]);
|
|
expect(providers.OpenAI).toBeUndefined();
|
|
expect(providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
expectOpenAiHeaderMarkers(providers);
|
|
});
|
|
|
|
it.each([
|
|
["before", true],
|
|
["after", false],
|
|
])(
|
|
"prefers canonical source secret ownership when it appears %s a case variant",
|
|
async (_position, first) => {
|
|
const canonical = getOpenAiProvider(createOpenAiApiKeySourceConfig());
|
|
const caseVariant = {
|
|
...canonical,
|
|
apiKey: {
|
|
source: "env" as const,
|
|
provider: "default",
|
|
id: "OPENAI_CASE_VARIANT",
|
|
},
|
|
};
|
|
const sourceProviders = first
|
|
? { openai: canonical, OpenAI: caseVariant }
|
|
: { OpenAI: caseVariant, openai: canonical };
|
|
const providers = await planGeneratedProviders({
|
|
config: createOpenAiApiKeyRuntimeConfig(),
|
|
sourceConfigForSecrets: { models: { providers: sourceProviders } },
|
|
});
|
|
|
|
expect(Object.keys(providers)).toEqual(["openai"]);
|
|
expect(providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
},
|
|
);
|
|
|
|
it("uses a valid case alias when the canonical source entry is not a provider record", () => {
|
|
const runtimeConfig = createOpenAiApiKeyRuntimeConfig();
|
|
const sourceProviders = {
|
|
openai: null,
|
|
OpenAI: getOpenAiProvider(createOpenAiApiKeySourceConfig()),
|
|
} as unknown as NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>;
|
|
|
|
const providers = enforceSourceManagedProviderSecrets({
|
|
providers: runtimeConfig.models!.providers!,
|
|
sourceProviders,
|
|
});
|
|
|
|
expect(providers?.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
|
|
});
|
|
});
|