fix: harden static provider catalog path

This commit is contained in:
Shakker
2026-04-22 02:55:19 +01:00
committed by Shakker
parent d73c31110b
commit f9bac5038c
7 changed files with 159 additions and 28 deletions

View File

@@ -243,7 +243,8 @@ API key auth, and dynamic model resolution.
provider auth. It may perform provider-specific discovery. Use
`buildStaticProvider` only for bundled/offline rows that are safe to show in
display-only surfaces such as `models list --all` before auth is configured;
it must not require credentials or make network requests.
it must not require credentials or make network requests. Static catalog
hooks run with an empty config, empty env, and no agent/workspace paths.
If your auth flow also needs to patch `models.providers.*`, aliases, and
the agent default model during onboarding, use the preset helpers from

View File

@@ -1,5 +1,6 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
export const VERCEL_AI_GATEWAY_PROVIDER_ID = "vercel-ai-gateway";
export const VERCEL_AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
@@ -191,18 +192,24 @@ export async function discoverVercelAiGatewayModels(): Promise<ModelDefinitionCo
}
try {
const response = await fetch(`${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`, {
signal: AbortSignal.timeout(5000),
const { response, release } = await fetchWithSsrFGuard({
url: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
timeoutMs: 5000,
auditContext: "vercel-ai-gateway.models",
});
if (!response.ok) {
log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${response.status}`);
return getStaticVercelAiGatewayModelCatalog();
try {
if (!response.ok) {
log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${response.status}`);
return getStaticVercelAiGatewayModelCatalog();
}
const data = (await response.json()) as VercelGatewayModelsResponse;
const discovered = (data.data ?? [])
.map(buildDiscoveredModelDefinition)
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog();
} finally {
await release();
}
const data = (await response.json()) as VercelGatewayModelsResponse;
const discovered = (data.data ?? [])
.map(buildDiscoveredModelDefinition)
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog();
} catch (error) {
log.warn(`Failed to discover Vercel AI Gateway models: ${String(error)}`);
return getStaticVercelAiGatewayModelCatalog();

View File

@@ -369,6 +369,16 @@ describe("models list/status", () => {
]);
});
it("models list all local skips unauthenticated provider catalog rows", async () => {
setDefaultZaiRegistry({ available: false });
loadProviderCatalogModelsForList.mockResolvedValueOnce([MOONSHOT_MODEL]);
const runtime = makeRuntime();
await modelsListCommand({ all: true, local: true, json: true }, runtime);
expect(loadProviderCatalogModelsForList).not.toHaveBeenCalled();
});
it("models list does not treat availability-unavailable code as discovery fallback", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), {

View File

@@ -2,6 +2,8 @@ import type { Api, Model } from "@mariozechner/pi-ai";
import { normalizeProviderId } from "../../agents/provider-id.js";
import type { ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
@@ -12,6 +14,7 @@ import { resolveOwningPluginIdsForProvider } from "../../plugins/providers.js";
const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const;
const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]);
const log = createSubsystemLogger("models/list-provider-catalog");
function modelFromProviderCatalog(params: {
provider: string;
@@ -76,7 +79,8 @@ export async function loadProviderCatalogModelsForList(params: {
agentDir: params.agentDir,
env,
});
} catch {
} catch (error) {
log.warn(`provider static catalog failed for ${provider.id}: ${formatErrorMessage(error)}`);
result = null;
}
const normalized = normalizePluginDiscoveryResult({ provider, result });

View File

@@ -160,6 +160,10 @@ export async function appendCatalogSupplementRows(params: {
params.seenKeys.add(key);
}
if (params.context.filter.local) {
return;
}
for (const model of await loadProviderCatalogModelsForList({
cfg: params.context.cfg,
agentDir: params.context.agentDir,

View File

@@ -4,6 +4,7 @@ import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
runProviderCatalog,
runProviderStaticCatalog,
} from "./provider-discovery.js";
import type { ProviderCatalogResult, ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
@@ -84,14 +85,21 @@ function expectNormalizedDiscoveryResult(params: {
result: Parameters<typeof normalizePluginDiscoveryResult>[0]["result"];
expected: Record<string, unknown>;
}) {
expect(
normalizePluginDiscoveryResult({
provider: params.provider,
result: params.result,
}),
).toEqual(params.expected);
const normalized = normalizePluginDiscoveryResult({
provider: params.provider,
result: params.result,
});
expect(Object.getPrototypeOf(normalized)).toBe(null);
expect(Object.fromEntries(Object.entries(normalized))).toEqual(params.expected);
}
type NormalizePluginDiscoveryResultCase = {
name: string;
provider: ProviderPlugin;
result: Parameters<typeof normalizePluginDiscoveryResult>[0]["result"];
expected: Record<string, unknown>;
};
async function expectProviderCatalogResult(params: {
provider: ProviderPlugin;
expected: Record<string, unknown>;
@@ -140,7 +148,7 @@ describe("groupPluginDiscoveryProvidersByOrder", () => {
});
describe("normalizePluginDiscoveryResult", () => {
it.each([
const cases: NormalizePluginDiscoveryResultCase[] = [
{
name: "maps a single provider result to the plugin id",
provider: makeProvider({ id: "Ollama" }),
@@ -205,11 +213,101 @@ describe("normalizePluginDiscoveryResult", () => {
},
},
},
] as const)("$name", ({ provider, result, expected }) => {
{
name: "drops dangerous normalized provider keys",
provider: makeProvider({ id: "__proto__", aliases: ["constructor"], hookAliases: ["safe"] }),
result: {
provider: makeModelProviderConfig({
baseUrl: "http://safe.example/v1",
}),
},
expected: {
safe: {
baseUrl: "http://safe.example/v1",
models: [],
},
},
},
{
name: "drops dangerous multi-provider discovery keys",
provider: makeProvider({ id: "ignored" }),
result: {
providers: {
["__proto__"]: makeModelProviderConfig({ baseUrl: "http://polluted.example/v1" }),
constructor: makeModelProviderConfig({ baseUrl: "http://constructor.example/v1" }),
prototype: makeModelProviderConfig({ baseUrl: "http://prototype.example/v1" }),
safe: makeModelProviderConfig({ baseUrl: "http://safe.example/v1" }),
},
},
expected: {
safe: {
baseUrl: "http://safe.example/v1",
models: [],
},
},
},
];
it.each(cases)("$name", ({ provider, result, expected }) => {
expectNormalizedDiscoveryResult({ provider, result, expected });
});
});
describe("runProviderStaticCatalog", () => {
it("runs static catalogs with a sterile context", async () => {
const seenContexts: unknown[] = [];
const provider: ProviderPlugin = {
id: "demo",
label: "Demo",
auth: [],
staticCatalog: {
run: async (ctx) => {
seenContexts.push(ctx);
return {
provider: makeModelProviderConfig({ baseUrl: "https://static.example/v1" }),
};
},
},
};
await expect(
runProviderStaticCatalog({
provider,
config: {
models: {
providers: {
demo: {
baseUrl: "https://configured.example/v1",
models: [],
apiKey: "secret-value",
},
},
},
},
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
env: {
SECRET_TOKEN: "secret-value",
},
}),
).resolves.toEqual({
provider: {
baseUrl: "https://static.example/v1",
models: [],
},
});
expect(seenContexts).toEqual([
expect.objectContaining({
config: {},
env: {},
}),
]);
expect(seenContexts[0]).not.toHaveProperty("agentDir");
expect(seenContexts[0]).not.toHaveProperty("workspaceDir");
});
});
describe("runProviderCatalog", () => {
it("prefers catalog over discovery when both exist", async () => {
const catalogRun = async () => ({

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"];
const DANGEROUS_PROVIDER_KEYS = new Set(["__proto__", "prototype", "constructor"]);
let providerRuntimePromise: Promise<typeof import("./provider-discovery.runtime.js")> | undefined;
function loadProviderRuntime() {
@@ -19,6 +20,14 @@ function resolveProviderCatalogOrderHook(provider: ProviderPlugin) {
return resolveProviderCatalogHook(provider) ?? provider.staticCatalog;
}
function createProviderConfigRecord(): Record<string, ModelProviderConfig> {
return Object.create(null) as Record<string, ModelProviderConfig>;
}
function isSafeProviderConfigKey(value: string): boolean {
return value !== "" && !DANGEROUS_PROVIDER_KEYS.has(value);
}
export async function resolvePluginDiscoveryProviders(params: {
config?: OpenClawConfig;
workspaceDir?: string;
@@ -67,14 +76,14 @@ export function normalizePluginDiscoveryResult(params: {
}
if ("provider" in result) {
const normalized: Record<string, ModelProviderConfig> = {};
const normalized = createProviderConfigRecord();
for (const providerId of [
params.provider.id,
...(params.provider.aliases ?? []),
...(params.provider.hookAliases ?? []),
]) {
const normalizedKey = normalizeProviderId(providerId);
if (!normalizedKey) {
if (!isSafeProviderConfigKey(normalizedKey)) {
continue;
}
normalized[normalizedKey] = result.provider;
@@ -82,10 +91,10 @@ export function normalizePluginDiscoveryResult(params: {
return normalized;
}
const normalized: Record<string, ModelProviderConfig> = {};
const normalized = createProviderConfigRecord();
for (const [key, value] of Object.entries(result.providers)) {
const normalizedKey = normalizeProviderId(key);
if (!normalizedKey || !value) {
if (!isSafeProviderConfigKey(normalizedKey) || !value) {
continue;
}
normalized[normalizedKey] = value;
@@ -132,10 +141,8 @@ export function runProviderStaticCatalog(params: {
env: NodeJS.ProcessEnv;
}) {
return params.provider.staticCatalog?.run({
config: params.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
config: {},
env: {},
resolveProviderApiKey: () => ({
apiKey: undefined,
}),