diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7ca12971..f92a5e1b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -630,13 +630,13 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. -⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. +⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model `, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. -**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. +**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) — and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). diff --git a/docs/custom-model-endpoints-plan.md b/docs/custom-model-endpoints-plan.md index 5078f17b..a72b1406 100644 --- a/docs/custom-model-endpoints-plan.md +++ b/docs/custom-model-endpoints-plan.md @@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway). Right now every Codeman session mode defaults to its native cloud backend with no way to redirect a session at any other endpoint from the UI — the -closest existing precedent is DeepSeek's server-env-sourced -`DEEPSEEK_BASE_URL`, which isn't user-facing. +closest existing precedent was DeepSeek's server-env-sourced +`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since +been superseded by deepseek's per-session patch recipe — see the table +below). **Scope note**: this plan originally said "local LLM." It now covers any OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama, @@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch. | `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done | | `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" | | `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly | -| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST /chat/completions` → `404`, `POST /v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp | +| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml` — a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY` — a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192` — the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` | | `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live | | `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism | diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 81bf7add..48084b94 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -601,14 +601,35 @@ for '' not found. Defaulting to fallback metadata...` on launch — `POST /chat/completions` 404s against llama-swap while `POST /v1/chat/completions` succeeds; the harness's own error template (`DeepSeek API error (HTTP ${status})`) matches the originally - reported symptom exactly. `customModelInjection`'s new `appendV1Suffix` - (deepseek's entry only — claude/gemini must NOT get it, since claude was - already confirmed working against the raw `baseUrl`) fixes it by writing - `DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a - real `dsh` binary (no install available in this environment) — the fix - is source-confirmed and live-verified at the HTTP level, but a real - "hello world" reply through `dsh` itself is still outstanding before - calling this fully verified like the harnesses above. + reported symptom exactly. The fix is no longer the original env redirect: + deepseek's `customModelInjection` is a `configDir` recipe — a per-session + `DSH_HOME` holding only a generated `cordis.patch.yml` that declares a + `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` + suffix from the shared `renderConfigFile` step every file template goes + through), so the installed profile keeps supplying the transport; the + recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and + `settings.yaml` back out of the real dsh home so installed bundles, + transcripts and user settings all survive the redirect (missing + directory entries are created first — a home that has never run a + turn would otherwise get a dangling link whose every write through + it failed with ENOENT). **Verified end-to-end against a real dsh + install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): + `--dump-config` shows the three patched rows, the TUI boots on the + chosen model, and a prompt reaches the endpoint as + `POST /v1/chat/completions` with the right model, the bearer key and + `max_tokens: 8192`; the reply renders and the context window shows in + the footer. What such a session SHARES with the real home is + exactly the linked entries — `profiles/`, `sessions/`, + `settings.yaml`. Everything else in the generated `DSH_HOME` is + isolated: `storages/` (dsh-TUI's persisted `/model` choice and + workspace state), `.agent-presets` and `.env`. Two consequences of + the generated home-level file follow: the user's own home-level + `cordis.patch.yml` does NOT apply in such a session (the generated + file replaces it; profile-level patch files reached through the + linked `profiles/` still compose), and the generated patch REPLACES + the `llm-pi-ai` provider row's whole `config` for the session. Note + too that it likewise REPLACES the `dsh-tui` row's whole `config`, so + a profile-level `effort`/`preset` does not apply. - **Antigravity** — no known custom-endpoint mechanism at all; unsupported. See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 8f927463..548b28f1 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -197,7 +197,7 @@ read is treated as one slot, never guessed. | **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. | | **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. | | **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. | -| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. | +| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. | | **Antigravity** | No known custom-endpoint mechanism at all. Not offered. | Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry, diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index f5529ba3..5c33de3d 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -286,6 +286,14 @@ const launchModelTemplate = z .regex(/^[a-zA-Z0-9._\-/:{}]+$/) .optional(); +/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment, + * leading alphanumeric, no traversal or hidden-dot escape. */ +const homeSegmentName = z + .string() + .min(1) + .max(40) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/); + const capabilitiesSchema = z .object({ external: z.boolean(), @@ -580,10 +588,61 @@ const capabilitiesSchema = z kind: z.literal('configDir'), dirEnvVar: envName, fileName: z.string().min(1).max(80), - template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']), + template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']), launchModel: launchModelTemplate, + // State that must survive the isolated-directory redirect, symlinked by name from + // the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path + // segments only — the consumer joins them, so a traversal here would escape the + // real home. See the field's comment in cli-registry/types.ts. + linkFromRealHome: z.array(homeSegmentName).max(8).optional(), + // Which of `linkFromRealHome` are directories (missing ones are created before + // linking; missing files are skipped). Same segment pattern, see types.ts. + linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(), + // The real home's fallback location, relative to the user's home: ONE segment — + // a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and + // bare `.` are not; the consumer joins it under the user's home, a traversal + // here escapes that home and `.` IS that home (linking THROUGH it unshielded). + realHomeFallback: z + .string() + .min(1) + .max(80) + .regex(/^(?!\.\.?$)[^/\\]+$/) + .optional(), }) - .strict(), + .strict() + .superRefine((recipe, ctx) => { + // Both rules were comments before; the schema is the security boundary, so + // enforce them at load time (see the header of this file). + if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) { + ctx.addIssue({ + code: 'custom', + message: 'realHomeFallback is required whenever linkFromRealHome is declared', + path: ['realHomeFallback'], + }); + } + for (const name of recipe.linkFromRealHome ?? []) { + if (name === recipe.fileName) { + ctx.addIssue({ + code: 'custom', + message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`, + path: ['linkFromRealHome'], + }); + } + } + // types.ts documents the dirs list as "must be a subset of linkFromRealHome"; + // a name absent from it names nothing (missing-file entries are skipped as + // files), so the recipe would silently stop creating a directory it declares. + const linkedNames = new Set(recipe.linkFromRealHome ?? []); + for (const dir of recipe.linkFromRealHomeDirs ?? []) { + if (!linkedNames.has(dir)) { + ctx.addIssue({ + code: 'custom', + message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`, + path: ['linkFromRealHomeDirs'], + }); + } + } + }), z.object({ kind: z.literal('unsupported') }).strict(), ]), }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 9c674cb0..ecaf7c34 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1549,30 +1549,28 @@ const DEEPSEEK: CliEntry = { // SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration // vector, not the key itself — a non-granted owner supplying THEIR OWN key removes // privilege rather than granting it, and clamping it here was a real regression - // (test/deepseek-mode.test.ts) fixed before this shipped. - privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'], - // Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No - // modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none' - // }` above), not an env var, so forcing a specific model name may not fully work; - // verify against a real profile before shipping. - // - // ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request - // 404s. Confirmed live and by reading dsh's own bundled source - // (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as - // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public - // API, https://api.deepseek.com, expects the caller's base URL to already carry any - // needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional - // "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the - // 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)") - // matches dsh's own error-message template for exactly this failure. See the - // customModelInjection doc comment in cli-registry/types.ts for the full reasoning, - // including why claude/gemini must NOT get this. + // (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY + // joins it (like codex's entry): the dedicated custom-endpoint key rides the same + // allowlisted-envOverrides path as the redirect itself. + privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'], + // The installed profile supplies the transport; a per-session patch selects its exact model. customModelInjection: { - kind: 'env', - baseUrlVar: 'DEEPSEEK_BASE_URL', - apiKeyVar: 'DEEPSEEK_API_KEY', - modelVars: [], - appendV1Suffix: true, + kind: 'configDir', + dirEnvVar: 'DSH_HOME', + fileName: 'cordis.patch.yml', + template: 'deepseek-patch-json', + // dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the + // session-log root (deleting it would take the user's transcripts with it on clear/ + // close), `profiles` are the installed bundles the patch composes over, and + // `settings.yaml` must keep applying. The generated home-level `cordis.patch.yml` + // deliberately replaces the user's own for the session rather than linking it. + // `sessions` and `profiles` are directories: dsh creates them itself on a first + // turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a + // dangling link whose every write fails ENOENT — the consumer creates missing + // directory targets and skips missing file targets like `settings.yaml`. + linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'], + linkFromRealHomeDirs: ['profiles', 'sessions'], + realHomeFallback: '.dsh', }, }, overlays: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index f802c17e..01475d49 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -859,12 +859,12 @@ export interface CliCapabilities { * * `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()` * applied before being written to `baseUrlVar`, instead of being used verbatim. Only - * deepseek and copilot declare it — both speak to `/v1/chat/completions` and a + * copilot declares it — its OpenAI-style route is `/v1/chat/completions`, and a * gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was * confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there * would be wrong, not just redundant. The per-session config-file recipes do not need - * this flag at all — `renderConfigFile()` applies the suffix inside every template, so - * no configDir entry carries a raw base URL. + * this flag at all — `renderConfigFile()` applies the suffix inside every template + * (deepseek's patch included), so no configDir entry carries a raw base URL. */ customModelInjection: | { @@ -884,8 +884,38 @@ export interface CliCapabilities { kind: 'configDir'; dirEnvVar: string; fileName: string; - template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml'; + template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json'; launchModel?: string; + /** + * Names of entries under this CLI's REAL home that must be symlinked into the + * isolated directory after the generated file is written — the same trick + * `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose + * home carries state that must survive the redirect (deepseek's `sessions` transcript + * root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop + * applying and — for `sessions` — get deleted by `removeConfigDir` on clear/close). + * Never include `fileName` itself: the generated file owns that name in the isolated + * directory, and a link there would write THROUGH into the user's real file. + * Consumed generically by custom-model-injection-apply.ts. + */ + linkFromRealHome?: string[]; + /** + * Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than + * files (`settings.yaml`). A missing directory target is created (mode 0700, what + * dsh does itself on a first turn) before linking — linking a nonexistent directory + * leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on + * EVERY turn. A missing FILE target is skipped instead of linked (dangling-file + * links serve no purpose the CLI could not do itself). Must be a subset of + * `linkFromRealHome`; entries absent from it are treated as files. + * Consumed generically by custom-model-injection-apply.ts. + */ + linkFromRealHomeDirs?: string[]; + /** + * Fallback location of the CLI's real home, relative to the user's home directory + * (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override + * nor the server's environment sets it. Required whenever `linkFromRealHome` is + * declared, so no consumer has to hardcode a per-CLI default of its own. + */ + realHomeFallback?: string; } | { kind: 'unsupported' }; } diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts index 43cfd5ad..018947b0 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -10,9 +10,9 @@ * cli-registry changes" requirement it was written against. */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { homedir, platform } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, isAbsolute } from 'node:path'; import { dataPath } from './config/instance.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { @@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string { * Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full * envOverrides object a caller should merge into the session/process env * (the dir-redirect var plus any `extraEnv` the config file references by - * name). Never touches anything outside `baseDir` — the caller is - * responsible for choosing an isolated directory (never the user's real - * `~/.codex`, `~/.pi`, etc.). + * name). The generated files stay under `baseDir`, but a recipe that declares + * `linkFromRealHome` additionally links (and, for missing directory targets, + * creates) entries in the CLI's REAL home — the caller is still responsible + * for choosing an isolated `baseDir` (never the user's real `~/.codex`, + * `~/.pi`, etc.), it is just no longer the only directory touched. * * pi and omp embed the API key literally in the file, so the tree is written * 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod * covers a re-apply onto a file that already exists (`mode` only applies at * creation). */ -export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record { +export function applyConfigDirInjection( + baseDir: string, + injection: ConfigDirInjection, + /** + * The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it + * set one — a user who relocated the CLI's real home (an allowlisted key like + * DSH_HOME) must have their state linked from THAT directory, not the server's. + */ + sessionHomeOverride?: string +): Record { for (const file of injection.files) { const filePath = join(baseDir, file.relPath); mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 }); writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 }); chmodSync(filePath, 0o600); } + linkRealHomeEntries(baseDir, injection, sessionHomeOverride); return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv }; } +/** + * Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home + * into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the + * generated config file and not the state that has to survive the session (deepseek: + * the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles` + * bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts — + * this consumer is recipe-driven and names no CLI itself. + * + * Home resolution: the session's own override first (unless it IS `baseDir` — a re-apply + * onto a session already holding a previous injection's value must not self-link), then + * the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values + * are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's + * cwd, and joining it here would mkdir/symlink under the SERVER's working directory and + * leave relative links that dangle. No home resolvable = + * no links, same best-effort posture as `linkSharedProjectsDir()`. + */ +function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void { + const names = injection.linkFromRealHome; + if (!names || names.length === 0) return; + const absoluteOnly = (value: string | undefined): string | undefined => + value && isAbsolute(value) ? value : undefined; + const fromSession = sessionHomeOverride?.trim(); + const realHome = + absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) || + absoluteOnly(process.env[injection.dirEnvVar]?.trim()) || + (injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined); + if (!realHome) return; + const dirs = new Set(injection.linkFromRealHomeDirs ?? []); + for (const name of names) { + linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name)); + } +} + +function linkRealHomeEntry(target: string, link: string, isDir: boolean): void { + // lstat, never existsSync: existsSync is false for a DANGLING link, and recovery + // re-applies on every restart, so the second pass would try to recreate the link and + // throw EEXIST. lstat answers for real entries, good links and dangling links alike. + try { + if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one + } catch { + // ENOENT — nothing is here yet, the only state in which a link gets created + } + if (!existsSync(target)) { + if (!isDir) return; // missing FILE target: skip, never plant a dangling file link + // Missing DIRECTORY target: a dangling directory link is not inert — the CLI's own + // recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under + // `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly + // what dsh itself does on a first turn. + try { + mkdirSync(target, { recursive: true, mode: 0o700 }); + } catch { + return; // e.g. the target's parent refuses writes — no link rather than a dangling one + } + } + try { + symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir'); + } catch { + // best-effort only — a platform that refuses symlinks keeps the documented + // side effect for this session rather than failing the whole apply over it + } +} + /** * Real, shared Claude config directory Codeman's own host process runs under — honors * `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()` @@ -250,7 +324,13 @@ export function applyCustomModelInjection( * trust-dialog seed, and only when provided (boot recovery, which has no reason to * re-answer a dialog that already fired once, omits it rather than re-deriving it). */ - workingDir?: string + workingDir?: string, + /** + * Reads one env var from the SESSION's own env overrides (single-key on purpose: the + * map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real + * home for `linkFromRealHome` from the session's value before the server's. + */ + sessionEnvLookup?: (envVar: string) => string | undefined ): AppliedCustomModel | undefined { const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength); if (injection.kind === 'unsupported') return undefined; @@ -283,6 +363,6 @@ export function applyCustomModelInjection( }; } const configDir = customModelConfigDir(sessionId); - const envOverrides = applyConfigDirInjection(configDir, injection); + const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar)); return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel }; } diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index d80c54a4..d64dc820 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -87,6 +87,12 @@ export interface ConfigDirInjection { * `customModelInjection.launchModel` template, never hand-built per CLI. */ launchModel?: string; + /** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHome`. */ + linkFromRealHome?: string[]; + /** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHomeDirs`. */ + linkFromRealHomeDirs?: string[]; + /** Carried from the registry recipe — see `cli-registry/types.ts` `realHomeFallback`. */ + realHomeFallback?: string; } export interface UnsupportedInjection { @@ -143,7 +149,15 @@ export function buildCustomModelInjection( case 'configDir': { const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength); return withLaunchModel( - { kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv }, + { + kind: 'configDir', + dirEnvVar: cap.dirEnvVar, + files: [{ relPath: cap.fileName, content }], + extraEnv, + ...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}), + ...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}), + ...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}), + }, cap.launchModel, modelId ); @@ -209,14 +223,21 @@ function renderConfigContent( } } -const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY'; +/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that + * reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native + * name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too, + * so an in-session `/model` switch back to `deepseek-official` (or a profile with web + * search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both + * entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars. + */ +const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY'; /** The `[model.]` block name grok's config.toml uses for the injected model — also * what `-m ` in the standalone script's ONE_SHOT argv must reference to select it. */ export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom'; function renderConfigFile( - template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml', + template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json', endpoint: CustomModelEndpoint, modelId: string, apiKey: string, @@ -224,6 +245,47 @@ function renderConfigFile( ): { content: string; extraEnv?: Record } { const baseUrl = withV1Suffix(endpoint.baseUrl); switch (template) { + case 'deepseek-patch-json': { + const route = { provider: 'codeman-custom', model: modelId }; + return { + content: JSON.stringify( + [ + { + id: 'llm-pi-ai', + name: '@deepseek-ai/dsh-llm-pi-ai', + config: { + providers: { + 'codeman-custom': { + // By NAME only (never the literal key — see CUSTOM_MODEL_API_KEY_ENV_VAR). + apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR, + api: 'openai-completions', + baseURL: baseUrl, + models: [ + { + id: modelId, + name: modelId, + ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0 + ? { + contextWindow: Math.trunc(contextLength), + // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window. + maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))), + } + : {}), + }, + ], + }, + }, + }, + }, + { id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route }, + { id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route }, + ], + null, + 2 + ), + extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey }, + }; + } case 'codex-toml': { // Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never // a `[model].default` table — codex rejects that with "invalid type: map, expected @@ -252,11 +314,11 @@ function renderConfigFile( '[model_providers.custom]', `name = "Custom Endpoint"`, `base_url = ${quoted(baseUrl)}`, - `env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`, + `env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`, `wire_api = "responses"`, '', ].join('\n'); - return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } }; + return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } }; } case 'pi-models-json': // Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of diff --git a/src/session.ts b/src/session.ts index 05d1f46d..564ee41d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1330,6 +1330,17 @@ export class Session extends EventEmitter { return value && value.trim() ? value.trim() : undefined; } + /** + * One env var from this session's overrides, for internal path resolution only + * (custom-model injection linking an isolated config dir back to a path that must + * survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the + * map can hold provider credentials and stays off the public `SessionState`. + */ + envOverrideValue(key: string): string | undefined { + const value = this._envOverrides?.[key]; + return value && value.trim() ? value.trim() : undefined; + } + /** Owning username in multi-user mode, else undefined. */ get owner(): string | undefined { return this._owner; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 1314f094..7545a01e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -7,7 +7,7 @@ import { claudeProjectDirName } from '../../case-rename.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; -import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; +import { join, dirname, extname, basename, isAbsolute, resolve as resolvePath } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -127,7 +127,7 @@ import { agentInbox } from '../agent-inbox.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; -import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js'; +import type { AgentSkillDir, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; @@ -231,6 +231,36 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId)); } +/** + * Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs. + * + * The apply resolves the CLI's real home from this session's env override and then runs + * synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future + * `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous + * stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole + * web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to + * the apply, which already refuses to join non-absolute values. + * + * Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on + * `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that + * one env var, so linking falls through to the server's own environment and the unresponsive + * path is never touched. Any other entry's lookup passes through untouched. + */ +async function guardSessionConfigDirHome( + entry: Pick, + baseLookup: (envVar: string) => string | undefined +): Promise<(envVar: string) => string | undefined> { + const recipe = entry.capabilities.customModelInjection; + if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup; + const dirEnvVar = recipe.dirEnvVar; + const supplied = baseLookup(dirEnvVar)?.trim(); + if (!supplied || !isAbsolute(supplied)) return baseLookup; + if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') { + return (envVar: string) => (envVar === dirEnvVar ? undefined : baseLookup(envVar)); + } + return baseLookup; +} + /** * The case a POST /api/sessions `workingDir` belongs to, for its default session * group: a direct child of the caller's case space, or (admin / single-user, as @@ -1641,7 +1671,8 @@ export function registerSessionRoutes( body.modelId, session.id, contextLength, - session.workingDir + session.workingDir, + await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar)) ); if (!applied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`); @@ -4528,7 +4559,8 @@ export function registerSessionRoutes( customModel.modelId, qsCustomModelSessionId, cmContextLength, - workspacePath + workspacePath, + await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar]) ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); diff --git a/src/web/server.ts b/src/web/server.ts index 172bc9f8..2166debe 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -81,7 +81,12 @@ import { folderGrants } from '../folder-listing.js'; import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js'; import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js'; -import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js'; +import { + applyCustomModelInjection, + customModelConfigDir, + removeConfigDir, + type AppliedCustomModel, +} from '../custom-model-injection-apply.js'; import type { CustomModelBookkeeping } from '../types/session.js'; import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js'; import { @@ -1454,7 +1459,7 @@ export class WebServer extends EventEmitter { private async _rebuildCustomModelEnv( session: Session, saved: CustomModelBookkeeping - ): Promise | undefined> { + ): Promise { const entry = getCli(session.mode); if (!entry) return undefined; const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId); @@ -1465,19 +1470,56 @@ export class WebServer extends EventEmitter { return undefined; } try { + // Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that + // already fired once) and the session-env real-home lookup: the injected VALUES are + // never persisted, so after a real restart the session can no longer carry its + // allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only + // `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`) — the lookup could only ever hit on the first + // apply in-process. Real-home resolution therefore falls back to the SERVER's + // `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh). return applyCustomModelInjection( entry, endpoint, saved.modelId, session.id, resolveCustomModelContextLength(endpoint, saved.modelId) - )?.envOverrides; + ); } catch (err) { console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err); return undefined; } } + /** + * Restore one persisted custom-model selection (both boot-recovery paths). The rebuilt + * bookkeeping must come from the CURRENT recipe, not from the saved record: this is the + * first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session + * patch home), so a selection saved by an old build restores with e.g. no `configDir` + * while the new recipe writes one — and clearing it later could then never delete the + * generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may + * still carry `setenv`s from both, and a clear has to unset every one. + */ + private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise { + const applied = await this._rebuildCustomModelEnv(session, saved); + if (!applied) { + // Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which + // is what a later clear needs. + session.setCustomModel(saved); + return; + } + session.setCustomModel( + { + ...saved, + // Prefer the current recipe's dir; keep the saved one when the recipe writes none, + // so a clear can still delete a directory an older recipe created. + configDir: applied.configDir ?? saved.configDir, + launchModel: applied.launchModel, + envKeys: [...new Set([...saved.envKeys, ...applied.envKeys])], + }, + applied.envOverrides + ); + } + /** Persists full session state including respawn config to state.json */ private _persistSessionStateNow(session: Session): void { // See session-manager.updateSessionState: __envOverrides is an internal disk-only @@ -3604,7 +3646,7 @@ export class WebServer extends EventEmitter { // bookkeeping survives a restart and the values are re-derived here. const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel; if (savedCustomModel) { - session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel)); + await this._restoreCustomModel(session, savedCustomModel); } if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) { session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue }); @@ -3906,7 +3948,7 @@ export class WebServer extends EventEmitter { // the isolated config dir; an endpoint that has since been deleted still gets // the bookkeeping restored, which is what a later clear needs to unset. if (savedCustomModel) { - session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel)); + await this._restoreCustomModel(session, savedCustomModel); } // Update session name if it was a "Restored:" placeholder or doesn't match saved name diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index d0fa5f5b..0d7f84b2 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => { }); }); +/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */ +function expectRejectedOnDeepSeek(mutate: (recipe: Record) => void, because: string): void { + const entry = baseEntry('deepseek'); + const recipe = (entry.capabilities as Record).customModelInjection as Record; + mutate(recipe); + const result = CliEntrySchema.safeParse(entry); + expect(result.success, `expected rejection: ${because}`).toBe(false); +} + +describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => { + it('rejects a realHomeFallback that is not a single relative path segment', () => { + // The consumer joins it under the user's home, so any of these escapes that home — + // and bare `.` IS that home: linking through it would expose the whole tree + // unshielded instead of just the declared segments. + for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) { + expectRejectedOnDeepSeek((r) => { + r.realHomeFallback = bad; + }, `realHomeFallback ${JSON.stringify(bad)}`); + } + }); + + it('accepts a shipped-style dot-prefixed segment', () => { + const entry = baseEntry('deepseek'); + expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh` + const recipe = (entry.capabilities as Record).customModelInjection as Record; + recipe.realHomeFallback = '.dsh-relocated'; + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); + + it('requires realHomeFallback whenever linkFromRealHome is declared', () => { + expectRejectedOnDeepSeek((r) => { + delete r.realHomeFallback; + }, 'without it the consumer would have to hardcode a per-CLI home default'); + }); + + it('rejects a linked entry that collides with fileName', () => { + // A symlink at the generated file's own name would write THROUGH into the user's real config. + expectRejectedOnDeepSeek((r) => { + r.linkFromRealHome = ['cordis.patch.yml', 'profiles']; + }, 'linkFromRealHome containing fileName'); + }); + + it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => { + // types.ts documents the rule; a name absent from the linked list names nothing, + // so the create-missing-directory behaviour would silently never apply to it. + expectRejectedOnDeepSeek((r) => { + r.linkFromRealHome = ['profiles', 'settings.yaml']; + r.linkFromRealHomeDirs = ['profiles', 'sessions']; + }, 'linkFromRealHomeDirs naming an unlinked entry'); + // The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs + // of the three linked entries) and keeps loading. + const entry = baseEntry('deepseek'); + const recipe = (entry.capabilities as Record).customModelInjection as Record; + expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']); + expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']); + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); +}); + describe('the shipped catalog', () => { it('validates every stock entry exactly as shipped', () => { // If this fails, the catalog cannot load at all — every other test here is downstream. diff --git a/test/custom-model-injection-apply.test.ts b/test/custom-model-injection-apply.test.ts index 9f870100..6aca903e 100644 --- a/test/custom-model-injection-apply.test.ts +++ b/test/custom-model-injection-apply.test.ts @@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => { expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); }); - it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => { - const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384); - expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']); + it('deepseek: passes the known context through its isolated model catalogue', () => { + const sessionId = 'sess-deepseek-context'; + sessionsToClean.push(sessionId); + const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768); + expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']); + expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768'); }); }); @@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId)); }); - it('deepseek: no configDirVar declared, so no config dir is created at all', () => { + it('deepseek: creates an isolated profile patch', () => { const sessionId = 'sess-cfgdir-deepseek'; + sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId); - expect(applied?.configDir).toBeUndefined(); - expect(existsSync(customModelConfigDir(sessionId))).toBe(false); + expect(applied?.configDir).toBe(customModelConfigDir(sessionId)); + expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true); }); }); diff --git a/test/custom-model-injection-contract.test.ts b/test/custom-model-injection-contract.test.ts index 1685504d..940e1ba0 100644 --- a/test/custom-model-injection-contract.test.ts +++ b/test/custom-model-injection-contract.test.ts @@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => { expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); }); - it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => { - // Confirmed by reading dsh's own bundled source: it fetches - // `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so - // this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat - // UNMODIFIED, exactly mirroring what the real harness does, rather than the test - // helping it along. + it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => { + // dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already + // carries /v1 and is passed to callOpenAiCompat unmodified. const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3'); - if (injection.kind !== 'env') throw new Error('unreachable'); - expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']); - expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`); + if (injection.kind !== 'configDir') throw new Error('unreachable'); + expect(injection.dirEnvVar).toBe('DSH_HOME'); + expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']); + const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom']; + expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`); + expect(provider.models[0].id).toBe('qwen3'); - await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3'); + await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3'); expect(mock.requests[0].path).toBe('/v1/chat/completions'); expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key'); diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index 11d4146d..a0ba3982 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -229,29 +229,32 @@ describe('buildCustomModelInjection', () => { expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' }); }); - it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => { - // appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own - // bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as - // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while - // llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every - // request 404s (confirmed live; this is the fix for the originally-reported - // "dsh: HTTP_404: DeepSeek API error (HTTP 404)"). + it('deepseek: isolates the selected model route and passes its key by reference', () => { const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3'); - if (result.kind !== 'env') throw new Error('unreachable'); - expect(result.envOverrides).toEqual({ - DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1', - DEEPSEEK_API_KEY: 'my-key', - }); + if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config'); + expect(result.dirEnvVar).toBe('DSH_HOME'); + expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' }); + expect(result.files[0].content).not.toContain('my-key'); + const patch = JSON.parse(result.files[0].content); + // The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY — + // dsh's llm-deepseek/web-search rows read that too and would leak this key to + // api.deepseek.com on an in-session /model switch back to the official route. + expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe( + 'CODEMAN_CUSTOM_MODEL_API_KEY' + ); + expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY'); + expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3'); }); - it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => { + it('deepseek: preserves an existing version suffix', () => { const result = buildCustomModelInjection( entryOrThrow('deepseek'), - { ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' }, + { ...endpoint, baseUrl: endpoint.baseUrl + '/v1' }, 'qwen3' ); - if (result.kind !== 'env') throw new Error('unreachable'); - expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1'); + if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config'); + expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1'); + expect(result.files[0].content).not.toContain('/v1/v1'); }); it('antigravity: unsupported', () => { diff --git a/test/custom-model-recovery-context.test.ts b/test/custom-model-recovery-context.test.ts new file mode 100644 index 00000000..b217c555 --- /dev/null +++ b/test/custom-model-recovery-context.test.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME + * context-length resolution the routes use: the model's discovered figure, falling back to + * the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the + * shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone — so a session + * on a model that only the endpoint default covers came back from a Codeman restart with + * `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone, + * re-arming exactly the window overflow the var exists to prevent. + * + * `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint + * store and the Session it is handed), so it is driven straight off the prototype with an + * empty receiver — `restoreMuxSessions()` itself cannot be reached under vitest, the same + * constraint recorded in test/session-model-recovery.test.ts. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { getDataDir } from '../src/config/instance.js'; +import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; +import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js'; +import { Session } from '../src/session.js'; +import { WebServer } from '../src/web/server.js'; +import type { CustomModelBookkeeping } from '../src/types/session.js'; + +type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise; + +/** The real method, with an empty receiver — see the file header for why that is sound. */ +function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) { + const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv; + expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function'); + return fn.call({}, session, saved); +} + +function endpoint(overrides: Partial = {}): CustomModelHost { + return { + id: 'ep', + label: 'Ep', + baseUrl: 'http://localhost:8080', + apiKey: 'recovery-probe-key', + models: ['qwen3'], + ...overrides, + }; +} + +describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => { + const configs: string[] = []; + + afterEach(() => { + for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id)); + }); + + it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => { + await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768'); + }); + + it('a discovered per-model figure still wins over the endpoint default', async () => { + await writeCustomModelHosts(getDataDir(), [ + endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }), + ]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072'); + }); + + it('states nothing rather than guessing when neither source knows a window', async () => { + await writeCustomModelHosts(getDataDir(), [endpoint()]); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + + expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); + }); + + it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => { + await writeCustomModelHosts(getDataDir(), []); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + configs.push(session.id); + + await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe( + undefined + ); + }); +}); diff --git a/test/custom-model-recovery.test.ts b/test/custom-model-recovery.test.ts index 5ef64a18..75f1b69b 100644 --- a/test/custom-model-recovery.test.ts +++ b/test/custom-model-recovery.test.ts @@ -1,18 +1,27 @@ /** - * @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME - * context-length resolution the routes use: the model's discovered figure, falling back to - * the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the - * shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone — so a session - * on a model that only the endpoint default covers came back from a Codeman restart with - * `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone, - * re-arming exactly the window overflow the var exists to prevent. + * @fileoverview Boot recovery re-derives a saved custom-model selection through + * `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe + * applied — not from the saved record. A selection persisted while deepseek still used + * the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/ + * `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch + * home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir + * that clearing could never delete. * - * `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint - * store and the Session it is handed), so it is driven straight off the prototype with an - * empty receiver — `restoreMuxSessions()` itself cannot be reached under vitest, the same - * constraint recorded in test/session-model-recovery.test.ts. + * Real-home resolution is NOT recovered from the session's env overrides any more: + * `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a + * session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on + * the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the + * recipe's `realHomeFallback` (~/.dsh) — see `_rebuildCustomModelEnv`'s comment. + * + * `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives + * everything from the endpoint store and the Session it is handed), so it is driven off + * the prototype with a minimal receiver — `restoreMuxSessions()` itself cannot be + * reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts. */ -import { afterEach, describe, expect, it } from 'vitest'; +import { mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getDataDir } from '../src/config/instance.js'; import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js'; @@ -20,72 +29,74 @@ import { Session } from '../src/session.js'; import { WebServer } from '../src/web/server.js'; import type { CustomModelBookkeeping } from '../src/types/session.js'; -type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise | undefined>; - -/** The real method, with an empty receiver — see the file header for why that is sound. */ -function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) { - const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv; - expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function'); - return fn.call({}, session, saved); +/** The real pair, on a receiver that only carries the rebuild helper — see the header. */ +async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise { + const proto = WebServer.prototype as unknown as { + _rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise; + _restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise; + }; + expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function'); + await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved); } -function endpoint(overrides: Partial = {}): CustomModelHost { +function endpoint(): CustomModelHost { return { id: 'ep', label: 'Ep', baseUrl: 'http://localhost:8080', apiKey: 'recovery-probe-key', models: ['qwen3'], - ...overrides, }; } -describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => { +describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => { const configs: string[] = []; afterEach(() => { for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id)); + vi.unstubAllEnvs(); }); - it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => { - await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]); - const session = new Session({ workingDir: '/tmp', mode: 'claude' }); - configs.push(session.id); - - const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); - - expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768'); - }); - - it('a discovered per-model figure still wins over the endpoint default', async () => { - await writeCustomModelHosts(getDataDir(), [ - endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }), - ]); - const session = new Session({ workingDir: '/tmp', mode: 'claude' }); - configs.push(session.id); - - const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); - - expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072'); - }); - - it('states nothing rather than guessing when neither source knows a window', async () => { + it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => { + // Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two + // keys, no configDir. The rebuilt record must carry the per-session config dir the + // CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which + // the tmux session may still carry `setenv`s for (the union is what a clear unsets). + const dshHome = join(homedir(), 'codeman-recovery-dsh-home'); + mkdirSync(join(dshHome, 'profiles'), { recursive: true }); + vi.stubEnv('DSH_HOME', dshHome); await writeCustomModelHosts(getDataDir(), [endpoint()]); - const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const session = new Session({ workingDir: '/tmp', mode: 'deepseek' }); configs.push(session.id); - const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] }); + await restoreCustomModel(session, { + endpointId: 'ep', + modelId: 'qwen3', + label: 'Ep', + envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'], + }); - expect(env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); + const persisted = session.getCustomModelForPersist(); + expect(persisted?.configDir).toBe(customModelConfigDir(session.id)); + expect(persisted?.launchModel).toBeUndefined(); + expect(new Set(persisted?.envKeys)).toEqual( + new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY']) + ); + rmSync(dshHome, { recursive: true, force: true }); }); - it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => { + it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => { await writeCustomModelHosts(getDataDir(), []); - const session = new Session({ workingDir: '/tmp', mode: 'claude' }); - configs.push(session.id); + const session = new Session({ workingDir: '/tmp', mode: 'deepseek' }); + const saved: CustomModelBookkeeping = { + endpointId: 'gone', + modelId: 'qwen3', + label: 'Gone', + envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'], + }; - await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe( - undefined - ); + await restoreCustomModel(session, saved); + + expect(session.getCustomModelForPersist()).toEqual(saved); }); }); diff --git a/test/deepseek-custom-endpoint.test.ts b/test/deepseek-custom-endpoint.test.ts new file mode 100644 index 00000000..6fed8202 --- /dev/null +++ b/test/deepseek-custom-endpoint.test.ts @@ -0,0 +1,207 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCli } from '../src/config/cli-registry/index.js'; +import { buildCustomModelInjection } from '../src/custom-model-injection.js'; +import { + applyCustomModelInjection, + removeConfigDir, + customModelConfigDir, +} from '../src/custom-model-injection-apply.js'; + +afterEach(() => { + removeConfigDir(customModelConfigDir('deepseek-probe')); + vi.unstubAllEnvs(); +}); + +describe('DeepSeek custom endpoint isolation', () => { + it('uses the chosen model and limits without changing installed profiles or embedding the key', () => { + const realHome = join(homedir(), '.dsh'); + const profile = join(realHome, 'profiles', 'dsh-tui'); + mkdirSync(profile, { recursive: true }); + mkdirSync(join(realHome, 'sessions'), { recursive: true }); + writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark'); + const original = join(profile, 'cordis.patch.yml'); + writeFileSync(original, 'original profile'); + vi.stubEnv('DSH_HOME', realHome); + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8'); + expect(content).not.toContain(endpoint.apiKey); + // The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search + // rows read that too, and an in-session /model switch back to the official route must + // not take the custom endpoint's key to api.deepseek.com with it. + expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey); + expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined(); + const patch = JSON.parse(content); + const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom']; + expect(provider.api).toBe('openai-completions'); + expect(provider.baseURL).toBe('http://box:8080/v1'); + expect(provider.models).toEqual([ + { id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 }, + ]); + expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({ + provider: 'codeman-custom', + model: 'chosen-model', + }); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles')); + // The rest of the linkFromRealHome list, consumed generically: `sessions` is the + // transcript root removeConfigDir would otherwise delete, and the user's settings.yaml + // must keep applying through the redirect. + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml')); + // Re-apply onto the same config dir (boot recovery re-runs this every restart): the + // lstat guard must skip every existing link, which the old existsSync check missed — + // recreating them would raise EEXIST. + const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir); + removeConfigDir(applied.configDir); + expect(readFileSync(original, 'utf8')).toBe('original profile'); + }); + + it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => { + // A fresh install (or relocated DSH_HOME) has never run a turn, so the real home + // carries none of the linked entries. Linking a missing DIRECTORY left a dangling + // symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn, + // and the lstat guard kept that dangling link forever. Directories are now created + // (0700, what dsh itself does on a first turn) before linking; a missing FILE + // (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both + // the fake real home and the generated dir off the developer's own dotfiles. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-')); + const realHome = join(sandbox, '.dsh'); + mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml + vi.stubEnv('HOME', sandbox); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback` + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles')); + expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700); + // The missing FILE target is skipped outright: not a dangling link either — lstat + // must find nothing, so a later apply (once the file exists for real) links it. + expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false); + expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined(); + + // Re-apply stays fine (boot recovery re-runs it every restart): idempotent... + const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir); + // ...and a target deleted AFTER linking keeps the existing link untouched (the + // lstat guard), rather than recreating it and throwing EEXIST. + rmSync(join(realHome, 'sessions'), { recursive: true, force: true }); + expect( + applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME + ).toBe(applied.configDir); + expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => { + // Both web routes pass the session's own env-override lookup as the seventh + // argument; a user who relocated the dsh home via an allowlisted DSH_HOME + // override must get their state linked from THAT directory, not the server's. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-')); + const serverHome = join(sandbox, 'server-home'); + const sessionHome = join(sandbox, 'session-home'); + mkdirSync(join(sessionHome, 'sessions'), { recursive: true }); + mkdirSync(join(sessionHome, 'profiles'), { recursive: true }); + writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light'); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', serverHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection( + entry, + endpoint, + 'chosen-model', + 'deepseek-probe', + 131072, + undefined, + (k) => (k === 'DSH_HOME' ? sessionHome : undefined) + ); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions')); + expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles')); + expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml')); + // The server-environment home was never consulted, let alone created. + expect(existsSync(serverHome)).toBe(false); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('never self-links when the session override IS its own config dir; falls through to the server home', () => { + // Boot recovery re-applies onto a session already holding a previous injection's + // DSH_HOME value; linking that value would point the isolated directory at itself. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-')); + const serverHome = join(sandbox, 'server-home'); + mkdirSync(join(serverHome, 'sessions'), { recursive: true }); + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', serverHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection( + entry, + endpoint, + 'chosen-model', + 'deepseek-probe', + 131072, + undefined, + (k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined) + ); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('skips a relative DSH_HOME rather than creating it under the server cwd', () => { + // A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in + // the SERVER's working directory and leave relative links that dangle (a relative + // home resolves against the CLI's cwd, which the server cannot know). Skipping it + // falls through to the recipe's `realHomeFallback`, and nothing is created under cwd. + const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-')); + const fallbackHome = join(sandbox, '.dsh'); + mkdirSync(fallbackHome, { recursive: true }); + const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`; + vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir() + vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data')); + vi.stubEnv('DSH_HOME', relativeHome); + try { + const entry = getCli('deepseek'); + if (!entry) throw new Error('Missing DeepSeek entry'); + const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' }; + const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072); + if (!applied?.configDir) throw new Error('Missing isolated profile'); + expect(existsSync(join(process.cwd(), relativeHome))).toBe(false); + expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions')); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); + +/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */ +function readlinkSafe(path: string): string | undefined { + try { + return readlinkSync(path); + } catch { + return undefined; + } +} diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 3be966a2..7ce1e0c8 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -454,6 +454,9 @@ export class MockSession extends EventEmitter { } ); restartCli = vi.fn(async () => true); + /** Mirrors Session.envOverrideValue; no override map here, so the custom-model + * real-home lookup defaults to "session says nothing" (overridable per test). */ + envOverrideValue = vi.fn((_key: string): string | undefined => undefined); getCustomModelForPersist = vi.fn(() => this._mockCustomModel); /** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */ reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;