mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012ca2e971 | ||
|
|
21256202ba | ||
|
|
46b85fa6d0 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. Behaviour change: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host, and a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out. Thanks to @Randalix for the report and the fix.
|
||||
@@ -21,9 +21,7 @@ permissions:
|
||||
jobs:
|
||||
browser:
|
||||
name: Playwright browser suite
|
||||
# Pinned, not ubuntu-latest: that moved to 26.04 on 2026-11-05 and the locked Playwright 1.60.0
|
||||
# refuses to install there (support starts at 1.61.0). Unpin when Playwright is bumped.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
|
||||
@@ -185,7 +185,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path, which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
⚠️ 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. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, a path, which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ 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.
|
||||
⚠️ **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`.
|
||||
|
||||
|
||||
@@ -535,19 +535,13 @@ id, model and injected key NAMES are persisted, the values are re-derived
|
||||
from the endpoint store on recovery, and the pane keeps running against the
|
||||
endpoint in between because tmux retains its environment.
|
||||
|
||||
A value the session set itself under an injected key comes back when the
|
||||
selection goes, on a clear or a switch to another endpoint. That is what
|
||||
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
|
||||
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
|
||||
|
||||
⚠️ Across a Codeman restart only the home directory comes back. The
|
||||
selection overwrote the tmux `setenv` that held it, so its record in
|
||||
`state.json` keeps that one path (`ownEnv`), and a non-granted owner loses it
|
||||
at recovery like any other privileged override. Any other own value can be a
|
||||
credential and stays in memory: a session that set its own `GEMINI_API_KEY`,
|
||||
picked an endpoint and then outlived a restart falls back to the server's key
|
||||
on clear. A selection saved by an older build has no such record, so its first
|
||||
clear still falls back to the server's default.
|
||||
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
|
||||
of the names claude's selection injects — so a session that ALSO had
|
||||
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
|
||||
per-client-account case) loses that override on clear too, and silently
|
||||
falls back to the server's default Claude account. If you route a session
|
||||
to a specific account this way, re-apply the override after clearing a
|
||||
custom-model selection from it.
|
||||
|
||||
**New sessions always default back to the harness's native backend.** A
|
||||
custom-endpoint selection is a per-session choice, never a sticky global
|
||||
|
||||
@@ -133,6 +133,24 @@ Key points:
|
||||
**login shell**, not the agent.
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- **That argument reaches the far end verbatim** (#760): the launch goes to the
|
||||
local `bash -c` in single quotes, so `$HOME`, `$PATH` and `$SHELL` are the
|
||||
remote user's. In double quotes the local `sh` expanded them first, which sent
|
||||
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
|
||||
now expands on the far end too. A Docker launch is verbatim as well, so a bare
|
||||
`$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal
|
||||
characters: write the value out.
|
||||
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
|
||||
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
|
||||
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
|
||||
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
|
||||
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
|
||||
override stays one argument on purpose, since it is written for the user's own shell
|
||||
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
|
||||
- **Login shells**: POSIX shells and fish work. tcsh refuses the `-i -l` pair and needs
|
||||
a `commands.*` override per mode (`exec claude`). nushell and elvish cannot run the
|
||||
PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe does
|
||||
not start under fish.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
||||
preserving historical token order.
|
||||
|
||||
Generated
+10
-28
@@ -63,7 +63,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
@@ -9736,50 +9736,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
|
||||
@@ -301,8 +301,6 @@ export interface AppliedCustomModel {
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,16 +360,9 @@ export function applyCustomModelInjection(
|
||||
envKeys: Object.keys(envOverrides),
|
||||
configDir,
|
||||
launchModel: injection.launchModel,
|
||||
homeEnvVar: injection.configDirVar,
|
||||
};
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
|
||||
return {
|
||||
envOverrides,
|
||||
envKeys: Object.keys(envOverrides),
|
||||
configDir,
|
||||
launchModel: injection.launchModel,
|
||||
homeEnvVar: injection.dirEnvVar,
|
||||
};
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
+1
-1
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
||||
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
||||
* interpolated into the `bash -c` launch layer is escaped through
|
||||
* this so a path with spaces stays a single shell token. Operator-entered fields
|
||||
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
||||
*/
|
||||
|
||||
+14
-10
@@ -66,10 +66,11 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
||||
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
|
||||
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
|
||||
* SHELL into a pane from its own `default-shell` option, so the command as USED
|
||||
* here is safe either way (also verified). The default matters because these
|
||||
* strings are the seed values a per-host `commands.*` override is edited from, and
|
||||
* nothing constrains where an edited one ends up running. Quoted for a shell path
|
||||
* containing spaces. `/bin/sh` exists on every POSIX host.
|
||||
* here is safe either way (also verified). Quoted for a shell path containing
|
||||
* spaces. `/bin/sh` exists on every POSIX host.
|
||||
*
|
||||
* ⚠️ This is POSIX text. fish refuses `${…}` (status 127), so a launch hands it to
|
||||
* `/bin/sh -c`, never to the remote default-shell (`buildRemoteLaunchCommand`).
|
||||
*/
|
||||
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
||||
|
||||
@@ -80,10 +81,12 @@ const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
||||
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
|
||||
* "command not found" (exit 127).
|
||||
*
|
||||
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
|
||||
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
|
||||
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
|
||||
* is for.
|
||||
* Shells that take neither flag cannot be detected from here the way
|
||||
* `loginShellArgs()` detects them locally, since the shell is whatever the REMOTE
|
||||
* passwd says. A host like that is what the per-host `commands.*` override is for
|
||||
* (tcsh, which rejects `-l` combined with `-i`). It only goes so far: the PATH
|
||||
* bootstrap below runs in the login shell first, so a shell that cannot parse
|
||||
* `export` at all (nushell, elvish) cannot be rescued by an override.
|
||||
*/
|
||||
export function remoteLoginShellCommand(command: string): string {
|
||||
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
||||
@@ -143,8 +146,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
|
||||
* The per-host `commands.*` override remains the escape hatch for every mode.
|
||||
*/
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
|
||||
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
|
||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, and
|
||||
// tmux exports its default-shell as SHELL into the pane, so this launches their actual
|
||||
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` so it sources rc files,
|
||||
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
|
||||
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
|
||||
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* re-resolves the grant at the moment it builds a session, for the same reason.
|
||||
*
|
||||
* @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
|
||||
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes, web/server
|
||||
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
|
||||
*
|
||||
* @module session-env-clamp
|
||||
*/
|
||||
|
||||
+8
-45
@@ -1994,36 +1994,21 @@ export class Session extends EventEmitter {
|
||||
|
||||
/**
|
||||
* The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the
|
||||
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no injected env
|
||||
* values, so nothing secret lands on disk; recovery re-derives them from the endpoint store.
|
||||
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no env values,
|
||||
* so nothing secret lands on disk; recovery re-derives them from the endpoint store.
|
||||
* Without this a Codeman restart left the pane on the custom endpoint (tmux keeps
|
||||
* its `setenv`s) while `customModel` came back undefined, so the state was wrong and
|
||||
* clearing had nothing to unset. Must NOT be included in any API-bound serializer.
|
||||
*
|
||||
* The one value it does carry is the session's own home directory (`ownEnv`, narrowed to
|
||||
* `homeEnvVar`), a path like `configDir` beside it: the selection overwrote the tmux
|
||||
* `setenv` that held it, so without this a clear after a restart would lose it. Any other
|
||||
* entry of `ownEnv` may be a credential and stays in memory.
|
||||
*/
|
||||
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
|
||||
if (!this._customModel) return undefined;
|
||||
const { ownEnv, ...selection } = this._customModel;
|
||||
const { homeEnvVar } = selection;
|
||||
const ownHome = homeEnvVar ? ownEnv?.[homeEnvVar] : undefined;
|
||||
return {
|
||||
...selection,
|
||||
envKeys: [...selection.envKeys],
|
||||
...(homeEnvVar && ownHome ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
|
||||
};
|
||||
return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this session's custom-model selection and merge the endpoint's injected env
|
||||
* vars into `_envOverrides` — first UNDOING whatever the previous selection injected
|
||||
* (removing exactly those env keys, and putting back a value the session had set under
|
||||
* one itself), so switching endpoints, or clearing back to the harness's native cloud
|
||||
* default, never leaves a stale key behind and never loses the session's own `DSH_HOME`
|
||||
* or `CLAUDE_CONFIG_DIR`. Synchronous and
|
||||
* (removing exactly those env keys), so switching endpoints, or clearing back to the
|
||||
* harness's native cloud default, never leaves a stale key behind. Synchronous and
|
||||
* side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this
|
||||
* class does no file IO, so it reports the PREVIOUS `configDir` (if any) for the
|
||||
* caller to clean up on disk (custom-model-injection.ts's configDir kind).
|
||||
@@ -2033,12 +2018,7 @@ export class Session extends EventEmitter {
|
||||
* `_buildRespawnPaneOptions().unsetEnvKeys`): the tmux session inherits every
|
||||
* `setenv` into `respawn-pane`, so dropping them from the map alone would relaunch
|
||||
* the CLI still pointed at the old endpoint — and for the `configDir` kinds, at a
|
||||
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted. A key put back stays out
|
||||
* of that queue: `applyEnvOverrides()` writes its value over the injected one.
|
||||
*
|
||||
* `next.ownEnv` is for recovery, which states the session's own values itself: after a
|
||||
* restart `_envOverrides` holds what the previous run injected (`CLAUDE_CONFIG_DIR` is
|
||||
* persisted), and reading those as the session's own would pin it to a deleted directory.
|
||||
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted.
|
||||
*/
|
||||
setCustomModel(
|
||||
next: CustomModelBookkeeping | undefined,
|
||||
@@ -2047,30 +2027,13 @@ export class Session extends EventEmitter {
|
||||
const previousConfigDir = this._customModel?.configDir;
|
||||
const removedEnvKeys: string[] = [];
|
||||
if (this._customModel) {
|
||||
const previousOwnEnv = this._customModel.ownEnv ?? {};
|
||||
for (const key of this._customModel.envKeys) {
|
||||
if (this._envOverrides) delete this._envOverrides[key];
|
||||
removedEnvKeys.push(key);
|
||||
const own = previousOwnEnv[key];
|
||||
if (own) {
|
||||
this._envOverrides = { ...(this._envOverrides ?? {}), [key]: own };
|
||||
this._pendingEnvUnsets.delete(key);
|
||||
} else {
|
||||
this._pendingEnvUnsets.add(key);
|
||||
}
|
||||
this._pendingEnvUnsets.add(key);
|
||||
}
|
||||
}
|
||||
// Read after the undo, so a switch between endpoints carries the own values across. An
|
||||
// empty value is skipped: `applyEnvOverrides()` never set one, so it has nothing to
|
||||
// restore, and the injected key still has to be unset.
|
||||
const ownEnv: Record<string, string> = { ...next?.ownEnv };
|
||||
if (!next?.ownEnv) {
|
||||
for (const key of Object.keys(envOverrides ?? {})) {
|
||||
const own = this._envOverrides?.[key];
|
||||
if (own) ownEnv[key] = own;
|
||||
}
|
||||
}
|
||||
this._customModel = next ? { ...next, envKeys: [...next.envKeys], ownEnv } : undefined;
|
||||
this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : undefined;
|
||||
if (envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
this._envOverrides = { ...(this._envOverrides ?? {}), ...envOverrides };
|
||||
// A key the new selection sets again does not need an unset (applyEnvOverrides
|
||||
|
||||
+45
-18
@@ -1303,10 +1303,12 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
|
||||
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
||||
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
||||
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
||||
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
|
||||
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
|
||||
* shell-quoted inside it too. This keeps escaping correct through every layer
|
||||
* even when the remote path contains spaces.
|
||||
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
|
||||
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
|
||||
* arguments, and a per-host `commands.*` override stays one argument, which
|
||||
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
|
||||
* Each is shell-quoted, so escaping stays correct through every layer even
|
||||
* when the remote path contains spaces.
|
||||
*/
|
||||
export function buildRemoteLaunchCommand(options: {
|
||||
mode: SessionMode;
|
||||
@@ -1389,17 +1391,25 @@ export function buildRemoteLaunchCommand(options: {
|
||||
}
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
|
||||
// the CLI so the pane PID is the agent itself.
|
||||
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
|
||||
// with the CLI so the pane PID is the agent itself.
|
||||
//
|
||||
// tmux runs a ONE-argument command through the remote default-shell, which is
|
||||
// the user's login shell, and bypasses it for two or more arguments (both
|
||||
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
|
||||
// a parse error in fish, status 127, a dead pane on every new session), so it
|
||||
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
|
||||
// An override is the user's own text for THEIR shell: it stays one argument,
|
||||
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
|
||||
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
||||
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
|
||||
|
||||
// The tmux command line, with `\;` separating commands so the config `set`s
|
||||
// apply on the SAME connection (and are idempotent on reattach). Options are
|
||||
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
||||
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
|
||||
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
|
||||
@@ -1574,7 +1584,7 @@ export interface DockerLaunchOptions {
|
||||
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
||||
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
||||
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
||||
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
|
||||
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
@@ -1602,8 +1612,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
} else if (resumeSessionId) {
|
||||
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
}
|
||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||
// pane PID the agent itself.
|
||||
// Run by tmux through the container's default-shell, so the path is shell-quoted
|
||||
// here. `exec` makes the pane PID the agent itself.
|
||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||
|
||||
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
||||
@@ -1665,11 +1675,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const ensure = adopted
|
||||
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
||||
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
|
||||
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
|
||||
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
|
||||
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
|
||||
// every other line in this builder already uses.
|
||||
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
|
||||
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
|
||||
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
|
||||
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
|
||||
const start = adopted
|
||||
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
|
||||
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
@@ -2661,7 +2670,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -2914,7 +2923,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
@@ -4663,6 +4672,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
|
||||
* local sh). A remote or docker command is already quoted for the shell at the far end and
|
||||
* must reach bash verbatim, so it goes in single quotes. Double quotes (JSON.stringify) let
|
||||
* the local sh expand every `$` first: the PATH bootstrap of `withRemoteTmuxPath`
|
||||
* (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this host's home and PATH, so
|
||||
* a remote user with another home found no tmux and every new remote pane exited 127.
|
||||
* `${SHELL:-/bin/sh}` in the remote launch was the local shell for the same reason.
|
||||
* Local commands keep their double-quoted form.
|
||||
*
|
||||
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
|
||||
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
|
||||
* as those literal characters.
|
||||
*/
|
||||
function bashCArg(launchCmd: string, verbatim: boolean): string {
|
||||
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a string for use as a single argument.
|
||||
* Wraps in single quotes, escaping any embedded single quotes.
|
||||
|
||||
@@ -702,19 +702,6 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
configDir?: string;
|
||||
/** Value forced onto the CLI's `model` launch param (pi/omp `custom/<id>`, grok's block name). */
|
||||
launchModel?: string;
|
||||
/**
|
||||
* The variable that moves the CLI's home, when the recipe redirects one (`DSH_HOME`,
|
||||
* `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`). Its value is a path, which is what
|
||||
* lets {@link CustomModelBookkeeping.ownEnv} keep that one entry on disk.
|
||||
*/
|
||||
homeEnvVar?: string;
|
||||
/**
|
||||
* What the session itself had set under keys the selection replaced, put back when the
|
||||
* selection goes. In memory this can hold a credential (a session's own `GEMINI_API_KEY`),
|
||||
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry and nothing else:
|
||||
* tmux keeps only the injected value, so state.json is the one copy left after a restart.
|
||||
*/
|
||||
ownEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1689,7 +1689,6 @@ export function registerSessionRoutes(
|
||||
envKeys: applied.envKeys,
|
||||
configDir: applied.configDir,
|
||||
launchModel: applied.launchModel,
|
||||
homeEnvVar: applied.homeEnvVar,
|
||||
},
|
||||
applied.envOverrides
|
||||
);
|
||||
@@ -4468,12 +4467,12 @@ export function registerSessionRoutes(
|
||||
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
||||
// a lighter version of them, since this is the same server-side authority reached a
|
||||
// different way, not a separate, less-checked path.
|
||||
//
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
|
||||
// merges these over what the Session constructor took, which is how it learns which of
|
||||
// the caller's values the selection replaced. Handing the constructor the merged set
|
||||
// would hide them, and giving setCustomModel() that set would put
|
||||
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
|
||||
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
||||
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
||||
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
||||
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
||||
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
@@ -4486,7 +4485,6 @@ export function registerSessionRoutes(
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (customModel) {
|
||||
@@ -4590,6 +4588,7 @@ export function registerSessionRoutes(
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
@@ -4599,7 +4598,6 @@ export function registerSessionRoutes(
|
||||
envKeys: cmApplied.envKeys,
|
||||
configDir: cmApplied.configDir,
|
||||
launchModel: cmApplied.launchModel,
|
||||
homeEnvVar: cmApplied.homeEnvVar,
|
||||
};
|
||||
|
||||
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
||||
@@ -4632,7 +4630,7 @@ export function registerSessionRoutes(
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
@@ -4661,10 +4659,10 @@ export function registerSessionRoutes(
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls, and merges the injected env, which the launch below reads along
|
||||
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
|
||||
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
|
||||
// no tmux IO of its own (see its own doc comment in session.ts).
|
||||
// clear/switch calls — the actual env vars and launch-model config are already part of
|
||||
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
||||
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
||||
// tmux IO of its own (see its own doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
|
||||
+9
-9
@@ -878,11 +878,11 @@ const RemoteCommandOverridesSchema = z
|
||||
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
||||
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
||||
|
||||
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
|
||||
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
|
||||
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
|
||||
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
|
||||
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
|
||||
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
|
||||
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
|
||||
// far end's shells parse, and hosts stored before a launch-quoting change are read with
|
||||
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
|
||||
// defense in depth, whatever the current quoting does with them.
|
||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||
|
||||
export const RemoteHostSchema = z.object({
|
||||
@@ -982,10 +982,10 @@ export const RemoteCaseLinkSchema = z.object({
|
||||
//
|
||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
||||
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
|
||||
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
|
||||
// `--privileged` and any docker-socket mount are structurally unrepresentable
|
||||
// (never accepted).
|
||||
|
||||
const DockerResourceLimitsSchema = z
|
||||
.object({
|
||||
|
||||
+7
-13
@@ -104,7 +104,6 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
@@ -1533,10 +1532,12 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
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 first apply already
|
||||
// linked this directory from the session's own home, and `linkRealHomeEntry()` keeps
|
||||
// what is there. Only a directory someone removed gets its links again, from the
|
||||
// SERVER's `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
// 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,
|
||||
@@ -1558,25 +1559,18 @@ export class WebServer extends EventEmitter {
|
||||
* 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.
|
||||
*
|
||||
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
|
||||
* holds what the previous run injected, and only this record says what was the session's
|
||||
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
|
||||
*/
|
||||
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
|
||||
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, ownEnv });
|
||||
session.setCustomModel(saved);
|
||||
return;
|
||||
}
|
||||
session.setCustomModel(
|
||||
{
|
||||
...saved,
|
||||
ownEnv,
|
||||
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
|
||||
// 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,
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
||||
* that clearing could never delete.
|
||||
*
|
||||
* Real-home resolution takes no session lookup on this path: the first apply's links are
|
||||
* still in the generated directory, and one someone removed links from the server's
|
||||
* `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s
|
||||
* comment. A session's own home comes back on clear from `ownEnv`, the last block here.
|
||||
* 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
|
||||
@@ -98,91 +99,4 @@ describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', ()
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual(saved);
|
||||
});
|
||||
|
||||
describe("a session's own value across a restart", () => {
|
||||
const OWN_HOME = '/home/u/my-dsh';
|
||||
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
|
||||
});
|
||||
|
||||
/** What a previous run left in state.json for a deepseek session on its own home. */
|
||||
function savedByPreviousRun(): CustomModelBookkeeping {
|
||||
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
|
||||
configs.push(previous.id);
|
||||
previous.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: customModelConfigDir(previous.id),
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
|
||||
);
|
||||
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
|
||||
}
|
||||
|
||||
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('keeps it when the endpoint is gone', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('never reads what the previous run injected as the session own value', async () => {
|
||||
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
|
||||
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
|
||||
// stays pinned to a directory the clear just deleted.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
|
||||
});
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, {
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
});
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
|
||||
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
|
||||
// this record must not be a way around it.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const saved = savedByPreviousRun();
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, saved);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Which shell parses a remote pane command (#760).
|
||||
*
|
||||
* tmux runs a ONE-argument command through the remote default-shell, the user's login shell, and
|
||||
* bypasses it for two or more arguments. Codeman's own text carries `"${SHELL:-/bin/sh}"`, a parse
|
||||
* error in fish that kills the pane with status 127, so it must reach `/bin/sh -c` as separate
|
||||
* arguments. A per-host `commands.*` override is the user's text for their own shell: `source` and
|
||||
* `[[` work in bash and zsh and die under a dash `/bin/sh`, so it must stay one argument.
|
||||
*
|
||||
* CI has no fish, so this reads the argv tmux would receive instead: the real builder's string goes
|
||||
* through a real sh with `ssh` and `tmux` defined as functions that report their arguments.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { listClis } from '../src/config/cli-registry/registry.js';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionMode, SessionRemote } from '../src/types.js';
|
||||
|
||||
const SESSION_ID = 'abcd1234-0000-0000-0000-000000000000';
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'gpu',
|
||||
label: 'GPU',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/it is work',
|
||||
};
|
||||
|
||||
/** The one argument ssh would send, as the login shell on the far end receives it. */
|
||||
function sshArgument(launch: string): string {
|
||||
return execFileSync('/bin/sh', ['-c', `ssh() { for a; do l="$a"; done; printf '%s' "$l"; }; ${launch}`], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
/** What follows `new-session … -c <dir>`, up to the end of that tmux command. */
|
||||
function paneArgv(launch: string): string[] {
|
||||
const argv = execFileSync('/bin/sh', ['-c', `tmux() { printf '%s\\0' "$@"; }; ${sshArgument(launch)}`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\0')
|
||||
.slice(0, -1);
|
||||
const head = argv.slice(0, argv.indexOf(';'));
|
||||
return head.slice(head.indexOf('-c', head.indexOf('new-session')) + 2);
|
||||
}
|
||||
|
||||
const launches: Array<[string, Parameters<typeof buildRemoteLaunchCommand>[0]]> = [
|
||||
...listClis().map((cli): [string, Parameters<typeof buildRemoteLaunchCommand>[0]] => [
|
||||
cli.id,
|
||||
{ mode: cli.id as SessionMode, remote, sessionId: SESSION_ID },
|
||||
]),
|
||||
['shell', { mode: 'shell', remote, sessionId: SESSION_ID }],
|
||||
['claude resume', { mode: 'claude', remote, sessionId: SESSION_ID, resumeSessionId: SESSION_ID }],
|
||||
['omp resume', { mode: 'omp', remote, sessionId: SESSION_ID, resumeSessionId: 'abc123' }],
|
||||
];
|
||||
|
||||
describe('the shell that parses a remote pane command', () => {
|
||||
it.each(launches)('%s: Codeman text goes to /bin/sh, never the default-shell', (_name, options) => {
|
||||
const argv = paneArgv(buildRemoteLaunchCommand(options));
|
||||
expect(argv.slice(0, 2)).toEqual(['/bin/sh', '-c']);
|
||||
expect(argv).toHaveLength(3);
|
||||
expect(argv[2]).toContain(`cd '/home/ubuntu/it is work' && `);
|
||||
});
|
||||
|
||||
it('leaves an override as one argument, parsed by the remote default-shell', () => {
|
||||
const override = 'source ~/env.sh && [[ -x ~/.local/bin/codx ]] && exec codx';
|
||||
const argv = paneArgv(
|
||||
buildRemoteLaunchCommand({
|
||||
mode: 'codex',
|
||||
remote: { ...remote, commands: { codex: override } },
|
||||
sessionId: SESSION_ID,
|
||||
})
|
||||
);
|
||||
expect(argv).toEqual([`cd '/home/ubuntu/it is work' && ${override}`]);
|
||||
});
|
||||
|
||||
it('keeps the rest of the tmux chain behind the longer pane command', () => {
|
||||
const sent = sshArgument(buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: SESSION_ID }));
|
||||
expect(sent).toContain(String.raw` \; set -t codeman-ssh-abcd1234 status off \; `);
|
||||
expect(sent).toMatch(/set -t codeman-ssh-abcd1234 remain-on-exit failed$/);
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} /bin/sh -c ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
|
||||
@@ -92,35 +92,6 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
});
|
||||
|
||||
it("learns the caller's own value under an injected key, so a clear can put it back", async () => {
|
||||
const own = join(getDataDir(), 'client-claude');
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-own-home',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: own },
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
const isolatedDir = customModelConfigDir(sessionId);
|
||||
// It launches on the isolated directory, with the caller's value set aside rather
|
||||
// than recorded as the injected one.
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(isolatedDir);
|
||||
expect(session.getCustomModelForPersist()?.ownEnv).toEqual({ CLAUDE_CONFIG_DIR: own });
|
||||
|
||||
const clear = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/custom-model`,
|
||||
payload: { clear: true },
|
||||
});
|
||||
expect(clear.statusCode).toBe(200);
|
||||
expect(session.customModel).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(own);
|
||||
expect(existsSync(isolatedDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-claude',
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
* gated on that transcript existing, so these tests write one.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
* 4. A value the session set itself under an injected key was overwritten, then deleted
|
||||
* with the injected one on clear: an own `DSH_HOME` fell back to the server's tree and
|
||||
* an own `CLAUDE_CONFIG_DIR` silently moved the session to the default Claude account.
|
||||
*
|
||||
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
@@ -256,104 +253,3 @@ describe('Codex custom endpoint model overrides', () => {
|
||||
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a value the session set itself comes back with the selection gone', () => {
|
||||
const DSH_SELECTION = {
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: '/isolated/dsh',
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
};
|
||||
const DSH_ENV = { DSH_HOME: '/isolated/dsh', CODEMAN_CUSTOM_MODEL_API_KEY: 'k' };
|
||||
|
||||
it('puts an own DSH_HOME back on clear and sets it rather than unsetting it', async () => {
|
||||
const { session, respawn } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
expect(session.deepSeekHomeOverride).toBe('/home/u/my-dsh');
|
||||
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(options.unsetEnvKeys).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
|
||||
});
|
||||
|
||||
it('carries it across a switch between endpoints', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
session.setCustomModel({ ...DSH_SELECTION, endpointId: 'other' }, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
});
|
||||
|
||||
it('keeps a claude session on its own account, the #255 override', async () => {
|
||||
const { session, respawn } = liveSession('claude', {
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/home/u/.claude-client', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: [...CLAUDE_KEYS, 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
configDir: '/isolated/claude',
|
||||
homeEnvVar: 'CLAUDE_CONFIG_DIR',
|
||||
},
|
||||
{ ...claudeEnv, CLAUDE_CONFIG_DIR: '/isolated/claude', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' }
|
||||
);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({
|
||||
CLAUDE_CONFIG_DIR: '/home/u/.claude-client',
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000',
|
||||
});
|
||||
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
||||
});
|
||||
|
||||
it('still unsets a key whose own value was empty, since tmux never held that value', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the own home to state.json and no other own value', () => {
|
||||
const { session } = liveSession('gemini', {
|
||||
envOverrides: { GEMINI_API_KEY: 'own-secret', DSH_HOME: '/home/u/my-dsh' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['GEMINI_API_KEY', 'DSH_HOME'],
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ GEMINI_API_KEY: 'endpoint-key', DSH_HOME: '/isolated/dsh' }
|
||||
);
|
||||
|
||||
const persisted = session.getCustomModelForPersist();
|
||||
expect(persisted?.ownEnv).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(JSON.stringify(persisted)).not.toContain('own-secret');
|
||||
// The secret is still put back: it just never leaves memory.
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
||||
});
|
||||
|
||||
it('persists nothing extra for a recipe with no home variable', () => {
|
||||
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] },
|
||||
{ GEMINI_API_KEY: 'endpoint-key' }
|
||||
);
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual({ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,20 @@ import type { MuxSession } from '../src/mux-interface.js';
|
||||
// ============================================================================
|
||||
|
||||
// Mock child_process
|
||||
/**
|
||||
* Runs a captured `… respawn-pane … bash -c <arg>` line through a real local sh (with a
|
||||
* foreign HOME/PATH) and returns the string bash would get, i.e. what ssh sends on.
|
||||
*/
|
||||
async function bashArgAfterLocalShell(respawnCmd: string): Promise<string> {
|
||||
const real = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||
const at = respawnCmd.indexOf(' bash -c ');
|
||||
expect(at).toBeGreaterThan(0);
|
||||
return real.execFileSync('/bin/sh', ['-c', `printf '%s' ${respawnCmd.slice(at + ' bash -c '.length)}`], {
|
||||
env: { HOME: '/local-home-xyz', PATH: '/local-path-xyz:/usr/bin:/bin' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual('node:child_process');
|
||||
return {
|
||||
@@ -865,6 +879,98 @@ describe('TmuxManager (unit)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hands a remote launch to ssh with $HOME and $PATH unexpanded', async () => {
|
||||
// respawn-pane runs `bash -c <arg>` through the local sh. In double quotes that sh
|
||||
// expanded withRemoteTmuxPath's `$HOME/…:$PATH` to the Codeman host's values, so a
|
||||
// remote user with another home found no tmux and the pane exited 127.
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.createSession({
|
||||
sessionId: 'abc12345-1234-5678-90ab-cdef12345679',
|
||||
workingDir: '/w',
|
||||
mode: 'shell',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
});
|
||||
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
|
||||
.map(([cmd]) => cmd)
|
||||
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||
const sent = await bashArgAfterLocalShell(launch as string);
|
||||
expect(sent).toContain('ssh ');
|
||||
expect(sent).toContain('export PATH="$HOME/');
|
||||
expect(sent).toContain(':$PATH"; tmux ');
|
||||
expect(sent).not.toContain('local-home-xyz');
|
||||
expect(sent).not.toContain('local-path-xyz');
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('respawns a remote pane with $HOME unexpanded as well', async () => {
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
nonTestManager.registerSession({
|
||||
sessionId: 'respawnremote2',
|
||||
muxName: 'codeman-abcd8888',
|
||||
pid: 1000,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/w',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await nonTestManager.respawnPane({
|
||||
sessionId: 'respawnremote2',
|
||||
workingDir: '/w',
|
||||
mode: 'shell',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
});
|
||||
const { exec: currentExec } = await import('node:child_process');
|
||||
const respawnCall = vi
|
||||
.mocked(currentExec)
|
||||
.mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||
const sent = await bashArgAfterLocalShell(respawnCall?.[0] as string);
|
||||
expect(sent).toContain('export PATH="$HOME/');
|
||||
expect(sent).not.toContain('local-home-xyz');
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('hands a docker override to bash with $HOME unexpanded', async () => {
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.createSession({
|
||||
sessionId: 'abc12345-1234-5678-90ab-cdef12345680',
|
||||
workingDir: '/w',
|
||||
mode: 'shell',
|
||||
docker: {
|
||||
hostId: 'h',
|
||||
label: 'L',
|
||||
engine: 'docker',
|
||||
image: 'codeman/agent:base',
|
||||
containerName: 'codeman-case-x',
|
||||
hostWorkspacePath: '/w',
|
||||
containerWorkdir: '/w',
|
||||
commands: { shell: 'echo CONTAINER_HOME=$HOME; exec sleep 30' },
|
||||
} as never,
|
||||
});
|
||||
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
|
||||
.map(([cmd]) => cmd)
|
||||
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||
const sent = await bashArgAfterLocalShell(launch as string);
|
||||
expect(sent).toContain('docker');
|
||||
expect(sent).toContain('CONTAINER_HOME=$HOME');
|
||||
expect(sent).not.toContain('local-home-xyz');
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('changes the global history default on tmux versions that cannot resize panes', async () => {
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
Reference in new issue
Block a user