diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index d71cc032d..dc679315d 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -90,6 +90,10 @@ export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || path.includes("?") || path.includes("#")) { return "discovery path must be a query-free relative/origin path"; } + if (path.includes("\\")) return "discovery path must use forward slashes"; + if (path.split("/").some(segment => segment.replace(/%2e/gi, ".") === "..")) { + return "discovery path must not contain parent-directory segments"; + } } const queryEntries = Object.entries(spec.query ?? {}); if (queryEntries.length > 32) return "discovery query may contain at most 32 entries"; diff --git a/tests/helpers/provider-registry-discovery.ts b/tests/helpers/provider-registry-discovery.ts new file mode 100644 index 000000000..2d1e28e65 --- /dev/null +++ b/tests/helpers/provider-registry-discovery.ts @@ -0,0 +1,33 @@ +import { clearModelCache } from "../../src/codex/model-cache"; +import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../../src/providers/registry"; + +interface RegistryDiscoveryOverrides { + preserveCustomDestination?: boolean; +} + +/** Temporarily override registry-owned discovery policy with a clean cache before and after. */ +export async function withRegistryDiscovery( + providerId: string, + spec: ProviderModelDiscoverySpec, + run: () => Promise | T, + overrides: RegistryDiscoveryOverrides = {}, +): Promise { + const entry = PROVIDER_REGISTRY.find(row => row.id === providerId); + if (!entry) throw new Error(`missing ${providerId} registry entry`); + const originalDiscovery = entry.modelDiscovery; + const originalPreserveCustomDestination = entry.preserveCustomDestination; + clearModelCache(providerId); + entry.modelDiscovery = spec; + if (overrides.preserveCustomDestination !== undefined) { + entry.preserveCustomDestination = overrides.preserveCustomDestination; + } + try { + return await run(); + } finally { + if (originalDiscovery === undefined) delete entry.modelDiscovery; + else entry.modelDiscovery = originalDiscovery; + if (originalPreserveCustomDestination === undefined) delete entry.preserveCustomDestination; + else entry.preserveCustomDestination = originalPreserveCustomDestination; + clearModelCache(providerId); + } +} diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 99d613157..74bda3dc4 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; -import { PROVIDER_REGISTRY } from "../src/providers/registry"; import type { OcxConfig } from "../src/types"; +import { withRegistryDiscovery } from "./helpers/provider-registry-discovery"; const TEST_DIR = join(tmpdir(), "ocx-conn-test"); const previousHome = process.env.OPENCODEX_HOME; @@ -178,13 +178,9 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { }); test("reports only eligible deduplicated models from a registry discovery contract", async () => { - const entry = PROVIDER_REGISTRY.find(row => row.id === "together"); - if (!entry) throw new Error("missing together registry entry"); - const original = entry.modelDiscovery; - entry.modelDiscovery = { + await withRegistryDiscovery("together", { filter: { anyOf: [{ path: ["type"], equalsAny: ["chat"] }] }, - }; - try { + }, async () => { globalThis.fetch = (async () => Response.json({ data: [ { id: "chat-model", type: "chat" }, @@ -202,10 +198,7 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { const { body } = await probe(config, "together"); expect(body.ok).toBe(true); expect(body.models).toBe(1); - } finally { - if (original === undefined) delete entry.modelDiscovery; - else entry.modelDiscovery = original; - } + }); }); test("Google's models-array response shape is accepted (x-goog-api-key path)", async () => { diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index 01b60f952..80dd02fa3 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { gatherRoutedModels } from "../src/codex/catalog"; import { catalogHintsFromModelsApiItem } from "../src/codex/catalog/provider-fetch"; -import { clearModelCache } from "../src/codex/model-cache"; +import { clearModelCache, getFreshCached, setCached } from "../src/codex/model-cache"; import { buildModelsRequest } from "../src/oauth"; import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; import { deriveKeyLoginMap, providerConfigSeed } from "../src/providers/derive"; @@ -17,6 +17,7 @@ import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../src/provi import { routeModel } from "../src/router"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { withRegistryDiscovery } from "./helpers/provider-registry-discovery"; const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/provider-model-discovery.json"), "utf8"); const originalFetch = globalThis.fetch; @@ -36,37 +37,7 @@ async function withTogetherDiscovery( spec: ProviderModelDiscoverySpec, run: () => Promise | T, ): Promise { - const entry = togetherEntry(); - const original = entry.modelDiscovery; - const originalPreserveCustomDestination = entry.preserveCustomDestination; - entry.modelDiscovery = spec; - entry.preserveCustomDestination = true; - try { - return await run(); - } finally { - if (original === undefined) delete entry.modelDiscovery; - else entry.modelDiscovery = original; - if (originalPreserveCustomDestination === undefined) delete entry.preserveCustomDestination; - else entry.preserveCustomDestination = originalPreserveCustomDestination; - clearModelCache("together"); - } -} - -async function withRegistryDiscovery( - providerId: string, - spec: ProviderModelDiscoverySpec, - run: () => Promise | T, -): Promise { - const entry = PROVIDER_REGISTRY.find(row => row.id === providerId); - if (!entry) throw new Error(`missing ${providerId} registry entry`); - const original = entry.modelDiscovery; - entry.modelDiscovery = spec; - try { - return await run(); - } finally { - if (original === undefined) delete entry.modelDiscovery; - else entry.modelDiscovery = original; - } + return withRegistryDiscovery("together", spec, run, { preserveCustomDestination: true }); } function togetherConfig(overrides: Partial = {}): OcxConfig { @@ -96,6 +67,18 @@ describe("registry-owned provider model discovery", () => { .toContain("https"); expect(providerModelDiscoverySpecError({ path: "models?unbounded=true" })) .toContain("query-free"); + for (const path of [ + "../../internal/models", + "models/../internal", + "models/%2e%2e/internal", + "models/.%2E/internal", + "models/%2e./internal", + ]) { + expect(providerModelDiscoverySpecError({ path })).toContain("parent-directory"); + } + expect(providerModelDiscoverySpecError({ path: String.raw`models\..\internal` })) + .toContain("forward slashes"); + expect(providerModelDiscoverySpecError({ path: "models/model..variant" })).toBeNull(); expect(providerModelDiscoverySpecError({ url: "https://api.example.test/models", path: "models", @@ -103,6 +86,15 @@ describe("registry-owned provider model discovery", () => { expect(providerModelDiscoverySpecError({ maxModels: 25 })).toBeNull(); }); + test("clears cached rows before applying a temporary registry discovery policy", async () => { + setCached("together", []); + expect(getFreshCached("together", 60_000)).toEqual([]); + + await withTogetherDiscovery({ maxModels: 25 }, () => { + expect(getFreshCached("together", 60_000)).toBeNull(); + }); + }); + test("limits collision preservation to fixed API-key destinations", () => { for (const entry of PROVIDER_REGISTRY) { if (entry.preserveCustomDestination !== true) continue;