From ec2832da44611f637ba57f277f845d34ac7c0611 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 10:48:28 +0700 Subject: [PATCH] fix(configure): patch CoDev Code's config in place, honor its env flags, validate keys via /v1/models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub's CoDev Code configuration stopped fitting how the agent works. Three mismatches with codev-code main (packages/codev-gateway, the config layer, the desktop gateway controller), each fixed here: 1. The writer whole-file-replaced ~/.config/codev/codev.json(c), carrying only `mcp` across. CoDev Code is a standalone product now: its TUI connects custom providers, its desktop app writes theme/keybind/ permission settings, users keep comments in codev.jsonc — and the hub rewrites this file on every gateway-key auto-refresh and model switch, not just at install. All of that was deleted on each run. `configureOpenCodeKind` now PATCHes the file through jsonc-parser the way the agent's own `Config.updateGlobal` does: `$schema` seeded only when absent, `compaction.auto`/`reserved` set key-by-key, the CoDev `provider.` block replaced wholesale (stale models and any inline apiKey go), everything else byte-identical. The agent's PATCH cannot delete, so convergence after the AIGW rename is the hub's job: other CoDev provider ids (`netgate`, `aigateway`, …) and a CoDev-authored top-level `model` pin are removed; a user's own provider never is. Syntax errors or a non-object root still fall back to a fresh file, with the backup holding the original. `readPreservedMcp` is gone — nothing needs carrying over any more. 2. `runAgent("codev")` set OPENCODE_DISABLE_AUTOUPDATE, but the fork renamed every OPENCODE_* env var to CODEV_* (codev-code #41), so the agent's self-updater was never disabled and raced `codevhub update`. Both spellings are set now. 3. `validateApiKey` probed LiteLLM's /key/info at the gateway ROOT. That root is now fronted by a web app whose catch-all answers HTTP 200 + HTML to any bearer — verified with a bogus key — so the probe could never return false: the launch-time refresh never fired on an expired key and SetupApp's "reuse existing key" offered dead keys as valid. It now lists /v1/models (authenticated by the key itself, 401s properly) and treats a non-JSON 200 as "can't tell", never "valid". `doctor` reports the same URL. Verified against the real agent: patching a copy of a live codev.jsonc that carried duplicate netgate + aigw blocks, a comment, a theme, a custom provider and the CodeGraph mcp entry, then running `codev models` under that HOME, lists each aigw model once plus the custom provider — with the comment and settings intact. Tests: writer tests now assert preservation (settings, comments, in-TUI providers, compaction siblings, $schema), legacy-block and pin removal, and the corrupt-file fallback; validateApiKey tests target /v1/models and pin the HTML-200 case; run tests assert both autoupdate flags. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0178nPaQqSGVe7Y1fR2ovDTK --- AGENTS.md | 21 +++- README.md | 3 +- src/lib/backend.ts | 53 +++++----- src/lib/configure.ts | 192 +++++++++++++++++++++++++----------- src/lib/doctor.ts | 6 +- src/lib/refresh.ts | 2 +- src/lib/run.ts | 6 +- tests/lib/backend.test.ts | 47 +++++---- tests/lib/configure.test.ts | 185 ++++++++++++++++++++++++++++++++-- tests/lib/run.test.ts | 11 ++- 10 files changed, 410 insertions(+), 116 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5481da5..20e0bee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,23 @@ The load-bearing consequence: the provider id **is** CoDev's authorship marker f `Credentials`/`ApiKeyCreds` carry `providerId`/`providerName` (persisted as `provider_id`/`provider_name`), and `resolveProvider` supplies the AIGW default when they're absent. **`saveApiKey` writes the whole api-key block, so an omitted provider pair clears it** — every re-save site (`ModelApp`'s two re-auth branches and its model switch, `refresh.ts#ensureFreshGatewayKey`, `SetupApp`'s model-choice) must thread it through, or a manually-named provider silently reverts to AIGW on the next model switch or launch-time key refresh. `logout()` is the same hazard by a different route: it rebuilds the surviving file field-by-field rather than deleting SSO keys from it, so anything not listed in its `preserved` object is dropped. The provider pair was missing there and had to be added back — a field added to `AuthFileContents` is not automatically a field that survives sign-out. +## CoDev Code config (`~/.config/codev/codev.json(c)`) + +CoDev Code is a standalone product with its own configure flows (the TUI's `/configure` and `codev configure`, the desktop sign-in), all built on its `packages/codev-gateway` package. The hub mirrors that package rather than the other way round, and three facts about it decide how the hub writes the file: + +1. **The agent PATCHes its config; it never rewrites it.** `Config.updateGlobal` (`packages/opencode/src/config/config.ts`) edits `codev.json(c)` through jsonc-parser, preserving comments and every key it doesn't own. The file also carries things that are not the hub's: providers the user connected in-TUI (`provider.` + an auth-store entry), desktop settings (`theme`, `keybinds`, `permission`, …), `disabled_providers`, `mcp`. So `configureOpenCodeKind` is a **surgical patch too** (`patchOpenCodeConfig`, same jsonc-parser `modify`/`applyEdits` as `lib/codegraph.ts`): it seeds `$schema` only when absent, sets `compaction.auto`/`compaction.reserved` key-by-key (a user's `prune` survives), replaces `provider.` wholesale (stale models and any inline `apiKey` go), and leaves everything else byte-identical. It used to whole-file-replace and carry only `mcp` across — which is exactly how a user's custom providers and desktop settings vanished on every key refresh. A file with syntax errors or a non-object root is still replaced from scratch; the backup taken first keeps the original. This runs for legacy OpenCode too; the two kinds share the writer. + +2. **The agent's PATCH cannot delete, so convergence is the hub's job.** After the AIGW rename (`aigw`, was `netgate`, before that `aigateway`) a re-run of the agent's own configure leaves the legacy block beside the new one and the model picker lists every model twice; the fork's own notes say "a hub-managed install converges on its next hub configure/refresh". The patch therefore removes `provider.` for every *other* id in `codevProviderIds()` and a top-level `model` pin on a CoDev provider (an older hub wrote one; it outranks the TUI's saved selection every startup). Only CoDev-written ids are candidates — a user's own provider is never touched. + +3. **The credential lives in the agent's auth store, not the config.** `~/.local/share/codev/auth.json` (`XDG_DATA_HOME`-aware; `Global.Path.data` in `packages/core/src/global.ts`), one `{ type: "api", key }` entry per provider id, merged into `options.apiKey` by the provider registry at load. The hub writes the entry *before* the keyless config block (a keyless block with no entry 401s on the first chat). The agent's startup migration (`config/gateway-key-migration.ts`) scrubs any inline key an older hub left, so the writer must never re-add one. The SSO session and gateway key are shared through `~/.codev-hub/auth.json` — the agent reads *and writes* the hub's file when it exists (same `AuthFileContents` shape), so a field added to the hub's file must be tolerated by, and ideally mirrored in, `packages/codev-gateway/src/auth-file.ts`. + +Two related contracts, both broken silently when they drift: + +- **Env flags are `CODEV_*`.** The fork renamed every `OPENCODE_*` variable (codev-code #41). `runAgent("codev")` sets `CODEV_DISABLE_AUTOUPDATE=1` (and the old spelling for pre-rename builds); setting only `OPENCODE_DISABLE_AUTOUPDATE` disabled nothing, and the agent's self-updater raced `codevhub update`. +- **Key validation goes through `/v1/models`, never the gateway root.** `backend.ts#validateApiKey` used to probe LiteLLM's `/key/info` at the root; the root is now fronted by a web app whose catch-all answers **HTTP 200 + HTML to any bearer, a bogus one included**, so the probe could never say "invalid": the launch-time refresh never fired and `SetupApp`'s "reuse existing key" offered dead keys. `/v1/models` is authenticated by the key itself and 401s properly. A 200 whose body isn't JSON is treated as "can't tell" (throw), not "valid" — that guard is what stops the same failure recurring behind another catch-all. `doctor`'s `llm` group reports the same URL. + +The gateway also mints **short-lived keys** now: the desktop's `ensureKey` (`packages/desktop/src/main/gateway-sso/controller.ts`) silently re-mints one from the SSO session when validation fails and re-keys the legacy ids too. `refresh.ts#ensureFreshGatewayKey` is the hub's counterpart at `codevhub codev` launch; it needs a `refresh_token` in `~/.codev-hub/auth.json`, and whether the IdP issues one is server-side (the hub already requests `offline_access`). + ## Context windows and auto-compaction Every agent CoDev configures has to be *told* the window of the model it's talking to. The gateway serves custom models none of them recognize, and each guesses differently when unconfigured: Codex assumes a 272K fallback, OpenCode assumes context `0` (which disables compaction outright), Continue falls back to a generic default. `src/lib/model-limits.ts` is the single source of truth; the four writers in `configure.ts` translate it into each agent's own knob and hold no window constants of their own. The flat `GATEWAY_CONTEXT_WINDOW` / `GATEWAY_COMPACT_*` constants that used to live in `const.ts` are gone — they encoded the assumption that every gateway model shares one 196608-token window, which stopped being true once the gateway served both a 1M-token and a 200K-token model. @@ -180,7 +197,7 @@ Net effect: Claude Code compacts at `0.8 × (200000 − min(modelMaxOutput, 2000 **Verify OpenCode-family behavior against the shipped binary, not the published schema.** `https://opencode.ai/config.json` documents `reserved` only as "token buffer for compaction" and says nothing about the `limit.input` branch that decides whether it is read at all. The threshold function is greppable in the binary (`grep -aob "cfg.compaction?.reserved"`, then read the surrounding bytes). -The remote source is wired but currently inert: `backend.ts#fetchModelWindows` reads LiteLLM's `/model_group/info` (at the gateway **root**, next to `/key/info`, not under `/v1`) and keeps entries with a numeric `max_input_tokens`. The live gateway reports `null` for every model, so it returns `{}` and the static table carries everything — the moment an admin populates the field, that model becomes gateway-driven with no CoDev release. Unlike `fetchModels`, it **never throws**: a window is an optimization over a sane default, and install must not break because a metadata endpoint 404s on some other gateway build. `ModelSelect` refreshes it fire-and-forget alongside the model list, so it can never delay or fail the picker. +The remote source is wired but currently inert: `backend.ts#fetchModelWindows` reads LiteLLM's `/model_group/info` (at the gateway **root**, not under `/v1` — and that root is now fronted by a web app whose catch-all answers HTML 200, so on the live gateway the JSON parse fails and it returns `{}`) and keeps entries with a numeric `max_input_tokens`. The live gateway reports `null` for every model, so it returns `{}` and the static table carries everything — the moment an admin populates the field, that model becomes gateway-driven with no CoDev release. Unlike `fetchModels`, it **never throws**: a window is an optimization over a sane default, and install must not break because a metadata endpoint 404s on some other gateway build. `ModelSelect` refreshes it fire-and-forget alongside the model list, so it can never delay or fail the picker. The cache is its **own top-level `model_limits` block** in auth.json with its own `saveModelLimits`/`loadModelLimits`, deliberately *not* a field on the api-key block — see the `saveApiKey` hazard above; a field there would be cleared by every re-save site that didn't thread it through. `limitsFor` memoizes the read once per process (configure* runs once per selected agent and once per model in the OpenCode map), so tests that write the cache must call `resetModelLimitsCache()`. @@ -341,7 +358,7 @@ CoDev Code is the flagship: `ALWAYS_AGENT` is in every target set, the picker re **CoDev Code wiring is special.** codegraph has no built-in target for the fork, so `setupCodegraph` wires it per run via one of two paths that produce byte-identical config (the `mcp.codegraph` entry in `codevCodeConfigPath()`, i.e. `~/.config/codev/codev.json(c)`): **Path A** — when `supportsCustomTargets()` (probe: `codegraph targets list`, read-only; the capability is codegraph PR #1459) reports support, register `CODEV_TARGET_SPEC` via `codegraph targets add` and append `codev` to the one install CSV; **Path B** — otherwise `wireCodevCodeMcp()` edits the config directly (surgical jsonc-parser `modify`/`applyEdits`; idempotent — an already-correct entry writes nothing; refuses malformed files). A registration failure on a capable binary silently falls back to the shim. Because codev-hub npm-installs the latest codegraph right before wiring, Path A activates machine-by-machine the moment upstream releases custom targets — once the released floor supports them, delete Path B and the probe (pure code removal, no migration). Gating uses `codegraphEligible(tools)` (built-in targets **or** codev-code), never `codegraphTargets(...).length` — codev-code is the always-on flagship tool, and gating on targets alone would write an MCP entry referencing a binary that was never installed. - **Config-rewrite preservation.** `configureOpenCodeKind` (OpenCode + the fork) whole-file-replaces its config, and runs not just at install time but on every gateway-key auto-refresh (`refresh.ts#ensureFreshGatewayKey`) and `codevhub model` switch. It therefore carries the existing top-level `mcp` map (via `readPreservedMcp` — best-effort, only object-valued maps) across the rewrite; without that, every refresh would silently unwire CodeGraph (either path) and any MCP servers the user added. The `mcp` entry never lands in `*.backup` (taken before CoDev's first write), so restore still returns the true pre-CoDev state. + **Config-rewrite preservation.** `configureOpenCodeKind` (OpenCode + the fork) patches its config **in place** (see "CoDev Code config" below), so the `mcp` map — CodeGraph's entry and any servers the user added — survives every gateway-key auto-refresh (`refresh.ts#ensureFreshGatewayKey`) and `codevhub model` switch without being carried over by hand. The `mcp` entry never lands in `*.backup` (taken before CoDev's first write), so restore still returns the true pre-CoDev state. 2. **Command passthrough.** `codevhub codegraph ` forwards verbatim to `codegraph ` via `forwardToCodegraph` (e.g. `codevhub codegraph init -y`). It mirrors `src/lib/run.ts#runAgent` (inherited stdio, SIGINT/SIGTERM swallowing, win32 `shell:true`) minus the shim-dir stripping and upload daemon — CodeGraph isn't a chat agent and isn't shimmed. ENOENT prints an install hint. diff --git a/README.md b/README.md index 0d680ef..a01efd9 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,7 @@ It is read-only (it installs and configures nothing) and checks, in order: backend (used by `codevhub upload`). - **LLM access** — the key is valid, models are listable, and a real one-token completion succeeds. Only the last of these proves inference is permitted; - `/key/info` and `/v1/models` both pass for a key that is then 403'd on every - completion. + `/v1/models` passes for a key that is then 403'd on every completion. - **This machine** — what is already installed, configured, and backed up. Failures expand in place into what happened, the most likely cause given your diff --git a/src/lib/backend.ts b/src/lib/backend.ts index f456243..905928e 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -102,34 +102,41 @@ export async function fetchCodevConfig( }; } -// Manual creds may include a `/v1` suffix (OpenAI-style); /key/info lives at -// the gateway root, so strip a trailing v1 segment before joining. Falls -// back to AI_GATEWAY_URL when the saved key has no base_url (SSO-fetched -// keys don't store one). -function keyInfoUrl(baseUrl?: string): string { - const base = baseUrl ?? AI_GATEWAY_URL(); - const stripped = base.replace(/\/?v1\/?$/, ""); - const trailing = stripped.endsWith("/") ? stripped : `${stripped}/`; - return `${trailing}key/info`; -} - -// Validates a key against the gateway's /key/info endpoint (LiteLLM): a single -// hash-based lookup against the key table. Returns true on 2xx, false on -// 401/403 (invalid/revoked), throws on network errors so the caller can -// distinguish "key is bad" from "couldn't reach the gateway". +// Validates a key by listing models through the OpenAI-compatible +// `/v1/models` endpoint — the same call fetchModels makes, and the one path the +// gateway is guaranteed to authenticate with the key itself. Returns true on a +// JSON 2xx, false on 401/403 (invalid/revoked), throws on network errors and +// anything else so the caller can tell "key is bad" from "couldn't tell". +// +// This used to hit LiteLLM's `/key/info` at the gateway ROOT. The root is now +// fronted by a web app whose catch-all answers **HTTP 200 with an HTML page to +// any bearer, including a bogus one**, so that probe could never return false: +// the launch-time refresh (refresh.ts) never fired and the "reuse existing +// key" path offered dead keys as valid. `/v1/models` 401s properly. The +// content-type guard is what keeps the same failure from recurring behind +// some other catch-all — a 200 that isn't JSON is "can't tell", never "valid". export async function validateApiKey( apiKey: string, baseUrl?: string, ): Promise { - const res = await loggedFetch("gateway.key-info", keyInfoUrl(baseUrl), { + const res = await loggedFetch("gateway.key-check", modelsUrl(baseUrl), { method: "GET", - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { + accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), }); if (res.status === 401 || res.status === 403) return false; if (!res.ok) { throw new Error(`Validation failed (${res.status}): ${res.statusText}`); } + const type = res.headers.get("content-type") ?? ""; + if (!type.toLowerCase().includes("json")) { + throw new Error( + `Validation failed: the gateway answered with ${type || "a non-JSON body"} instead of JSON`, + ); + } return true; } @@ -185,9 +192,11 @@ export async function fetchModels( return ids; } -// LiteLLM's aggregated per-model-name view. Lives at the gateway root next to -// /key/info, not under /v1, so it reuses the root-stripping join rather than -// gatewayV1Url. +// LiteLLM's aggregated per-model-name view. Lives at the gateway ROOT, not +// under /v1, so it uses a root-stripping join rather than gatewayV1Url. Note +// the root is now fronted by a web app (see validateApiKey), so on the live +// gateway this answers an HTML 200 — the JSON parse fails and the catch below +// returns {}, which is the documented "gateway reports nothing" outcome. function modelGroupInfoUrl(baseUrl?: string): string { const base = baseUrl ?? AI_GATEWAY_URL(); const stripped = base.replace(/\/?v1\/?$/, ""); @@ -258,8 +267,8 @@ export async function fetchModelWindows( } // Confirms the configured key can actually RUN the chosen model through the -// gateway. validateApiKey (/key/info) and fetchModels (/v1/models) only prove -// the key exists and that models are listable — neither proves inference is +// gateway. validateApiKey and fetchModels (both /v1/models) only prove the key +// authenticates and that models are listable — neither proves inference is // permitted. This 1-token chat completion catches the gateway 403s ("key not // allowed to access model", over-budget, edge/WAF blocks) that otherwise stay // hidden until the agent's first message. Returns null on success, or a short diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 9ffb7b3..f4129c5 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -11,7 +11,7 @@ import { import { homedir } from "node:os"; import { dirname, join } from "node:path"; import TOML from "@iarna/toml"; -import { type ParseError, parse } from "jsonc-parser"; +import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"; import { AI_GATEWAY_OPENAI_URL, AI_GATEWAY_URL } from "@/lib/const.js"; import { logInfo } from "@/lib/log.js"; import { @@ -136,7 +136,6 @@ export const OPENCODE_SCHEMA_URL = atob( ); const OPENCODE_K = { schema: atob("JHNjaGVtYQ=="), - mcp: atob("bWNw"), provider: atob("cHJvdmlkZXI="), npm: atob("bnBt"), npmPkg: atob("QGFpLXNkay9vcGVuYWktY29tcGF0aWJsZQ=="), @@ -145,6 +144,9 @@ const OPENCODE_K = { baseURL: atob("YmFzZVVSTA=="), apiKey: atob("YXBpS2V5"), models: atob("bW9kZWxz"), + // Top-level default-model pin (`"/"`). Never written; the + // writer removes a CoDev-authored one left by an older hub. + model: atob("bW9kZWw="), recent: atob("cmVjZW50"), providerID: atob("cHJvdmlkZXJJRA=="), modelID: atob("bW9kZWxJRA=="), @@ -924,21 +926,120 @@ export function configureCodevCode(creds: Credentials): ConfigureResult[] { return configureOpenCodeKind("codev-code-config", creds); } -// Read the top-level `mcp` map from an existing OpenCode-family config, or -// undefined when the file is absent, unparseable, or has no object-valued -// `mcp`. Best-effort by design: this writer has always recovered from corrupt -// configs by replacing them, and preservation must never change that. -function readPreservedMcp(path: string): Record | undefined { - if (!existsSync(path)) return undefined; - try { - const raw = parseJsonc(readFileSync(path, "utf-8")); - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - const mcp = (raw as Record)[OPENCODE_K.mcp]; - if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return undefined; - return mcp as Record; - } catch { - return undefined; +// jsonc-parser edit formatting for the OpenCode-family writer — the same +// options lib/codegraph.ts and lib/vscode-settings.ts use, so a hub-edited +// file looks like one the agent edited itself. +const JSONC_FORMATTING = { insertSpaces: true, tabSize: 2, eol: "\n" } as const; + +// One jsonc-parser edit; `value === undefined` removes the property. Offsets +// are computed against the text handed in, so consecutive edits must each +// start from the previous result — never batch them against the original. +function patchJsonc( + text: string, + path: (string | number)[], + value: unknown, +): string { + return applyEdits( + text, + modify(text, path, value, { formattingOptions: JSONC_FORMATTING }), + ); +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +// Patch CoDev's entries into an OpenCode-family config IN PLACE, the way the +// agent writes its own config (`Config.updateGlobal` PATCHes the file through +// jsonc-parser, never rewrites it). CoDev Code is a standalone product now: +// its TUI connects custom providers, its desktop app writes theme/keybind/ +// permission settings, users keep comments in codev.jsonc — and this writer +// runs on every gateway-key auto-refresh (refresh.ts) and `codevhub model` +// switch, not just at install. The previous whole-file replace silently +// deleted all of that on each run (only `mcp` was carried over, after the +// CodeGraph wiring got wiped once). Everything not listed here is untouched. +// +// What CoDev owns and rewrites: +// - `$schema`: seeded when absent (the agent's own first-run stub does the +// same); an existing value is left alone. +// - `compaction.auto` / `compaction.reserved`: one global reserve for every +// model in the map — OpenCode's schema has no per-model compaction block. +// Each model's own trigger comes from its `limit.input`, sized against +// exactly this number (see declaredInput). Sibling compaction keys (e.g. +// `prune`) are the user's and survive. +// - `provider.`: replaced wholesale, so stale models and any inline +// `options.apiKey` an older hub wrote are gone; the agent's startup +// migration would scrub the key anyway, but a hub must never re-add one. +// - `provider.`: removed. The agent's own configure flow +// can't delete (its PATCH is a deep merge), so after the AIGW rename a +// legacy `netgate` block lingers beside the new one and the model picker +// shows every model twice — the fork documents that "a hub-managed install +// converges on its next hub configure/refresh". Only ids CoDev itself +// writes (codevProviderIds) are candidates; a provider the user connected +// in-TUI is never touched. +// - top-level `model`: removed when it pins a CoDev provider. The pin was +// dropped from this writer because it outranks the TUI's persisted model +// selection on every startup (Provider defaultModel checks config first, +// then the state dir's model.json recents), so every in-CLI switch +// reverted on restart. A whole-file replace erased an old pin implicitly; +// a patch has to do it on purpose. A pin on a user's own provider stays. +// +// A file that can't be edited safely — syntax errors, or a root that isn't an +// object — is replaced with a fresh one, as this writer always has: the backup +// taken by the caller still holds whatever was there. +function patchOpenCodeConfig( + path: string, + providerId: string, + block: Record, +): void { + let text = existsSync(path) ? readFileSync(path, "utf-8") : ""; + const errors: ParseError[] = []; + const parsed: unknown = parse(text, errors, { + allowTrailingComma: true, + allowEmptyContent: true, + }); + const editable = + errors.length === 0 && (parsed === undefined || isPlainObject(parsed)); + const root: Record = + editable && isPlainObject(parsed) ? parsed : {}; + if (!editable || text.trim() === "") text = "{}"; + + if (root[OPENCODE_K.schema] === undefined) { + text = patchJsonc(text, [OPENCODE_K.schema], OPENCODE_SCHEMA_URL); } + + const compaction = { + [OPENCODE_K.auto]: true, + [OPENCODE_K.reserved]: COMPACT_RESERVED, + }; + if (isPlainObject(root[OPENCODE_K.compaction])) { + for (const [key, value] of Object.entries(compaction)) { + text = patchJsonc(text, [OPENCODE_K.compaction, key], value); + } + } else { + text = patchJsonc(text, [OPENCODE_K.compaction], compaction); + } + + const ids = codevProviderIds(); + const providers = root[OPENCODE_K.provider]; + if (isPlainObject(providers)) { + text = patchJsonc(text, [OPENCODE_K.provider, providerId], block); + for (const id of ids) { + if (id !== providerId && id in providers) { + text = patchJsonc(text, [OPENCODE_K.provider, id], undefined); + } + } + } else { + text = patchJsonc(text, [OPENCODE_K.provider], { [providerId]: block }); + } + + const pin = root[OPENCODE_K.model]; + if (typeof pin === "string" && ids.includes(pin.split("/")[0] ?? "")) { + text = patchJsonc(text, [OPENCODE_K.model], undefined); + } + + writeFileSync(path, text, { mode: 0o600 }); + chmodSync(path, 0o600); } function configureOpenCodeKind( @@ -949,13 +1050,6 @@ function configureOpenCodeKind( const sourcePath = sourcePathOf(kind); mkdirSync(dirname(sourcePath), { recursive: true }); - // Carry the `mcp` map across the rewrite. This writer doesn't just run at - // install time: every gateway-key auto-refresh (refresh.ts) and model - // switch rewrites the whole file, and dropping `mcp` there would silently - // unwire MCP servers — CodeGraph's entry, or servers the user added — that - // were wired after configure last ran. - const mcp = readPreservedMcp(sourcePath); - const baseUrl = creds.baseUrl ? normalizeOpenCodeBaseUrl(creds.baseUrl) : AI_GATEWAY_OPENAI_URL(); @@ -1027,41 +1121,25 @@ function configureOpenCodeKind( }), ); - writeJson(sourcePath, { - [OPENCODE_K.schema]: OPENCODE_SCHEMA_URL, - ...(mcp !== undefined ? { [OPENCODE_K.mcp]: mcp } : {}), - // No top-level `model`. The install-time model choice exists for Claude - // Code and Codex, which can only run one model at a time; OpenCode and - // CoDev Code switch freely in-CLI (docs/hub/installation). A pin there - // outranks their saved selection on every launch — it beats the recent - // models in their state dir — so each in-CLI switch reverted on restart - // and only hand-editing the config undid it. The chosen model is - // seeded into that saved selection instead (seedOpenCodeRecentModel, - // below), which decides the first launch and yields to any later - // switch. The models-map order alone can't carry the choice: with no - // pin and no saved selection the TUI falls back to the first provider - // in its list — upstream's hosted Zen provider, which self-registers - // ahead of config providers — and, within a provider, to the server's - // own model sort, neither of which reads this map's order. This is why - // `codevhub model` steers only Claude Code and Codex directly. - // One global reserve for every model in the map — OpenCode's schema has no - // per-model compaction block. Each model's own trigger comes from its - // `limit.input` above, which is sized against exactly this number. - [OPENCODE_K.compaction]: { - [OPENCODE_K.auto]: true, - [OPENCODE_K.reserved]: COMPACT_RESERVED, - }, - [OPENCODE_K.provider]: { - [provider.id]: { - [OPENCODE_K.npm]: OPENCODE_K.npmPkg, - [OPENCODE_K.name]: provider.name, - [OPENCODE_K.options]: { - [OPENCODE_K.baseURL]: baseUrl, - ...(keyless ? {} : { [OPENCODE_K.apiKey]: creds.apiKey }), - }, - [OPENCODE_K.models]: modelsMap, - }, + // No top-level `model` and nothing beyond CoDev's own entries: the install- + // time model choice exists for Claude Code and Codex, which can only run one + // model at a time; OpenCode and CoDev Code switch freely in-CLI + // (docs/hub/installation), and the chosen model is seeded into their saved + // selection instead (seedOpenCodeRecentModel, below), which decides the + // first launch and yields to any later switch. The models-map order alone + // can't carry the choice: with no pin and no saved selection the TUI falls + // back to the first provider in its list — upstream's hosted Zen provider, + // which self-registers ahead of config providers — and, within a provider, + // to the server's own model sort, neither of which reads this map's order. + // This is why `codevhub model` steers only Claude Code and Codex directly. + patchOpenCodeConfig(sourcePath, provider.id, { + [OPENCODE_K.npm]: OPENCODE_K.npmPkg, + [OPENCODE_K.name]: provider.name, + [OPENCODE_K.options]: { + [OPENCODE_K.baseURL]: baseUrl, + ...(keyless ? {} : { [OPENCODE_K.apiKey]: creds.apiKey }), }, + [OPENCODE_K.models]: modelsMap, }); seedOpenCodeRecentModel(kind, provider.id, defaultModel); diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 6cfcaee..86f97e7 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -1444,7 +1444,7 @@ const gatewayKeyCheck: Check = { detail: "Skipped — the gateway URL could not be fetched.", }; } - const attempt = { url: `${ctx.gatewayUrl}/key/info`, method: "GET" }; + const attempt = { url: `${ctx.gatewayUrl}/v1/models`, method: "GET" }; return guard(attempt, async () => { const ok = await validateApiKey(ctx.apiKey as string, ctx.gatewayUrl); return ok @@ -1487,8 +1487,8 @@ const modelsCheck: Check = { }, }; -// The only check that proves inference is actually permitted: /key/info and -// /v1/models both succeed for a key that is then 403'd on every completion. +// The only check that proves inference is actually permitted: /v1/models +// succeeds for a key that is then 403'd on every completion. const completionCheck: Check = { key: "llm-completion", label: "Send a test request to the LLM", diff --git a/src/lib/refresh.ts b/src/lib/refresh.ts index 0cdc137..54ebdb7 100644 --- a/src/lib/refresh.ts +++ b/src/lib/refresh.ts @@ -16,7 +16,7 @@ import { import { logInfo, logWarn } from "@/lib/log.js"; // Cap the pre-flight key check so a slow/hung gateway never stalls an agent -// launch. A real /key/info round-trip is well under this; if it's slower we just +// launch. A real /v1/models round-trip is well under this; if it's slower we just // launch with the existing config — we can't prove the key is dead, so we leave // it alone. const PREFLIGHT_TIMEOUT_MS = 2_500; diff --git a/src/lib/run.ts b/src/lib/run.ts index b1dd64d..810bbc4 100644 --- a/src/lib/run.ts +++ b/src/lib/run.ts @@ -73,8 +73,12 @@ export function runAgent(cmd: string, args: string[]): Promise { }; // CoDev Code (the codev-code package) has its own self-updater, but the // hub owns updates (`codevhub update`) — disable the agent's updater at - // every launch so the two never race. + // every launch so the two never race. The fork renamed its env flags + // OPENCODE_* → CODEV_* (codev-code #41), so the switch it reads today is + // CODEV_DISABLE_AUTOUPDATE; the old spelling stays for builds that + // predate the rename. Setting only the old one disabled nothing. if (cmd === "codev") { + env.CODEV_DISABLE_AUTOUPDATE = "1"; env.OPENCODE_DISABLE_AUTOUPDATE = "1"; } // On Windows, npm-installed agent binaries are `.cmd` shims (e.g. diff --git a/tests/lib/backend.test.ts b/tests/lib/backend.test.ts index 02dcc1d..5a9d9e3 100644 --- a/tests/lib/backend.test.ts +++ b/tests/lib/backend.test.ts @@ -115,17 +115,17 @@ describe("fetchApiKey", () => { }); describe("validateApiKey", () => { - test("returns true on a 2xx response from /key/info", async () => { + test("returns true on a JSON 2xx from /v1/models", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValue(jsonResponse(200, { key: "sk-abc", spend: 0 })); + .mockResolvedValue(jsonResponse(200, { data: [{ id: "m" }] })); await expect(validateApiKey("sk-abc")).resolves.toBe(true); const [url, init] = fetchSpy.mock.calls[0] as [ string, { method?: string; headers?: Record }, ]; - expect(url).toBe(`${AI_GATEWAY_URL()}/key/info`); + expect(url).toBe(`${AI_GATEWAY_OPENAI_URL()}/models`); expect(init.method).toBe("GET"); expect(init.headers?.Authorization).toBe("Bearer sk-abc"); }); @@ -160,36 +160,49 @@ describe("validateApiKey", () => { await expect(validateApiKey("sk-x")).rejects.toThrow("ECONNREFUSED"); }); - test("strips a trailing /v1 from the baseUrl when targeting /key/info", async () => { + test("treats a non-JSON 200 as unverifiable, never as valid", async () => { + // The live gateway's ROOT is fronted by a web app whose catch-all answers + // 200 + HTML to any bearer, bogus included. The old /key/info probe read + // that as "valid", so an expired key was never refreshed at launch and + // the "reuse existing key" path offered it. A 200 that isn't JSON must + // surface as "couldn't tell" (throw), which callers already handle. + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Sign in", { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }), + ); + await expect(validateApiKey("sk-bogus")).rejects.toThrow("instead of JSON"); + }); + + test("appends /models to a baseUrl that already ends in /v1", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValue(jsonResponse(200, {})); + .mockResolvedValue(jsonResponse(200, { data: [] })); await validateApiKey("sk-y", "https://my-gw.example.com/v1"); const [url] = fetchSpy.mock.calls[0] as [string]; - expect(url).toBe("https://my-gw.example.com/key/info"); + expect(url).toBe("https://my-gw.example.com/v1/models"); }); - test("handles a baseUrl without /v1", async () => { + test("adds the /v1 segment for a baseUrl without it", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValue(jsonResponse(200, {})); + .mockResolvedValue(jsonResponse(200, { data: [] })); await validateApiKey("sk-z", "https://gw.example.com/"); const [url] = fetchSpy.mock.calls[0] as [string]; - expect(url).toBe("https://gw.example.com/key/info"); + expect(url).toBe("https://gw.example.com/v1/models"); }); - test("inserts the path separator when AI_GATEWAY_URL has no trailing slash", async () => { - // Regression: keyInfoUrl(undefined) used to produce ".../gatewaykey/info" - // because it concatenated AI_GATEWAY_URL (no trailing slash) directly - // with "key/info". SSO-fetched keys have no base_url, so they hit this - // fallback — and the malformed URL made validateApiKey throw, hiding - // the "use existing API key" option even when the key was valid. + test("falls back to the cached gateway URL when the key has no base_url", async () => { + // SSO-fetched keys store no base_url, so they take this fallback; the + // URL must be well-formed or validateApiKey throws and the "use existing + // API key" option disappears even though the key is fine. const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValue(jsonResponse(200, {})); + .mockResolvedValue(jsonResponse(200, { data: [] })); await validateApiKey("sk-w"); const [url] = fetchSpy.mock.calls[0] as [string]; - expect(url.endsWith("/gateway/key/info")).toBe(true); + expect(url).toBe(`${AI_GATEWAY_URL()}/v1/models`); }); }); diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index 190cfa5..0378afa 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -541,7 +541,7 @@ describe("configureOpenCode", () => { expect(existsSync(join(tempDir, ".claude.json"))).toBe(false); }); - test("replaces existing opencode.json and backs up the file", async () => { + test("patches CoDev's entries into an existing opencode.json and backs up the file", async () => { const dir = join(tempDir, ".config", "opencode"); const filePath = join(dir, "opencode.json"); const backupPath = `${filePath}.backup`; @@ -562,9 +562,12 @@ describe("configureOpenCode", () => { expect(backup.someSetting).toBe("keep"); expect(backup.provider.other.name).toBe("Other"); + // The file is the user's: their settings and their own providers survive + // the write, the way the agent's own config PATCH leaves them. Only + // CoDev's entries are (re)written. const config = JSON.parse(readFileSync(filePath, "utf-8")); - expect(config.someSetting).toBeUndefined(); - expect(config.provider.other).toBeUndefined(); + expect(config.someSetting).toBe("keep"); + expect(config.provider.other.name).toBe("Other"); expect(config.provider.aigw.options.apiKey).toBe("sk-new"); }); @@ -603,7 +606,7 @@ describe("configureOpenCode", () => { expect(config.provider.aigw.options.apiKey).toBe("sk-new"); }); - test("does not carry a non-object `mcp` value across a rewrite", async () => { + test("leaves a non-object `mcp` value alone — it is not ours to fix", async () => { const dir = join(tempDir, ".config", "opencode"); const filePath = join(dir, "opencode.json"); mkdirSync(dir, { recursive: true }); @@ -613,7 +616,8 @@ describe("configureOpenCode", () => { configureOpenCode({ apiKey: "sk-new", model: "m" }); const config = JSON.parse(readFileSync(filePath, "utf-8")); - expect(config.mcp).toBeUndefined(); + expect(config.mcp).toBe("not a server map"); + expect(config.provider.aigw.options.apiKey).toBe("sk-new"); }); test("preserves a pre-existing opencode.json backup across repeated runs", async () => { @@ -695,7 +699,7 @@ describe("configureCodevCode", () => { ).toBe(false); }); - test("replaces existing codev.json and backs up the file", async () => { + test("patches CoDev's entries into an existing codev.json and backs up the file", async () => { const dir = join(tempDir, ".config", "codev"); const filePath = join(dir, "codev.json"); const backupPath = `${filePath}.backup`; @@ -717,9 +721,12 @@ describe("configureCodevCode", () => { expect(backup.someSetting).toBe("keep"); expect(backup.provider.other.name).toBe("Other"); + // CoDev Code owns this file as a product now (desktop settings, in-TUI + // custom providers, comments in codev.jsonc); the hub writes only its own + // entries and leaves the rest exactly as found. const config = JSON.parse(readFileSync(filePath, "utf-8")); - expect(config.someSetting).toBeUndefined(); - expect(config.provider.other).toBeUndefined(); + expect(config.someSetting).toBe("keep"); + expect(config.provider.other.name).toBe("Other"); expect(config.provider.aigw.options.apiKey).toBeUndefined(); const auth = JSON.parse( readFileSync( @@ -769,6 +776,168 @@ describe("configureCodevCode", () => { expect(auth.aigw).toEqual({ type: "api", key: "sk-new" }); }); + test("keeps comments, user settings and in-TUI providers in codev.jsonc", async () => { + // The shape CoDev Code itself produces: a .jsonc with comments, desktop + // settings, a provider the user connected through the TUI's custom + // flow, and a stale CoDev block from an older hub carrying an inline key. + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.jsonc"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + filePath, + `{ + // user comment: keep me + "$schema": "https://opencode.ai/config.json", + "theme": "codev-dark", + "keybinds": { "leader": "ctrl+x" }, + "compaction": { "auto": true, "reserved": 12345, "prune": false }, + "provider": { + "aigw": { + "npm": "@ai-sdk/openai-compatible", + "name": "AIGW", + "options": { "baseURL": "https://old.example/v1", "apiKey": "sk-inline-old" }, + "models": { "stale/model": { "name": "stale/model" } } + }, + "mycorp": { + "name": "My Corp", + "options": { "baseURL": "https://llm.mycorp.example/v1" }, + "models": { "corp/model": { "name": "corp/model" } } + } + }, + "mcp": { "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true } } +} +`, + ); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ + apiKey: "sk-new", + model: "m", + baseUrl: "https://new.example", + }); + + const text = readFileSync(filePath, "utf-8"); + expect(text).toContain("// user comment: keep me"); + const { parse } = await import("jsonc-parser"); + const config = parse(text); + // User-owned: untouched. + expect(config.theme).toBe("codev-dark"); + expect(config.keybinds).toEqual({ leader: "ctrl+x" }); + expect(config.provider.mycorp.options.baseURL).toBe( + "https://llm.mycorp.example/v1", + ); + expect(config.mcp.codegraph.command).toEqual([ + "codegraph", + "serve", + "--mcp", + ]); + // CoDev-owned: rewritten. The block is replaced wholesale — the stale + // model and the inline key are gone — and compaction siblings survive. + expect(config.provider.aigw.options).toEqual({ + baseURL: "https://new.example/v1", + }); + expect(Object.keys(config.provider.aigw.models)).toEqual(["m"]); + expect(config.compaction).toEqual({ + auto: true, + reserved: 40000, + prune: false, + }); + }); + + test("drops the other CoDev provider blocks so the picker converges on one id", async () => { + // CoDev Code's own configure flow is a deep-merge PATCH and cannot + // delete, so after the AIGW rename a legacy `netgate` block lingers + // beside the new one and every model shows twice. The fork relies on + // the hub's next write to converge; a provider the user connected + // themselves is not a candidate. + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + filePath, + JSON.stringify({ + provider: { + netgate: { name: "netGate", options: { baseURL: "x" } }, + aigateway: { name: "aigateway", options: { baseURL: "x" } }, + "ai-gateway": { name: "AI Gateway", options: { baseURL: "x" } }, + mine: { name: "Mine", options: { baseURL: "x" } }, + }, + }), + ); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + + const config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(Object.keys(config.provider).sort()).toEqual(["aigw", "mine"]); + }); + + test("removes a CoDev-authored top-level model pin and keeps a user's", async () => { + // An older hub pinned `model: "/"`, which outranks the + // TUI's saved selection on every startup. The whole-file replace used + // to erase it implicitly; the patch must do so explicitly — and only for + // a pin on a CoDev provider. + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.json"); + mkdirSync(dir, { recursive: true }); + const { configureCodevCode } = await import("@/lib/configure.js"); + + writeFileSync( + filePath, + JSON.stringify({ model: "netgate/MiniMax/MiniMax-M3" }), + ); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + expect(JSON.parse(readFileSync(filePath, "utf-8")).model).toBeUndefined(); + + writeFileSync(filePath, JSON.stringify({ model: "anthropic/claude" })); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + expect(JSON.parse(readFileSync(filePath, "utf-8")).model).toBe( + "anthropic/claude", + ); + }); + + test("leaves an existing $schema alone and seeds it when absent", async () => { + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.json"); + mkdirSync(dir, { recursive: true }); + const { configureCodevCode } = await import("@/lib/configure.js"); + + writeFileSync(filePath, JSON.stringify({ $schema: "https://custom" })); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + expect(JSON.parse(readFileSync(filePath, "utf-8")).$schema).toBe( + "https://custom", + ); + + writeFileSync(filePath, JSON.stringify({ theme: "x" })); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + expect(JSON.parse(readFileSync(filePath, "utf-8")).$schema).toBe( + "https://opencode.ai/config.json", + ); + }); + + test("replaces a config it cannot edit safely (syntax error, non-object root)", async () => { + // The one case where the file is rewritten from scratch, as it always + // was — the backup taken first still holds the broken original. + const dir = join(tempDir, ".config", "codev"); + const filePath = join(dir, "codev.json"); + mkdirSync(dir, { recursive: true }); + const { configureCodevCode } = await import("@/lib/configure.js"); + + writeFileSync(filePath, "{ not json at all"); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + let config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.provider.aigw.options.baseURL).toBe(AI_GATEWAY_OPENAI_URL()); + expect(config.compaction).toEqual({ auto: true, reserved: 40000 }); + expect(readFileSync(`${filePath}.backup`, "utf-8")).toBe( + "{ not json at all", + ); + + writeFileSync(filePath, JSON.stringify(["an", "array"])); + configureCodevCode({ apiKey: "sk-new", model: "m" }); + config = JSON.parse(readFileSync(filePath, "utf-8")); + expect(config.provider.aigw.models.m.name).toBe("m"); + }); + test("preserves a pre-existing codev.json backup across repeated runs", async () => { const dir = join(tempDir, ".config", "codev"); const filePath = join(dir, "codev.json"); diff --git a/tests/lib/run.test.ts b/tests/lib/run.test.ts index df3ed4e..4ed611d 100644 --- a/tests/lib/run.test.ts +++ b/tests/lib/run.test.ts @@ -310,19 +310,24 @@ describe("runAgent", () => { }); } - test("disables CoDev Code's self-updater via OPENCODE_DISABLE_AUTOUPDATE", async () => { + test("disables CoDev Code's self-updater via CODEV_DISABLE_AUTOUPDATE", async () => { // The hub owns updates (`codevhub update`), so every launch of the agent - // must pin the kill switch on its own updater. + // must pin the kill switch on its own updater. The fork reads the + // CODEV_-prefixed flag (it renamed every OPENCODE_* env var); the old + // spelling rides along for builds that predate the rename. const env = await runCapturingEnv("codev"); + expect(env?.CODEV_DISABLE_AUTOUPDATE).toBe("1"); expect(env?.OPENCODE_DISABLE_AUTOUPDATE).toBe("1"); }); - test("does not set OPENCODE_DISABLE_AUTOUPDATE for the other agents", async () => { + test("does not set the autoupdate kill switch for the other agents", async () => { // Guard against the parent process's own env leaking into the assertion. + vi.stubEnv("CODEV_DISABLE_AUTOUPDATE", undefined); vi.stubEnv("OPENCODE_DISABLE_AUTOUPDATE", undefined); try { for (const cmd of ["opencode", "claude", "codex"]) { const env = await runCapturingEnv(cmd); + expect(env?.CODEV_DISABLE_AUTOUPDATE).toBeUndefined(); expect(env?.OPENCODE_DISABLE_AUTOUPDATE).toBeUndefined(); } } finally {