diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index 35fc53a242..64e379cc03 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -165,7 +165,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro | Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` | | Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` | | Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` | -| `combo/` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks | +| `combo/` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks | | Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks | | Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical β†’ alias to pick up enrichment for short aliases (`dg/nova-3 β†’ Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks | | Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra | both hooks | @@ -179,30 +179,33 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro ## Plugin options -| Option | Type | Default | Description | -| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- | -| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries | -| `displayName` | `string` | `"OmniRoute"` or `OmniRoute ()` | Label in the OC UI | -| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms | -| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL | -| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) | +| Option | Type | Default | Description | +| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries | +| `displayName` | `string` | `"OmniRoute"` or `OmniRoute ()` | Label in the OC UI | +| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms | +| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL | +| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` | +| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) | + +For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior. ### `features` block Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change. -| Feature | Type | Default | What it does | -| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/` namespace and labelled `Combo: ` in the model picker so they're distinguishable from raw provider/model pairs. | -| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` β†’ `cacheRead`, `cache_creation` β†’ `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). | -| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`. Intensity tokens render as traffic-light emoji (🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra) so the picker advertises "how compressed" each combo is at a glance. | -| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 β†’ Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 β†’ Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[].name` verbatim when ≀8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` β†’ `GHM`, `Gemini` β†’ `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). | -| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh β€” never hides the whole catalog. | -| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write β€” never blocks publishing. | -| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` | -| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.` remote entry into the OC config pointing at `/api/mcp/stream` with the resolved Bearer token | -| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset | -| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) | +| Feature | Type | Default | What it does | +| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/` namespace and labelled `Combo: ` in the model picker so they're distinguishable from raw provider/model pairs. | +| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` β†’ `cacheRead`, `cache_creation` β†’ `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). | +| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`. Intensity tokens render as traffic-light emoji (🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra) so the picker advertises "how compressed" each combo is at a glance. | +| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 β†’ Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 β†’ Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[].name` verbatim when ≀8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` β†’ `GHM`, `Gemini` β†’ `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). | +| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh β€” never hides the whole catalog. | +| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write β€” never blocks publishing. | +| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` | +| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.` remote entry into the OC config pointing at `/api/mcp/stream` with the resolved Bearer token | +| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset | +| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) | #### Example β€” enrichment + compression tags + MCP auto-emit @@ -214,6 +217,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode. { "providerId": "omniroute", "baseURL": "https://or.example.com", + "managementReadToken": "", "features": { "combos": true, "enrichment": true, diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 717c101001..b715eaf3bd 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 62cdd5afc4..526fa99799 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -91,6 +91,10 @@ import { * - `baseURL` Override base URL for this OmniRoute instance. When * absent, the loader falls back to a credential-attached * baseURL set by `/connect`. + * - `managementReadToken` Optional read-only management-plane bearer token. + * Used only for catalog GETs under `/api/*`; `/v1/*` + * inference continues to use the connected `apiKey`. + * Falls back to `apiKey` when unset. */ /** * Optional feature toggles. Every field is opt-in/out per call; defaults @@ -122,8 +126,9 @@ import { * provider's API key (from auth.json) when unset. * Useful when a narrower-scoped MCP-only key is * preferred over the chat/inference key. - * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on - * every outbound request to baseURL. Default true. + * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type only + * on same-origin `/v1/chat/completions` and + * `/v1/models` requests. Default true. * - `debugLog` Capture every outbound request + response to a * JSONL file at * `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`. @@ -186,6 +191,7 @@ const optionsSchema = z displayName: z.string().min(1).optional(), modelCacheTtl: z.number().positive().optional(), baseURL: z.string().url().optional(), + managementReadToken: z.string().min(1).optional(), features: featuresSchema.optional(), }) .strict(); @@ -253,7 +259,7 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re * lookup fails with "No credentials for opencode-". */ omnirouteProviderId: string; -} & Pick { +} & Pick { const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId); // OC 1.17.8+ native-adapter gate rejects providerID not in @@ -277,6 +283,7 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re displayName, modelCacheTtl, baseURL: opts?.baseURL, + managementReadToken: opts?.managementReadToken, features: opts?.features, }; } @@ -2348,12 +2355,14 @@ export function buildComboKey( } /** - * Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so - * the key is safe to log / inspect via debugger without leaking the secret. - * Different (baseURL, apiKey) tuples MUST keep independent cache entries: - * a single OC user may register prod + preprod OmniRoute side-by-side with - * distinct keys, and serving one's catalog from the other's cache would be - * a correctness bug, not just a privacy one. + * Internal cache key: `${baseURL}::sha256(credentialId)`. The credential id + * combines the inference key and effective management-read token so catalog + * results fetched under different permissions never share an entry. We hash + * it so the cache key is safe to inspect without leaking either secret. + * Different credential tuples MUST keep independent cache entries: a single + * OC user may register prod + preprod OmniRoute side-by-side with distinct + * keys, and serving one's catalog from the other's cache would be a + * correctness bug, not just a privacy one. */ // codeql[js/insufficient-password-hash]: the input here is an API-key // identifier we use solely to derive an in-memory cache lookup key β€” it is @@ -2504,6 +2513,9 @@ export function createOmniRouteProviderHook( return {}; } const apiKey = (auth as { key: string }).key; + // Management-plane catalog reads may use a narrower read-only token. + // Backward compatibility: when unset, retain the historical apiKey use. + const managementReadToken = resolved.managementReadToken ?? apiKey; // baseURL resolution: plugin opts first, then credential-attached // baseURL (auth backends sometimes stash it next to the key), then the @@ -2533,7 +2545,7 @@ export function createOmniRouteProviderHook( return {}; } - const cacheKey = modelsCacheKey(baseURL, apiKey); + const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`); const t = now(); const cached = cache.get(cacheKey); @@ -2565,7 +2577,7 @@ export function createOmniRouteProviderHook( rawCombos = []; if (wantCombos) { try { - rawCombos = await combosFetcher(baseURL, apiKey, 10_000); + rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000); } catch (err) { console.warn( "[omniroute-plugin] combos fetch failed, falling back to models-only catalog", @@ -2580,7 +2592,7 @@ export function createOmniRouteProviderHook( rawAutoCombos = []; if (wantAutoCombos) { try { - rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000); + rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000); } catch { // Already handled inside the default fetcher β€” this catch // is belt-and-suspenders for injected stubs. @@ -2592,7 +2604,7 @@ export function createOmniRouteProviderHook( rawEnrichment = new Map(); if (wantEnrichment) { try { - rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000); + rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000); } catch (err) { console.warn( "[omniroute-plugin] enrichment fetch failed, falling back to raw ids", @@ -2606,7 +2618,11 @@ export function createOmniRouteProviderHook( rawCompressionCombos = []; if (wantCompressionMeta) { try { - rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000); + rawCompressionCombos = await compressionMetaFetcher( + baseURL, + managementReadToken, + 10_000 + ); } catch (err) { console.warn("[omniroute-plugin] compression-metadata fetch failed", err); } @@ -2620,7 +2636,7 @@ export function createOmniRouteProviderHook( rawConnections = []; if (wantUsableOnly) { try { - rawConnections = await providersFetcher(baseURL, apiKey, 10_000); + rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000); } catch (err) { console.warn( "[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh", @@ -3008,17 +3024,18 @@ export function createOmniRouteProviderHook( } // ──────────────────────────────────────────────────────────────────────────── -// Fetch interceptor (T-04) β€” Bearer + Content-Type injection on outbound -// provider requests targeting the configured OmniRoute baseURL +// Fetch interceptor (T-04) β€” Bearer + Content-Type injection on intended +// same-origin OmniRoute inference requests only // ──────────────────────────────────────────────────────────────────────────── /** * Build a `fetch`-compatible interceptor that injects `Authorization: Bearer` - * (and a default `Content-Type`) onto outbound requests targeting the given - * `baseURL`. Requests to any other host pass through untouched β€” the apiKey - * is treated as a secret bound to the configured OmniRoute instance and - * MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally - * exercise when a tool call rewrites the URL mid-flight). + * (and a default `Content-Type`) only onto same-origin requests targeting + * `/chat/completions` or `/models`, where `` is the + * configured baseURL path normalized to end in `/v1`. Management, MCP, and + * unrelated inference paths pass through untouched. The apiKey is treated as + * a secret bound to both the configured OmniRoute origin and these intended + * inference endpoints, and MUST NOT leak elsewhere. * * Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor` * (their `dist/src/plugin.js:477-516`) with these intentional deviations: @@ -3045,17 +3062,35 @@ export function createOmniRouteFetchInterceptor(config: { apiKey: string; baseURL: string; }): typeof fetch { - const trimmed = trimTrailingSlashes(config.baseURL); - // Use `/` for prefix matching to prevent suffix-spoof attacks - // (e.g. baseURL `https://or.example.com/v1` should NOT match - // `https://or.example.com/v1-attacker.evil/...`). - const prefix = `${trimmed}/`; + let baseOrigin: string | undefined; + const inferencePaths = new Set(); + try { + const baseUrl = new URL(config.baseURL); + baseOrigin = baseUrl.origin; + const basePath = ensureV1Suffix(baseUrl.pathname); + inferencePaths.add(`${basePath}/chat/completions`); + inferencePaths.add(`${basePath}/models`); + } catch { + // Credential-attached base URLs are not schema-validated. A malformed + // value must disable injection rather than broaden the credential scope. + } return async (input, init = {}) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const targetsOmniRoute = url === trimmed || url.startsWith(prefix); - if (!targetsOmniRoute) { + let requestUrl: URL | undefined; + try { + requestUrl = new URL(url); + } catch { + // Native fetch will report malformed/relative URLs as usual. We only + // decline to attach credentials before forwarding the original input. + } + const normalizedPath = requestUrl ? trimTrailingSlashes(requestUrl.pathname) || "/" : undefined; + const targetsInference = + requestUrl?.origin === baseOrigin && + normalizedPath !== undefined && + inferencePaths.has(normalizedPath); + if (!targetsInference) { return fetch(input, init); } @@ -3997,7 +4032,9 @@ type AuthJsonShape = Record + entry: Omit, + identityFingerprint: string ) => Promise; export type OmniRouteDiskSnapshotReader = ( - providerId: string + providerId: string, + identityFingerprint: string ) => Promise | undefined>; +/** + * Bind a snapshot to the endpoint and effective credential tuple without + * persisting any raw token. This opaque value is only stored and compared; + * it is never logged or included in generated provider configuration. + */ +function diskSnapshotIdentityFingerprint( + baseURL: string, + apiKey: string, + managementReadToken: string +): string { + let normalizedBaseURL: string; + try { + const parsed = new URL(baseURL); + parsed.hash = ""; + parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/"; + normalizedBaseURL = parsed.toString(); + } catch { + normalizedBaseURL = trimTrailingSlashes(baseURL); + } + return createHash("sha256") + .update(JSON.stringify([normalizedBaseURL, apiKey, managementReadToken])) + .digest("hex"); +} + /** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */ -export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => { +export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async ( + providerId, + entry, + identityFingerprint +) => { try { const file = diskSnapshotPath(providerId); // Restrict perms to the owner: the snapshot lives alongside auth.json // (0o600) and embeds provider topology + masked connection records. await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); const snapshot: OmniRouteDiskSnapshot = { - v: 1, + v: 2, + identityFingerprint, rawModels: entry.rawModels, rawCombos: entry.rawCombos, rawAutoCombos: entry.rawAutoCombos, @@ -4051,12 +4119,22 @@ export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (pro }; /** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */ -export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => { +export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async ( + providerId, + identityFingerprint +) => { try { const file = diskSnapshotPath(providerId); const body = await readFile(file, "utf8"); const parsed = JSON.parse(body) as Partial; - if (!parsed || parsed.v !== 1) return undefined; + if ( + !parsed || + parsed.v !== 2 || + typeof parsed.identityFingerprint !== "string" || + parsed.identityFingerprint !== identityFingerprint + ) { + return undefined; + } return { rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [], rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [], @@ -4480,6 +4558,9 @@ export function createOmniRouteConfigHook( ); return; } + // Management-plane catalog reads may use a narrower read-only token. + // Backward compatibility: when unset, retain the historical apiKey use. + const managementReadToken = resolved.managementReadToken ?? apiKey; // baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL. // No silent localhost default β€” a misconfigured plugin should surface a @@ -4496,7 +4577,12 @@ export function createOmniRouteConfigHook( // Try the shared cache first. On OC β‰₯1.14.49 the provider hook may have // populated it moments earlier; on OC ≀1.14.48 only this hook runs but // the cache still works (single producer + consumer through one Map). - const cacheKey = modelsCacheKey(baseURL, apiKey); + const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`); + const snapshotFingerprint = diskSnapshotIdentityFingerprint( + baseURL, + apiKey, + managementReadToken + ); const t = now(); const cached = cache.get(cacheKey); @@ -4537,7 +4623,7 @@ export function createOmniRouteConfigHook( rawCombos = []; try { - rawCombos = await combosFetcher(baseURL, apiKey, 10_000); + rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000); } catch (err) { logger.warn( "[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog", @@ -4548,7 +4634,7 @@ export function createOmniRouteConfigHook( rawAutoCombos = []; if (wantAutoCombos) { try { - rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000); + rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000); } catch { // Already handled inside the default fetcher } @@ -4564,7 +4650,7 @@ export function createOmniRouteConfigHook( rawEnrichment = new Map(); if (wantEnrichment) { try { - rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000); + rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000); } catch (err) { logger.warn( "[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog", @@ -4579,7 +4665,7 @@ export function createOmniRouteConfigHook( rawCompressionCombos = []; if (wantCompressionMeta) { try { - rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000); + rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000); } catch (err) { logger.warn( "[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix", @@ -4595,7 +4681,7 @@ export function createOmniRouteConfigHook( rawConnections = []; if (wantUsableOnly) { try { - rawConnections = await providersFetcher(baseURL, apiKey, 10_000); + rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000); } catch (err) { logger.warn( "[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh", @@ -4611,7 +4697,7 @@ export function createOmniRouteConfigHook( // a healthy refresh; staleness is bounded only by how recently the // user was online. if (modelsFetchThrew && wantDiskCache) { - const snapshot = await diskSnapshotReader(resolved.providerId); + const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint); if (snapshot && snapshot.rawModels.length > 0) { logger.warn( `[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)` @@ -4656,14 +4742,18 @@ export function createOmniRouteConfigHook( // Best-effort; soft-fail keeps us moving when the data dir isn't // writable (e.g. read-only container). if (modelsFetchOk && wantDiskCache) { - await diskSnapshotWriter(resolved.providerId, { - rawModels, - rawCombos, - rawAutoCombos, - rawEnrichment, - rawCompressionCombos, - rawConnections, - }); + await diskSnapshotWriter( + resolved.providerId, + { + rawModels, + rawCombos, + rawAutoCombos, + rawEnrichment, + rawCompressionCombos, + rawConnections, + }, + snapshotFingerprint + ); } } diff --git a/@omniroute/opencode-plugin/tests/disk-snapshot-perms.test.ts b/@omniroute/opencode-plugin/tests/disk-snapshot-perms.test.ts index cf3a2750da..84f8a8e93d 100644 --- a/@omniroute/opencode-plugin/tests/disk-snapshot-perms.test.ts +++ b/@omniroute/opencode-plugin/tests/disk-snapshot-perms.test.ts @@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot", process.env.OPENCODE_DATA_DIR = tmp; try { - await defaultDiskSnapshotWriter("perm-test", makeEntry()); + await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity"); const file = diskSnapshotPath("perm-test"); assert.ok(fs.existsSync(file), "snapshot file should be written"); diff --git a/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts b/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts index 2787894868..8c3f2a5389 100644 --- a/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts +++ b/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts @@ -50,6 +50,52 @@ test("createOmniRouteFetchInterceptor: targets baseURL β†’ Authorization header } }); +test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const prefixedBase = "https://or.example.com/tenant-a/v1"; + const f = createOmniRouteFetchInterceptor({ + apiKey: KEY, + baseURL: `${prefixedBase}///`, + }); + const streamingBody = '{"stream":true}'; + + await f(`${prefixedBase}/chat/completions?trace=1`, { + method: "POST", + body: streamingBody, + headers: { Accept: "text/event-stream" }, + }); + await f(`${prefixedBase}/models/?refresh=1`); + await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" }); + await f("https://or.example.com/v1/models"); + await f("https://or.example.com/tenant-b/v1/chat/completions", { + method: "POST", + body: "{}", + }); + await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" }); + + const headers = calls.map(({ init }) => new Headers(init?.headers)); + assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`); + assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`); + for (const index of [2, 3, 4, 5]) { + assert.equal(headers[index]?.get("Authorization"), null); + } + assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`); + assert.equal(calls[0]?.init?.body, streamingBody); + assert.equal(headers[0]?.get("Accept"), "text/event-stream"); + + const suffixingInterceptor = createOmniRouteFetchInterceptor({ + apiKey: KEY, + baseURL: "https://or.example.com/tenant-a/", + }); + await suffixingInterceptor(`${prefixedBase}/models`); + const suffixedHeaders = new Headers(calls[6]?.init?.headers); + assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); + test("createOmniRouteFetchInterceptor: targets baseURL β†’ Authorization OVERRIDES caller-supplied Bearer", async () => { const { calls, restore } = installFetchRecorder(); try { @@ -259,7 +305,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked {} as never ); const wiredFetch = (result as { fetch: typeof fetch }).fetch; - await wiredFetch(`${BASE}/v1/models`, {}); + await wiredFetch(`${BASE}/models`, {}); assert.equal(calls.length, 1); const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers); assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); diff --git a/@omniroute/opencode-plugin/tests/management-read-token.test.ts b/@omniroute/opencode-plugin/tests/management-read-token.test.ts new file mode 100644 index 0000000000..5c94953ba7 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/management-read-token.test.ts @@ -0,0 +1,271 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + createOmniRouteAuthHook, + createOmniRouteConfigHook, + createOmniRouteProviderHook, + parseOmniRoutePluginOptions, + type OmniRouteCompressionMetaFetcher, + type OmniRouteEnrichmentFetcher, + type OmniRouteProvidersFetcher, + type OmniRouteRawModelEntry, +} from "../src/index.js"; + +const BASE_URL = "https://or.example.com/v1"; +const API_KEY = "sk-inference-only"; +const MANAGEMENT_READ_TOKEN = "sk-management-read-only"; + +const RAW_MODELS: OmniRouteRawModelEntry[] = [ + { + id: "openai/gpt-test", + context_length: 16_000, + max_output_tokens: 4_000, + capabilities: { + tool_calling: true, + reasoning: false, + vision: false, + thinking: false, + temperature: true, + }, + input_modalities: ["text"], + output_modalities: ["text"], + }, +]; + +function apiAuth(key: string) { + return { type: "api" as const, key }; +} + +test("options: managementReadToken is accepted and preserved", () => { + const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN }); + assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN); +}); + +test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => { + const calls: Array<[string, string]> = []; + const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => { + calls.push(["pricing", token]); + return new Map(); + }; + const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => { + calls.push(["context", token]); + return []; + }; + const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => { + calls.push(["providers", token]); + return []; + }; + + const hook = createOmniRouteProviderHook( + { + baseURL: BASE_URL, + managementReadToken: MANAGEMENT_READ_TOKEN, + features: { compressionMetadata: true, usableOnly: true }, + }, + { + fetcher: async (_baseURL, token) => { + calls.push(["models", token]); + return RAW_MODELS; + }, + combosFetcher: async (_baseURL, token) => { + calls.push(["combos", token]); + return []; + }, + autoCombosFetcher: async (_baseURL, token) => { + calls.push(["auto-combos", token]); + return []; + }, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + } + ); + + await hook.models!({} as never, { auth: apiAuth(API_KEY) as never }); + + assert.deepEqual(calls, [ + ["models", API_KEY], + ["combos", MANAGEMENT_READ_TOKEN], + ["auto-combos", MANAGEMENT_READ_TOKEN], + ["pricing", MANAGEMENT_READ_TOKEN], + ["context", MANAGEMENT_READ_TOKEN], + ["providers", MANAGEMENT_READ_TOKEN], + ]); +}); + +test("provider hook: absent managementReadToken preserves apiKey fallback", async () => { + const calls: Array<[string, string]> = []; + const hook = createOmniRouteProviderHook( + { baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } }, + { + fetcher: async (_baseURL, token) => { + calls.push(["models", token]); + return RAW_MODELS; + }, + combosFetcher: async (_baseURL, token) => { + calls.push(["combos", token]); + return []; + }, + } + ); + + await hook.models!({} as never, { auth: apiAuth(API_KEY) as never }); + + assert.deepEqual(calls, [ + ["models", API_KEY], + ["combos", API_KEY], + ]); +}); + +test("config hook: managementReadToken stays out of provider inference and MCP config", async () => { + const calls: Array<[string, string]> = []; + const hook = createOmniRouteConfigHook( + { + baseURL: BASE_URL, + managementReadToken: MANAGEMENT_READ_TOKEN, + features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true }, + }, + { + readAuthJson: async () => ({ + "opencode-omniroute": { type: "api" as const, key: API_KEY }, + }), + fetcher: async (_baseURL, token) => { + calls.push(["models", token]); + return RAW_MODELS; + }, + combosFetcher: async (_baseURL, token) => { + calls.push(["combos", token]); + return []; + }, + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = {}; + + await hook(input as never); + + assert.deepEqual(calls, [ + ["models", API_KEY], + ["combos", MANAGEMENT_READ_TOKEN], + ]); + assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY); + assert.equal( + input.mcp?.["opencode-omniroute"]?.headers?.Authorization, + `Bearer ${API_KEY}`, + "mcpAutoEmit remains independent of managementReadToken" + ); +}); + +test("auth fetch: only intended same-origin inference paths receive apiKey", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response("ok"); + }) as typeof fetch; + + try { + const hook = createOmniRouteAuthHook({ + baseURL: `${BASE_URL}/`, + managementReadToken: MANAGEMENT_READ_TOKEN, + }); + const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never); + const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch; + + const streamingBody = '{"stream":true}'; + await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, { + method: "POST", + body: streamingBody, + headers: { Accept: "text/event-stream" }, + }); + await interceptedFetch(`${BASE_URL}/models/?refresh=1`); + await interceptedFetch("https://or.example.com/api/combos"); + await interceptedFetch("https://or.example.com/api/mcp/stream"); + await interceptedFetch("https://or.example.com/v1/embeddings"); + await interceptedFetch("https://third-party.example/v1/chat/completions", { + method: "POST", + body: "{}", + }); + + const headers = calls.map(({ init }) => new Headers(init?.headers)); + assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`); + assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`); + for (const index of [2, 3, 4, 5]) { + assert.equal(headers[index]?.get("Authorization"), null); + } + assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`); + assert.equal(calls[0]?.init?.body, streamingBody); + assert.equal(headers[0]?.get("Accept"), "text/event-stream"); + assert.equal( + calls.some(({ init }) => + [...new Headers(init?.headers).values()].some((value) => + value.includes(MANAGEMENT_READ_TOKEN) + ) + ), + false, + "managementReadToken must never enter inference fetch headers" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("disk cache: snapshot written under management token A is rejected under token B", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-")); + const previousDataDir = process.env.OPENCODE_DATA_DIR; + process.env.OPENCODE_DATA_DIR = tmp; + + try { + const commonDeps = { + readAuthJson: async () => ({ + "opencode-omniroute": { + type: "api" as const, + key: API_KEY, + baseURL: BASE_URL, + }, + }), + combosFetcher: async () => [], + logger: { warn: () => {} }, + }; + const features = { + enrichment: false, + autoCombos: false, + diskCache: true, + } as const; + + const tokenAHook = createOmniRouteConfigHook( + { managementReadToken: "token-A", features }, + { + ...commonDeps, + fetcher: async () => RAW_MODELS, + } + ); + await tokenAHook({} as never); + + const tokenBHook = createOmniRouteConfigHook( + { managementReadToken: "token-B", features }, + { + ...commonDeps, + fetcher: async () => { + throw new Error("offline"); + }, + } + ); + const input: { provider?: Record }> } = {}; + await tokenBHook(input as never); + + assert.deepEqual( + input.provider?.["opencode-omniroute"]?.models, + {}, + "catalog from token A must not hydrate after switching to token B" + ); + } finally { + if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR; + else process.env.OPENCODE_DATA_DIR = previousDataDir; + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/changelog.d/fixes/7884-opencode-management-read-token.md b/changelog.d/fixes/7884-opencode-management-read-token.md new file mode 100644 index 0000000000..1ab12e0f29 --- /dev/null +++ b/changelog.d/fixes/7884-opencode-management-read-token.md @@ -0,0 +1 @@ +- **fix(opencode-plugin):** the official `@omniroute/opencode-plugin` now accepts an optional `managementReadToken` for management-plane catalog GETs while keeping inference requests on the connected `apiKey` β€” existing configurations continue to fall back to `apiKey`, and MCP token selection remains independent. (thanks @RaviTharuma)