mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
fix: harden static provider catalog path
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user