diff --git a/server/lib/README.md b/server/lib/README.md index 1ea10ade91..8888a0ddf8 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -165,7 +165,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `videoDurationProfiles.js` | Pure pinned duration/frame contracts shared by model-registry upgrades and migrations. LTX-2.5 A2V follows the full uploaded audio, rounds up to its 8n+1 temporal grid, and tops out at 1017 frames under the API's single-pass boundary. | | `videoReferenceModes.js` | The i2v reference-mode contract (#4874) — what a supplied conditioning image PROMISES. `I2V_REFERENCE_MODES` (`anchor` \| `inspire`) + `I2V_REFERENCE_MODE_OPTIONS` (the label + the promise sentence the UI prints), `I2V_REFERENCE_MODE_RUNTIMES` (only `ltx25` can honor `inspire` — it needs per-image conditioning strength), `INSPIRE_DEFAULT_IMAGE_STRENGTH`, plus `normalizeI2vReferenceMode` / `isDefaultI2vReferenceMode` / `isKnownI2vReferenceMode` / `runtimeSupportsI2vReferenceMode` / `i2vReferenceModeLabel` / `resolveI2vReferenceStrength` and the one rule `i2vReferenceModeViolation({ model, mode, referenceMode, hasFirstImage })` → `{ code, message }` or null. Pure (no `ServerError`) because it is MIRRORED to `client/src/lib/videoReferenceModes.js`; `videoGen/modeContract.js#videoReferenceModeError` wraps it for the route + render boundaries. | | `videoTextEncoders.js` | Swappable prompt conditioners for local video runtimes. MiniMax H3 reads the *unnormalized* hidden state after Qwen3-VL language layer 49 (layers 50-63, the final norm and `lm_head` are never evaluated), so any checkpoint carrying the same embedding + layers 0-49 + vision tower is a drop-in conditioner — swapping it changes how the model reads a prompt without touching the diffusion weights. `TEXT_ENCODERS_BY_RUNTIME` declares the shipped options per runtime (pinned repo/revision plus an explicit `files` LIST — one repackaged safetensors, or just the shards of an upstream checkpoint that carry parameters the loader actually builds; in code rather than the media-models registry so a stale `data/media-models.json` can't name a file the runner can't map); `videoTextEncoderOptions(model)` returns the TRUE list stock-first (it deliberately does NOT collapse a one-entry runtime to `[]` — that is a presentation rule, and folding it in here would change what the server believes a model supports and empty the "offers …" list in the error; `TextEncoderPicker` owns the hide-when-there-is-no-real-choice check), `isStockTextEncoder(id)` makes absence and the `stock` sentinel the same request, `resolveVideoTextEncoder(model, id)` returns `null` for the stock choice or throws `VIDEO_TEXT_ENCODER_UNSUPPORTED` (with the non-throwing `supportsVideoTextEncoder` + `videoTextEncoderUnsupportedError` split out so the request path can reject before staging uploads), and `downloadableVideoTextEncoders()` (deduped by id — the table is keyed by RUNTIME, so one conditioner can be offered by two) / `downloadableVideoTextEncoder(id)` feed the `/api/video-gen/text-encoders/:id/(download\|repair)` lane. Two loader-mechanics fields exist because a ComfyUI-packaged conditioner is namespaced differently from the HF checkpoint the MLX port matches: `keyPrefixMap` (`model.` → `model.language_model.`, `visual.` → `model.visual.`) is applied to every checkpoint key by `scripts/generate_minimax_h3.py` BEFORE the pinned loader sees it — no fork of the pinned runtime — and `finalNormKey` names where the runner synthesizes a ones-filled `norm.weight` for a checkpoint published without one (correct upstream, since H3 reads the state *before* the norm, but the pinned loader refuses to load with any parameter missing). Both are absent for an UPSTREAM Qwen3-VL-32B checkpoint, which already uses the loader namespace and ships its own norm. A candidate must BE Qwen3-VL-32B (the shim reuses upstream's config/tokenizer/processor) — a different Qwen generation is not a substitute however close its conditioning width looks; see docs/features/video-text-encoders.md. `publicTextEncoderOption(entry)` is the client projection and deliberately drops both, so the UI can't reimplement the remap. The `ltx25` table (#4320) uses a third mechanic, `configOverrides`, because an LTX-2.5 pack's OWN Gemma 4 tower wins over `--gemma` inside the pinned fork: the substitution is a standalone shim directory whose generated `config.json` is the substitute's own with these keys merged over it (only ever the `model_type` label a unified checkpoint gets wrong — never `text_config`/`quantization`), and a candidate must BE Gemma 4 12B at 48 layers / hidden 3840 / vocab 262144 / `k_eq_v`. `verified` gates a substitute out of BOTH lanes (picker AND download) until it has been A/B-rendered against its runtime's stock conditioner — required on every non-built-in entry and fail-closed on absence, so a new entry is unreachable until someone states a verdict; both ltx25 substitutes are `verified: false` today. `declaredVideoTextEncoders()` is the UNFILTERED table for shape/invariant checks only — never the render or download path, and `videoTextEncoderRuntimes()` enumerates the table's runtime keys so parity/shape tests cover every runtime rather than the one that happened to exist when they were written. | -| `providerModels.js` | Provider model resolution sentinels + helpers (`CODEX_CONFIGURED_DEFAULT` / `ANTIGRAVITY_CONFIGURED_DEFAULT` / `GROK_CONFIGURED_DEFAULT` / `KIMI_CONFIGURED_DEFAULT`, `resolveCliModel`, `filterSelectableModels`, Bedrock/OpenCode model mappers, `normalizeClaudeModelId` / `resolveClaudeCliModel` — the Claude-argv chokepoint that rewrites a dotted first-party version (`claude-fable-5.1`) to the dashed id Claude Code actually serves before the Bedrock mapping runs, model-flag scan helpers incl. `stripBrokenModelFlags`, `isCodexProvider`, `isKimiProvider`, `isAntigravityProvider`, `isCursorProvider`) plus reasoning-effort helpers for the claude/codex/agy/cursor CLIs (`CLAUDE_EFFORT_LEVELS` / `CODEX_EFFORT_LEVELS` / `ANTIGRAVITY_EFFORT_LEVELS` / `CURSOR_EFFORT_LEVELS` / `EFFORT_LEVELS`, `effortLevelsForProvider`, `resolveCliEffort` — clamps an out-of-range effort to the nearest level the target CLI accepts rather than dropping it, so a value saved against a wider ladder survives a provider switch — `hasEffortFlag`, `buildEffortArgs` — the one emitter of `--effort ` / `-c model_reasoning_effort=`, and deliberately silent for cursor — and `foldCursorEffortIntoModel`, which carries a cursor level inside `--model` as Cursor’s own variant syntax (`gpt-5[effort=max]`) because `cursor-agent` has no `--effort` flag) plus codex startup-arg helpers (`CODEX_EFFORT_KEY`, `CODEX_UPDATE_CHECK_KEY`, `hasCodexUpdateCheckConfig`, `buildCodexStartupArgs` — the one emitter of `-c check_for_update_on_startup=false`, spread by every codex spawn builder to disable the blocking startup update modal) plus `PORTOS_CLI_CONFIG_KEYS` / `isPortosSuppliedConfigKey` — the exhaustive list of `-c =` config keys PortOS injects, read by the `cli-config-invalid` error analyzer to tell a rejected PortOS override apart from a bad line in the user's own CLI config file. | +| `providerModels.js` | Provider model resolution sentinels + helpers (`CODEX_CONFIGURED_DEFAULT` / `ANTIGRAVITY_CONFIGURED_DEFAULT` / `GROK_CONFIGURED_DEFAULT` / `KIMI_CONFIGURED_DEFAULT`, `resolveCliModel`, `filterSelectableModels`, Bedrock/OpenCode model mappers, `localRuntimeNamespace(provider)` — the OpenCode namespace only when it names a LOCAL daemon, i.e. the composed "namespace and not a hosted gateway" test that `cliChildEnv.js`, `localProviderRuntime.js` and `providerVendors.js` all key on, `OPENCODE_PUBLIC_REVIEW_AGENT` — the read-only OpenCode agent a no-tool public-review stage runs as, kept in this leaf because `providerVendors.js` must not import `opencodeConfig.js`, `parseOpencodeConfigContent` — the shared "is this stored OPENCODE_CONFIG_CONTENT usable?" read — plus `opencodeConfigIsLocalOnly` / `opencodeProviderIsLocalOnly`, the ONE locality rule `providerVendors.js` (gate eligibility) and `cliChildEnv.js` (public-review env allowlist) must not disagree about: if eligibility says yes where the allowlist strips the config, the stage spawns against the user's own ~/.config/opencode with tools intact while still reporting an enforced tool-free gate, `normalizeClaudeModelId` / `resolveClaudeCliModel` — the Claude-argv chokepoint that rewrites a dotted first-party version (`claude-fable-5.1`) to the dashed id Claude Code actually serves before the Bedrock mapping runs, model-flag scan helpers incl. `stripBrokenModelFlags`, `isCodexProvider`, `isKimiProvider`, `isAntigravityProvider`, `isCursorProvider`) plus reasoning-effort helpers for the claude/codex/agy/cursor CLIs (`CLAUDE_EFFORT_LEVELS` / `CODEX_EFFORT_LEVELS` / `ANTIGRAVITY_EFFORT_LEVELS` / `CURSOR_EFFORT_LEVELS` / `EFFORT_LEVELS`, `effortLevelsForProvider`, `resolveCliEffort` — clamps an out-of-range effort to the nearest level the target CLI accepts rather than dropping it, so a value saved against a wider ladder survives a provider switch — `hasEffortFlag`, `buildEffortArgs` — the one emitter of `--effort ` / `-c model_reasoning_effort=`, and deliberately silent for cursor — and `foldCursorEffortIntoModel`, which carries a cursor level inside `--model` as Cursor’s own variant syntax (`gpt-5[effort=max]`) because `cursor-agent` has no `--effort` flag) plus codex startup-arg helpers (`CODEX_EFFORT_KEY`, `CODEX_UPDATE_CHECK_KEY`, `hasCodexUpdateCheckConfig`, `buildCodexStartupArgs` — the one emitter of `-c check_for_update_on_startup=false`, spread by every codex spawn builder to disable the blocking startup update modal) plus `PORTOS_CLI_CONFIG_KEYS` / `isPortosSuppliedConfigKey` — the exhaustive list of `-c =` config keys PortOS injects, read by the `cli-config-invalid` error analyzer to tell a rejected PortOS override apart from a bad line in the user's own CLI config file. | | `providerVendors.js` | `PROVIDER_VENDORS` — one row per coding-agent CLI/TUI vendor (claude/codex/antigravity/opencode/grok/kimi/cursor, plus a deliberately-incomplete legacy `gemini-cli` row), consumed by every dispatch site that used to hand-roll its own vendor if-chain across ~8 branches in 5 files (#3618): `applyCommandDefaults`/`prepareCliPrompt` (re-exported from `tuiHandshake.js`/`cliProviderArgs.js`), `buildVendorCliArgs`/`buildVendorSpawnConfig` (consumed by `cliProviderArgs.js#buildCliArgs` / `agentCliSpawning.js#buildCliSpawnConfig`), `inferTuiCommand` (re-exported from `tuiHandshake.js`), and `injectTuiModelAndEffort` — the shared antigravity-validates-the-pair-vs-everyone-else `--model`/`--effort` injection used by both `tuiHandshake.js#buildTuiInvocation` and `agentTuiSpawning.js#buildTuiSpawnConfig`, replacing a second copy of that split that had already drifted once before this file existed. Doesn't rewrite any vendor's argv-building logic — that stays in `antigravity.js`/`grok.js`/`kimi.js`/`cursor.js`/`codex.js`. Dependency-light on purpose, mirroring those files. | | `modelCapabilityTests.js` | Catalog + scoring for the CAPABILITY tests on `/models/performance` (run by `services/modelCapabilityTests.js`): `CAPABILITY_TESTS` (sandbox repair / image analysis / story outline / fiction scene / rhetoric evaluator, each gated on the capability badges the install catalog already shows), `applicabilityFor` + `applicableTests` (`applicable` / `not-applicable` / `unknown` — an UNCLAIMED capability is never a failure, and `null` capabilities mean the runtime reported none, which is distinct from `[]`), `scoreKeywords` + `VISION_FIXTURE_KEYWORDS` (required vs bonus terms, word-boundary matched with a negation guard so "no dog" doesn't score a dog), `scoreStoryBeats` + `HEROS_JOURNEY_BEATS` (coverage AND ordering, judged only over the beats present), `scoreSandboxRepair` (verdict from observed disk facts — editing the test instead of the module fails outright), `formatAgentEvent` (one agent stream frame → a transcript line), `rollUpVerdict`, and the verbatim `CAPABILITY_TEST_PROMPTS` / `SANDBOX_TASK_PROMPT` the consent gate shows. Pure, so any stored transcript can be re-scored with no provider call. | | `modelPricing.js` | Per-model API billing rates for the /devtools/usage cost estimates — `resolveModelRates(providerId, model)` (exact → family regex → provider default → blended fallback, with a `matched` tier; also derives `cacheReadPer1M`/`cacheWritePer1M` from the input rate via per-family multipliers), `isFreeProvider` (ollama/lmstudio/`ollamaBacked`/localhost = free), `estimateCostUsd(tokensIn, tokensOut, rates, cache?)` — `tokensIn` is UNCACHED input; cache tiers are priced separately via the optional 4th arg — and `PRICING_AS_OF`. Informational only (PortOS runs on subscriptions); still excludes batch/long-context tiers. | @@ -178,7 +178,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `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/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. | | `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. | +| `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. | | `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. | | `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. | | `mtplxModels.js` | `listMtplxCachedModels({command?})` → `{models, error}` from `mtplx models --json` (walks local directories — pulls no weights, loads no model, but see `mtplxRuntime.js`: on an un-warmed Homebrew wrapper the spawn ITSELF is a several-hundred-megabyte runtime download, so poll callers must gate on `describeMtplxRuntime().ready` first) and `pickMtplxCachedModel(models)` → the repo id to hand `mtplx serve --model`. `models: null` means the cache could not be READ (no binary, command failed, unparseable) and is deliberately distinct from `[]` (read, and empty), because `services/localRuntimeSetup.js` starts MTPLX on its own default in the first case and refuses with the `mtplx pull` command in the second. Exists because `mtplx serve` defaults `--model` to one hard-coded checkpoint and exits 1 before binding when that repo is not cached — even on a host holding a different MTP model that serves fine. Picks only entries MTPLX itself calls complete (`validation.ok !== false`, so a half-finished pull is not served), preferring one with a recorded `mtplx_runtime.json` exactness contract. `describeMtplxCache(cache)` → `{state: 'unknown'\|'empty'\|'partial'\|'ready', model, count, error}` folds both into the one value `services/providerReadiness.js` puts on the checklist and `describeRuntimeSetup` picks a button from — so an empty cache is named up front instead of only inside the failure of a Start that could never work. | diff --git a/server/lib/cliChildEnv.js b/server/lib/cliChildEnv.js index bac4b90ba8..b5694cc58b 100644 --- a/server/lib/cliChildEnv.js +++ b/server/lib/cliChildEnv.js @@ -42,8 +42,13 @@ import { withSpawnCwdEnv } from './spawnCwd.js'; import { buildOpencodeEnvVars } from './opencodeConfig.js'; -import { getOpencodeLocalProviderNamespace, isClaudeCommand } from './providerModels.js'; -import { isGatewayNamespace } from './providerGateways.js'; +import { + localRuntimeNamespace, + isClaudeCommand, + parseOpencodeConfigContent, + opencodeConfigIsLocalOnly, +} from './providerModels.js'; +import { isLocalInstanceEndpoint } from './localEndpoint.js'; import { agentGuardEnv } from './agentGuard/index.js'; import { buildSafeCliBaseEnv } from './processEnv.js'; import { isPublicReviewNoToolProfile, isPublicReviewRestrictedProfile } from './agentExecutionProfiles.js'; @@ -70,8 +75,7 @@ const CLAUDE_LOCAL_MAX_OUTPUT_TOKENS = '65536'; * `localRuntimeKind` makes. */ function isLocalBackedClaude(provider) { - const namespace = getOpencodeLocalProviderNamespace(provider); - return !!namespace && !isGatewayNamespace(namespace) && isClaudeCommand(provider?.command); + return !!localRuntimeNamespace(provider) && isClaudeCommand(provider?.command); } function claudeLocalEnvDefaults(provider) { @@ -111,9 +115,11 @@ function claudeLocalEnvDefaults(provider) { * per-call model — `provider.defaultModel` is always declared regardless. * @param {object|null} [options.extra] - layered last, so it overrides every * other layer including `provider.envVars` (TERM/COLORTERM for a PTY). + * @param {string|null} [options.safetyProfile] - a public-review execution + * profile, which hardens the OpenCode config (see `buildOpencodeEnvVars`). * @returns {object} a fresh object holding only these layers */ -export function composeProviderEnv({ before = null, provider = null, model = null, extra = null } = {}) { +export function composeProviderEnv({ before = null, provider = null, model = null, extra = null, safetyProfile = null } = {}) { return { ...(before || {}), ...claudeLocalEnvDefaults(provider), @@ -122,14 +128,17 @@ export function composeProviderEnv({ before = null, provider = null, model = nul // local providers (an empty object for everyone else) so the injected // namespaced `--model` isn't rejected as "not valid" — see #2190. It lands // after provider.envVars to override the provider's STATIC - // OPENCODE_CONFIG_CONTENT, which it was built from. - ...buildOpencodeEnvVars(provider, model), + // OPENCODE_CONFIG_CONTENT, which it was built from. `safetyProfile` also + // reaches it because OpenCode's tool posture lives in that config — see + // `hardenOpencodeConfigForNoTool`. + ...buildOpencodeEnvVars(provider, model, { safetyProfile }), ...(extra || {}), }; } -// Public contributor content is run through a no-tools local Claude wrapper. -// Keep only runtime essentials plus the local Anthropic-compatible endpoint; +// Public contributor content is run through a no-tools local harness — a Claude +// or an OpenCode wrapper pointed at a loopback daemon. +// Keep only runtime essentials plus the local model endpoint; // in particular, never pass forge, cloud, SSH, auth, or arbitrary provider env // vars into the child. This is a second boundary in addition to the CLI argv. const PUBLIC_REVIEW_ENV_KEYS = new Set([ @@ -148,28 +157,49 @@ const PUBLIC_REVIEW_ENV_KEYS = new Set([ // disables the keychain — so without the token the CLI exits "Not logged in" // before reading the prompt. Keep the credential only for a loopback base URL; // against any other host it is a real cloud credential and stays stripped. +// `isLocalInstanceEndpoint` (localEndpoint.js) is the tree-wide answer to "is +// this endpoint on the machine PortOS runs on?" — the same predicate +// `localRuntimeForProvider` uses to decide a provider HAS a local daemon. +// Reused here rather than re-typed so a credential boundary cannot classify a +// host differently from the runtime resolver; it also counts the bind-all +// addresses (`0.0.0.0`, `::`) as local, which they are. const LOCAL_ANTHROPIC_CREDENTIAL_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']; -const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); function localAnthropicCredentialEnv(env) { - let hostname; - try { - hostname = new URL(env?.ANTHROPIC_BASE_URL).hostname.toLowerCase(); - } catch { - return {}; - } - if (!LOOPBACK_HOSTNAMES.has(hostname) && !hostname.startsWith('127.')) return {}; + if (!isLocalInstanceEndpoint(env?.ANTHROPIC_BASE_URL)) return {}; return Object.fromEntries(LOCAL_ANTHROPIC_CREDENTIAL_KEYS .filter((key) => env[key] != null) .map((key) => [key, env[key]])); } +/** + * An OpenCode run carries its whole configuration — provider endpoint, declared + * models, and (under a `no-tool` profile) its entire tool posture — in + * `OPENCODE_CONFIG_CONTENT`, so stripping it does not harden the child, it just + * points it at the user's own `~/.config/opencode` instead. Keep it, on the same + * terms as the local Anthropic credential above: only when every endpoint it + * declares is loopback. A config naming a hosted gateway carries that gateway's + * API key, which is a real cloud credential and stays stripped — leaving an + * OpenCode wrapper front-ending a gateway ineligible for these stages, which is + * why `providerVendors.js` scopes the OpenCode recipe to local namespaces. + */ +function opencodeLocalConfigEnv(env) { + const raw = env?.OPENCODE_CONFIG_CONTENT; + // `requireDeclaration` marks this as the provenance-checking caller: a value + // declaring no endpoint is not a config PortOS built for an eligible provider, + // so it is dropped with every other inherited env var. + return opencodeConfigIsLocalOnly(parseOpencodeConfigContent(raw), { requireDeclaration: true }) + ? { OPENCODE_CONFIG_CONTENT: raw } + : {}; +} + function allowlistEnv(env, keys) { return { ...Object.fromEntries(Object.entries(env || {}).filter(([key, value]) => ( value != null && (keys.has(key) || key.startsWith('LC_')) ))), ...localAnthropicCredentialEnv(env), + ...opencodeLocalConfigEnv(env), }; } @@ -235,7 +265,7 @@ export function buildCliChildEnv({ safetyProfile = null, } = {}) { const composed = withSpawnCwdEnv( - { ...buildSafeCliBaseEnv(baseEnv, provider), ...composeProviderEnv({ before, provider, model, extra }) }, + { ...buildSafeCliBaseEnv(baseEnv, provider), ...composeProviderEnv({ before, provider, model, extra, safetyProfile }) }, cwd, ); diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js index 6f0e82f2a9..2720b4569b 100644 --- a/server/lib/cliChildEnv.test.js +++ b/server/lib/cliChildEnv.test.js @@ -4,6 +4,7 @@ import { posixPath } from './testHelper.js'; import { buildCliChildEnv, buildPublicReviewCliEnv, composeProviderEnv } from './cliChildEnv.js'; import { PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, PUBLIC_REVIEW_EXECUTION_PROFILE } from './agentExecutionProfiles.js'; import { cliProviderAuthDescriptor } from './processEnv.js'; +import { supportsPublicReviewProvider } from './providerVendors.js'; import { AGENT_GUARD_BIN } from './agentGuard/index.js'; import { collectServerSources, readServerSource } from './testHelper.js'; import { readFileSync } from 'node:fs'; @@ -178,6 +179,79 @@ describe('buildCliChildEnv — public-review profile', () => { }); }); +describe('buildCliChildEnv — public-review profile, OpenCode harness', () => { + // OpenCode is the natural way to drive a local Ollama model, and its whole + // tool posture lives in OPENCODE_CONFIG_CONTENT — stripping the variable does + // not harden the child, it points it back at the user's own + // ~/.config/opencode. It survives on the same loopback terms as the local + // Anthropic credential. + it('hardens the OpenCode config and carries it through the allowlist', () => { + const env = buildCliChildEnv({ + baseEnv: { PATH: '/usr/bin', GH_TOKEN: 'ambient' }, + provider: OLLAMA_OPENCODE, + model: 'qwen2.5:7b', + cwd: '/tmp/public-review', + safetyProfile: PUBLIC_REVIEW_EXECUTION_PROFILE, + }); + + // One posture marker is enough here — `opencodeConfig.test.js` owns the + // full matrix. What this test uniquely proves is that the hardened config + // survives the allowlist while the credentials beside it do not. + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT).tools).toEqual({ '*': false }); + expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env).not.toHaveProperty('API_KEY'); + }); + + it('leaves the ordinary (non-public-review) OpenCode config tool-enabled', () => { + const env = buildCliChildEnv({ provider: OLLAMA_OPENCODE, model: 'qwen2.5:7b', cwd: '/tmp/work' }); + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT); + expect(config.permission).toBe('deny'); // the provider's stored value, untouched + expect(config.provider.ollama.models['qwen2.5:7b'].tool_call).toBe(true); + expect(config).not.toHaveProperty('tools'); + }); + + // The load-bearing invariant: whatever the vendor row declares eligible for + // the gate must keep its hardened config through this allowlist. If the two + // sides ever disagree, the stage spawns with the config stripped — OpenCode + // then reads the user's own ~/.config/opencode, tools intact, while every + // signal still reports an enforced tool-free gate. + it('keeps the config for exactly the providers the vendor row makes eligible', () => { + const relocated = { + ...OLLAMA_OPENCODE, + type: 'tui', + envVars: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + provider: { ollama: { options: { baseURL: 'http://192.0.2.10:11434/v1' } } }, + }), + }, + }; + const eligible = { ...OLLAMA_OPENCODE, type: 'tui' }; + for (const provider of [eligible, relocated]) { + const env = buildCliChildEnv({ + provider, + model: 'qwen2.5:7b', + cwd: '/tmp/public-review', + safetyProfile: PUBLIC_REVIEW_EXECUTION_PROFILE, + }); + expect( + Object.hasOwn(env, 'OPENCODE_CONFIG_CONTENT'), + provider === eligible ? 'eligible provider kept its config' : 'off-box provider was stripped', + ).toBe(supportsPublicReviewProvider(provider)); + } + }); + + it('strips a config declaring a non-loopback endpoint, key and all', () => { + const gatewayConfig = JSON.stringify({ + provider: { openrouter: { options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: 'cloud-secret' } } }, + }); + expect(buildPublicReviewCliEnv({ PATH: '/usr/bin', OPENCODE_CONFIG_CONTENT: gatewayConfig })) + .not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + // A config that no longer parses tells us nothing about the endpoint. + expect(buildPublicReviewCliEnv({ OPENCODE_CONFIG_CONTENT: '{not json' })) + .not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + }); +}); + describe('buildCliChildEnv — public-review profile, cloud endpoint', () => { it('strips the Anthropic credential when the base URL is not loopback', () => { const env = buildPublicReviewCliEnv({ diff --git a/server/lib/localProviderRuntime.js b/server/lib/localProviderRuntime.js index 286265c00d..5aa734ec88 100644 --- a/server/lib/localProviderRuntime.js +++ b/server/lib/localProviderRuntime.js @@ -28,9 +28,8 @@ * report their working setup as broken. */ -import { getOpencodeLocalProviderNamespace, isOpencodeCommand } from './providerModels.js'; +import { localRuntimeNamespace, isOpencodeCommand, parseOpencodeConfigContent } from './providerModels.js'; import { opencodeLocalBaseUrl } from './opencodeConfig.js'; -import { isGatewayNamespace } from './providerGateways.js'; import { PORTS } from './ports.js'; import { isLocalInstanceHost, isLocalInstanceEndpoint, localEndpointPort } from './localEndpoint.js'; @@ -266,16 +265,10 @@ function envBaseUrl(kind) { /** The `baseURL` an OpenCode provider config declares for `namespace`, if any. */ function opencodeConfiguredBaseUrl(provider, namespace) { - const stored = provider?.envVars?.OPENCODE_CONFIG_CONTENT; - if (typeof stored !== 'string' || stored === '') return null; - let parsed = null; - try { - parsed = JSON.parse(stored); - } catch { - // A hand-edited config that no longer parses tells us nothing about the - // endpoint; fall through to the provider's own fields. - return null; - } + // A hand-edited config that no longer parses tells us nothing about the + // endpoint; `parseOpencodeConfigContent` answers null and we fall through to + // the provider's own fields. + const parsed = parseOpencodeConfigContent(provider?.envVars?.OPENCODE_CONFIG_CONTENT); const baseUrl = parsed?.provider?.[namespace]?.options?.baseURL; return typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : null; } @@ -296,8 +289,8 @@ export function localRuntimeKind(provider) { if (!provider || typeof provider !== 'object') return null; // Marker-based, NOT command-based: this also resolves `claude-ollama`, which // carries `ollamaBacked` without being an OpenCode provider. - const namespace = getOpencodeLocalProviderNamespace(provider); - if (namespace && !isGatewayNamespace(namespace)) return namespace; + const namespace = localRuntimeNamespace(provider); + if (namespace) return namespace; if (provider?.id === 'slotstream' || /slotstream/i.test(provider?.name || '')) return 'slotstream'; if (Number(localEndpointPort(provider?.endpoint)) === PORTS.SLOTSTREAM) return 'slotstream'; return localBackendForProvider(provider); diff --git a/server/lib/opencodeConfig.js b/server/lib/opencodeConfig.js index efa5371bf6..d73988b7cf 100644 --- a/server/lib/opencodeConfig.js +++ b/server/lib/opencodeConfig.js @@ -18,8 +18,16 @@ * `provider..models` with bare ids. */ -import { getOpencodeLocalProviderNamespace, isOpencodeCommand, prefixOpencodeModel } from './providerModels.js'; +import { + getOpencodeLocalProviderNamespace, + isOpencodeCommand, + prefixOpencodeModel, + parseOpencodeConfigContent, + OPENCODE_PUBLIC_REVIEW_AGENT, +} from './providerModels.js'; import { PROVIDER_GATEWAYS, PROVIDER_GATEWAY_IDS, gatewayById, isGatewayNamespace } from './providerGateways.js'; +import { isPublicReviewNoToolProfile } from './agentExecutionProfiles.js'; +import { isPlainObject } from './objects.js'; import { PORTS } from './ports.js'; const LLAMA_SERVER_BASE_URL = `http://127.0.0.1:${PORTS.LLAMA_SERVER}/v1`; @@ -272,6 +280,80 @@ export function buildOpencodeConfig(models, base = null, providerKey = 'ollama', return config; } +// `'*'` is OpenCode's documented wildcard for a tool map; `deny` is its hard +// permission refusal (as opposed to `ask`, which in a headless `opencode run` +// would simply hang). +// +// At the ROOT the string shorthand is used rather than the per-action object: +// it denies EVERY permission category, including any OpenCode adds later, where +// naming `edit`/`bash`/`webfetch` explicitly would silently leave a new one at +// its default. The shorthand is the same form the shipped provider records +// already store (`{"permission":"allow"}`), so it is known-good. Per-agent +// entries keep the explicit object, which is the shape documented there. +const DENY_ALL_TOOLS = Object.freeze({ '*': false }); +const DENY_ALL_PERMISSIONS = 'deny'; +const DENY_ALL_AGENT_PERMISSIONS = Object.freeze({ edit: 'deny', bash: 'deny', webfetch: 'deny' }); + +const asObject = (value) => (isPlainObject(value) ? value : {}); + +/** + * Harden an OpenCode config for the `no-tool` public-review posture. + * + * OpenCode has no argv equivalent of codex's `--sandbox read-only` or claude's + * `--restricted --tools ''` — its tool posture lives entirely in the config — + * so THIS is the vendor's enforced recipe, and `providerVendors.js` pairs it + * with `run --agent plan`. Four controls, none of them redundant with another: + * + * 1. the root `permission` denies every category, covering any agent the + * config never names; + * 2. every agent gets the same denials plus an emptied tool map — per-agent + * settings OVERRIDE the root block, and OpenCode's built-in `build` and + * `plan` agents carry tool maps of their own, so hardening only the root + * would leave those definitions in force; + * 3. every declared model is marked `tool_call: false`, so OpenCode never + * advertises a tool schema to a local model in the first place; + * 4. MCP servers and plugins are cleared, and session sharing and autoupdate + * are switched off, so nothing reaches the network on the side. + * + * A user's stored config is otherwise PRESERVED (base URLs, models, generation + * settings) — this only overwrites the fields that carry the posture. Mutates + * and returns `config`; callers pass a config they already own. + * + * @param {object} config + * @returns {object} the same config, hardened + */ +function hardenOpencodeConfigForNoTool(config) { + if (!isPlainObject(config)) return config; + config.permission = DENY_ALL_PERMISSIONS; + config.tools = { ...DENY_ALL_TOOLS }; + const agents = asObject(config.agent); + const agentNames = new Set([...Object.keys(agents), 'build', OPENCODE_PUBLIC_REVIEW_AGENT]); + // `buildAgentGeneration` writes the stage's temperature / topP / thinking / + // reasoningEffort onto `agent.build` — OpenCode's default agent — but this + // profile runs `--agent plan`. Seed the review agent from `build` so the + // stage's configured effort actually reaches the model that runs, instead of + // silently falling back to the backend default. An explicit `agent.plan` in + // the user's own config still wins (it is spread after). + const generationSource = asObject(agents.build); + config.agent = Object.fromEntries([...agentNames].map((name) => [name, { + ...(name === OPENCODE_PUBLIC_REVIEW_AGENT ? generationSource : {}), + ...asObject(agents[name]), + tools: { ...DENY_ALL_TOOLS }, + permission: { ...DENY_ALL_AGENT_PERMISSIONS }, + }])); + for (const entry of Object.values(asObject(config.provider))) { + const models = asObject(entry?.models); + for (const [id, model] of Object.entries(models)) { + models[id] = { ...asObject(model), tool_call: false }; + } + } + config.mcp = {}; + config.plugin = []; + config.share = 'disabled'; + config.autoupdate = false; + return config; +} + /** * Build the `OPENCODE_CONFIG_CONTENT` env var value (JSON string) declaring the * given models under the selected local provider, merging into `base` when @@ -304,9 +386,12 @@ export function buildOpencodeConfigContent(models, base = null, providerKey = 'o * * @param {{command?:string, ollamaBacked?:boolean, mtplxBacked?:boolean, llamaBacked?:boolean, vllmBacked?:boolean, sglangBacked?:boolean, gatewayBacked?:string, orcarouterBacked?:boolean, models?:string[], defaultModel?:string|null, apiKey?:string, orcarouterApiKey?:string, envVars?:object}} provider * @param {string|null|undefined} model - the model being run (may differ from defaultModel) + * @param {{safetyProfile?:string|null}} [options] - a `no-tool` public-review + * profile applies `hardenOpencodeConfigForNoTool`, which IS OpenCode's + * enforced tool-free recipe (it has no argv equivalent). * @returns {{OPENCODE_CONFIG_CONTENT?: string}} env vars to merge */ -export function buildOpencodeEnvVars(provider, model) { +export function buildOpencodeEnvVars(provider, model, { safetyProfile = null } = {}) { const providerKey = getOpencodeLocalProviderNamespace(provider); if (!isOpencodeCommand(provider?.command)) { return {}; @@ -314,15 +399,7 @@ export function buildOpencodeEnvVars(provider, model) { // Parse the provider's stored config as the base so any user customization // (custom baseURL, permission, hand-maintained models) is preserved rather // than clobbered by the hardcoded localhost default. - const stored = provider?.envVars?.OPENCODE_CONFIG_CONTENT; - let base = null; - if (typeof stored === 'string' && stored.length > 0) { - try { - base = JSON.parse(stored); - } catch { - base = null; // unparseable stored config → fall back to the canonical default - } - } + const base = parseOpencodeConfigContent(provider?.envVars?.OPENCODE_CONFIG_CONTENT); // A record with NO namespace and NO stored config is a hand-made plain // `opencode` provider: it has always run against the user's own // `~/.config/opencode`, and `OPENCODE_CONFIG_CONTENT` REPLACES that file @@ -377,6 +454,10 @@ export function buildOpencodeEnvVars(provider, model) { apiKey, }; } + // LAST, so it overrides every field composed above — including a stored + // `permission: "allow"` and the `tool_call: true` the models map is built + // with. This is the enforcement boundary, not a default. + if (isPublicReviewNoToolProfile(safetyProfile)) hardenOpencodeConfigForNoTool(config); return { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), ...(apiKey && gateway ? { [gateway.apiKeyEnv]: apiKey } : {}), diff --git a/server/lib/opencodeConfig.test.js b/server/lib/opencodeConfig.test.js index e0ce01dec0..3ebf351bc6 100644 --- a/server/lib/opencodeConfig.test.js +++ b/server/lib/opencodeConfig.test.js @@ -8,6 +8,7 @@ import { toBareModelIds, } from './opencodeConfig.js'; import { LOCAL_RUNTIMES } from './localProviderRuntime.js'; +import { PUBLIC_REVIEW_GATE_EXECUTION_PROFILE } from './agentExecutionProfiles.js'; describe('toBareModelIds', () => { it('strips the ollama/ namespace, drops empties, and dedupes', () => { @@ -465,3 +466,78 @@ describe('small-model pin', () => { expect(config.small_model).toBe('ollama/qwen3'); }); }); + +describe('hardenOpencodeConfigForNoTool', () => { + // OpenCode has no read-only argv flag — this IS the vendor's enforced recipe + // for the pr-reviewer eligibility gate, so it has to override a user's stored + // config rather than merely default around it. + const harden = (provider, model) => JSON.parse( + buildOpencodeEnvVars(provider, model, { safetyProfile: PUBLIC_REVIEW_GATE_EXECUTION_PROFILE }) + .OPENCODE_CONFIG_CONTENT, + ); + + it('overrides a stored allow-everything posture', () => { + const stored = JSON.stringify({ + permission: 'allow', + mcp: { fetch: { type: 'local', command: ['mcp-fetch'] } }, + plugin: ['some-plugin'], + agent: { build: { temperature: 0.2, tools: { bash: true } } }, + }); + const config = harden( + { command: 'opencode', ollamaBacked: true, temperature: 0.2, models: ['gemma3:27b'], envVars: { OPENCODE_CONFIG_CONTENT: stored } }, + 'gemma3:27b', + ); + + // The string shorthand denies EVERY permission category, including any + // OpenCode adds later; naming three would leave a new one at its default. + expect(config.permission).toBe('deny'); + expect(config.tools).toEqual({ '*': false }); + // Per-agent settings override the root block, and OpenCode's built-in + // `build`/`plan` agents carry tool maps of their own. + expect(config.agent.build.tools).toEqual({ '*': false }); + expect(config.agent.build.permission).toEqual({ edit: 'deny', bash: 'deny', webfetch: 'deny' }); + // Generation settings are configuration, not posture — they survive. + expect(config.agent.build.temperature).toBe(0.2); + // The agent the spawner actually selects is hardened even when the stored + // config never mentioned it. + expect(config.agent.plan.tools['*']).toBe(false); + expect(config.provider.ollama.models['gemma3:27b'].tool_call).toBe(false); + expect(config.mcp).toEqual({}); + expect(config.plugin).toEqual([]); + expect(config.share).toBe('disabled'); + expect(config.autoupdate).toBe(false); + }); + + // The stage's effort control writes to `agent.build` (OpenCode's default + // agent), but this profile runs `--agent plan` — so an uncopied level would + // silently leave the gate on the backend default. + it('carries the stage generation settings onto the agent it actually runs', () => { + const config = harden( + { command: 'opencode', ollamaBacked: true, temperature: 0.3, effort: 'high', models: ['gemma3:27b'] }, + 'gemma3:27b', + ); + expect(config.agent.plan.reasoningEffort).toBe('high'); + expect(config.agent.plan.temperature).toBe(0.3); + }); + + it('lets a user-declared plan agent keep its own generation settings', () => { + const stored = JSON.stringify({ agent: { plan: { reasoningEffort: 'low' } } }); + const config = harden( + { + command: 'opencode', + ollamaBacked: true, + effort: 'high', + models: ['gemma3:27b'], + envVars: { OPENCODE_CONFIG_CONTENT: stored }, + }, + 'gemma3:27b', + ); + expect(config.agent.plan.reasoningEffort).toBe('low'); + }); + + it('leaves the endpoint and the auxiliary-model pin intact', () => { + const config = harden({ command: 'opencode', ollamaBacked: true, models: ['gemma3:27b'] }, 'gemma3:27b'); + expect(config.provider.ollama.options.baseURL).toBe('http://localhost:11434/v1'); + expect(config.small_model).toBe('ollama/gemma3:27b'); + }); +}); diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index 59190bdc7f..59ef839a01 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -4,6 +4,8 @@ */ import { gatewayIdForProvider, isGatewayNamespace } from './providerGateways.js'; +import { isLocalInstanceEndpoint } from './localEndpoint.js'; +import { isPlainObject } from './objects.js'; export const CODEX_CONFIGURED_DEFAULT = 'codex-configured-default'; export const ANTIGRAVITY_CONFIGURED_DEFAULT = 'antigravity-configured-default'; @@ -601,6 +603,20 @@ export function isOpencodeCommand(command) { return commandBasename(command) === 'opencode'; } +/** + * The OpenCode agent a `no-tool` public-review stage runs as (`opencode run + * --agent …`). `plan` is OpenCode's OWN built-in read-only agent, chosen over a + * PortOS-declared one so the stage never depends on a custom agent definition + * being accepted by whatever OpenCode version is installed. + * + * It is a belt, not the braces: `hardenOpencodeConfigForNoTool` + * (`opencodeConfig.js`) still empties this agent's tool map, so a future + * OpenCode that widens `plan` cannot widen the stage. Lives HERE rather than + * beside that function because `providerVendors.js` — which passes the flag — + * must not import `opencodeConfig.js`; doing so pulls `ports.js` in behind it + * and breaks a suite that partially mocks it. + */ +export const OPENCODE_PUBLIC_REVIEW_AGENT = 'plan'; /** * OpenCode addresses models as `provider/model` (e.g. `ollama/qwen2.5:7b`). The @@ -659,6 +675,107 @@ export function getOpencodeLocalProviderNamespace(provider) { return gatewayIdForProvider(provider); } +/** + * The namespace above, but only when it names a LOCAL daemon — a hosted gateway + * (`providerGateways.js`) is an OpenCode namespace and a remote API, so every + * consumer asking "is there a daemon on this machine behind this provider?" + * has to exclude it. + * + * That exclusion was hand-written at three sites (`isLocalBackedClaude` in + * `cliChildEnv.js`, `localRuntimeKind` in `localProviderRuntime.js`, and the + * OpenCode public-review recipe in `providerVendors.js`), which is one more + * than the `orcarouterBacked` → `providerGateways.js` sweep was meant to leave + * behind — so the composed predicate lives here, beside the namespace resolver + * it wraps. + * + * @param {object|null|undefined} provider + * @returns {'ollama'|'mtplx'|'llama'|'vllm'|'sglang'|null} + */ +export function localRuntimeNamespace(provider) { + const namespace = getOpencodeLocalProviderNamespace(provider); + return namespace && !isGatewayNamespace(namespace) ? namespace : null; +} + +/** + * Parse a stored `OPENCODE_CONFIG_CONTENT` value, or null when it is absent or + * no longer valid JSON (a hand-edited config tells us nothing, so every caller + * falls back to its own default rather than guessing). + * + * Shared because four call sites read the same variable and must agree on what + * counts as unusable: `opencodeConfig.js`'s merge base, `localProviderRuntime.js`'s + * endpoint lookup, `cliChildEnv.js`'s public-review allowlist, and the locality + * predicate below. + * + * @param {unknown} value + * @returns {object|null} + */ +export function parseOpencodeConfigContent(value) { + if (typeof value !== 'string' || value === '') return null; + try { + const parsed = JSON.parse(value); + return isPlainObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Every provider endpoint an OpenCode config object declares is on this machine. + * + * This is the ONE rule two public-review sites must not disagree about, which is + * why it lives here rather than being spelled out at each of them: + * + * - `providerVendors.js` decides whether an OpenCode wrapper may run the + * tool-free gate at all; + * - `cliChildEnv.js` decides whether `OPENCODE_CONFIG_CONTENT` survives the + * public-review env allowlist. + * + * If the first says yes and the second says no, the stage still spawns — but + * with the hardened config stripped, so OpenCode falls back to reading the + * user's own `~/.config/opencode`, tools and MCP servers and all, while every + * signal still reports an enforced tool-free gate. A remote `baseURL` inside an + * otherwise `ollamaBacked` provider is exactly that shape, and the marker alone + * cannot see it. + * + * `requireDeclaration` is the one place the two callers legitimately differ, and + * it is about PROVENANCE, not locality: + * + * - A provider RECORD storing a config with no `provider` entry has relocated + * nothing — the builder still adds the canonical per-namespace entry, all of + * which are loopback — so that is vacuously local (the default). + * - The env allowlist sees a bare string that may be ambient rather than the + * config PortOS just built for this spawn. A value declaring no endpoint is + * not one we produced for an eligible provider, so it passes + * `requireDeclaration: true` and is dropped with every other inherited var. + * + * The locality rule itself — every declared endpoint is on this machine — is + * identical for both, which is what keeps eligibility and the allowlist in step. + * Absent config (`null`) is never local: there is nothing to keep. + * + * @param {object|null|undefined} config - a parsed OpenCode config + * @param {{requireDeclaration?: boolean}} [options] + * @returns {boolean} + */ +export function opencodeConfigIsLocalOnly(config, { requireDeclaration = false } = {}) { + if (!isPlainObject(config)) return false; + const declared = isPlainObject(config.provider) ? Object.values(config.provider) : []; + if (declared.length === 0) return !requireDeclaration; + return declared.every((entry) => isLocalInstanceEndpoint(entry?.options?.baseURL)); +} + +/** + * The same question asked of a PROVIDER RECORD, before its config has been + * built. A record storing no config — or an unparseable one, which the builder + * discards too — runs against those same canonical defaults. + * + * @param {object|null|undefined} provider + * @returns {boolean} + */ +export function opencodeProviderIsLocalOnly(provider) { + const stored = parseOpencodeConfigContent(provider?.envVars?.OPENCODE_CONFIG_CONTENT); + return stored === null || opencodeConfigIsLocalOnly(stored); +} + /** * Claude Code on AWS Bedrock wants region-prefixed model ids * (`global.anthropic.claude-opus-5`, `us.anthropic.claude-opus-4-1-...-v1:0`). diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index 913324f18f..2ac353ae67 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -51,7 +51,10 @@ * cursor.js / codex.js): imports only the vendor files above, providerModels.js, * and node builtins, so it stays importable from the standalone autofixer * process (which pulls in cliProviderArgs.js and must NOT drag in the AI - * toolkit / data layer). + * toolkit / data layer). That is load-bearing, not cosmetic: reaching into + * opencodeConfig.js for the OpenCode public-review agent name pulled ports.js + * in behind it and broke a suite that partially mocks it — which is why that + * constant lives in providerModels.js beside its siblings. */ import { @@ -68,6 +71,9 @@ import { buildEffortArgs, isOpencodeCommand, prefixOpencodeModel, + localRuntimeNamespace, + opencodeProviderIsLocalOnly, + OPENCODE_PUBLIC_REVIEW_AGENT, applyLeanClaudeArgs, } from './providerModels.js'; import { @@ -333,6 +339,57 @@ function opencodeCliArgs(baseArgs, { model, provider }) { return args; } +/** + * An OpenCode wrapper this install can actually run the tool-free gate on. + * Three conditions, each closing a different way the stage would otherwise be + * offered and then fail — or, worse, appear to succeed: + * + * - **an Ollama namespace.** `validatePublicReviewModel` can only probe an + * Ollama catalog for the authoritative "no `tools` capability" answer, and + * rejects every other local runtime with `public-review-runtime-unsupported`. + * Offering MTPLX / llama.cpp / vLLM / SGLang here would put a permanently + * blocking choice in the picker. (A hosted gateway is excluded by + * `localRuntimeNamespace` before that.) + * - **only local endpoints.** The enforcement rides in + * `OPENCODE_CONFIG_CONTENT`, which `cliChildEnv.js` keeps through the + * public-review env allowlist under the SAME `opencodeConfigIsLocalOnly` + * rule. A provider carrying `ollamaBacked` but a relocated off-box + * `baseURL` would pass a marker-only check here, then have its hardened + * config stripped there — and OpenCode falls back to the user's own + * `~/.config/opencode`, tools and MCP servers intact, while the gate still + * reports as enforced. Sharing one predicate is what makes that + * unrepresentable. + * - **a spawnable binary**, as for every other vendor. + */ +const isLocalOpencodeProvider = (provider) => isDirectBinaryProvider(provider) + && isOpencodeCommand(provider?.command) + && localRuntimeNamespace(provider) === 'ollama' + && opencodeProviderIsLocalOnly(provider); + +/** + * OpenCode is the natural harness for a local Ollama model — but unlike every + * other vendor here it has NO read-only argv flag: its tool posture, permission + * block and per-model `tool_call` advertisement all live in the config. So this + * recipe is only half the enforcement; the other half is + * `hardenOpencodeConfigForNoTool` in `opencodeConfig.js`, which the same + * `safetyProfile` applies to `OPENCODE_CONFIG_CONTENT`. + * + * The argv is the ordinary headless one seeded with the read-only agent (the + * shape grok's recipe uses), so `run`/`-m` namespacing cannot drift from the + * normal path. Provider args are deliberately not forwarded: a saved + * `--agent build` would select the tool-enabled agent. There is no effort flag + * to add — `opencode run` has none; the level rides + * `agent..reasoningEffort` in the config, which the harden step copies + * onto this agent (see `hardenOpencodeConfigForNoTool`). + */ +function opencodePublicReviewSpawnArgs(provider, { effectiveModel } = {}) { + return { + command: provider?.command || 'opencode', + args: opencodeCliArgs(['--agent', OPENCODE_PUBLIC_REVIEW_AGENT], { model: effectiveModel, provider }), + stdinMode: 'prompt', + }; +} + const OPENCODE = { id: 'opencode', idFragment: 'opencode', @@ -343,6 +400,15 @@ const OPENCODE = { // matchCliProvider is absent). cliArgs: opencodeCliArgs, spawnArgs: defaultSpawnArgs(opencodeCliArgs, 'opencode'), + publicReview: { + [PUBLIC_REVIEW_NO_TOOL_POSTURE]: { + spawnArgs: opencodePublicReviewSpawnArgs, + matchProvider: isLocalOpencodeProvider, + }, + // No `sandboxed-actions` recipe: OpenCode ships no OS sandbox of its own, + // so it stays in the open-to-every-binary tier where the disposable + // worktree is the isolation — see `supportsPublicReviewPosture`. + }, }; // ─── grok ─────────────────────────────────────────────────────────────────── diff --git a/server/lib/providerVendors.publicReview.test.js b/server/lib/providerVendors.publicReview.test.js index a4bc095c06..010dd2f81d 100644 --- a/server/lib/providerVendors.publicReview.test.js +++ b/server/lib/providerVendors.publicReview.test.js @@ -29,6 +29,13 @@ const localClaude = { const codex = { id: 'codex-cli', type: 'cli', command: 'codex', models: ['gpt-5.6'] }; const antigravity = { id: 'antigravity-cli', type: 'cli', command: 'agy', models: ['gemini-3.6-flash-high'] }; const grok = { id: 'grok-cli', type: 'cli', command: 'grok' }; +const opencodeOllama = { + id: 'opencode-ollama-tui', + type: 'tui', + command: 'opencode', + ollamaBacked: true, + models: ['gemma3:27b'], +}; describe('public-review provider postures', () => { // The whole point of the posture table: eligibility is DECLARED per vendor, @@ -103,12 +110,89 @@ describe('public-review provider postures', () => { expect(config.args).not.toContain('--full-auto'); }); + // OpenCode is how a user actually drives a local Ollama model, so the gate + // has to be configurable on it — otherwise the only local option is a Claude + // binary pointed at an Anthropic-compatible shim. + it('offers the no-tool gate on a local-backed OpenCode wrapper', () => { + expect(publicReviewPosturesForProvider(opencodeOllama)) + .toEqual([PUBLIC_REVIEW_NO_TOOL_POSTURE, PUBLIC_REVIEW_ACTIONS_POSTURE]); + // Enforced for the gate (config recipe), worktree-only for the actions + // stage — OpenCode ships no OS sandbox of its own. + expect(enforcedPublicReviewPosturesForProvider(opencodeOllama)).toEqual([PUBLIC_REVIEW_NO_TOOL_POSTURE]); + }); + + // The gate's enforcement rides in OPENCODE_CONFIG_CONTENT, which the + // public-review env allowlist keeps only for a config declaring on-box + // endpoints — so a wrapper fronting a hosted gateway must not be offered a + // stage it cannot authenticate. + it('withholds the gate from an OpenCode wrapper with no local backend', () => { + const gateway = { id: 'opencode-openrouter-tui', type: 'tui', command: 'opencode', gatewayBacked: 'openrouter' }; + expect(publicReviewPosturesForProvider(gateway)).toEqual([PUBLIC_REVIEW_ACTIONS_POSTURE]); + expect(supportsPublicReviewProvider(gateway)).toBe(false); + }); + + // The marker alone is not enough, and this is the security case, not a tidy-up: + // `cliChildEnv.js` strips a config whose endpoint is off-box, and a stripped + // config does not harden the child — OpenCode falls back to reading the user's + // own ~/.config/opencode with its tools, plugins and MCP servers intact, while + // the stage still reports an enforced tool-free gate. Eligibility must use the + // same locality rule the allowlist does. + it('withholds the gate from an ollama-marked wrapper pointed off-box', () => { + const relocated = { + ...opencodeOllama, + envVars: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + provider: { ollama: { options: { baseURL: 'http://192.0.2.10:11434/v1' } } }, + }), + }, + }; + expect(supportsPublicReviewProvider(relocated)).toBe(false); + // A config that keeps the daemon on this machine is still eligible. + expect(supportsPublicReviewProvider({ + ...opencodeOllama, + envVars: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + provider: { ollama: { options: { baseURL: 'http://127.0.0.1:11434/v1' } } }, + }), + }, + })).toBe(true); + }); + + // Every other local runtime is rejected at spawn time by + // `validatePublicReviewModel` (`public-review-runtime-unsupported` — only an + // Ollama catalog can be probed for the authoritative no-tools answer), so + // offering them would put a permanently blocking choice in the picker. + it('withholds the gate from local runtimes the model check cannot validate', () => { + for (const marker of ['llamaBacked', 'vllmBacked', 'sglangBacked', 'mtplxBacked']) { + const provider = { id: `opencode-${marker}`, type: 'tui', command: 'opencode', [marker]: true }; + expect(supportsPublicReviewProvider(provider), marker).toBe(false); + expect(publicReviewPosturesForProvider(provider), marker).toEqual([PUBLIC_REVIEW_ACTIONS_POSTURE]); + } + }); + + it('builds the OpenCode gate on its read-only agent with a namespaced model and no provider args', () => { + const config = buildVendorSpawnConfig({ ...opencodeOllama, args: ['--agent', 'build'] }, { + effectiveModel: 'gemma3:27b', + effort: 'low', + safetyProfile: PUBLIC_REVIEW_GATE_EXECUTION_PROFILE, + }); + expect(config).toEqual({ + command: 'opencode', + args: ['run', '--agent', 'plan', '-m', 'ollama/gemma3:27b'], + stdinMode: 'prompt', + }); + // A saved `--agent build` would select the tool-enabled agent; `--effort` is + // not an `opencode run` flag at all. + expect(config.args).not.toContain('build'); + expect(config.args).not.toContain('--effort'); + }); + it('fails closed for the no-tool gate on transports and vendors with no maintained recipe', () => { // An HTTP api provider has no binary to spawn and no enforced argv. expect(publicReviewPosturesForProvider({ ...codex, type: 'api' })).toEqual([]); - // opencode/kimi/cursor have no maintained no-tool recipe, so they can run - // only the actions stage. An unknown command must never inherit claude's - // always-true fallback row for the gate either. + // A namespace-less opencode record, and kimi/cursor, have no maintained + // no-tool recipe, so they can run only the actions stage. An unknown command + // must never inherit claude's always-true fallback row for the gate either. expect(publicReviewPosturesForProvider({ id: 'opencode-tui', type: 'tui', command: 'opencode' })).toEqual([PUBLIC_REVIEW_ACTIONS_POSTURE]); expect(publicReviewPosturesForProvider({ id: 'custom', type: 'cli', command: 'custom-agent' })).toEqual([PUBLIC_REVIEW_ACTIONS_POSTURE]); expect(supportsPublicReviewProvider({ id: 'kimi', type: 'cli', command: 'kimi' })).toBe(false);