From 8fe3fe42abf2dc98531f9ddd492493920d56943c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 19:29:53 +0000 Subject: [PATCH 1/2] let the OpenCode harness run the pr-reviewer eligibility gate on a local model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 offered only Claude/Codex/Antigravity/Grok, so the one way to run a local Ollama model through it was a Claude binary pointed at an Anthropic-compatible shim — which also emits `[claude-code:unrecognized_model]` for every Ollama model id it is handed. OpenCode is the natural harness for Ollama and was ineligible. It had no vendor recipe because, 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 `OPENCODE_CONFIG_CONTENT`. So the recipe is two halves — `run --agent plan` from the vendor row, and `hardenOpencodeConfigForNoTool` applied to that config under the same `safetyProfile`, which denies every tool at the root and on every agent, marks each declared model `tool_call: false`, and clears MCP/plugins/share/autoupdate. The public-review env allowlist stripped the config outright, which did not harden the child — it pointed it back at the user's own ~/.config/opencode. It now survives on the same loopback terms as the local Anthropic credential, so a gateway config (which carries a cloud API key) still does not; the recipe is scoped to local namespaces to match. Existing spawn-time validation is unchanged and still applies: an Ollama-backed OpenCode wrapper must name an installed model whose /api/show reports no `tools` capability. Also collapses three copies of "local namespace, not a gateway" into `localRuntimeNamespace`, three copies of the OPENCODE_CONFIG_CONTENT parse into `parseOpencodeConfigContent`, and two hand-rolled loopback tests into the existing `isLocalInstanceEndpoint`. --- server/lib/README.md | 4 +- server/lib/cliChildEnv.js | 63 +++++++--- server/lib/cliChildEnv.test.js | 43 +++++++ server/lib/localProviderRuntime.js | 23 ++-- server/lib/opencodeConfig.js | 108 ++++++++++++++++-- server/lib/opencodeConfig.test.js | 47 ++++++++ server/lib/providerModels.js | 35 ++++++ server/lib/providerVendors.js | 54 ++++++++- .../lib/providerVendors.publicReview.test.js | 51 ++++++++- 9 files changed, 377 insertions(+), 51 deletions(-) diff --git a/server/lib/README.md b/server/lib/README.md index 1ea10ade91..65147503d2 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`, `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`). `parseOpencodeConfigContent` is the shared "is this stored config usable?" read, so the merge base here, the endpoint lookup in `localProviderRuntime.js`, and the public-review env allowlist in `cliChildEnv.js` cannot disagree. | | `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..2c2947b15e 100644 --- a/server/lib/cliChildEnv.js +++ b/server/lib/cliChildEnv.js @@ -41,9 +41,10 @@ */ import { withSpawnCwdEnv } from './spawnCwd.js'; -import { buildOpencodeEnvVars } from './opencodeConfig.js'; -import { getOpencodeLocalProviderNamespace, isClaudeCommand } from './providerModels.js'; -import { isGatewayNamespace } from './providerGateways.js'; +import { buildOpencodeEnvVars, parseOpencodeConfigContent } from './opencodeConfig.js'; +import { localRuntimeNamespace, isClaudeCommand } from './providerModels.js'; +import { isLocalInstanceEndpoint } from './localEndpoint.js'; +import { isPlainObject } from './objects.js'; import { agentGuardEnv } from './agentGuard/index.js'; import { buildSafeCliBaseEnv } from './processEnv.js'; import { isPublicReviewNoToolProfile, isPublicReviewRestrictedProfile } from './agentExecutionProfiles.js'; @@ -70,8 +71,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 +111,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 +124,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 +153,48 @@ 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; + const declared = parseOpencodeConfigContent(raw)?.provider; + const entries = isPlainObject(declared) ? Object.values(declared) : []; + const allLocal = entries.length > 0 + && entries.every((entry) => isLocalInstanceEndpoint(entry?.options?.baseURL)); + return allLocal ? { 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 +260,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..509ef3e613 100644 --- a/server/lib/cliChildEnv.test.js +++ b/server/lib/cliChildEnv.test.js @@ -178,6 +178,49 @@ 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'); + }); + + 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..9b5373ae37 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 { opencodeLocalBaseUrl } from './opencodeConfig.js'; -import { isGatewayNamespace } from './providerGateways.js'; +import { localRuntimeNamespace, isOpencodeCommand } from './providerModels.js'; +import { opencodeLocalBaseUrl, parseOpencodeConfigContent } from './opencodeConfig.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..d49e64de39 100644 --- a/server/lib/opencodeConfig.js +++ b/server/lib/opencodeConfig.js @@ -18,10 +18,39 @@ * `provider..models` with bare ids. */ -import { getOpencodeLocalProviderNamespace, isOpencodeCommand, prefixOpencodeModel } from './providerModels.js'; +import { + getOpencodeLocalProviderNamespace, + isOpencodeCommand, + prefixOpencodeModel, + 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'; +/** + * 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 three call sites read the same variable and must agree on what + * counts as unusable: the merge base below, `localProviderRuntime.js`'s endpoint + * lookup, and `cliChildEnv.js`'s public-review allowlist. + * + * @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; + } +} + const LLAMA_SERVER_BASE_URL = `http://127.0.0.1:${PORTS.LLAMA_SERVER}/v1`; const VLLM_QWEN_BASE_URL = `http://127.0.0.1:${PORTS.VLLM_QWEN}/v1`; const SGLANG_QWEN_BASE_URL = `http://127.0.0.1:${PORTS.SGLANG_QWEN}/v1`; @@ -272,6 +301,64 @@ 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). Copied on use — both land in a mutable config. +const DENY_ALL_TOOLS = Object.freeze({ '*': false }); +const DENY_ALL_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` block denies edit/bash/webfetch, 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]); + config.agent = Object.fromEntries([...agentNames].map((name) => [name, { + ...asObject(agents[name]), + tools: { ...DENY_ALL_TOOLS }, + permission: { ...DENY_ALL_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 +391,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 +404,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 +459,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..eb004680e2 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,49 @@ 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', + ); + + expect(config.permission).toEqual({ edit: 'deny', bash: 'deny', webfetch: '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); + }); + + 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..4a376a7999 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -601,6 +601,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 +673,27 @@ 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; +} + /** * 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..8fdfabbe08 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,8 @@ import { buildEffortArgs, isOpencodeCommand, prefixOpencodeModel, + localRuntimeNamespace, + OPENCODE_PUBLIC_REVIEW_AGENT, applyLeanClaudeArgs, } from './providerModels.js'; import { @@ -333,6 +338,44 @@ function opencodeCliArgs(baseArgs, { model, provider }) { return args; } +/** + * An OpenCode wrapper fronting a LOCAL OpenAI-compatible daemon (Ollama, + * MTPLX, llama.cpp, vLLM, SGLang) — not a hosted gateway. + * + * The gate stage's enforcement rides in `OPENCODE_CONFIG_CONTENT`, and + * `cliChildEnv.js` keeps that variable through the public-review env allowlist + * only when every endpoint it declares is on this machine (a gateway config + * carries that gateway's cloud API key, which stays stripped). So a + * gateway-backed wrapper would be offered a stage it could never authenticate — + * scope the recipe to the namespaces that can actually run it instead. + */ +const isLocalOpencodeProvider = (provider) => isDirectBinaryProvider(provider) + && isOpencodeCommand(provider?.command) + && Boolean(localRuntimeNamespace(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, and a level rides the config's + * `agent..reasoningEffort` exactly as it does for an ordinary run. + */ +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 +386,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..211f330e3c 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,50 @@ 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 loopback config — 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); + }); + + 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); From d1433e5920626ee94e6f47457a069cdd987220c8 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 19:45:11 +0000 Subject: [PATCH 2/2] close the OpenCode gate's marker-only eligibility hole, and route effort to the agent that runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the previous commit: - An `ollamaBacked` provider whose stored config relocated the daemon off-box passed the marker-only eligibility check, but `cliChildEnv` then stripped its config — and a stripped config does not harden the child, it points OpenCode at the user's own ~/.config/opencode with tools, plugins and MCP servers intact, while the stage still reported an enforced tool-free gate. Both sides now gate on one `opencodeConfigIsLocalOnly` rule so the disagreement is unrepresentable. - The recipe offered MTPLX/llama.cpp/vLLM/SGLang wrappers that `validatePublicReviewModel` rejects unconditionally (only an Ollama catalog can be probed for the authoritative no-tools answer), i.e. a permanently blocking choice in the picker. Scoped to Ollama. - `buildAgentGeneration` writes reasoningEffort onto `agent.build`, but the stage runs `--agent plan`, so the configured thinking effort never reached the model. The harden step now seeds the review agent from `build`; an explicit user-declared `agent.plan` still wins. - Root `permission` is the string shorthand `deny` rather than three named categories, so a category OpenCode adds later cannot default to allowed. `parseOpencodeConfigContent` moves to providerModels.js, the leaf both the vendor row and the config builder can reach. --- server/lib/README.md | 4 +- server/lib/cliChildEnv.js | 21 +++-- server/lib/cliChildEnv.test.js | 31 +++++++ server/lib/localProviderRuntime.js | 4 +- server/lib/opencodeConfig.js | 51 ++++++------ server/lib/opencodeConfig.test.js | 31 ++++++- server/lib/providerModels.js | 82 +++++++++++++++++++ server/lib/providerVendors.js | 36 +++++--- .../lib/providerVendors.publicReview.test.js | 45 +++++++++- 9 files changed, 250 insertions(+), 55 deletions(-) diff --git a/server/lib/README.md b/server/lib/README.md index 65147503d2..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, `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`, `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. 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`). `parseOpencodeConfigContent` is the shared "is this stored config usable?" read, so the merge base here, the endpoint lookup in `localProviderRuntime.js`, and the public-review env allowlist in `cliChildEnv.js` cannot disagree. | +| `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 2c2947b15e..b5694cc58b 100644 --- a/server/lib/cliChildEnv.js +++ b/server/lib/cliChildEnv.js @@ -41,10 +41,14 @@ */ import { withSpawnCwdEnv } from './spawnCwd.js'; -import { buildOpencodeEnvVars, parseOpencodeConfigContent } from './opencodeConfig.js'; -import { localRuntimeNamespace, isClaudeCommand } from './providerModels.js'; +import { buildOpencodeEnvVars } from './opencodeConfig.js'; +import { + localRuntimeNamespace, + isClaudeCommand, + parseOpencodeConfigContent, + opencodeConfigIsLocalOnly, +} from './providerModels.js'; import { isLocalInstanceEndpoint } from './localEndpoint.js'; -import { isPlainObject } from './objects.js'; import { agentGuardEnv } from './agentGuard/index.js'; import { buildSafeCliBaseEnv } from './processEnv.js'; import { isPublicReviewNoToolProfile, isPublicReviewRestrictedProfile } from './agentExecutionProfiles.js'; @@ -181,11 +185,12 @@ function localAnthropicCredentialEnv(env) { */ function opencodeLocalConfigEnv(env) { const raw = env?.OPENCODE_CONFIG_CONTENT; - const declared = parseOpencodeConfigContent(raw)?.provider; - const entries = isPlainObject(declared) ? Object.values(declared) : []; - const allLocal = entries.length > 0 - && entries.every((entry) => isLocalInstanceEndpoint(entry?.options?.baseURL)); - return allLocal ? { OPENCODE_CONFIG_CONTENT: raw } : {}; + // `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) { diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js index 509ef3e613..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'; @@ -209,6 +210,36 @@ describe('buildCliChildEnv — public-review profile, OpenCode harness', () => { 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' } } }, diff --git a/server/lib/localProviderRuntime.js b/server/lib/localProviderRuntime.js index 9b5373ae37..5aa734ec88 100644 --- a/server/lib/localProviderRuntime.js +++ b/server/lib/localProviderRuntime.js @@ -28,8 +28,8 @@ * report their working setup as broken. */ -import { localRuntimeNamespace, isOpencodeCommand } from './providerModels.js'; -import { opencodeLocalBaseUrl, parseOpencodeConfigContent } from './opencodeConfig.js'; +import { localRuntimeNamespace, isOpencodeCommand, parseOpencodeConfigContent } from './providerModels.js'; +import { opencodeLocalBaseUrl } from './opencodeConfig.js'; import { PORTS } from './ports.js'; import { isLocalInstanceHost, isLocalInstanceEndpoint, localEndpointPort } from './localEndpoint.js'; diff --git a/server/lib/opencodeConfig.js b/server/lib/opencodeConfig.js index d49e64de39..d73988b7cf 100644 --- a/server/lib/opencodeConfig.js +++ b/server/lib/opencodeConfig.js @@ -22,6 +22,7 @@ import { getOpencodeLocalProviderNamespace, isOpencodeCommand, prefixOpencodeModel, + parseOpencodeConfigContent, OPENCODE_PUBLIC_REVIEW_AGENT, } from './providerModels.js'; import { PROVIDER_GATEWAYS, PROVIDER_GATEWAY_IDS, gatewayById, isGatewayNamespace } from './providerGateways.js'; @@ -29,28 +30,6 @@ import { isPublicReviewNoToolProfile } from './agentExecutionProfiles.js'; import { isPlainObject } from './objects.js'; import { PORTS } from './ports.js'; -/** - * 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 three call sites read the same variable and must agree on what - * counts as unusable: the merge base below, `localProviderRuntime.js`'s endpoint - * lookup, and `cliChildEnv.js`'s public-review allowlist. - * - * @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; - } -} - const LLAMA_SERVER_BASE_URL = `http://127.0.0.1:${PORTS.LLAMA_SERVER}/v1`; const VLLM_QWEN_BASE_URL = `http://127.0.0.1:${PORTS.VLLM_QWEN}/v1`; const SGLANG_QWEN_BASE_URL = `http://127.0.0.1:${PORTS.SGLANG_QWEN}/v1`; @@ -303,9 +282,17 @@ export function buildOpencodeConfig(models, base = null, providerKey = 'ollama', // `'*'` 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). Copied on use — both land in a mutable config. +// 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 = Object.freeze({ edit: 'deny', bash: 'deny', webfetch: 'deny' }); +const DENY_ALL_PERMISSIONS = 'deny'; +const DENY_ALL_AGENT_PERMISSIONS = Object.freeze({ edit: 'deny', bash: 'deny', webfetch: 'deny' }); const asObject = (value) => (isPlainObject(value) ? value : {}); @@ -317,8 +304,8 @@ const asObject = (value) => (isPlainObject(value) ? value : {}); * 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` block denies edit/bash/webfetch, covering any - * agent the config never names; + * 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 @@ -337,14 +324,22 @@ const asObject = (value) => (isPlainObject(value) ? value : {}); */ function hardenOpencodeConfigForNoTool(config) { if (!isPlainObject(config)) return config; - config.permission = { ...DENY_ALL_PERMISSIONS }; + 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_PERMISSIONS }, + permission: { ...DENY_ALL_AGENT_PERMISSIONS }, }])); for (const entry of Object.values(asObject(config.provider))) { const models = asObject(entry?.models); diff --git a/server/lib/opencodeConfig.test.js b/server/lib/opencodeConfig.test.js index eb004680e2..3ebf351bc6 100644 --- a/server/lib/opencodeConfig.test.js +++ b/server/lib/opencodeConfig.test.js @@ -488,7 +488,9 @@ describe('hardenOpencodeConfigForNoTool', () => { 'gemma3:27b', ); - expect(config.permission).toEqual({ edit: 'deny', bash: 'deny', webfetch: 'deny' }); + // 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. @@ -506,6 +508,33 @@ describe('hardenOpencodeConfigForNoTool', () => { 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'); diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index 4a376a7999..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'; @@ -694,6 +696,86 @@ export function localRuntimeNamespace(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 8fdfabbe08..2ac353ae67 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -72,6 +72,7 @@ import { isOpencodeCommand, prefixOpencodeModel, localRuntimeNamespace, + opencodeProviderIsLocalOnly, OPENCODE_PUBLIC_REVIEW_AGENT, applyLeanClaudeArgs, } from './providerModels.js'; @@ -339,19 +340,31 @@ function opencodeCliArgs(baseArgs, { model, provider }) { } /** - * An OpenCode wrapper fronting a LOCAL OpenAI-compatible daemon (Ollama, - * MTPLX, llama.cpp, vLLM, SGLang) — not a hosted gateway. + * 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: * - * The gate stage's enforcement rides in `OPENCODE_CONFIG_CONTENT`, and - * `cliChildEnv.js` keeps that variable through the public-review env allowlist - * only when every endpoint it declares is on this machine (a gateway config - * carries that gateway's cloud API key, which stays stripped). So a - * gateway-backed wrapper would be offered a stage it could never authenticate — - * scope the recipe to the namespaces that can actually run it instead. + * - **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) - && Boolean(localRuntimeNamespace(provider)); + && localRuntimeNamespace(provider) === 'ollama' + && opencodeProviderIsLocalOnly(provider); /** * OpenCode is the natural harness for a local Ollama model — but unlike every @@ -365,8 +378,9 @@ const isLocalOpencodeProvider = (provider) => isDirectBinaryProvider(provider) * 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, and a level rides the config's - * `agent..reasoningEffort` exactly as it does for an ordinary run. + * 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 { diff --git a/server/lib/providerVendors.publicReview.test.js b/server/lib/providerVendors.publicReview.test.js index 211f330e3c..010dd2f81d 100644 --- a/server/lib/providerVendors.publicReview.test.js +++ b/server/lib/providerVendors.publicReview.test.js @@ -122,15 +122,54 @@ describe('public-review provider postures', () => { }); // The gate's enforcement rides in OPENCODE_CONFIG_CONTENT, which the - // public-review env allowlist keeps only for a loopback config — so a wrapper - // fronting a hosted gateway must not be offered a stage it cannot - // authenticate. + // 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',