mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge pull request #755: feat(custom-model): DeepSeek Harness injection through a per-session patch
This commit is contained in:
commit
4c29aedbc5
20 files changed
+866
-151
No files matched your search
@@ -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 <baseUrl>/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.<name>]` 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.<name>]` 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 <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <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/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, 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 `</script>` 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).
|
||||
|
||||
|
||||
@@ -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/<id>` 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.<name>]` 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 <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/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/<id>` 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 |
|
||||
|
||||
|
||||
@@ -601,14 +601,35 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST <baseUrl>/chat/completions` 404s against llama-swap while
|
||||
`POST <baseUrl>/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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 `<base>/v1/chat/completions` and a
|
||||
* copilot declares it — its OpenAI-style route is `<base>/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' };
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string> {
|
||||
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 };
|
||||
}
|
||||
@@ -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.<name>]` block name grok's config.toml uses for the injected model — also
|
||||
* what `-m <name>` 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<string, string> } {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CliEntry, 'capabilities'>,
|
||||
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`);
|
||||
|
||||
+47
-5
@@ -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<Record<string, string> | undefined> {
|
||||
): Promise<AppliedCustomModel | undefined> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>) => void, because: string): void {
|
||||
const entry = baseEntry('deepseek');
|
||||
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
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<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
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<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<AppliedCustomModel | 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);
|
||||
}
|
||||
|
||||
function endpoint(overrides: Partial<CustomModelHost> = {}): 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string> | 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<void> {
|
||||
const proto = WebServer.prototype as unknown as {
|
||||
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
|
||||
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
|
||||
};
|
||||
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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user