diff --git a/server/lib/README.md b/server/lib/README.md index 20101255fe..61deb3ccc1 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -185,6 +185,9 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `fleetQuotas.js` | Unifies subscription-quota readings across federated instances — one plan, several machines, each able to read only its own local CLI. `sanitizeQuotaCards` bounds a peer-supplied payload to the wire shape; `mergeFleetQuotaCards(localCards, peerEntries)` folds every peer's reading into this install's cards, taking the FRESHEST reading per limit key (the meters are account-wide, so summing them would multiply one allowance) and SUMMING activity counts (those are per-machine, which is why the provider captions them "does not include other devices"); `fleetNote` writes the caption naming what was combined. `metrics[]` is left local — its values are prose, not addends. Fed by `services/providerQuotaShare.js` (this machine's readings, persisted) and `services/peerUsage.js` (the `usage` sync category that carries them). | | `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. | | `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providerGateways.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. | +| `providerHarnesses.js` | Provider HARNESS identity for the connection graph (#6366) — `PROVIDER_HARNESSES` (one row per agent program PortOS drives: claude/opencode/codex/antigravity/cursor/grok/kimi/pi, each with its supported `modes`, wire `protocol` and the existing `is*Provider` predicate as its matcher), `PROVIDER_HARNESS_IDS`, `harnessById`, `harnessForProvider` (null for an `api` record AND for an unknown harness — use `providerRouteMode` to tell them apart), `harnessSupportsMode`, `ROUTE_MODES`, plus the model-name adapters `toExecutableModelName` / `toCanonicalModelName` (the verified inverse of `prefixOpencodeModel`: a stored alias is canonicalized only when it maps back to the exact stored string, so import never rewrites a saved model by heuristically stripping a prefix). Distinct from `providerVendors.js` (argv-shaped), `providerFamilies.js` (subscription quota) and `providerGateways.js` (hosted backend). | +| `providerConnections.js` | Provider CONNECTION identity for the connection graph (#6366) — `providerConnectionProfile(provider)` reads one record as the backend it describes (`kind`, `protocol`, `transports`, RAW server-only `credentials`, the `owned` field/env split, and `reasons` from `CONNECTION_ISOLATION_REASONS` when the config is dynamic, external, unparsable or endpoint-less). `sameConnectionIdentity` compares real credential values and refuses once any is the `REDACTED_CREDENTIAL` sentinel, so a sanitized record can never merge two connections; `compareBackendEndpoints` is the looser same-daemon test that only ever produces a link SUGGESTION, applying the recognized OpenAI-compatible `/v1` conversion (never DNS resolution, loopback/remote equivalence or arbitrary path trimming). `withoutConnectionOwnedFields` / `withConnectionOwnedFields` are the lossless split/materialize pair that keeps a downgraded install runnable from `data/providers.json` alone. Pure: no I/O. | +| `providerGraphPreview.js` | READ-ONLY import preview for the provider connection graph (#6366) — `buildProviderGraphPreview({providers, activeProvider})` projects the records an install already runs into proposed connections, harness bindings and executable routes, grouping ONLY the proven same-harness CLI/TUI siblings from `providerModeGroups` and surfacing a cross-harness daemon match as a `suggestedLinks` entry requiring an explicit link. `toManagementPreviewDto` sanitizes and validates the version-1 DTOs (`managementPreviewSchema`, `PROVIDER_GRAPH_SCHEMA_VERSION`) that `GET /api/providers/management/preview` returns; `projectPreviewToProviders` re-materializes every original record (pins, secrets, consent flags and unknown custom fields included) as the import-fidelity and downgrade proof; `providerGraphUniquenessViolations` checks the UNIQUE constraints #6367 will enforce; `routeModeEligibility` is the pure declarative half of mode eligibility (harness support + enabled + caller-allowed mode + text-transport consent), with the prerequisite/fallback policy left to #6368. Persists nothing and makes no generation call or runtime launch. | | `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions///`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. | | `opencodeCatalogCache.js` | Primes the on-disk catalog `opencode models` prints from — `primeOpencodeCatalogCache()` fetches OpenCode's `api.json` with Node's fetch and atomically writes `$XDG_CACHE_HOME/opencode/models.json` (`~/.cache` when unset). OpenCode refreshes that file from a forked task whose failures it swallows (`opencode models --refresh` still prints `Models cache refreshed`) and its HTTP client has no Happy Eyeballs, so a host advertising an unreachable IPv6 default route freezes the catalog indefinitely while other machines on the same account list newer models. Refuses to fetch or write when `OPENCODE_MODELS_PATH` / a custom `OPENCODE_MODELS_URL` / `OPENCODE_DISABLE_MODELS_FETCH` means PortOS cannot be sure which file OpenCode reads, when the file is under five minutes old, or when the body did not parse as a catalog — a stale list beats an empty picker. Never throws; the caller probes either way. | | `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring model ids under the namespace the provider's marker selects: a local runtime (`ollama` / `mtplx` / `llama` / `vllm` / `sglang`, bare ids) or a hosted gateway from `providerGateways.js` (`vendor/model` ids kept whole). Fixes --model rejection. Also attaches the key for a key-bearing namespace, and pins `small_model` to the run model for a gateway so OpenCode's own side calls (titles, summarization) can't land on its built-in default — a billed model the operator never chose. Under a `no-tool` public-review profile it also applies `hardenOpencodeConfigForNoTool` — root `permission: deny`, an emptied tool map on every agent, `tool_call: false` on every declared model, and no MCP/plugins/share/autoupdate — which IS OpenCode's enforced tool-free recipe, since it ships no read-only argv flag (`providerVendors.js` pairs it with `run --agent` + `OPENCODE_PUBLIC_REVIEW_AGENT`). The harden step also copies `agent.build`'s generation settings onto that agent, so the stage's configured thinking effort reaches the model that actually runs. | diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json index ea013c4a12..d40813031c 100644 --- a/server/lib/apiRouteCatalog.generated.json +++ b/server/lib/apiRouteCatalog.generated.json @@ -13984,6 +13984,14 @@ "server/routes/providers.js" ] }, + { + "method": "GET", + "path": "/api/providers/management/preview", + "mountPath": "/api/providers", + "sources": [ + "server/routes/providers.js" + ] + }, { "method": "POST", "path": "/api/providers/opencode/install", @@ -17788,8 +17796,8 @@ ], "stats": { "mounts": 150, - "operations": 2203, - "declarations": 2211, + "operations": 2204, + "declarations": 2212, "sourceFiles": 233 } } diff --git a/server/lib/index.js b/server/lib/index.js index c7eb93f03a..da226fee86 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -399,6 +399,9 @@ export * from './providerFamilies.js'; export * from './fleetQuotas.js'; export * from './harnessOutput.js'; export * from './providerGateways.js'; +export * from './providerHarnesses.js'; +export * from './providerConnections.js'; +export * from './providerGraphPreview.js'; export * from './personaTraitBlend.js'; export * from './pipelineIssueOrder.js'; export * from './postAdaptive.js'; diff --git a/server/lib/providerConnections.js b/server/lib/providerConnections.js new file mode 100644 index 0000000000..8f528f0b77 --- /dev/null +++ b/server/lib/providerConnections.js @@ -0,0 +1,318 @@ +import { isDeepStrictEqual } from 'node:util'; +import { localRuntimeKind, normalizeOpenAiBaseUrl } from './localProviderRuntime.js'; +import { PROVIDER_GATEWAYS, gatewayIdForProvider } from './providerGateways.js'; +import { harnessForProvider } from './providerHarnesses.js'; +import { + getOpencodeLocalProviderNamespace, + isOpencodeCommand, + parseOpencodeConfigContent, +} from './providerModels.js'; + +/** + * The CONNECTION half of the provider-connection graph proposed in + * `docs/plans/2026-09-06-provider-connections-and-harnesses.md` (#6366). + * + * A connection is the backend a harness talks to: its wire protocol, its base + * URL, and the credential material that reaches it. This module is the pure + * adapter that reads one existing provider record and answers what connection + * it describes — nothing here opens a socket, spawns a process, or writes a + * file, because the import preview must be able to run at boot and on every + * page render without touching an AI provider. + * + * IDENTITY RULES (from the decision record, and the reason this is one module + * rather than an inline comparison at each call site): + * + * - An endpoint string alone is NOT identity. Scheme, host, port, path, + * protocol adapter and credential material all participate. + * - Equal vendor names, display names, catalogs, `hasCredentials` booleans or + * REDACTED secrets never establish equivalence. {@link sameConnectionIdentity} + * compares real server-side values and refuses outright once a value has + * been redacted, so a sanitized record can never merge two connections. + * - Never resolve DNS, never equate loopback with a remote name, and never + * strip an arbitrary URL path. The one normalization allowed is the + * RECOGNIZED OpenAI-compatible `/v1` suffix, and only between two records + * already known to front the same local-runtime kind — an adapter + * operation, not generic URL trimming. + */ + +/** Why a provider record cannot be mapped into the graph and stays a legacy route. */ +export const CONNECTION_ISOLATION_REASONS = Object.freeze([ + 'unknown-harness', + 'unknown-endpoint', + 'dynamic-config', + 'external-harness-config', + 'unparsable-harness-config', + 'foreign-namespace-config', + 'redacted-credential', + 'sibling-configuration-mismatch', +]); + +/** Wire protocols a connection transport can speak. */ +export const CONNECTION_PROTOCOLS = Object.freeze(['anthropic', 'openai', 'native']); + +/** + * Env vars that carry a connection's BASE URL rather than harness behavior. + * Everything not listed here stays route-owned, so a harness-specific var like + * `ANTHROPIC_SMALL_FAST_MODEL` is never materialized out of a connection. + */ +const TRANSPORT_ENV_VARS = Object.freeze({ + ANTHROPIC_BASE_URL: 'anthropic', + OPENAI_BASE_URL: 'openai', +}); + +/** Env vars that carry connection CREDENTIALS, including each gateway's key var. */ +const CREDENTIAL_ENV_VARS = Object.freeze([ + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + ...PROVIDER_GATEWAYS.map((gateway) => gateway.apiKeyEnv), +]); + +/** + * The value `server/routes/providers.js` substitutes for a secret on the way + * out. A profile carrying it was built from a SANITIZED record, which can never + * be trusted to prove two connections are the same. + */ +export const REDACTED_CREDENTIAL = '***'; + +/** A `${VAR}` / `$VAR` / `$(cmd)` reference — resolved at spawn time, not here. */ +const DYNAMIC_REFERENCE_RE = /\$[({]?[A-Za-z_]/; + +/** Flags that point a harness at an external config file we cannot read here. */ +const EXTERNAL_CONFIG_FLAGS = new Set(['--config', '--config-file', '--configuration']); + +const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== ''; + +/** + * The base URL an OpenCode record declares for `namespace` inside its inline + * config, plus every OTHER namespace it declares. A config naming a namespace + * the record has no marker for is one this adapter cannot fully understand, and + * the decision record says such a record stays isolated rather than guessing. + */ +function opencodeDeclaredEndpoints(provider, namespace) { + const raw = provider?.envVars?.OPENCODE_CONFIG_CONTENT; + if (!isNonEmptyString(raw)) return { baseUrl: null, foreign: [], unparsable: false, declared: false }; + const parsed = parseOpencodeConfigContent(raw); + if (!parsed) return { baseUrl: null, foreign: [], unparsable: true, declared: true }; + const declaredProviders = parsed.provider && typeof parsed.provider === 'object' ? parsed.provider : {}; + const baseUrl = declaredProviders?.[namespace]?.options?.baseURL; + return { + baseUrl: isNonEmptyString(baseUrl) ? baseUrl : null, + foreign: Object.keys(declaredProviders).filter((key) => key !== namespace), + unparsable: false, + declared: true, + }; +} + +/** The backend kind a record fronts. Markers and ids first, never a guess from auth. */ +function connectionKind(provider) { + const runtime = localRuntimeKind(provider); + if (runtime) return runtime; + const gateway = gatewayIdForProvider(provider); + if (gateway) return `gateway:${gateway}`; + return provider?.type === 'api' ? 'api' : 'vendor'; +} + +/** + * Read one provider record as the connection it describes. + * + * The returned `credentials` map holds RAW server-side secret material: it + * exists so identity can be decided honestly, and it must never be serialized + * into a response, a log line, or a preview DTO. + * + * `owned` names exactly the record keys this connection would own, so the + * executable record can be split and re-materialized losslessly — see + * {@link withoutConnectionOwnedFields} / {@link withConnectionOwnedFields}. + * + * @param {object|null|undefined} provider + * @returns {{kind:string, protocol:string, transports:object, credentials:object, + * owned:{fields:object, envVars:object, hasEnvVars:boolean}, reasons:{code:string,detail:string}[]}} + */ +export function providerConnectionProfile(provider) { + const reasons = []; + const addReason = (code, detail) => reasons.push({ code, detail }); + + const record = provider && typeof provider === 'object' ? provider : {}; + const envVars = record.envVars && typeof record.envVars === 'object' ? record.envVars : {}; + const hasEnvVars = Object.hasOwn(record, 'envVars'); + const harness = harnessForProvider(record); + const kind = connectionKind(record); + const namespace = getOpencodeLocalProviderNamespace(record); + + if (record.type !== 'api' && !harness) addReason('unknown-harness', String(record.command || record.type || '')); + + // --- transport ------------------------------------------------------------ + const ownedFields = {}; + const ownedEnvVars = {}; + const transports = {}; + let protocol = harness?.protocol || 'openai'; + let baseUrl = null; + + if (isOpencodeCommand(record.command) && namespace) { + // OpenCode's inline config is BOTH transport and harness behavior + // (permissions, agents, generation defaults). Splitting that JSON string + // would be lossy, so it stays route-owned and the connection only derives + // its endpoint from it — the binding-level "harness-specific transport + // configuration" the decision record allows. + const declared = opencodeDeclaredEndpoints(record, namespace); + if (declared.unparsable) addReason('unparsable-harness-config', 'OPENCODE_CONFIG_CONTENT'); + if (declared.foreign.length > 0) addReason('foreign-namespace-config', declared.foreign.join(',')); + protocol = 'openai'; + baseUrl = declared.baseUrl; + } else { + // `endpoint` is connection-owned whenever the key exists — including when it + // is null/empty — so the split below stays lossless either way. + if (Object.hasOwn(record, 'endpoint')) ownedFields.endpoint = record.endpoint; + for (const [name, envProtocol] of Object.entries(TRANSPORT_ENV_VARS)) { + if (!isNonEmptyString(envVars[name])) continue; + ownedEnvVars[name] = envVars[name]; + protocol = envProtocol; + baseUrl = envVars[name]; + break; + } + if (!baseUrl && isNonEmptyString(record.endpoint)) baseUrl = record.endpoint; + } + + if (baseUrl) transports[protocol] = { baseUrl }; + + // A record fronting a local daemon or a bare API endpoint MUST name where it + // is. Falling back to a conventional default here would silently equate two + // installs' different daemons, so an undeclared endpoint isolates instead. + const needsEndpoint = Boolean(localRuntimeKind(record)) || record.type === 'api'; + if (needsEndpoint && !baseUrl) addReason('unknown-endpoint', kind); + + // --- credentials ---------------------------------------------------------- + const credentials = {}; + if (Object.hasOwn(record, 'apiKey')) { + ownedFields.apiKey = record.apiKey; + if (isNonEmptyString(record.apiKey)) credentials.apiKey = record.apiKey; + } + for (const name of CREDENTIAL_ENV_VARS) { + if (!Object.hasOwn(envVars, name)) continue; + ownedEnvVars[name] = envVars[name]; + if (isNonEmptyString(envVars[name])) credentials[name] = envVars[name]; + } + if (Object.values(credentials).includes(REDACTED_CREDENTIAL)) { + addReason('redacted-credential', 'profile built from a sanitized record'); + } + + // --- configuration this adapter cannot fully understand -------------------- + const dynamic = [ + ...Object.entries(ownedEnvVars), + ...Object.entries(ownedFields), + ].filter(([, value]) => typeof value === 'string' && DYNAMIC_REFERENCE_RE.test(value)); + if (dynamic.length > 0) addReason('dynamic-config', dynamic.map(([name]) => name).join(',')); + + const externalConfig = [ + ...Object.keys(envVars).filter((name) => /_CONFIG(_FILE)?$/.test(name) && isNonEmptyString(envVars[name])), + ...(Array.isArray(record.args) ? record.args.filter((arg) => EXTERNAL_CONFIG_FLAGS.has(arg)) : []), + ]; + if (externalConfig.length > 0) addReason('external-harness-config', externalConfig.join(',')); + + return { + kind, + protocol, + transports, + credentials, + owned: { fields: ownedFields, envVars: ownedEnvVars, hasEnvVars }, + reasons, + }; +} + +/** + * The executable record with its connection-owned values removed — the half a + * route row would store once the connection owns the rest. + */ +export function withoutConnectionOwnedFields(provider, owned) { + const rest = { ...provider }; + for (const key of Object.keys(owned.fields)) delete rest[key]; + if (owned.hasEnvVars) { + rest.envVars = Object.fromEntries( + Object.entries(provider.envVars || {}).filter(([name]) => !Object.hasOwn(owned.envVars, name)), + ); + } + return rest; +} + +/** + * Materialize connection-owned values back into an executable record. + * + * This is the COMPATIBILITY contract, not a convenience: a downgraded install + * runs `data/providers.json` with no graph at all, so every connection-owned + * value has to live in the executable record too. Round-tripping a record + * through {@link withoutConnectionOwnedFields} and back must reproduce it + * exactly, unknown custom fields included. + */ +export function withConnectionOwnedFields(routeRecord, owned) { + const merged = { ...routeRecord, ...owned.fields }; + // `Object.assign` rather than an object spread here: the spawn-site guard in + // `cliChildEnv.test.js` reads a spread of a record's env map beside an + // `envVars:` key as a hand-rolled CLI child environment, and this module can + // never spawn anything. Same result, no false positive to exempt. + if (owned.hasEnvVars) merged.envVars = Object.assign({}, routeRecord.envVars, owned.envVars); + return merged; +} + +/** + * A stable, SECRET-FREE bucket key. Two profiles can only be the same + * connection if their keys match — but a matching key is not sufficient, which + * is why {@link sameConnectionIdentity} still compares real credentials. + */ +export const connectionBucketKey = (harnessId, profile) => JSON.stringify([ + harnessId ?? null, + profile.kind, + Object.entries(profile.transports).sort(([a], [b]) => a.localeCompare(b)), +]); + +/** + * Whether two profiles describe the SAME backend connection. + * + * Compares actual server-side credential values; a redacted value can never + * satisfy it, so a sanitized record cannot merge two distinct connections. + */ +export function sameConnectionIdentity(a, b) { + if (!a || !b) return false; + const values = [...Object.values(a.credentials), ...Object.values(b.credentials)]; + if (values.includes(REDACTED_CREDENTIAL)) return false; + return a.kind === b.kind + && isDeepStrictEqual(a.transports, b.transports) + && isDeepStrictEqual(a.credentials, b.credentials); +} + +/** + * Whether two profiles reach the same backend endpoint, and how they differ. + * + * This is the only basis on which the preview may SUGGEST a cross-harness link + * — it never performs one. A suggestion is deliberately looser than + * {@link sameConnectionIdentity}: two harnesses commonly reach one daemon + * through different protocol adapters and different auth (Claude Code sends an + * Anthropic token to Ollama's Anthropic-compatible port; OpenCode sends none to + * its `/v1` port). Those differences are what a human confirms at link time, so + * they are REPORTED rather than used to suppress the suggestion. + * + * The `/v1` reconciliation is applied only when both records front the same + * recognized local-runtime kind, where the OpenAI-compatible suffix is a known + * property of that backend. Anything else compares the URLs verbatim — no DNS + * resolution, no loopback/remote equivalence, no arbitrary path trimming. + * + * @returns {{sameEndpoint:boolean, differences:string[]}} + */ +export function compareBackendEndpoints(a, b) { + const none = { sameEndpoint: false, differences: [] }; + if (!a || !b || a.kind !== b.kind) return none; + const urls = [a, b].map((profile) => Object.values(profile.transports)[0]?.baseUrl || null); + if (urls.some((url) => !url)) return none; + const [left, right] = a.kind.startsWith('gateway:') || a.kind === 'vendor' + ? urls + : urls.map((url) => normalizeOpenAiBaseUrl(url)); + if (left !== right) return none; + + const differences = []; + if (a.protocol !== b.protocol) differences.push('protocol'); + if ([...Object.values(a.credentials), ...Object.values(b.credentials)].includes(REDACTED_CREDENTIAL)) { + differences.push('credentials-unknown'); + } else if (!isDeepStrictEqual(a.credentials, b.credentials)) { + differences.push('credentials'); + } + return { sameEndpoint: true, differences }; +} diff --git a/server/lib/providerConnections.test.js b/server/lib/providerConnections.test.js new file mode 100644 index 0000000000..069dfe0b7d --- /dev/null +++ b/server/lib/providerConnections.test.js @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { + REDACTED_CREDENTIAL, + compareBackendEndpoints, + providerConnectionProfile, + sameConnectionIdentity, + withConnectionOwnedFields, + withoutConnectionOwnedFields, +} from './providerConnections.js'; + +// Transport identity is the one place the graph can silently do damage: merge +// two connections that are not the same backend and a later slice would write +// one machine's credentials over another's. These are focused adapter tests +// because the failures are about exact URL/credential comparison, which a +// route-level assertion can only observe indirectly. + +const claudeOllama = (overrides = {}) => ({ + id: 'claude-ollama', + name: 'Claude Ollama', + type: 'cli', + command: 'claude', + ollamaBacked: true, + models: [], + envVars: { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_AUTH_TOKEN: 'example-token', + ANTHROPIC_SMALL_FAST_MODEL: 'example-small', + }, + secretEnvVars: ['ANTHROPIC_AUTH_TOKEN'], + ...overrides, +}); + +const opencodeOllama = (baseURL = 'http://localhost:11434/v1', overrides = {}) => ({ + id: 'opencode-ollama', + name: 'OpenCode Ollama', + type: 'cli', + command: 'opencode', + ollamaBacked: true, + models: [], + envVars: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + permission: 'allow', + provider: { ollama: { npm: '@ai-sdk/openai-compatible', options: { baseURL } } }, + }), + }, + secretEnvVars: [], + ...overrides, +}); + +describe('providerConnectionProfile', () => { + it('reads the Claude wrapper transport from its own env var, not the endpoint field', () => { + const profile = providerConnectionProfile(claudeOllama()); + expect(profile.kind).toBe('ollama'); + expect(profile.transports).toEqual({ anthropic: { baseUrl: 'http://localhost:11434' } }); + expect(profile.credentials).toEqual({ ANTHROPIC_AUTH_TOKEN: 'example-token' }); + expect(profile.reasons).toEqual([]); + }); + + it('leaves harness behavior route-owned while owning the transport env var', () => { + // ANTHROPIC_SMALL_FAST_MODEL is a Claude Code behavior knob, not a backend + // address — materializing it out of a shared connection would let one + // route's model choice follow another route onto the same daemon. + const profile = providerConnectionProfile(claudeOllama()); + expect(Object.keys(profile.owned.envVars).sort()) + .toEqual(['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']); + }); + + it('reads the OpenCode transport out of its inline config and leaves that config route-owned', () => { + const profile = providerConnectionProfile(opencodeOllama()); + expect(profile.transports).toEqual({ openai: { baseUrl: 'http://localhost:11434/v1' } }); + // The config string also carries permissions/agents, so splitting it would + // be lossy; only its derived endpoint informs the connection. + expect(profile.owned.envVars).toEqual({}); + }); + + it('isolates an unparsable harness config instead of guessing an endpoint', () => { + const profile = providerConnectionProfile( + opencodeOllama('http://localhost:11434/v1', { envVars: { OPENCODE_CONFIG_CONTENT: '{not json' } }), + ); + expect(profile.reasons.map((r) => r.code)).toContain('unparsable-harness-config'); + }); + + it('isolates a config declaring a namespace the record has no marker for', () => { + const provider = opencodeOllama(); + provider.envVars.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + provider: { + ollama: { options: { baseURL: 'http://localhost:11434/v1' } }, + openai: { options: { baseURL: 'https://api.example.com/v1' } }, + }, + }); + const profile = providerConnectionProfile(provider); + expect(profile.reasons.map((r) => r.code)).toContain('foreign-namespace-config'); + }); + + it('isolates a dynamic environment reference the adapter cannot resolve', () => { + const profile = providerConnectionProfile(claudeOllama({ + envVars: { ANTHROPIC_BASE_URL: '${OLLAMA_URL}', ANTHROPIC_AUTH_TOKEN: 'example-token' }, + })); + expect(profile.reasons.map((r) => r.code)).toContain('dynamic-config'); + }); + + it('isolates a local-runtime record that names no endpoint at all', () => { + const profile = providerConnectionProfile(claudeOllama({ envVars: {} })); + expect(profile.reasons.map((r) => r.code)).toContain('unknown-endpoint'); + }); + + it('isolates a cli record whose command matches no known harness', () => { + const profile = providerConnectionProfile({ id: 'custom', type: 'cli', command: 'my-agent' }); + expect(profile.reasons.map((r) => r.code)).toContain('unknown-harness'); + }); +}); + +describe('sameConnectionIdentity', () => { + it('matches two records pointed at the same daemon with the same credential', () => { + expect(sameConnectionIdentity( + providerConnectionProfile(claudeOllama()), + providerConnectionProfile(claudeOllama({ id: 'claude-ollama-tui', type: 'tui' })), + )).toBe(true); + }); + + it('refuses a differing auth token even when the endpoint is identical', () => { + const other = claudeOllama(); + other.envVars = { ...other.envVars, ANTHROPIC_AUTH_TOKEN: 'different-token' }; + expect(sameConnectionIdentity( + providerConnectionProfile(claudeOllama()), + providerConnectionProfile(other), + )).toBe(false); + }); + + it('refuses REDACTED credentials outright — equal `***` is not equal auth', () => { + // The whole risk of comparing sanitized records: two unrelated backends + // both read `***` and would merge into one connection carrying one + // machine's key. Identity is decided on real server-side values only. + const redacted = claudeOllama(); + redacted.envVars = { ...redacted.envVars, ANTHROPIC_AUTH_TOKEN: REDACTED_CREDENTIAL }; + const profile = providerConnectionProfile(redacted); + expect(profile.reasons.map((r) => r.code)).toContain('redacted-credential'); + expect(sameConnectionIdentity(profile, providerConnectionProfile(redacted))).toBe(false); + }); + + it('refuses a remote host that merely shares the local daemon port', () => { + expect(sameConnectionIdentity( + providerConnectionProfile(claudeOllama()), + providerConnectionProfile(claudeOllama({ + envVars: { ANTHROPIC_BASE_URL: 'http://ollama.example.com:11434', ANTHROPIC_AUTH_TOKEN: 'example-token' }, + })), + )).toBe(false); + }); +}); + +describe('compareBackendEndpoints', () => { + it('reconciles the recognized /v1 suffix across two harnesses and reports the differences', () => { + const result = compareBackendEndpoints( + providerConnectionProfile(claudeOllama()), + providerConnectionProfile(opencodeOllama()), + ); + expect(result.sameEndpoint).toBe(true); + // Reported, not merged: the human confirms the protocol/auth difference. + expect(result.differences).toEqual(expect.arrayContaining(['protocol', 'credentials'])); + }); + + it('never equates a remote daemon with the local one', () => { + expect(compareBackendEndpoints( + providerConnectionProfile(claudeOllama()), + providerConnectionProfile(opencodeOllama('https://ollama.example.com/v1')), + ).sameEndpoint).toBe(false); + }); +}); + +describe('connection-owned field split', () => { + it('round-trips a record exactly, including keys this module has never heard of', () => { + // The downgrade contract: an older release runs providers.json alone, so + // every connection-owned value has to survive back into the record. + const original = claudeOllama({ endpoint: null, apiKey: '', someFutureField: { nested: true } }); + const { owned } = providerConnectionProfile(original); + const routeRecord = withoutConnectionOwnedFields(original, owned); + expect(routeRecord.envVars.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(withConnectionOwnedFields(routeRecord, owned)).toEqual(original); + }); + + it('does not invent an endpoint key on a record that never had one', () => { + const original = claudeOllama(); + const { owned } = providerConnectionProfile(original); + const projected = withConnectionOwnedFields(withoutConnectionOwnedFields(original, owned), owned); + expect(Object.hasOwn(projected, 'endpoint')).toBe(false); + }); +}); diff --git a/server/lib/providerGraphPreview.js b/server/lib/providerGraphPreview.js new file mode 100644 index 0000000000..28f1c2a340 --- /dev/null +++ b/server/lib/providerGraphPreview.js @@ -0,0 +1,475 @@ +import { z } from 'zod'; +import { providerModeGroups } from './aiToolkit/internal/providerModes.js'; +import { + CONNECTION_ISOLATION_REASONS, + CONNECTION_PROTOCOLS, + compareBackendEndpoints, + connectionBucketKey, + providerConnectionProfile, + sameConnectionIdentity, + withConnectionOwnedFields, + withoutConnectionOwnedFields, +} from './providerConnections.js'; +import { + PROVIDER_HARNESS_IDS, + ROUTE_MODES, + harnessForProvider, + harnessSupportsMode, + providerRouteMode, + toCanonicalModelName, +} from './providerHarnesses.js'; +import { isConfiguredDefaultModel } from './providerModels.js'; + +/** + * READ-ONLY import preview for the provider connection graph proposed in + * `docs/plans/2026-09-06-provider-connections-and-harnesses.md` (#6366). + * + * Given the provider records this install already runs, this answers what + * connections, harness bindings and executable routes an import WOULD create — + * and which records it would refuse to touch, with reasons. It persists + * nothing, migrates nothing, and changes no execution path. Slices #6367-#6369 + * add the durable graph, the routing policy and the management UI on top. + * + * Two invariants make the preview trustworthy rather than merely informative: + * + * 1. **Every original route ID survives.** `routes` has exactly one entry per + * input provider, and {@link projectPreviewToProviders} reconstructs each + * original record — settings, pins, secrets and unknown custom fields + * included. A preview that cannot round-trip an install is a preview that + * would lose data on import. + * 2. **Automatic grouping is limited to PROVEN same-harness siblings.** The + * binding grouping is `providerModeGroups` — the already-shipped + * CLI/TUI pairing — and nothing else. Two harnesses reaching one daemon + * are surfaced in `suggestedLinks`, never merged: cross-harness sharing + * requires an explicit link in a later slice. + * + * Nothing here performs I/O. Building a preview must never make a generation + * call, probe a backend or launch a runtime. + */ + +/** Wire version of the management DTOs. Bump with any breaking shape change. */ +export const PROVIDER_GRAPH_SCHEMA_VERSION = 1; + +/** Model pins a route can carry, in the order the management UI shows them. */ +export const ROUTE_MODEL_PINS = Object.freeze([ + 'defaultModel', + 'lightModel', + 'mediumModel', + 'heavyModel', + 'fallbackModel', +]); + +/** Why a route is not eligible to execute in its own mode. */ +export const ROUTE_ELIGIBILITY_REASONS = Object.freeze([ + 'mode-unsupported', + 'mode-not-allowed', + 'route-disabled', + 'consent-required', +]); + +/** Why a stored model string or pin could not be resolved to the catalog. */ +export const MODEL_RESOLUTION_REASONS = Object.freeze([ + 'unmappable-model-alias', + 'pin-not-in-catalog', +]); + +// --- version-1 management DTOs ---------------------------------------------- +// `.strict()` throughout is a boundary guard, not tidiness: a field added to a +// provider record must never ride out to a client because a builder forgot to +// drop it. Validation failure is a bug in this module, so it throws. + +const transportSchema = z.object({ baseUrl: z.string().min(1) }).strict(); + +// A record keyed by an enum is EXHAUSTIVE in zod 4 — every protocol would +// become required. A connection declares only the protocols it actually +// speaks, so the key set is checked rather than enumerated. +const transportsSchema = z.record(z.string(), transportSchema) + .refine((value) => Object.keys(value).every((protocol) => CONNECTION_PROTOCOLS.includes(protocol)), + { message: `transports keys must be one of: ${CONNECTION_PROTOCOLS.join(', ')}` }); + +const connectionDtoSchema = z.object({ + id: z.string().min(1), + revision: z.number().int().positive(), + kind: z.string().min(1), + label: z.string(), + transports: transportsSchema, + hasCredentials: z.boolean(), + catalog: z.object({ + state: z.enum(['unknown', 'known', 'failed']), + models: z.array(z.string()), + }).strict(), +}).strict(); + +const bindingDtoSchema = z.object({ + id: z.string().min(1), + revision: z.number().int().positive(), + variantKey: z.string().min(1), + connectionId: z.string().min(1), + harnessId: z.enum(PROVIDER_HARNESS_IDS).nullable(), + label: z.string(), + enabled: z.boolean(), + selectedModels: z.array(z.string()), +}).strict(); + +const routeDtoSchema = z.object({ + providerId: z.string().min(1), + bindingId: z.string().min(1).nullable(), + mode: z.enum(ROUTE_MODES).nullable(), + modelMap: z.record(z.string(), z.string()), + unresolvedModels: z.array(z.object({ + model: z.string(), + reason: z.enum(MODEL_RESOLUTION_REASONS), + }).strict()), + unresolvedPins: z.array(z.object({ + pin: z.enum(ROUTE_MODEL_PINS), + model: z.string(), + reason: z.enum(MODEL_RESOLUTION_REASONS), + }).strict()), + eligibility: z.object({ + mode: z.enum(ROUTE_MODES).nullable(), + supported: z.boolean(), + enabled: z.boolean(), + consentRequired: z.boolean(), + consentGranted: z.boolean(), + eligible: z.boolean(), + reasons: z.array(z.enum(ROUTE_ELIGIBILITY_REASONS)), + }).strict(), +}).strict(); + +const unresolvedDtoSchema = z.object({ + providerId: z.string().min(1), + reasons: z.array(z.object({ + code: z.enum(CONNECTION_ISOLATION_REASONS), + detail: z.string(), + }).strict()), +}).strict(); + +const suggestedLinkDtoSchema = z.object({ + connectionIds: z.array(z.string().min(1)).length(2), + harnessIds: z.array(z.enum(PROVIDER_HARNESS_IDS).nullable()).length(2), + reason: z.literal('same-backend-endpoint'), + differences: z.array(z.string()), + requiresExplicitLink: z.literal(true), +}).strict(); + +/** The full `GET /api/providers/management/preview` body. */ +export const managementPreviewSchema = z.object({ + schemaVersion: z.literal(PROVIDER_GRAPH_SCHEMA_VERSION), + activeProvider: z.string().nullable(), + connections: z.array(connectionDtoSchema), + bindings: z.array(bindingDtoSchema), + routes: z.array(routeDtoSchema), + unresolved: z.array(unresolvedDtoSchema), + suggestedLinks: z.array(suggestedLinkDtoSchema), +}).strict(); + +// --- builder ---------------------------------------------------------------- + +/** Attach secret-bearing internals so a stray `JSON.stringify` cannot leak them. */ +function hide(target, props) { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(target, key, { value, enumerable: false, configurable: true }); + } + return target; +} + +/** + * Whether this route may execute in its own mode, from the record alone. + * + * Deliberately NOT a routing policy — prerequisite probes and fallback + * candidate selection need host I/O and belong to #6368. This is the pure, + * declarative half: the harness supports the mode, the route is enabled, the + * caller allows the mode, and any text-transport consent has been granted. + * `allowedModes` is the caller's intersection; a CLI-only caller passing + * `['cli']` can never receive a TUI route through it. + * + * @param {object} provider + * @param {{allowedModes?: readonly string[]}} [options] + */ +export function routeModeEligibility(provider, { allowedModes = ROUTE_MODES } = {}) { + const mode = providerRouteMode(provider); + const harness = harnessForProvider(provider); + // An `api` record is a direct API binding with no harness, so `api` support + // comes from the record's own type rather than a harness row. + const supported = mode === 'api' ? provider?.type === 'api' : harnessSupportsMode(harness?.id, mode); + const enabled = provider?.enabled !== false; + const consentRequired = typeof provider?.textTransport === 'string' && provider.textTransport !== ''; + const consentGranted = provider?.textTransportEnabled === true; + + const reasons = []; + if (!supported) reasons.push('mode-unsupported'); + if (mode && !allowedModes.includes(mode)) reasons.push('mode-not-allowed'); + if (!enabled) reasons.push('route-disabled'); + if (consentRequired && !consentGranted) reasons.push('consent-required'); + + return { mode, supported, enabled, consentRequired, consentGranted, eligible: reasons.length === 0, reasons }; +} + +/** + * Canonical/executable model mapping, plus every stored alias that would not + * resolve. + * + * An alias resolves only when the harness's own adapter maps the candidate back + * to the exact stored string, so a bare `example-model` saved on an OpenCode + * route — which needs its `/` prefix to execute, and could equally + * be a fully-qualified id for some other backend — is reported rather than + * rewritten. That verified round-trip is also why two DIFFERENT stored strings + * can never claim one canonical name: `modelMap` is keyed by canonical, so a + * literal duplicate collapses and nothing else can collide. + */ +function resolveRouteModels(provider) { + const stored = Array.isArray(provider?.models) ? provider.models : []; + const modelMap = {}; + const unresolvedModels = []; + + for (const entry of stored) { + const { canonical, executable, resolved, reason } = toCanonicalModelName(provider, entry); + if (resolved) modelMap[canonical] = executable; + else if (!unresolvedModels.some((u) => u.model === entry)) unresolvedModels.push({ model: entry, reason }); + } + + const unresolvedPins = ROUTE_MODEL_PINS + .filter((pin) => typeof provider?.[pin] === 'string' && provider[pin] !== '' + && !isConfiguredDefaultModel(provider[pin]) + && stored.length > 0 && !stored.includes(provider[pin])) + .map((pin) => ({ pin, model: provider[pin], reason: 'pin-not-in-catalog' })); + + return { modelMap, unresolvedModels, unresolvedPins }; +} + +/** A record's model catalog as a connection-level catalog with an honest state. */ +function connectionCatalog(models) { + // `[]` on a provider record cannot distinguish "never fetched" from + // "fetched and legitimately empty", so it reads as `unknown` rather than + // claiming a successful empty result the record does not attest to. + return models.length > 0 ? { state: 'known', models } : { state: 'unknown', models: [] }; +} + +/** + * Build the read-only import preview for a set of provider records. + * + * @param {{providers?: object[], activeProvider?: string|null}} data + * @returns {object} preview — secret-bearing internals are non-enumerable + */ +export function buildProviderGraphPreview({ providers = [], activeProvider = null } = {}) { + const records = Array.isArray(providers) ? providers.filter((p) => p && typeof p === 'object' && p.id) : []; + const connections = []; + const bindings = []; + const routes = []; + const unresolved = []; + const buckets = new Map(); + const variantsByBinding = new Map(); + + const isolate = (group, reasons) => { + for (const provider of group) { + unresolved.push({ providerId: provider.id, reasons }); + routes.push(hide({ + providerId: provider.id, + bindingId: null, + ...resolveRouteModels(provider), + mode: providerRouteMode(provider), + eligibility: routeModeEligibility(provider), + }, { record: provider, routeRecord: provider, owned: null })); + } + }; + + for (const group of providerModeGroups(records)) { + const lead = group.find((provider) => provider.type === 'cli') || group[0]; + const harness = harnessForProvider(lead); + const profiles = group.map((provider) => providerConnectionProfile(provider)); + const leadProfile = profiles[group.indexOf(lead)]; + + // A sibling whose transport or credentials read differently from the lead's + // is not the same connection, however conventional its id looks. + const reasons = profiles.flatMap((profile) => profile.reasons); + if (profiles.some((profile) => !sameConnectionIdentity(profile, leadProfile))) { + reasons.push({ code: 'sibling-configuration-mismatch', detail: 'siblings disagree on backend kind, transport or credentials' }); + } + // A record whose `type` names no executable mode cannot become a route. + for (const provider of group.filter((candidate) => !providerRouteMode(candidate))) { + reasons.push({ code: 'unknown-harness', detail: `unsupported type: ${provider.type ?? ''}` }); + } + if (reasons.length > 0) { + isolate(group, reasons); + continue; + } + + const harnessId = harness?.id ?? null; + const bucketKey = connectionBucketKey(harnessId, leadProfile); + const bucket = buckets.get(bucketKey) || []; + // A record that declares NO transport (a vendor subscription harness, say) + // offers no evidence about which backend it reaches, so it never shares a + // connection with another group — matching endpoints is the only thing that + // may make two records one connection, and "both named nothing" is not a + // match. Its connection is exclusive to this binding. + const sharable = Object.keys(leadProfile.transports).length > 0; + let connection = sharable + ? bucket.find((candidate) => sameConnectionIdentity(candidate.profile, leadProfile)) + : null; + if (!connection) { + connection = hide({ + id: `conn:${harnessId ?? 'api'}:${lead.id}`, + revision: 1, + kind: leadProfile.kind, + label: String(lead.name || lead.id), + transports: leadProfile.transports, + hasCredentials: Object.keys(leadProfile.credentials).length > 0, + catalog: connectionCatalog([]), + }, { profile: leadProfile, harnessId }); + connections.push(connection); + if (sharable) buckets.set(bucketKey, [...bucket, connection]); + } + + // The connection's catalog is the union of what its bindings observe, by + // canonical name — a shared catalog, never a per-route one. + const resolvedModels = group.map((provider) => resolveRouteModels(provider)); + const unionModels = [...new Set([ + ...connection.catalog.models, + ...resolvedModels.flatMap((resolved) => Object.keys(resolved.modelMap)), + ])]; + connection.catalog = connectionCatalog(unionModels); + + // UNIQUE(connection_id, harness_id, variant_key): the first binding on a + // connection is `default`; a second custom configuration of the same + // harness on the same connection is a distinct labeled variant, never a + // merge that would discard one of its executable route IDs. + const variantScope = `${connection.id}|${harnessId ?? ''}`; + const taken = variantsByBinding.get(variantScope) || []; + // Prefixed so a provider literally named `default` cannot collide with the + // first binding's reserved key. + const variantKey = taken.length === 0 ? 'default' : `variant:${lead.id}`; + variantsByBinding.set(variantScope, [...taken, variantKey]); + + const binding = { + id: `binding:${lead.id}`, + revision: 1, + variantKey, + connectionId: connection.id, + harnessId, + label: String(lead.name || lead.id), + // OR across proven siblings, matching `unifyProviderModes`. Consent flags + // never participate — they are per-route and never granted by grouping. + enabled: group.some((provider) => provider.enabled === true), + selectedModels: unionModels, + }; + bindings.push(binding); + + for (const [index, provider] of group.entries()) { + const profile = profiles[index]; + routes.push(hide({ + providerId: provider.id, + bindingId: binding.id, + ...resolvedModels[index], + mode: providerRouteMode(provider), + eligibility: routeModeEligibility(provider), + }, { + record: provider, + routeRecord: withoutConnectionOwnedFields(provider, profile.owned), + owned: profile.owned, + })); + } + } + + const suggestedLinks = []; + for (let i = 0; i < connections.length; i += 1) { + for (let j = i + 1; j < connections.length; j += 1) { + const [a, b] = [connections[i], connections[j]]; + if (a.harnessId === b.harnessId) continue; + const { sameEndpoint, differences } = compareBackendEndpoints(a.profile, b.profile); + if (!sameEndpoint) continue; + suggestedLinks.push({ + connectionIds: [a.id, b.id], + harnessIds: [a.harnessId, b.harnessId], + reason: 'same-backend-endpoint', + differences, + requiresExplicitLink: true, + }); + } + } + + const preview = { + schemaVersion: PROVIDER_GRAPH_SCHEMA_VERSION, + activeProvider: typeof activeProvider === 'string' ? activeProvider : null, + connections, + bindings, + routes, + unresolved, + suggestedLinks, + }; + + const violations = providerGraphUniquenessViolations(preview); + if (violations.length > 0) { + throw new Error(`Provider graph preview violates uniqueness: ${violations.join('; ')}`); + } + return preview; +} + +/** + * The uniqueness constraints the durable schema in #6367 will enforce, checked + * here so a preview can never propose a graph the database would reject. + * + * @returns {string[]} one message per violation; empty means valid + */ +export function providerGraphUniquenessViolations(preview) { + const violations = []; + const seen = (label, keys) => { + const counts = new Map(); + for (const key of keys) counts.set(key, (counts.get(key) || 0) + 1); + for (const [key, count] of counts) if (count > 1) violations.push(`${label} ${key} x${count}`); + }; + + seen('route provider_id', preview.routes.map((route) => route.providerId)); + seen('connection id', preview.connections.map((connection) => connection.id)); + seen('binding id', preview.bindings.map((binding) => binding.id)); + seen('(binding_id, mode)', preview.routes + .filter((route) => route.bindingId) + .map((route) => `${route.bindingId}|${route.mode}`)); + seen('(connection_id, harness_id, variant_key)', preview.bindings + .filter((binding) => binding.harnessId !== null) + .map((binding) => `${binding.connectionId}|${binding.harnessId}|${binding.variantKey}`)); + // The partial unique index for null-harness (direct API) bindings. + seen('(connection_id, variant_key) [api]', preview.bindings + .filter((binding) => binding.harnessId === null) + .map((binding) => `${binding.connectionId}|${binding.variantKey}`)); + return violations; +} + +/** + * The sanitized, validated body a client receives. + * + * Credentials, raw provider records and the connection-owned snapshots never + * appear: the browser is told only WHETHER a connection has credentials, and + * every identity decision was already made server-side on real values. + */ +export function toManagementPreviewDto(preview) { + return managementPreviewSchema.parse({ + schemaVersion: preview.schemaVersion, + activeProvider: preview.activeProvider, + connections: preview.connections.map(({ id, revision, kind, label, transports, hasCredentials, catalog }) => + ({ id, revision, kind, label, transports, hasCredentials, catalog })), + bindings: preview.bindings, + routes: preview.routes.map(({ providerId, bindingId, mode, modelMap, unresolvedModels, unresolvedPins, eligibility }) => + ({ providerId, bindingId, mode, modelMap, unresolvedModels, unresolvedPins, eligibility })), + unresolved: preview.unresolved, + suggestedLinks: preview.suggestedLinks, + }); +} + +/** + * Re-materialize the executable provider records from a preview. + * + * This is the import-fidelity proof AND the downgrade contract in one function: + * an install that drops back to a graph-unaware release runs + * `data/providers.json` alone, so every connection-owned value has to be + * present in the executable record. Round-tripping must reproduce each input + * record exactly — including pins, secrets, consent flags and unknown custom + * fields this module has never heard of. + * + * @returns {Record} keyed by the original provider id + */ +export function projectPreviewToProviders(preview) { + return Object.fromEntries(preview.routes.map((route) => [ + route.providerId, + route.owned ? withConnectionOwnedFields(route.routeRecord, route.owned) : route.record, + ])); +} diff --git a/server/lib/providerGraphPreview.test.js b/server/lib/providerGraphPreview.test.js new file mode 100644 index 0000000000..98d483dbc3 --- /dev/null +++ b/server/lib/providerGraphPreview.test.js @@ -0,0 +1,240 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + PROVIDER_GRAPH_SCHEMA_VERSION, + buildProviderGraphPreview, + projectPreviewToProviders, + providerGraphUniquenessViolations, + routeModeEligibility, + toManagementPreviewDto, +} from './providerGraphPreview.js'; + +const SAMPLE_PROVIDERS = join( + dirname(fileURLToPath(import.meta.url)), + 'aiToolkit/defaults/providers.sample.json', +); + +// Synthetic fixtures only — never a record read out of a running install. +const OLLAMA_CONFIG = (baseURL) => JSON.stringify({ + permission: 'allow', + provider: { ollama: { npm: '@ai-sdk/openai-compatible', options: { baseURL } } }, +}); + +const claudeOllamaCli = { + id: 'claude-ollama', + name: 'Claude Ollama (local model)', + type: 'cli', + command: 'claude', + args: ['--print'], + ollamaBacked: true, + models: ['example-model:8b'], + defaultModel: 'example-model:8b', + enabled: true, + timeout: 600000, + envVars: { + ANTHROPIC_BASE_URL: 'http://localhost:11434', + ANTHROPIC_AUTH_TOKEN: 'example-token', + ANTHROPIC_SMALL_FAST_MODEL: 'example-small:3b', + }, + secretEnvVars: ['ANTHROPIC_AUTH_TOKEN'], +}; +const claudeOllamaTui = { + ...claudeOllamaCli, + id: 'claude-ollama-tui', + name: 'Claude Ollama TUI (local model)', + type: 'tui', + enabled: false, + tuiPromptDelayMs: 2500, +}; +const opencodeOllamaCli = { + id: 'opencode-ollama', + name: 'OpenCode Ollama (local model)', + type: 'cli', + command: 'opencode', + args: ['run'], + ollamaBacked: true, + models: ['ollama/example-model:8b'], + defaultModel: 'ollama/example-model:8b', + enabled: false, + envVars: { OPENCODE_CONFIG_CONTENT: OLLAMA_CONFIG('http://localhost:11434/v1') }, + secretEnvVars: [], +}; +const opencodeOllamaTui = { ...opencodeOllamaCli, id: 'opencode-ollama-tui', type: 'tui', enabled: false }; +const remoteOllamaApi = { + id: 'remote-ollama', + name: 'Remote Ollama', + type: 'api', + endpoint: 'https://ollama.example.com/v1', + apiKey: 'example-remote-key', + models: ['example-model:8b'], + enabled: true, +}; + +const FIXTURES = [claudeOllamaCli, claudeOllamaTui, opencodeOllamaCli, opencodeOllamaTui, remoteOllamaApi]; + +const previewOf = (providers, activeProvider = null) => + buildProviderGraphPreview({ providers, activeProvider }); +const byProviderId = (preview, id) => preview.routes.find((route) => route.providerId === id); +const bindingOf = (preview, id) => preview.bindings.find((b) => b.id === byProviderId(preview, id).bindingId); + +describe('provider graph import preview', () => { + it('groups proven CLI/TUI siblings into one binding and keeps both executable route IDs', () => { + const preview = previewOf(FIXTURES, 'claude-ollama'); + + const binding = bindingOf(preview, 'claude-ollama'); + expect(binding.harnessId).toBe('claude'); + expect(binding.variantKey).toBe('default'); + // OR across proven siblings, exactly as `unifyProviderModes` already does. + expect(binding.enabled).toBe(true); + expect(byProviderId(preview, 'claude-ollama-tui').bindingId).toBe(binding.id); + expect(byProviderId(preview, 'claude-ollama').mode).toBe('cli'); + expect(byProviderId(preview, 'claude-ollama-tui').mode).toBe('tui'); + expect(preview.activeProvider).toBe('claude-ollama'); + }); + + it('never merges two harnesses on one daemon — it suggests an explicit link instead', () => { + // The whole point of the slice: Claude and OpenCode both reach the same + // local Ollama, and importing them into ONE connection would silently make + // a Claude auth token the OpenCode route's credential. + const preview = previewOf(FIXTURES); + const claude = bindingOf(preview, 'claude-ollama'); + const opencode = bindingOf(preview, 'opencode-ollama'); + expect(opencode.harnessId).toBe('opencode'); + expect(opencode.connectionId).not.toBe(claude.connectionId); + + expect(preview.suggestedLinks).toEqual([expect.objectContaining({ + connectionIds: [claude.connectionId, opencode.connectionId], + harnessIds: ['claude', 'opencode'], + reason: 'same-backend-endpoint', + requiresExplicitLink: true, + })]); + }); + + it('keeps a separate remote API its own connection even when the model names match', () => { + const preview = previewOf(FIXTURES); + const api = bindingOf(preview, 'remote-ollama'); + expect(api.harnessId).toBeNull(); + expect(byProviderId(preview, 'remote-ollama').mode).toBe('api'); + // A remote host is never suggested against the local daemon. + expect(preview.suggestedLinks.flatMap((link) => link.connectionIds)).not.toContain(api.connectionId); + }); + + it('imports a second custom configuration of one harness as a distinct labeled variant', () => { + // Same connection, same harness, different argv: merging would have to + // discard one of the two executable route IDs. + const custom = { ...claudeOllamaCli, id: 'claude-ollama-fast', name: 'Claude Ollama Fast', args: ['--print', '--fast'] }; + const preview = previewOf([...FIXTURES, custom]); + const first = bindingOf(preview, 'claude-ollama'); + const second = bindingOf(preview, 'claude-ollama-fast'); + + expect(second.connectionId).toBe(first.connectionId); + expect(second.variantKey).not.toBe(first.variantKey); + expect(providerGraphUniquenessViolations(preview)).toEqual([]); + }); + + it('isolates a record whose configuration the adapter cannot fully understand', () => { + const dynamic = { ...claudeOllamaCli, id: 'claude-ollama-dyn', envVars: { ...claudeOllamaCli.envVars, ANTHROPIC_BASE_URL: '${OLLAMA_URL}' } }; + const unknownHarness = { id: 'house-agent', name: 'House Agent', type: 'cli', command: 'house-agent', models: [] }; + const preview = previewOf([...FIXTURES, dynamic, unknownHarness]); + + expect(byProviderId(preview, 'claude-ollama-dyn').bindingId).toBeNull(); + expect(byProviderId(preview, 'house-agent').bindingId).toBeNull(); + const reasons = Object.fromEntries(preview.unresolved.map((u) => [u.providerId, u.reasons.map((r) => r.code)])); + expect(reasons['claude-ollama-dyn']).toContain('dynamic-config'); + expect(reasons['house-agent']).toContain('unknown-harness'); + }); + + it('isolates a conventional sibling pair whose backend markers disagree', () => { + // `providerModeGroups` pairs on command/endpoint/apiKey/envVars alone, so a + // pair that differs only by its `*Backed` marker still arrives here as one + // group while describing two different backends. Adopting the lead's + // connection for both would attach a route to a daemon it does not use. + const cli = { ...claudeOllamaCli, id: 'claude-split' }; + const tui = { ...claudeOllamaTui, id: 'claude-split-tui', ollamaBacked: false, lmstudioBacked: true }; + const preview = previewOf([cli, tui]); + + expect(preview.routes.every((route) => route.bindingId === null)).toBe(true); + expect(preview.unresolved.flatMap((u) => u.reasons.map((r) => r.code))) + .toContain('sibling-configuration-mismatch'); + }); +}); + +describe('model aliases and pins', () => { + it('maps an OpenCode namespaced alias back to its canonical backend name', () => { + const preview = previewOf(FIXTURES); + expect(byProviderId(preview, 'opencode-ollama').modelMap) + .toEqual({ 'example-model:8b': 'ollama/example-model:8b' }); + }); + + it('reports a bare alias an OpenCode route could not execute, without prefixing it', () => { + // A bare id on a namespaced harness is ambiguous — it may be an un-prefixed + // Ollama model or a qualified id for another backend — so it stays visible + // and unselectable rather than being rewritten into something executable. + const mixed = { ...opencodeOllamaCli, id: 'opencode-mixed', models: ['ollama/example-model:8b', 'example-model:8b'] }; + const route = byProviderId(previewOf([mixed]), 'opencode-mixed'); + expect(route.modelMap).toEqual({ 'example-model:8b': 'ollama/example-model:8b' }); + expect(route.unresolvedModels).toEqual([{ model: 'example-model:8b', reason: 'unmappable-model-alias' }]); + }); + + it('reports a stored pin the catalog no longer offers, without repairing it', () => { + const stale = { ...claudeOllamaCli, id: 'claude-stale', defaultModel: 'removed-model:8b' }; + const route = byProviderId(previewOf([stale]), 'claude-stale'); + expect(route.unresolvedPins).toEqual([{ pin: 'defaultModel', model: 'removed-model:8b', reason: 'pin-not-in-catalog' }]); + // Reported, never rewritten — the record still carries the user's value. + expect(projectPreviewToProviders(previewOf([stale]))['claude-stale'].defaultModel).toBe('removed-model:8b'); + }); +}); + +describe('mode eligibility', () => { + it('is route-scoped: a CLI-only caller can never reach the TUI sibling', () => { + expect(routeModeEligibility(claudeOllamaCli, { allowedModes: ['cli'] }).eligible).toBe(true); + const tui = routeModeEligibility({ ...claudeOllamaTui, enabled: true }, { allowedModes: ['cli'] }); + expect(tui.eligible).toBe(false); + expect(tui.reasons).toContain('mode-not-allowed'); + }); + + it('requires text-transport consent explicitly and never infers it from enablement', () => { + const advertised = { ...claudeOllamaCli, textTransport: 'subscription' }; + expect(routeModeEligibility(advertised).reasons).toContain('consent-required'); + expect(routeModeEligibility({ ...advertised, textTransportEnabled: true }).eligible).toBe(true); + }); +}); + +describe('import fidelity and the wire DTO', () => { + it('round-trips every shipped provider record byte-for-byte', () => { + // The shipped catalog is the widest real input matrix available: every + // harness, both modes, gateways, secret env vars and custom markers. If a + // preview cannot re-materialize these, an import built on it loses data. + const { providers } = JSON.parse(readFileSync(SAMPLE_PROVIDERS, 'utf8')); + const records = Object.values(providers); + const preview = previewOf(records); + + expect(preview.routes).toHaveLength(records.length); + expect(projectPreviewToProviders(preview)).toEqual(Object.fromEntries(records.map((p) => [p.id, p]))); + expect(providerGraphUniquenessViolations(preview)).toEqual([]); + }); + + it('preserves a field this module has never heard of', () => { + const future = { ...claudeOllamaCli, id: 'claude-future', unknownFutureField: { nested: ['keep', 'me'] } }; + expect(projectPreviewToProviders(previewOf([future]))['claude-future']).toEqual(future); + }); + + it('publishes no credential material and no raw provider record', () => { + const dto = toManagementPreviewDto(previewOf(FIXTURES, 'claude-ollama')); + expect(dto.schemaVersion).toBe(PROVIDER_GRAPH_SCHEMA_VERSION); + expect(dto.activeProvider).toBe('claude-ollama'); + const serialized = JSON.stringify(dto); + expect(serialized).not.toContain('example-token'); + expect(serialized).not.toContain('example-remote-key'); + expect(dto.connections.find((c) => c.kind === 'ollama').hasCredentials).toBe(true); + }); + + it('keeps secrets out of the internal preview serialization too', () => { + // Belt and braces: the raw records and credential map hang off the preview + // as non-enumerable properties, so an accidental log of the whole object + // cannot leak them even before the DTO's `.strict()` guard runs. + expect(JSON.stringify(previewOf(FIXTURES))).not.toContain('example-token'); + }); +}); diff --git a/server/lib/providerHarnesses.js b/server/lib/providerHarnesses.js new file mode 100644 index 0000000000..cf4ec315a0 --- /dev/null +++ b/server/lib/providerHarnesses.js @@ -0,0 +1,180 @@ +import { + commandBasename, + getOpencodeLocalProviderNamespace, + isAntigravityProvider, + isClaudeProvider, + isCodexProvider, + isCursorProvider, + isGrokProvider, + isKimiProvider, + isOpencodeProvider, + prefixOpencodeModel, +} from './providerModels.js'; + +/** + * The HARNESS half of the provider-connection graph proposed in + * `docs/plans/2026-09-06-provider-connections-and-harnesses.md` (#6366). + * + * A harness is the agent program PortOS drives (Claude Code, OpenCode, Codex, + * …) — stable, shipped-in-code identity, never a user-typed command fetched + * from a server. It is deliberately SEPARATE from: + * + * - `providerVendors.js`, which is argv/sandbox-recipe shaped; + * - `providerFamilies.js`, which answers "which paid subscription quota?"; + * - `providerGateways.js`, which is a hosted OpenAI-compatible backend. + * + * A harness answers only: which program runs, which execution modes it can be + * driven in, which wire protocol it speaks to a backend connection, and how a + * canonical backend model name becomes the string that program accepts. + * + * Rows match through the existing `is*Provider` predicates rather than a fresh + * command-string test, so a path-configured binary, a `.exe`, and the shipped + * ids all resolve the same way they already do everywhere else in PortOS. + */ + +/** Execution modes a route can carry. Mirrors the provider record's `type`. */ +export const ROUTE_MODES = Object.freeze(['cli', 'tui', 'api']); + +/** Modes every CLI/TUI harness supports. Direct API bindings carry no harness. */ +const CLI_TUI_MODES = Object.freeze(['cli', 'tui']); + +/** + * @type {readonly {id:string,label:string,modes:readonly string[],protocol:string,matches:(p:object)=>boolean}[]} + */ +export const PROVIDER_HARNESSES = Object.freeze([ + Object.freeze({ + id: 'claude', + label: 'Claude Code', + modes: CLI_TUI_MODES, + protocol: 'anthropic', + matches: isClaudeProvider, + }), + Object.freeze({ + id: 'opencode', + label: 'OpenCode', + modes: CLI_TUI_MODES, + protocol: 'openai', + matches: isOpencodeProvider, + }), + Object.freeze({ + id: 'codex', + label: 'Codex', + modes: CLI_TUI_MODES, + protocol: 'openai', + matches: isCodexProvider, + }), + Object.freeze({ + id: 'antigravity', + label: 'Antigravity', + modes: CLI_TUI_MODES, + protocol: 'native', + matches: isAntigravityProvider, + }), + Object.freeze({ + id: 'cursor', + label: 'Cursor Agent', + modes: CLI_TUI_MODES, + protocol: 'native', + matches: isCursorProvider, + }), + Object.freeze({ + id: 'grok', + label: 'Grok', + modes: CLI_TUI_MODES, + protocol: 'native', + matches: isGrokProvider, + }), + Object.freeze({ + id: 'kimi', + label: 'Kimi Code', + modes: CLI_TUI_MODES, + protocol: 'native', + matches: isKimiProvider, + }), + Object.freeze({ + id: 'pi', + label: 'Pi', + modes: CLI_TUI_MODES, + protocol: 'native', + // No `isPiProvider` predicate exists — `pi` has no vendor module of its own + // beyond `aiToolkit/internal/pi.js`, so match its binary basename directly. + matches: (provider) => commandBasename(provider?.command) === 'pi', + }), +]); + +/** Every harness id, for schemas that must accept only a real harness. */ +export const PROVIDER_HARNESS_IDS = Object.freeze(PROVIDER_HARNESSES.map((h) => h.id)); + +/** The registry row for a harness id, or `null` for anything else. */ +export const harnessById = (id) => PROVIDER_HARNESSES.find((h) => h.id === id) || null; + +/** + * The harness a provider record is driven by, or `null`. + * + * `null` has TWO distinct causes and the caller must not conflate them: an + * `api`-type record legitimately has no harness (a direct API binding), while a + * `cli`/`tui` record with no matching row is an UNKNOWN harness that must stay + * an unlinked legacy route. Use {@link providerRouteMode} to tell them apart. + * + * @param {{id?:string, type?:string, command?:string}|null|undefined} provider + * @returns {{id:string,label:string,modes:readonly string[],protocol:string}|null} + */ +export function harnessForProvider(provider) { + if (!provider || typeof provider !== 'object' || provider.type === 'api') return null; + return PROVIDER_HARNESSES.find((h) => h.matches(provider)) || null; +} + +/** Whether `harnessId` can be driven in `mode`. Unknown harness → false. */ +export const harnessSupportsMode = (harnessId, mode) => + Boolean(harnessById(harnessId)?.modes.includes(mode)); + +/** + * The route mode a provider record executes in, or `null` when its `type` is + * not one PortOS can execute. Never inferred from a name or command — the + * record's own `type` is the execution contract. + */ +export const providerRouteMode = (provider) => + ROUTE_MODES.includes(provider?.type) ? provider.type : null; + +/** + * The string this provider's harness actually accepts for a canonical backend + * model name — today only OpenCode needs one (its `/` form). + * + * Delegates to `prefixOpencodeModel`, the same adapter the spawner uses, so the + * preview can never disagree with what a run would really send. + */ +export const toExecutableModelName = (provider, canonicalModel) => + prefixOpencodeModel(provider, canonicalModel); + +/** + * The inverse adapter: the canonical backend name behind a STORED model string. + * + * Import must never rewrite a saved model string by heuristically stripping a + * prefix, so this is verified rather than guessed — a candidate is accepted + * only when {@link toExecutableModelName} maps it back to the exact stored + * string. A stored string that no candidate reproduces is left untouched and + * reported, so it stays a visible unresolved alias instead of silently becoming + * a model the harness cannot serve. + * + * @param {object} provider + * @param {string} stored - the model string as saved on the provider record + * @returns {{canonical:string, executable:string, resolved:boolean, reason:string|null}} + */ +export function toCanonicalModelName(provider, stored) { + const unresolved = { canonical: stored, executable: stored, resolved: false, reason: 'unmappable-model-alias' }; + if (typeof stored !== 'string' || stored === '') return unresolved; + + const namespace = getOpencodeLocalProviderNamespace(provider); + const stripped = namespace && stored.startsWith(`${namespace}/`) + ? stored.slice(namespace.length + 1) + : null; + + // Shortest-first: a namespaced string canonicalizes to its bare id, and an + // already-canonical string round-trips to itself. + for (const candidate of [stripped, stored]) { + if (candidate && toExecutableModelName(provider, candidate) === stored) { + return { canonical: candidate, executable: stored, resolved: true, reason: null }; + } + } + return unresolved; +} diff --git a/server/routes/providers.js b/server/routes/providers.js index 9a397e9226..37ce54d4ac 100644 --- a/server/routes/providers.js +++ b/server/routes/providers.js @@ -1,4 +1,5 @@ import { providerModeGroups } from '../lib/aiToolkit/internal/providerModes.js'; +import { buildProviderGraphPreview, toManagementPreviewDto } from '../lib/providerGraphPreview.js'; import { Router } from 'express'; import { asyncHandler, ServerError } from '../lib/errorHandler.js'; import { testVision, runVisionTestSuite, checkVisionHealth } from '../services/visionTest.js'; @@ -270,6 +271,25 @@ export function createPortOSProviderRoutes(aiToolkit) { res.json(presentProvider(provider, await detectSystemCapabilities())); })); + /** + * READ-ONLY preview of the provider connection graph (#6366) — what an + * import WOULD create from the records this install already runs, and which + * records it would leave isolated, with reasons. + * + * Nothing is persisted, no provider is written, and no AI provider is + * contacted: this is a pure projection of `providers.json` and must stay one, + * because it is meant to be safe to open from a configuration screen. The + * flat `GET /api/providers` shape is untouched and remains the execution + * contract; `activeProvider` here is the same executable provider id string. + * + * A client talking to a server without this endpoint gets a 404 and falls + * back to the flat list — an explicit unsupported answer, not a guess. + */ + router.get('/management/preview', asyncHandler(async (_req, res) => { + const data = await providerService.getAllProviders(); + res.set('Cache-Control', 'no-store').json(toManagementPreviewDto(buildProviderGraphPreview(data))); + })); + router.get('/samples', asyncHandler(async (req, res) => { const providers = await providerService.getSampleProviders(); const capabilities = await detectSystemCapabilities(); diff --git a/server/routes/providers.management.test.js b/server/routes/providers.management.test.js new file mode 100644 index 0000000000..053719eb4f --- /dev/null +++ b/server/routes/providers.management.test.js @@ -0,0 +1,132 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express, { Router } from 'express'; +import { request } from '../lib/testHelper.js'; +import { errorMiddleware } from '../lib/errorHandler.js'; +import { staticImportClosure, specifierMatchesPackage } from '../lib/staticImportGraph.js'; +import { createPortOSProviderRoutes } from './providers.js'; + +const SERVER_DIR = dirname(dirname(fileURLToPath(import.meta.url))); +const abs = (relative) => join(SERVER_DIR, ...relative.split('/')); + +// Synthetic fixtures — never a record read out of a running install. +const CLAUDE_OLLAMA = { + id: 'claude-ollama', + name: 'Claude Ollama', + type: 'cli', + command: 'claude', + ollamaBacked: true, + models: ['example-model:8b'], + enabled: true, + envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434', ANTHROPIC_AUTH_TOKEN: 'example-token' }, + secretEnvVars: ['ANTHROPIC_AUTH_TOKEN'], +}; +const CLAUDE_OLLAMA_TUI = { ...CLAUDE_OLLAMA, id: 'claude-ollama-tui', type: 'tui', enabled: false }; +const REMOTE_API = { + id: 'remote-ollama', + name: 'Remote Ollama', + type: 'api', + endpoint: 'https://ollama.example.com/v1', + apiKey: 'example-remote-key', + models: [], + enabled: true, +}; + +function appWith(providerService) { + const toolkit = { services: { providers: providerService }, routes: { providers: Router() } }; + const app = express(); + app.use(express.json()); + app.use('/api/providers', createPortOSProviderRoutes(toolkit)); + app.use(errorMiddleware); + return app; +} + +const providersFixture = () => ({ + activeProvider: 'claude-ollama', + providers: [CLAUDE_OLLAMA, CLAUDE_OLLAMA_TUI, REMOTE_API], +}); + +describe('GET /api/providers/management/preview', () => { + it('returns the version-1 graph preview without touching the flat provider API', async () => { + const getAllProviders = vi.fn().mockResolvedValue(providersFixture()); + const res = await request(appWith({ getAllProviders })).get('/api/providers/management/preview'); + + expect(res.status).toBe(200); + expect(res.body.schemaVersion).toBe(1); + // Same executable provider id string the flat API publishes. + expect(res.body.activeProvider).toBe('claude-ollama'); + expect(res.body.routes.map((route) => route.providerId).sort()) + .toEqual(['claude-ollama', 'claude-ollama-tui', 'remote-ollama']); + // The graph is read-only: nothing was written back. + expect(getAllProviders).toHaveBeenCalledTimes(1); + }); + + it('publishes credential PRESENCE, never a credential or a redacted stand-in', async () => { + const res = await request(appWith({ getAllProviders: vi.fn().mockResolvedValue(providersFixture()) })) + .get('/api/providers/management/preview'); + + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain('example-token'); + expect(serialized).not.toContain('example-remote-key'); + expect(res.body.connections.every((connection) => connection.hasCredentials)).toBe(true); + }); + + it('leaves GET /api/providers byte-compatible for existing clients', async () => { + // A new endpoint must not become a reason for an old client to change. + const res = await request(appWith({ getAllProviders: vi.fn().mockResolvedValue(providersFixture()) })) + .get('/api/providers'); + + expect(res.status).toBe(200); + expect(res.body.activeProvider).toBe('claude-ollama'); + expect(res.body.providers.map((p) => p.id)) + .toEqual(['claude-ollama', 'claude-ollama-tui', 'remote-ollama']); + expect(res.body.providers[0].hasApiKey).toBe(false); + expect(res.body.providers[0].executionModes).toBeDefined(); + }); +}); + +describe('the preview contacts no AI provider and launches no runtime', () => { + // The AI Provider Usage Policy in AGENTS.md: rendering a configuration screen + // must never be what starts a generation call or a CLI/TUI process. The + // preview is designed to be safe to open, so the absence of those calls is a + // product contract and gets an assertion rather than a comment. + // `request()` is itself fetch-based against a loopback server, so the spy + // passes through and the assertion is about the DESTINATION: anything the + // handler reached for would show up as a non-loopback URL. + let fetchSpy; + + beforeEach(() => { + const real = globalThis.fetch; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((...args) => real(...args)); + }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('makes no outbound request while serving the preview', async () => { + const res = await request(appWith({ getAllProviders: vi.fn().mockResolvedValue(providersFixture()) })) + .get('/api/providers/management/preview'); + + expect(res.status).toBe(200); + const destinations = fetchSpy.mock.calls.map(([input]) => String(input?.url ?? input)); + expect(destinations.filter((url) => !url.startsWith('http://127.0.0.1:'))).toEqual([]); + }); + + it('builds the graph from modules that cannot spawn a process or open a socket', () => { + // The runtime assertion above only proves this request did not call out. + // This proves the preview modules have no way to: nothing in their static + // import closure can spawn, pty, or reach an AI provider service. + const closure = staticImportClosure(abs('lib/providerGraphPreview.js')); + const forbidden = ['child_process', 'node:child_process', 'node-pty', 'node:net', 'node:dgram']; + for (const pkg of closure.packages) { + expect(forbidden.some((banned) => specifierMatchesPackage(pkg, banned))).toBe(false); + } + expect([...closure.files].filter((file) => file.includes('/services/'))).toEqual([]); + + // Positive control: the walk really does see packages, so a resolver gap + // cannot make the negative assertions above pass vacuously. + expect(closure.packages.has('zod')).toBe(true); + expect(closure.files.has(abs('lib/providerConnections.js'))).toBe(true); + }); +});