mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3248f35081 | ||
|
|
5b920cb43d | ||
|
|
c4322513d9 | ||
|
|
018f0c4160 | ||
|
|
88e3faa456 | ||
|
|
70fc6b32d5 | ||
|
|
9591b973cf | ||
|
|
025f061383 | ||
|
|
7a5543da09 | ||
|
|
cbb7f635ff | ||
|
|
e5684d0bba | ||
|
|
c7cc8e28d5 | ||
|
|
01da577053 | ||
|
|
631386d3f7 | ||
|
|
b3a6ba2eb6 | ||
|
|
897a63183f | ||
|
|
942bf37e48 | ||
|
|
1e42cb4e2d | ||
|
|
e49c48145b | ||
|
|
792a251e35 | ||
|
|
6dc27ae727 | ||
|
|
1306f731cf | ||
|
|
d9364f52e1 | ||
|
|
b0dddc9c57 | ||
|
|
f5f399a8b7 | ||
|
|
2bda191471 | ||
|
|
f92883704e | ||
|
|
1851d80f3a | ||
|
|
a29e1f61ef | ||
|
|
653e3cdf96 | ||
|
|
e54a8b1189 | ||
|
|
44a754ea73 | ||
|
|
7c3c5b8f72 | ||
|
|
1e5a53830f | ||
|
|
63aafdf274 | ||
|
|
013a5d9cc8 | ||
|
|
b6f75b87f5 | ||
|
|
e18499aa67 | ||
|
|
61779745aa | ||
|
|
41416566aa | ||
|
|
c179daf869 | ||
|
|
ae32daf135 | ||
|
|
8fe3f34fc5 | ||
|
|
89e2cb5814 | ||
|
|
d38bf33a69 | ||
|
|
9702126046 | ||
|
|
748bbf5423 | ||
|
|
10876aa440 | ||
|
|
b357fe832e | ||
|
|
349a89ec3b | ||
|
|
268e4819ff |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"aicodeman": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fix an invisible terminal text selection on the light skins (#360). Every xterm palette declared its selection colour under the key `selection`, which xterm.js renamed to `selectionBackground` in v5. An `ITheme` is a plain object, so the unknown key was dropped without an error and every skin fell back to xterm's own default of `rgba(255,255,255,0.3)`: unnoticeable on the dark skins, which wanted roughly that anyway, and effectively invisible on Paper Gray, Solarized Light, Catppuccin Latte and Rosé Pine Dawn, where white at 30% over a near-white background moves a channel by about 3/255. Selecting text on those skins now highlights it, with desktop drag-select and the mobile long-press both fixed by the same rename.
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/codeman",
|
"source": "./plugins/codeman",
|
||||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.28.2",
|
"version": "1.29.1",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
**/.env
|
**/.env
|
||||||
**/.env.*
|
**/.env.*
|
||||||
!**/.env.example
|
!**/.env.example
|
||||||
|
# Same shape: docker/docker-compose.override.yml is the documented home for
|
||||||
|
# host-specific settings, so it must not ride COPY . . into the image either.
|
||||||
|
**/docker-compose.override.*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ jobs:
|
|||||||
offer_ai_cli_install >/dev/null 2>&1
|
offer_ai_cli_install >/dev/null 2>&1
|
||||||
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
||||||
'
|
'
|
||||||
|
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
|
||||||
|
# array, and on stock macOS there is no `timeout`, so the array is empty and the
|
||||||
|
# expansion aborts the whole installer under `set -u`. The step above cannot
|
||||||
|
# reach that branch: this image HAS `timeout`, and with no `dsh` on PATH the
|
||||||
|
# probe is never called at all. So hide `timeout` and call it directly.
|
||||||
|
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
. /w/install.sh
|
||||||
|
printf "#!/bin/sh\necho \"DeepSeek Harness 0.1\"\n" > /tmp/dsh
|
||||||
|
printf "#!/bin/sh\necho \"dancer shell (Debian dsh)\"\n" > /tmp/not-dsh
|
||||||
|
chmod 755 /tmp/dsh /tmp/not-dsh
|
||||||
|
# A PATH the probe can still work on, minus the binary under test.
|
||||||
|
mkdir -p /tmp/nobin
|
||||||
|
for b in grep sh; do ln -sf "$(command -v $b)" "/tmp/nobin/$b"; done
|
||||||
|
export PATH=/tmp/nobin
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
echo "timeout is still on PATH, so this is NOT exercising the empty-array branch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dsh_banner_probe /tmp/dsh
|
||||||
|
if dsh_banner_probe /tmp/not-dsh; then
|
||||||
|
echo "identity probe accepted a foreign dsh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
|
||||||
|
'
|
||||||
|
|
||||||
- name: CLI catalogue artifacts are in sync with stock.ts
|
- name: CLI catalogue artifacts are in sync with stock.ts
|
||||||
run: npm run generate:cli-catalog -- --check
|
run: npm run generate:cli-catalog -- --check
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Local Compose customisation (host-specific, not part of the project)
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.override.yaml
|
||||||
|
|
||||||
# State files (local to each machine)
|
# State files (local to each machine)
|
||||||
.claude/ralph-loop.local.md
|
.claude/ralph-loop.local.md
|
||||||
|
|
||||||
@@ -105,3 +109,7 @@ readme-preview.mjs
|
|||||||
|
|
||||||
# Uploaded images land here under each session working dir (runtime artifact)
|
# Uploaded images land here under each session working dir (runtime artifact)
|
||||||
.claude-images/
|
.claude-images/
|
||||||
|
|
||||||
|
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
|
||||||
|
# alongside it in scripts/, which IS tracked as the template.
|
||||||
|
scripts/local-llm-test.config.json
|
||||||
|
|||||||
@@ -1,5 +1,91 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.29.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5b920cb: Auto-name sessions from the first prompt (#376, opt-in). With the new synced **Auto-name Sessions** setting on (App Settings → Appearance → Tabs, default off), a tab that still carries its generated name takes a title from the first real prompt you submit, keeping the case prefix: `w3-myapp` becomes `w3-myapp: fix the login redirect`. The strip shows the title with the prefix in the tooltip, and the next session in that case still counts up. It happens once per session, only for prompts you type or send through the input API (never a Ralph, respawn, cron or approval answer), never for shells, and a name you set yourself is never touched. Slash commands such as `/clear` do not become titles. The title is derived locally from the prompt's first sentence; no text leaves the machine. `nameSource` (`placeholder` / `auto` / `manual`) is a new additive field on session state.
|
||||||
|
|
||||||
|
Landed with the fixes the review of #376 asked for: first prompt only (not every prompt), a user-input gate so Ralph, respawn, cron and approval writes cannot name a tab, the prefix form so the case identity and `w<n>` counter survive, and a keystroke tracker that handles a bare Esc, bracketed pastes, wheel reports, Tab and history recall instead of mis-titling the tab.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- @shenlvkang-collab for #376, the auto-naming idea and the ownership plumbing (`nameSource`, the listener wiring, the restore path) it shipped with.
|
||||||
|
|
||||||
|
## 1.29.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- **Custom model endpoints, HTTP API first** (#393). Any run mode that has a mechanism for it can be pointed at a custom OpenAI-compatible endpoint (a local llama.cpp, llama-swap, Ollama or vLLM, or a cloud gateway) instead of its native backend, per session. Endpoints are stored in `~/.codeman/custom-model-hosts.json` (`GET/POST/PUT/DELETE /api/model-endpoints`, admin-only in multi-user mode), their model lists are discovered from the endpoint's own `/v1/models`, and `POST /api/sessions/:id/custom-model` applies one to a session by restarting its CLI in place. The mechanism is per-CLI registry data (`capabilities.customModelInjection`): env vars for Claude, Gemini, Grok and DeepSeek, `OPENCODE_CONFIG_CONTENT` for opencode, an isolated config dir for Codex, Pi and OMP, unsupported for Antigravity. Verified live against a llama-swap server for claude, opencode, pi, grok and omp; gemini and deepseek reach the server and fail for reasons not yet understood, and codex only speaks the Responses API, so a plain chat-completions server cannot serve it. Those three are documented as gaps rather than shipped as working. The toolbar picker is a follow-up; until it lands the feature is HTTP-API only (`docs/custom-model-endpoints.md`), and the `customModelEndpointsEnabled` setting is declared but read by nothing yet. Merged with maintainer follow-ups: clearing a selection now actually clears it (the injected vars are delivered by `tmux setenv`, which `respawn-pane` inherits, so the relaunched CLI came back still pointed at the endpoint; retired keys are now `setenv -u`'d before the respawn), applying a model to a local claude session no longer kills the pane (the relaunch pins `--resume <id>` with the `--session-id` fallback, since Claude Code refuses a session id that already has a transcript), pi, omp and grok now select the generated model through a registry-declared `launchModel` (`custom/<id>`, `-m codeman-custom`) instead of writing a config the CLI then ignored, remote and Docker sessions are refused with a clear 400 until those paths are plumbed, the selection survives a Codeman restart, discovery goes through the egress-guarded `webviewFetch()`, key-bearing files are written 0600 and the per-session config dir is removed with the session, and the design plan moved from the repo root to `docs/custom-model-endpoints-plan.md`. Along the way the multi-user clamp learned about `GOOGLE_GEMINI_BASE_URL`, `GROK_BASE_URL`, `CODEX_HOME`, `PI_CONFIG_DIR` and `OPENCODE_CONFIG_CONTENT`, which were already reachable through `envOverrides` and now count as privileged keys.
|
||||||
|
|
||||||
|
**Single-page apps work as web tabs, and a frame that reloads comes back** (#402). A history-routed dashboard (React Router, Vue Router, a Vite dev server) read `/webview/<cap>/` as its `location.pathname` and rendered its own "page not found" the moment its script ran. The proxy's runtime shim now masks the prefix off the document URL before any page script runs, while every URL the page emits still goes through the rewrite layers (now including `Worker`, `SharedWorker`, `sendBeacon` and `window.open`). A navigation the page starts itself afterwards (a dev server's full reload, a root-absolute `location.href`) used to land on Codeman's root with no capability; it is now recognised by shape, answered with a static recovery page that posts the lost path to the owning tab, and the frame is remounted inside the prefix at that path, bounded to five recoveries a minute per frame. Merged with maintainer follow-ups: the recovery path is sanitised properly (a leading backslash, or a tab/newline the URL parser deletes before parsing, resolved `/\evil.com` to a foreign origin in a direct-mode tab); a reload on the dashboard's landing page is recovered too, on password-protected and passwordless installs alike (it used to render Codeman's own shell inside the web tab); and the recovery page is written down as the third unauthenticated 200 in the security table and `docs/security-architecture.md`, with the route-enumeration property it implies stated rather than left to be discovered.
|
||||||
|
|
||||||
|
**Shift arrows for Codex on the phone keyboard bar** (#408). Two keys, `⇧←` and `⇧→`, send the Shift-modified arrows Codex binds to editing the last queued message and walking the prompt stack (verified against Codex 0.154.0's `/keymap`). Merged with a maintainer follow-up: the keys are shown only on Codex sessions (a `codex-enabled` class on the bar, the same shape as the Read My Mind key), because tapping one in any other session did nothing except hand that session to plain PTY echo for the rest of the prompt.
|
||||||
|
|
||||||
|
**Remote (SSH) cases can finally show you their files** (#421, fixes #415). File previews, downloads, text reads and the out-of-workspace attachment path resolved every path against the Codeman host's own filesystem, so in a remote case every click ended in "File not found" while the file plainly existed on the other machine. A single new ssh read layer (`src/remote-files.ts`, built on the same `buildSshConnectionArgs()` the launch uses) probes realpath and stat for the file and the workspace root in one round trip, then streams the body with `cat` (or a `tail`/`head` slice for a `Range`), so the 200/206/416 contract holds and nothing is buffered on the server. Symlinks are resolved on the host that can resolve them, containment is checked against the resolved remote root, the size cap applies to the remote size before a byte is requested, an unreachable host is a 502 rather than a 404, and there is deliberately no local fallback: a same-named file on the Codeman host is never served under a remote name. Writes, Office previews and generated thumbnails answer 400 for a remote case instead of a misleading 404. Merged with maintainer follow-ups: the `readlink -f` fallback resolved only the directory chain, so on a host without it a symlink's final component was returned unresolved and `ws/notes.txt -> ~/.ssh/id_rsa` passed containment while `cat` served the key; it now follows the last component with plain `readlink` for a bounded number of hops and fails closed (404) on a loop or the cap; `PUT /api/sessions/:id/file-content` answers 400 for a remote case as the PR already claimed (it still validated against the local filesystem, so a same-named local directory took the write); ssh children are bounded by a small semaphore (`CODEMAN_MAX_REMOTE_FILE_SSH`, default 4) covering the attachment-history fan-out, which now probes the whole history in one batched call, and the fire-and-forget magic-link registrations an injected agent could use to fork hundreds of `ssh` processes; probe records are NUL-delimited and index-keyed so a newline in a filename cannot shift one path's result onto the next; and a 502 body never carries the ssh command line.
|
||||||
|
|
||||||
|
**Docker Compose: bind-mount ownership, override files, a `codeman` runtime account, and no more stale volumes** (#377). A missing bind source (first run, cleared appdata, restored backup) is created root-owned by the daemon, and the unprivileged server crash-looped on `EACCES` when Compose was run directly; the image now starts through an entrypoint that corrects a root-owned bind mount and drops to `PUID:PGID` with `setpriv`, and the compose file adds back only the capabilities that needs. `Start-Codeman.sh` honours `docker-compose.override.yml` (naming a Compose file with `-f` silently disables Compose's own discovery of it), pre-creates the cases directory like it already did for appdata, and detects when the checkout's HEAD or lockfile moved under the `codeman-node-modules`/`codeman-dist` volumes and refreshes them, which used to leave a `docker compose build` serving stale compiled routes. The default runtime account is named `codeman` (it was `opencode`), the four global agent CLIs live in their own `/opt/codeman-cli` prefix so the runtime account can update them in place without owning `/usr/local/bin`, and `CODEMAN_ALLOWED_HOSTS` is documented and forwarded. Merged with maintainer follow-ups: `cap_add` gains `KILL` (with `init: true` tini runs as root while the server runs as `PUID`, and without CAP_KILL its SIGTERM forward failed and the server was SIGKILLed on every `compose down`/`restart`); the CLI prefix is appended to `PATH` rather than prepended and the root entrypoint pins its own `PATH`, since a `PUID`-writable directory ahead of `/usr/bin` let the runtime account plant a `setpriv` that ran as root on the next start; the entrypoint decides with a real writability probe as the runtime identity instead of an owner comparison, so ACLs, group-writable trees and NFS/CIFS mounts work and only a genuinely unwritable directory is refused, by name; the cases directory is created with the runtime owner after `PUID`/`PGID` are known; the build-source marker is written only when a refresh actually happened, an empty Compose project name falls back to `down --volumes`, the build runs before the `down` so the stack is offline only for the recreate, `docker-compose.override.*` stays out of the image, and `test/docker-entrypoint.test.ts` pins `cap_add` against what the entrypoint needs. ⚠️ Compose users: run `Start-Codeman.sh` once for this release rather than a plain `docker compose up`, so the rebuilt image, the refreshed volumes and the new entrypoint arrive together.
|
||||||
|
|
||||||
|
**Selected text is visible again on the light skins** (#423, part of #360). Every skin palette named its selection layer `selection`, the key xterm renamed to `selectionBackground` in v5, so all seven skins had been painting xterm's default white at 30% instead of the colour next to it in the palette. Dark skins hid it; on the four light skins a selection was white on near-white. The key is renamed and `test/skin-themes.test.ts` pins it. CI additionally exercises `install.sh`'s dsh identity probe with `timeout` missing under bash 3.2 (#422), the guard #382's fix shipped without.
|
||||||
|
|
||||||
|
**Eight fixes salvaged from #375** (dignfei; landed with the author's commits preserved, the rest of that PR is covered below). Shift+drag starts a text selection in a pane whose mouse reports go to the CLI, and right-click copies the selection. Ctrl- and Alt-modified navigation keys typed through the CJK composer reach the CLI as the modified sequences instead of plain arrows. A browser whose reliable-input sequence counter fell behind the server's watermark (a restored tab, a cleared localStorage) now recovers: the duplicate ACK carries `dup: true` plus the watermark, the client lifts its counter and re-sends, so a session that had silently stopped accepting typed prompts accepts them again. An SSE reconnect that lands on the session you are already looking at keeps its terminal buffer and resyncs instead of resetting the whole terminal. The hidden offline overlay and the file-preview overlay only apply `backdrop-filter` while shown, which removes a stale compositing layer that swallowed clicks. One adopted Docker container can back several cases at different in-container directories, and the adopt panel gains a "copy an existing case" picker. Of the PR's 27 commits, 14 had already shipped through #357, the selection theme key rename shipped as #423, and foreign tmux adoption plus SSH password auth stay with the author.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- **@opticon454** for custom model endpoints (#393), including the part nobody enjoys: working out each CLI's real endpoint mechanism against real binaries and writing down which ones do not work yet instead of claiming they do; and for the Docker Compose deployment fixes (#377), rebased and reworked through three review rounds.
|
||||||
|
- **@shenlvkang-collab** for making single-page apps route inside web tabs and recovering a frame that reloads (#402), the best-engineered PR of this batch, and for the Codex Shift arrows on the phone keyboard bar (#408), verified against Codex's own keymap.
|
||||||
|
- **@dignfei** for the eight fixes salvaged from #375 (terminal selection and copy, CJK navigation keys, input recovery, SSE reconnect, overlay compositing, multi-case adopted containers), landed under their own name.
|
||||||
|
- **@Randalix** for reporting #415 and then fixing it themselves with the whole missing ssh read side for remote cases (#421), with a real-shell test for the probe script and a full route suite.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 349a89e: fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads
|
||||||
|
|
||||||
|
A dashboard served through a web tab saw `/webview/<cap>/` as its `location.pathname`, and
|
||||||
|
no app has a route for that: a React Router, Vue Router or Vite dev-server page painted its
|
||||||
|
HTML and CSS and then replaced them with its own "page not found" the moment its script ran.
|
||||||
|
The proxy's runtime shim now rewrites the history entry to the path the page would see on its
|
||||||
|
own origin before any page script runs, while every URL the page emits still goes through
|
||||||
|
the existing rewrite layers (plus `Worker`, `sendBeacon` and `window.open`, which the masked
|
||||||
|
Referer can no longer rescue). A navigation the page starts itself afterwards — a dev
|
||||||
|
server's full-reload HMR, a root-absolute `location.href` — lands on Codeman's root with no
|
||||||
|
capability; it is recognised by shape (an iframe navigation asking for HTML for a path Codeman
|
||||||
|
does not serve), answered with a static page that tells the owning tab which path was lost,
|
||||||
|
and the tab remounts the frame inside the prefix at that path. That answer is served before
|
||||||
|
the credential checks, so it never counts as a failed login.
|
||||||
|
|
||||||
|
- 013a5d9: File previews, downloads and text reads now work in a **remote (SSH) case**.
|
||||||
|
|
||||||
|
A remote case's working directory is an absolute path on the _remote_ host, but the
|
||||||
|
file routes resolved it with local `fs` — so a clicked path (or the File Viewer) always
|
||||||
|
failed as "File not found" even though the file existed and the session was clearly
|
||||||
|
working in that directory. `GET /api/sessions/:id/file-raw`, `file-content`,
|
||||||
|
`file-preview` and `file-thumbnail` now resolve and read through the same
|
||||||
|
`buildSshConnectionArgs()` connection the launch uses (`src/remote-files.ts`, one
|
||||||
|
`realpath`+`stat` probe per request returning both the file and the workspace root).
|
||||||
|
|
||||||
|
Clicked paths that point OUTSIDE the case directory (a remote `/tmp` scratchpad capture,
|
||||||
|
a screenshot elsewhere in the remote home) go through the attachment routes, which had
|
||||||
|
the same local-`fs` assumption: registration, the by-id `raw` stream, the metadata poll
|
||||||
|
and the attachment history list now resolve over ssh as well, so the click-path works
|
||||||
|
whether the file sits inside or outside the case. Which host a record is read from
|
||||||
|
follows the SESSION, never the path string — the same absolute path means a different
|
||||||
|
file on each host, and a remote session never falls back to a local file.
|
||||||
|
|
||||||
|
The guards are unchanged in strength: the workspace boundary is still enforced (now
|
||||||
|
resolved on the host that can actually resolve it), the sensitive-path blocklist and
|
||||||
|
the size cap (`CODEMAN_MAX_DOWNLOAD_BYTES`) still apply before any bytes are read, and
|
||||||
|
`Range` requests keep working, so remote `<video>`/`<audio>` seeking behaves like a
|
||||||
|
local file. An unreachable host is reported as `502` with the remote reason instead of
|
||||||
|
a misleading 404. Nothing is ever copied to the Codeman host.
|
||||||
|
|
||||||
|
Still not available for remote cases, and now said explicitly instead of 404-ing:
|
||||||
|
editing a file (`edit=1` / `PUT` answer 400, the viewer hides its Edit affordance),
|
||||||
|
office-document previews and generated thumbnails (both need the bytes on the server's
|
||||||
|
disk), the file tree / path picker, and `tail-file`. Docker cases are unaffected (their
|
||||||
|
workspace is bind-mounted at the same absolute path).
|
||||||
|
|
||||||
|
- b357fe8: Add Shift+Left and Shift+Right buttons to the default and extended mobile agent keyboard bars, shown only on Codex sessions, enabling Codex queued-message editing and prompt-stack navigation. Flush locally buffered drafts before navigation and keep terminal focus after taps.
|
||||||
|
- 9acc5aa: Fix an invisible terminal text selection on the light skins (#360). Every xterm palette declared its selection colour under the key `selection`, which xterm.js renamed to `selectionBackground` in v5. An `ITheme` is a plain object, so the unknown key was dropped without an error and every skin fell back to xterm's own default of `rgba(255,255,255,0.3)`: unnoticeable on the dark skins, which wanted roughly that anyway, and effectively invisible on Paper Gray, Solarized Light, Catppuccin Latte and Rosé Pine Dawn, where white at 30% over a near-white background moves a channel by about 3/255. Selecting text on those skins now highlights it, with desktop drag-select and the mobile long-press both fixed by the same rename.
|
||||||
|
|
||||||
## 1.28.2
|
## 1.28.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">Mission control for AI coding agents</h2>
|
<h2 align="center">Mission control for AI coding agents</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • OMP • Terminal - One Dashboard • Any Device</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Terminal - One Dashboard • Any Device</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||||
|
|
||||||
Get started in one line (macOS & Linux, Windows via WSL):
|
Get started in one line (macOS & Linux, Windows via WSL):
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ codeman web
|
|||||||
|
|
||||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||||
|
|
||||||
- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
|
- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
||||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||||
@@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
|
|||||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
||||||
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||||
|
|
||||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
|
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -82,7 +82,7 @@ codeman users add alice --admin # create the first admin account
|
|||||||
codeman web --multiuser # named logins + per-user case spaces
|
codeman web --multiuser # named logins + per-user case spaces
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
|
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
|
||||||
|
|
||||||
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
||||||
|
|
||||||
@@ -209,10 +209,10 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
|
|||||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident
|
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
|
||||||
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
||||||
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
||||||
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
|
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web --https
|
codeman web --https
|
||||||
@@ -255,7 +255,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
|||||||
| Field | What it does |
|
| Field | What it does |
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). |
|
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). |
|
||||||
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
||||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
|||||||
|
|
||||||
### 3. Read the dashboard
|
### 3. Read the dashboard
|
||||||
|
|
||||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices).
|
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet.
|
||||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||||
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||||
|
|
||||||
@@ -271,8 +271,10 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
|||||||
|
|
||||||
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
||||||
- **Paste or drag-and-drop images** directly into the session.
|
- **Paste or drag-and-drop images** directly into the session.
|
||||||
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop).
|
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
|
||||||
- **Attachments** — register external files/docs and preview Office/PDF inline.
|
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view.
|
||||||
|
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
|
||||||
|
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it.
|
||||||
|
|
||||||
### 5. Make it autonomous
|
### 5. Make it autonomous
|
||||||
|
|
||||||
@@ -291,7 +293,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
|||||||
|
|
||||||
### 7. Operate & maintain
|
### 7. Operate & maintain
|
||||||
|
|
||||||
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance animations, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||||
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
|
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
|
||||||
- **Deploy your own changes** — see [Development](#development).
|
- **Deploy your own changes** — see [Development](#development).
|
||||||
@@ -439,16 +441,21 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add dashboard**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
|
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||||
|
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
- **Voice input** — dictate prompts with Deepgram Nova-3, or through this machine's Claude Code login with no API key at all (App Settings → Voice; Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||||
- **Image input** — paste or drag-and-drop images straight into a session
|
- **Image input** — paste or drag-and-drop images straight into a session
|
||||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
|
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
|
||||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
|
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
|
||||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
|
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||||
|
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones
|
||||||
|
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order
|
||||||
|
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in entrance animations for tabs, agent windows, the terminal pane and connection lines
|
||||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -461,8 +468,9 @@ Run a case inside its own hardened Docker container instead of directly on your
|
|||||||
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
||||||
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
||||||
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
|
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
|
||||||
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
|
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
|
||||||
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
- **Attach to a container you already run** — tick **Attach to an existing container** on the Docker panel to link a case to it instead of creating one. Codeman only `exec`s into it and never starts, stops, restarts or removes it; one adopted container can back several cases at different directories, and **copy an existing case** pre-fills the form from a sibling. Admin-only in multi-user mode, since the container's mounts belong to whoever started it.
|
||||||
|
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
||||||
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
||||||
|
|
||||||
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
||||||
@@ -478,6 +486,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
|
|||||||
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
||||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||||
|
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||||
|
|
||||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||||
|
|
||||||
@@ -647,8 +656,8 @@ These run for **every** request — before auth, even on the default no-password
|
|||||||
|
|
||||||
### Input, files & headers
|
### Input, files & headers
|
||||||
|
|
||||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` env-prefix allowlist gates which settings each CLI can receive
|
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
||||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||||
|
|
||||||
### Supply chain & isolation
|
### Supply chain & isolation
|
||||||
@@ -698,6 +707,10 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
|||||||
| `Ctrl/Cmd +` / `-` | Font size |
|
| `Ctrl/Cmd +` / `-` | Font size |
|
||||||
| `Ctrl/Cmd+?` | Keyboard help |
|
| `Ctrl/Cmd+?` | Keyboard help |
|
||||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||||
|
| `Shift+drag` | Select text in a pane whose mouse events go to the CLI |
|
||||||
|
| Right-click | Copy the selection (the native menu stays when nothing is selected) |
|
||||||
|
| `Shift+Wheel` | Scroll the local scrollback while the wheel is forwarded to the CLI |
|
||||||
|
| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended; normal job control in a shell |
|
||||||
| `Escape` | Close panels & modals |
|
| `Escape` | Close panels & modals |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -762,7 +775,7 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and
|
|||||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
| [`SKILL.md`](skills/codeman/SKILL.md) | Safety rules, the ready-made fast path (spawn N workers, task them, collect), and the verb index. Always loaded. |
|
| [`SKILL.md`](skills/codeman/SKILL.md) | Safety rules, the ready-made fast path (spawn N workers, task them, collect), and the verb index. Always loaded. |
|
||||||
| [`reference/verbs.md`](skills/codeman/reference/verbs.md) | The 14 verbs in detail: readiness, send-and-wait, markers, interrupts, cleanup. On demand. |
|
| [`reference/verbs.md`](skills/codeman/reference/verbs.md) | The 14 verbs in detail: readiness, send-and-wait, markers, interrupts, cleanup. On demand. |
|
||||||
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 6 worked multi-worker flows (fan-out, blocked-worker watch, messaging fan-out). On demand. |
|
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. |
|
||||||
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
|
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
|
||||||
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
|
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
|
||||||
|
|
||||||
@@ -798,8 +811,8 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
|||||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||||
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
||||||
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
7. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Those two come from hooks (Claude Code's own, and the DeepSeek Harness status bridge); `shell` and the other external CLIs (opencode/codex/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
||||||
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
||||||
|
|
||||||
### Recipes
|
### Recipes
|
||||||
|
|
||||||
@@ -866,9 +879,20 @@ curl -sG "$API/api/sessions/$SID/wait-output" \
|
|||||||
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
||||||
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
||||||
|
|
||||||
# 5. Read the terminal back. ⚠️ Use terminal?tail=, NOT /output: the latter's
|
# 5. Read the answer. claude / codex / deepseek sessions have last-response: it comes
|
||||||
# textOutput is empty for every tmux-backed (i.e. every interactive) session.
|
# from the transcript, not the screen, so no TUI frames or repaint noise.
|
||||||
# tail counts BYTES, and what comes back is terminal data, ANSI included.
|
# ⚠️ Poll rather than read once: the transcript lands slightly after the stop
|
||||||
|
# signal, so a read right after send-and-wait returns often comes back empty.
|
||||||
|
for _ in $(seq 1 10); do
|
||||||
|
TXT=$(curl -s "$API/api/sessions/$SID/last-response" | jq -r '.data.text')
|
||||||
|
[ -n "$TXT" ] && break; sleep 1
|
||||||
|
done
|
||||||
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
|
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript:
|
||||||
|
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
||||||
|
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
||||||
|
# BYTES, and what comes back is terminal data, ANSI included.
|
||||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||||
|
|
||||||
# 6. Stream live events (session output, agent activity, status)
|
# 6. Stream live events (session output, agent activity, status)
|
||||||
@@ -914,7 +938,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
REST over Fastify — **~230 handlers across 25 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
@@ -925,11 +949,13 @@ REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE str
|
|||||||
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`: `clientId`+`seq` = exactly-once; `wait` blocks until the turn ends) |
|
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`: `clientId`+`seq` = exactly-once; `wait` blocks until the turn ends) |
|
||||||
| `GET` | `/api/sessions/:id/terminal` | Read terminal output (`?tail=<bytes>`, `?full=1`); the read path for interactive sessions |
|
| `GET` | `/api/sessions/:id/terminal` | Read terminal output (`?tail=<bytes>`, `?full=1`); the read path for interactive sessions |
|
||||||
| `GET` | `/api/sessions/:id/output` | Parsed one-shot output (`textOutput` is empty for tmux-backed sessions) |
|
| `GET` | `/api/sessions/:id/output` | Parsed one-shot output (`textOutput` is empty for tmux-backed sessions) |
|
||||||
|
| `GET` | `/api/sessions/:id/last-response` | The last answer as clean text, read from the transcript (claude, codex, deepseek) |
|
||||||
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
|
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
|
||||||
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
|
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
|
||||||
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
|
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
|
||||||
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
|
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
|
||||||
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
|
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
|
||||||
|
| `POST` | `/api/sessions/:id/custom-model` | Restart the session's CLI on a saved custom endpoint (`{endpointId, modelId}`; `{clear: true}` returns to the native backend) |
|
||||||
| `DELETE` | `/api/sessions/:id` | Delete session |
|
| `DELETE` | `/api/sessions/:id` | Delete session |
|
||||||
|
|
||||||
### Respawn
|
### Respawn
|
||||||
@@ -978,6 +1004,7 @@ REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE str
|
|||||||
| `GET` | `/api/system/update/check` | Check for a new release |
|
| `GET` | `/api/system/update/check` | Check for a new release |
|
||||||
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
||||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||||
|
| `GET` / `POST` | `/api/model-endpoints` | List / save custom OpenAI-compatible endpoints (`PUT` / `DELETE` `/:id`; admin-only in multi-user mode) |
|
||||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||||
|
|
||||||
> **Building something on top of Codeman?** [`docs/extending-codeman.md`](docs/extending-codeman.md) is the integration guide: render your own UI as a tab, subscribe to the SSE event stream to react when an agent needs you, drive Codeman from a script, and the traps worth knowing before you start. Codeman has no plugin runtime on purpose, so an integration is just your own process talking HTTP.
|
> **Building something on top of Codeman?** [`docs/extending-codeman.md`](docs/extending-codeman.md) is the integration guide: render your own UI as a tab, subscribe to the SSE event stream to react when an agent needs you, drive Codeman from a script, and the traps worth knowing before you start. Codeman has no plugin runtime on purpose, so an integration is just your own process talking HTTP.
|
||||||
@@ -1014,8 +1041,8 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["External"]
|
subgraph External["External"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
|
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1081,7 +1108,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
|||||||
|
|
||||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||||
|
|
||||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 175 tests.
|
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install xterm-zerolag-input
|
npm install xterm-zerolag-input
|
||||||
|
|||||||
+207
-51
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • 终端 —— 统一仪表盘 • 任意设备</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||||
|
<a href="https://www.npmjs.com/package/aicodeman"><img src="https://img.shields.io/npm/v/aicodeman?style=flat-square&label=npm&color=22c55e" alt="npm version"></a>
|
||||||
|
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -25,12 +27,10 @@
|
|||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
|
|
||||||
|
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness 或 OMP,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||||
|
|
||||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,6 +44,17 @@ codeman web
|
|||||||
|
|
||||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||||
|
|
||||||
|
- **一个仪表盘,九个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek 或 OMP](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||||
|
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||||
|
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||||
|
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||||
|
- **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放
|
||||||
|
- **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始 — 安装
|
## 快速开始 — 安装
|
||||||
@@ -52,13 +63,14 @@ codeman web
|
|||||||
curl -fsSL https://getcodeman.com/install | bash
|
curl -fsSL https://getcodeman.com/install | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||||
|
|
||||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
||||||
|
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。手动运行的 `codeman web` 仍默认仅环回。
|
||||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||||
|
|
||||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
|
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -72,12 +84,34 @@ codeman users add alice --admin # 创建第一个管理员账号
|
|||||||
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
|
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**更喜欢 Docker Compose?** `docker/` 里附带一套本地镜像的 Compose 部署:把 `docker/.env.example` 复制为 `docker/.env`,设置 `CODEMAN_PASSWORD`,然后在 Linux 上运行 `bash docker/Start-Codeman.sh`。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新之后请再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
|
||||||
|
|
||||||
详见下文[多用户模式](#多用户模式可选启用)。
|
详见下文[多用户模式](#多用户模式可选启用)。
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>作为后台服务运行</strong></summary>
|
<summary><strong>让它在后台一直运行</strong></summary>
|
||||||
|
|
||||||
安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置:
|
想让它活过你启动它的那个 shell,而且什么都不用配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeman web -d # 脱离终端;日志写到 ~/.codeman/web.log
|
||||||
|
codeman web --status # 是否在运行,pid 是多少
|
||||||
|
codeman web --stop # 优雅的 SIGTERM;智能体继续留在 tmux 里运行
|
||||||
|
```
|
||||||
|
|
||||||
|
`-d` 会等到服务器真正应答后才报告成功,并且拒绝在同一个数据目录上启动第二个(两个服务器共用一个 tmux socket 会互相附着对方的会话)。
|
||||||
|
|
||||||
|
想让它在重启后自动回来,就装成服务。安装器结尾的菜单(选项 2)会替你完成;`codeman service` 是 `npm i -g aicodeman` 安装的等价物:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeman service install # systemd 用户单元(Linux)或 LaunchAgent(macOS)
|
||||||
|
codeman service status
|
||||||
|
codeman service uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
`service install` 会把你当前的 PATH 写进单元文件,这比听起来重要得多:launchd 只给任务 `/usr/bin:/bin:/usr/sbin:/sbin`,所以手写的 plist 根本找不到 Homebrew 或 nvm 装的 `node`、`tmux` 或 `claude`。它绝不会把 `CODEMAN_PASSWORD` 复制进单元文件;服务需要认证的话请自行添加。
|
||||||
|
|
||||||
|
如需手动编写单元文件:
|
||||||
|
|
||||||
**Linux(systemd):**
|
**Linux(systemd):**
|
||||||
|
|
||||||
@@ -177,17 +211,17 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
|
|||||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触
|
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退)
|
||||||
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
||||||
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
||||||
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动
|
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web --https
|
codeman web --https
|
||||||
# 在手机上打开:https://<你的IP>:3000
|
# 在手机上打开:https://<你的IP>:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。
|
||||||
|
|
||||||
### 安全的二维码认证
|
### 安全的二维码认证
|
||||||
|
|
||||||
@@ -210,6 +244,8 @@ codeman web # localhost:3000(仅环回 —— 安全默
|
|||||||
codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT)
|
codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT)
|
||||||
codeman web --https # 自签名 TLS(仅远程访问时需要)
|
codeman web --https # 自签名 TLS(仅远程访问时需要)
|
||||||
codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」)
|
codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」)
|
||||||
|
codeman web -d # 脱离终端:关掉 shell 也在跑(--status、--stop)
|
||||||
|
codeman service install # systemd/launchd 服务:重启后自动回来
|
||||||
```
|
```
|
||||||
|
|
||||||
打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。
|
打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。
|
||||||
@@ -220,16 +256,16 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
|||||||
|
|
||||||
| 字段 | 作用 |
|
| 字段 | 作用 |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
|
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
||||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok` 或 `Terminal`(普通 shell)。 |
|
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP` 或 `Terminal`(普通 shell)。 |
|
||||||
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
|
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||||
|
|
||||||
点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。
|
点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。
|
||||||
|
|
||||||
### 3. 读懂仪表盘
|
### 3. 读懂仪表盘
|
||||||
|
|
||||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
|
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。
|
||||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||||
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||||
|
|
||||||
@@ -237,8 +273,10 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
|||||||
|
|
||||||
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
||||||
- **粘贴或拖放图片**,直接进入会话。
|
- **粘贴或拖放图片**,直接进入会话。
|
||||||
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,自动静音停止)。
|
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
|
||||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF。
|
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行。
|
||||||
|
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
|
||||||
|
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。
|
||||||
|
|
||||||
### 5. 让它自主运行
|
### 5. 让它自主运行
|
||||||
|
|
||||||
@@ -246,7 +284,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
|||||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Display → Header Displays) |
|
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Header & Panels → Scheduling) |
|
||||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||||
|
|
||||||
### 6. 随时随地访问
|
### 6. 随时随地访问
|
||||||
@@ -257,8 +295,9 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
|||||||
|
|
||||||
### 7. 运维与维护
|
### 7. 运维与维护
|
||||||
|
|
||||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场动画、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
||||||
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
|
- **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。
|
||||||
|
- **自更新** —— git-clone 安装可在 **App Settings → System → Updates** 中原地更新。
|
||||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||||
|
|
||||||
> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。
|
> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。
|
||||||
@@ -373,6 +412,14 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
|||||||
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
|
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
|
||||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||||
|
|
||||||
|
### 标签提醒(Tab Alerts)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="会话标签:一个普通的活动标签,旁边是黄色的等待输入标签和红色的需要决定标签,都带着呼吸式光晕" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。
|
||||||
|
|
||||||
### 通知
|
### 通知
|
||||||
|
|
||||||
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
|
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
|
||||||
@@ -393,17 +440,24 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
|
|
||||||
## 更多特性
|
## 更多特性
|
||||||
|
|
||||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
- **后台守护进程与服务安装** —— `codeman web -d` 以脱离终端的方式运行服务器,带 pid 文件、`~/.codeman/web.log` 和经过校验的启动(它会轮询到服务器应答为止,所以端口冲突绝不会被当成成功);`codeman service install` 写入一个 systemd 用户单元(Linux)或 LaunchAgent(macOS),并把你 shell 的 PATH 一并写进去,这样 nvm 或 Homebrew 装的 `node`、`tmux` 和 `claude` 才真的找得到。机密永远不会写进单元文件
|
||||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi** 或 **Grok**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*`、`GROK_*`/`XAI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md) 与 [`docs/grok-integration.md`](docs/grok-integration.md)
|
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → System → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||||
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
||||||
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||||
|
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
|
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add dashboard**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
|
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||||
|
- **远程 SSH 会话** —— 把 case 指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话;文件预览与下载走同一条 ssh 连接。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
- **语音输入** —— 用 Deepgram Nova-3 口述提示,或者干脆用这台机器的 Claude Code 登录、不需要任何 API key(App Settings → Voice;带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||||
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用
|
||||||
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||||
- **文件查看器按钮** _(可选)_ —— 头部新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Display → Header Displays 中启用
|
- **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Header & Panels → Header buttons 中启用
|
||||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
|
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入,Ctrl、Alt 修饰的导航键也会原样透传给 CLI
|
||||||
|
- **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关
|
||||||
|
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序
|
||||||
|
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的入场动画,覆盖标签、智能体窗口、终端面板和连接线
|
||||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -416,7 +470,8 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。
|
- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。
|
||||||
- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。
|
- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。
|
||||||
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
||||||
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
||||||
|
- **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。
|
||||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
||||||
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
||||||
|
|
||||||
@@ -433,6 +488,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
|
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
|
||||||
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
||||||
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
||||||
|
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
|
||||||
|
|
||||||
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
||||||
|
|
||||||
@@ -486,7 +542,7 @@ codeman users list
|
|||||||
systemctl --user enable codeman-tunnel
|
systemctl --user enable codeman-tunnel
|
||||||
loginctl enable-linger $USER
|
loginctl enable-linger $USER
|
||||||
|
|
||||||
# 或通过 Codeman Web UI:Settings → Tunnel → 切换为开
|
# 或通过 Codeman Web UI:App Settings → System → Remote access → Cloudflare Tunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -588,7 +644,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||||
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
||||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
||||||
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Claude CLI → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
|
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
|
||||||
|
|
||||||
### 始终开启的浏览器加固(v0.9.5)
|
### 始终开启的浏览器加固(v0.9.5)
|
||||||
|
|
||||||
@@ -602,8 +658,8 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
|
|
||||||
### 输入、文件与响应头
|
### 输入、文件与响应头
|
||||||
|
|
||||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
||||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||||
|
|
||||||
### 供应链与隔离
|
### 供应链与隔离
|
||||||
@@ -615,6 +671,22 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 终端界面(`codeman tui`)
|
||||||
|
|
||||||
|
一个在终端里运行的全屏会话仪表盘。状态与 Web UI 完全一致,因为它就是同一个服务器的客户端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeman tui # 仪表盘
|
||||||
|
codeman tui --list # 带编号的会话列表,随即退出(可用于脚本)
|
||||||
|
codeman tui 2 # 直接附着到列表里的第 2 个会话
|
||||||
|
```
|
||||||
|
|
||||||
|
会话按 **NEEDS YOU → WORKING → IDLE → RECENT** 分组,等得最久的排最前。`↑↓`/`j`/`k` 选择,`1`-`9` 与 `[`/`]` 切换会话,`Enter` 附着进 tmux 面板(按 **`F1`** 回来)。在面板里,顶部的横条会一直显示会话条,`Alt+1`-`Alt+9` 不用离开就能切换。`y`/`n`/数字可以直接在列表里回答待处理的权限对话框,`p` 发送一行提示,`n` 新建会话并直接进入,`x` 杀掉一个(`y` 确认),`/` 搜索,`g` 显示离开摘要,`?` 是帮助,`q` 退出。窄于 72 列时它会去掉预览面板、变成单列列表,所以在手机上的 Termius 里依然好用。没有服务器在跑时,它仍会以仅附着的降级模式启动。
|
||||||
|
|
||||||
|
Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英文)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 键盘快捷键
|
## 键盘快捷键
|
||||||
|
|
||||||
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
||||||
@@ -626,15 +698,21 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||||
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
|
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
|
||||||
|
| `Alt/Option+B` | 折叠 / 展开会话侧边栏(仅侧边栏布局) |
|
||||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||||
| `Ctrl/Cmd+C` | 复制选中内容;未选中时中断代理 |
|
| `Ctrl/Cmd+C` | 复制选中内容;未选中时中断代理 |
|
||||||
| `Ctrl+Shift+C` | 复制选中内容(永不中断) |
|
| `Ctrl+Shift+C` | 复制选中内容(永不中断) |
|
||||||
|
| `Ctrl/Cmd+V` | 粘贴,或上传剪贴板里的图片并粘贴其路径 |
|
||||||
| `Ctrl/Cmd+L` | 清屏 |
|
| `Ctrl/Cmd+L` | 清屏 |
|
||||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||||
|
| `Shift+拖动` | 在鼠标事件交给 CLI 的面板里选中文本 |
|
||||||
|
| 右键 | 复制选中内容(没有选中时保留原生菜单) |
|
||||||
|
| `Shift+滚轮` | 滚轮被转发给 CLI 时,滚动本地回滚缓冲区 |
|
||||||
|
| `Ctrl+Z` | 在智能体会话里被吞掉,运行中的 CLI 不会被挂起;shell 里照常是作业控制 |
|
||||||
| `Escape` | 关闭面板与模态框 |
|
| `Escape` | 关闭面板与模态框 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -643,16 +721,78 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
|
|
||||||
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
|
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
|
||||||
|
|
||||||
> **捷径:装上打包好的智能体技能。** 下面这一整套(外加多工作会话的实战配方)已经作为 Claude Code 技能随仓库发布在 [`skills/codeman`](skills/codeman/SKILL.md),会话内部的智能体不必等你把文档粘进提示词就能驱动 Codeman。三种获取方式:
|
### 智能体技能(从这里开始)
|
||||||
>
|
|
||||||
> - `npx skills add Ark0N/Codeman --skill codeman -g`:全局安装,任何支持技能的智能体都能用
|
这一节的所有内容也打包成了一个 **Claude Code 技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md)。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
|
||||||
> - Claude Code 插件:`/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman`:通过 Claude Code 自带的插件管理器全局安装,`/plugin update codeman` 跟随新版本;与 `codeman skill install` 二选一,两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`)
|
|
||||||
> - `codeman skill install`(全局)或 `codeman skill install --case <name>`:给那些从 npm 安装、从未克隆过仓库的用户;`codeman skill uninstall` 可撤销
|
#### 第 1 步:安装
|
||||||
> - **App Settings → Agent Skill**(`agentSkillEnabled`,默认关闭):开启后,Codeman 会在每次于某个 case 中创建 Claude 会话时把技能注入该 case;case 里用户自己写的 `skills/codeman` 永远不会被覆盖
|
|
||||||
>
|
| 方式 | 命令 | 范围 |
|
||||||
> 全局安装(`codeman skill install` 或 `npx skills add`)会被**本机每一个新建的 Claude Code 会话**读到,无论它在不在 Codeman 里。技能自带门禁:不在 Codeman 会话中(`CODEMAN_MUX` 未设置)时它拒绝动作,所以全局装上它对无关会话没有代价。
|
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
>
|
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用 |
|
||||||
> ⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
|
||||||
|
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
|
||||||
|
| 内置 CLI | `codeman skill install --case <name>` | 仅一个 case |
|
||||||
|
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | 每次在某个 case 创建 Claude 会话时自动注入(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||||
|
|
||||||
|
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装,并且绝不会碰你自己写的 `skills/codeman`。
|
||||||
|
|
||||||
|
#### 第 2 步:开口要
|
||||||
|
|
||||||
|
整个界面就这么多。不用 curl,不用端点名,不用会话 id。下面这些提示照原样就能用:
|
||||||
|
|
||||||
|
| 你说 | 技能做的事 |
|
||||||
|
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| _「现在有哪些会话在跑?」_ | 列出它们的名字、模式和状态。只读,随时可以问。 |
|
||||||
|
| _「在 `myapp` case 上起一个 shell 工作会话,跑测试套件,告诉我过没过。」_ | 拉起、等待一个拆开的完成标记、读回退出码、清理。 |
|
||||||
|
| _「起 3 个工作会话分别跑 lint、typecheck 和测试。并行跑,报告失败的。」_ | 扇出流程:每个任务一个会话,先全部启动,再逐个收集完成的。 |
|
||||||
|
| _「让一个 claude 工作会话在 `refactor-auth` 上总结 `src/session.ts`,然后关掉它。」_ | 拉起、走完就绪阶梯(包括首次运行的信任对话框)、发送并等待、读取干净的 transcript 答案、删除。 |
|
||||||
|
| _「盯着会话 w4,如果它卡在权限提示上就告诉我。」_ | 阻塞在 `blocked` 信号上,并把问题交给**你**。它绝不会替另一个会话回答提示。 |
|
||||||
|
|
||||||
|
#### 第 3 步:没有了
|
||||||
|
|
||||||
|
智能体会删掉它启动的每一个会话。你可以在仪表盘里看着标签出现又消失。
|
||||||
|
|
||||||
|
#### 一次真实的运行,从头到尾
|
||||||
|
|
||||||
|
> **你:** 起 3 个 shell 工作会话,并行跑 lint / typecheck / 前端语法检查,告诉我哪个失败了。
|
||||||
|
|
||||||
|
```text
|
||||||
|
lint -> 9f2d8e5f dispatched
|
||||||
|
typecheck -> aff9c691 dispatched 仪表盘里出现 3 个标签
|
||||||
|
syntax -> be9f1f15 dispatched
|
||||||
|
|
||||||
|
lint DONE_lint_17909 rc=0
|
||||||
|
typecheck DONE_typecheck_3409 rc=0 每完成一个就收集一个
|
||||||
|
syntax DONE_syntax_18501 rc=0
|
||||||
|
|
||||||
|
deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||||
|
```
|
||||||
|
|
||||||
|
那些 `DONE_<task>_<random>` 字符串就是技能的**拆分标记**技巧,也是扇出在没有 hook 的 `shell` 会话上依然可靠的原因:敲进去的那一行只含 `${M}_17909`,因此只有命令真正的*输出*里才会出现 `DONE_17909`。不拆开的标记会在命令还没跑之前就匹配到你自己按键的回显。
|
||||||
|
|
||||||
|
#### 盒子里有什么
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| [`SKILL.md`](skills/codeman/SKILL.md) | 安全规则、现成的快速路径(起 N 个工作会话、派任务、收集)和动词索引。始终加载。 |
|
||||||
|
| [`reference/verbs.md`](skills/codeman/reference/verbs.md) | 14 个动词的详细说明:就绪、发送并等待、标记、中断、清理。按需加载。 |
|
||||||
|
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 |
|
||||||
|
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 |
|
||||||
|
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 |
|
||||||
|
|
||||||
|
里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。
|
||||||
|
|
||||||
|
#### 两件值得知道的事
|
||||||
|
|
||||||
|
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||||
|
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||||
|
|
||||||
|
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**这一节余下的部分是手动路径**:同样的操作用裸 HTTP 来做,适合 CI 机器人、shell 脚本,或任何不支持技能的智能体。
|
||||||
|
|
||||||
### 检测自己身处 Codeman 内部
|
### 检测自己身处 Codeman 内部
|
||||||
|
|
||||||
@@ -673,7 +813,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||||
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
||||||
7. **只有 `claude` 会话会发出 `stop` 与 `blocked`。** 这两个来自 Claude Code hook;`shell` 与外部 CLI(opencode/codex/gemini/antigravity/pi)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop` 与 `blocked`。** 这两个来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接);`shell` 与其他外部 CLI(opencode/codex/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
||||||
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
||||||
|
|
||||||
### 常用配方
|
### 常用配方
|
||||||
@@ -738,7 +878,7 @@ curl -sG "$API/api/sessions/$SID/wait-output" \
|
|||||||
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
||||||
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
||||||
|
|
||||||
# 5. 读回答案。claude / codex 会话用 last-response:它取自 transcript 而不是屏幕,
|
# 5. 读回答案。claude / codex / deepseek 会话用 last-response:它取自 transcript 而不是屏幕,
|
||||||
# 因此不带 TUI 的画框与重画噪声。⚠️ 要轮询,别只读一次:transcript 落盘比 stop
|
# 因此不带 TUI 的画框与重画噪声。⚠️ 要轮询,别只读一次:transcript 落盘比 stop
|
||||||
# 信号稍晚,紧跟着「发送并等待」返回后立刻读,常常拿到空串。
|
# 信号稍晚,紧跟着「发送并等待」返回后立刻读,常常拿到空串。
|
||||||
for _ in $(seq 1 10); do
|
for _ in $(seq 1 10); do
|
||||||
@@ -747,7 +887,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi)没有 transcript,读终端。
|
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp)没有 transcript,读终端。
|
||||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||||
@@ -780,7 +920,9 @@ codeman session start -d /path/to/repo # (s) 启动会话
|
|||||||
codeman session list # 列出会话
|
codeman session list # 列出会话
|
||||||
codeman session logs <id> # 查看输出
|
codeman session logs <id> # 查看输出
|
||||||
codeman task add "fix the failing test" # (t) 排入任务
|
codeman task add "fix the failing test" # (t) 排入任务
|
||||||
codeman attach <path> # 附着 Claude hook 上下文
|
codeman attach <path> # 为本地文件显示一张附件卡片
|
||||||
|
codeman tui --list # 带编号的会话列表(管道输出时为纯文本)
|
||||||
|
codeman tui 3 # 附着到该列表里的第 3 个会话
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hook(事件*回流*到 Codeman)
|
### Hook(事件*回流*到 Codeman)
|
||||||
@@ -793,7 +935,7 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
基于 Fastify 的 REST —— **21 个路由模块中约 200 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
基于 Fastify 的 REST —— **25 个路由模块中约 230 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||||
|
|
||||||
### 会话(Sessions)
|
### 会话(Sessions)
|
||||||
|
|
||||||
@@ -804,11 +946,13 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
|||||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`:`clientId`+`seq` = 精确一次;`wait` 阻塞到这一回合结束) |
|
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`:`clientId`+`seq` = 精确一次;`wait` 阻塞到这一回合结束) |
|
||||||
| `GET` | `/api/sessions/:id/terminal` | 读取终端输出(`?tail=<bytes>`、`?full=1`):交互式会话的读取路径 |
|
| `GET` | `/api/sessions/:id/terminal` | 读取终端输出(`?tail=<bytes>`、`?full=1`):交互式会话的读取路径 |
|
||||||
| `GET` | `/api/sessions/:id/output` | 一次性的解析输出(tmux 承载的会话里 `textOutput` 为空) |
|
| `GET` | `/api/sessions/:id/output` | 一次性的解析输出(tmux 承载的会话里 `textOutput` 为空) |
|
||||||
|
| `GET` | `/api/sessions/:id/last-response` | 从 transcript 读出的最后一条回答,纯文本(claude、codex、deepseek) |
|
||||||
| `GET` | `/api/sessions/:id/wait` | 阻塞到某个信号触发(`?until=stop,idle,exit&timeout=&fresh=`);超时是 `200` |
|
| `GET` | `/api/sessions/:id/wait` | 阻塞到某个信号触发(`?until=stop,idle,exit&timeout=&fresh=`);超时是 `200` |
|
||||||
| `GET` | `/api/sessions/:id/wait-output` | 阻塞到某个字面串出现(`?match=&nocase=&from=now\|buffer&timeout=`) |
|
| `GET` | `/api/sessions/:id/wait-output` | 阻塞到某个字面串出现(`?match=&nocase=&from=now\|buffer&timeout=`) |
|
||||||
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
||||||
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
||||||
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
||||||
|
| `POST` | `/api/sessions/:id/custom-model` | 让会话的 CLI 在一个已保存的自定义端点上原地重启(`{endpointId, modelId}`;`{clear: true}` 回到官方后端) |
|
||||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||||
|
|
||||||
### 重生(Respawn)
|
### 重生(Respawn)
|
||||||
@@ -857,6 +1001,7 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
|||||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||||
|
| `GET` / `POST` | `/api/model-endpoints` | 列出 / 保存自定义的 OpenAI 兼容端点(`PUT` / `DELETE` `/:id`;多用户模式下仅管理员) |
|
||||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||||
|
|
||||||
> **想在 Codeman 之上做集成?**[`docs/extending-codeman.md`](docs/extending-codeman.md)(英文)是集成指南:把你自己的界面作为标签页嵌入、订阅 SSE 事件流以便在 agent 需要你时做出响应、用脚本驱动 Codeman,以及动手前值得先了解的那些坑。Codeman 刻意不提供插件运行时,所以一个集成就是你自己的进程在讲 HTTP。
|
> **想在 Codeman 之上做集成?**[`docs/extending-codeman.md`](docs/extending-codeman.md)(英文)是集成指南:把你自己的界面作为标签页嵌入、订阅 SSE 事件流以便在 agent 需要你时做出响应、用脚本驱动 Codeman,以及动手前值得先了解的那些坑。Codeman 刻意不提供插件运行时,所以一个集成就是你自己的进程在讲 HTTP。
|
||||||
@@ -893,7 +1038,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["外部"]
|
subgraph External["外部"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
||||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -931,6 +1076,12 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 社区
|
||||||
|
|
||||||
|
提问、安装求助和想法都在 [GitHub Discussions](https://github.com/Ark0N/Codeman/discussions):[Q&A 板块](https://github.com/Ark0N/Codeman/discussions/categories/q-a)回答了最常见的那些(手机访问、通宵运行、更新),路线图则在 [Ideas](https://github.com/Ark0N/Codeman/discussions/categories/ideas) 里决定。Bug 请提到 [issues](https://github.com/Ark0N/Codeman/issues);报告通常一天内会得到回复,每个发行版都会点名感谢报告者和贡献者。想参与贡献?[CONTRIBUTING.md](.github/CONTRIBUTING.md) 是地图:皮肤、翻译和文档都是很好的第一个 PR,更大的特性先从一个 Discussion 开始。如果你对自己的配置很自豪,发到 [Show and tell](https://github.com/Ark0N/Codeman/discussions/300) 来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 代码库质量
|
## 代码库质量
|
||||||
|
|
||||||
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
|
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
|
||||||
@@ -954,7 +1105,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
|
|||||||
|
|
||||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||||
|
|
||||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、可配置的提示符检测、带 78 个测试的完整状态机。
|
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install xterm-zerolag-input
|
npm install xterm-zerolag-input
|
||||||
@@ -977,3 +1128,8 @@ MIT —— 见 [LICENSE](LICENSE)
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>跟踪会话。可视化智能体。掌控重生。让它在你睡觉时持续运行。</strong>
|
<strong>跟踪会话。可视化智能体。掌控重生。让它在你睡觉时持续运行。</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
如果 Codeman 帮你省了时间,<a href="https://github.com/Ark0N/Codeman/stargazers">点个 star</a> 能让更多人找到它。<br>
|
||||||
|
欢迎到 <a href="https://github.com/Ark0N/Codeman/issues">Issues</a> 报告 bug 和提出特性想法。
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "..",
|
||||||
|
"noEmit": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["../scripts/test-local-llm-harnesses.ts"]
|
||||||
|
}
|
||||||
+11
-5
@@ -13,24 +13,24 @@ TZ=Australia/Perth
|
|||||||
|
|
||||||
# Name of the account that runs Codeman and all local CLI sessions. Changing
|
# Name of the account that runs Codeman and all local CLI sessions. Changing
|
||||||
# this value rebuilds the image with a matching account.
|
# this value rebuilds the image with a matching account.
|
||||||
CODEMAN_RUNTIME_USER=opencode
|
CODEMAN_RUNTIME_USER=codeman
|
||||||
|
|
||||||
# Required. Persistent Codeman application data, CLI credentials, and session
|
# Required. Persistent Codeman application data, CLI credentials, and session
|
||||||
# state are stored here on the host and mounted at the runtime account's home
|
# state are stored here on the host and mounted at the runtime account's home
|
||||||
# directory in the container.
|
# directory in the container.
|
||||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
|
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
|
||||||
|
|
||||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||||
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
|
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
|
||||||
# start script detects it from the compose file's own location, so it only needs
|
# start script detects it from the compose file's own location, so it only needs
|
||||||
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
|
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
|
||||||
# at a directory that is not a git checkout and in-app updates are unavailable.
|
# at a directory that is not a git checkout and in-app updates are unavailable.
|
||||||
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
|
# CODEMAN_REPO_PATH=/mnt/user/appdata/codeman/app
|
||||||
|
|
||||||
# Required for Docker cases. This must be an absolute path on the Docker host.
|
# Required for Docker cases. This must be an absolute path on the Docker host.
|
||||||
# Codeman and each isolated case use this same path, so it cannot be a
|
# Codeman and each isolated case use this same path, so it cannot be a
|
||||||
# container-only path such as /home/opencode/codeman-cases.
|
# container-only path such as /home/codeman/codeman-cases.
|
||||||
CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases
|
CODEMAN_CASES_PATH=/mnt/user/appdata/codeman/codeman-cases
|
||||||
|
|
||||||
# Required. Network bind address, host port, and local image tag.
|
# Required. Network bind address, host port, and local image tag.
|
||||||
CODEMAN_HOST=0.0.0.0
|
CODEMAN_HOST=0.0.0.0
|
||||||
@@ -44,6 +44,12 @@ CODEMAN_PASSWORD=changeme
|
|||||||
# Required. Username for Codeman HTTP Basic authentication.
|
# Required. Username for Codeman HTTP Basic authentication.
|
||||||
CODEMAN_USERNAME=admin
|
CODEMAN_USERNAME=admin
|
||||||
|
|
||||||
|
# Optional. Extra Host-header allowlist entries for a reverse-proxied domain
|
||||||
|
# (comma-separated; a bare `.suffix` matches every subdomain). Without it a
|
||||||
|
# proxied request is rejected with `403 Forbidden: host not allowed`. See
|
||||||
|
# README.md, "Reverse-proxy host allowlist".
|
||||||
|
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
|
||||||
|
|
||||||
# Optional: authenticate Gemini CLI without an interactive login.
|
# Optional: authenticate Gemini CLI without an interactive login.
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
|||||||
+46
-6
@@ -11,18 +11,21 @@ cp docker/.env.example docker/.env
|
|||||||
bash docker/Start-Codeman.sh
|
bash docker/Start-Codeman.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On PowerShell, use the following command instead.
|
On PowerShell, use the following commands instead. Running Compose from inside `docker/` with no `-f` lets it discover `docker-compose.override.yml` on its own (see [Local customisation](#local-customisation)); naming the file with `-f docker/docker-compose.yaml` from the repository root silently drops the override unless it is named too.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Copy-Item docker/.env.example docker/.env
|
Copy-Item docker/.env.example docker/.env
|
||||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
|
Set-Location docker
|
||||||
|
docker compose --env-file .env up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank.
|
Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank.
|
||||||
|
|
||||||
|
The container starts as root so `entrypoint.sh` can correct the ownership of a bind source the Docker daemon created (it creates a missing one as `root:root`), then drops to `PUID:PGID` with `setpriv` before the server starts, so Codeman itself never runs privileged. That drop needs `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against the file's `cap_drop: ALL`; a compose file written elsewhere (Unraid's Compose Manager, a hand-written unit) must carry the same additions, and the entrypoint names them when they are missing. A directory owned by neither root nor `PUID:PGID` is never re-owned: it is probed for writability as the runtime account and refused with a clear message if that fails. Setting `user:` in Compose skips the whole step.
|
||||||
|
|
||||||
On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.
|
On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.
|
||||||
|
|
||||||
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
|
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `codeman`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
|
||||||
|
|
||||||
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
|
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
|
||||||
|
|
||||||
@@ -38,6 +41,43 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
|||||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||||
|
|
||||||
|
## Local customisation
|
||||||
|
|
||||||
|
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
|
||||||
|
|
||||||
|
`Start-Codeman.sh` names the Compose file explicitly, which disables Compose's automatic discovery of the override file, so the script adds it back when one is present and prints the file it used. Running `docker compose` from this folder without any `-f` option finds it automatically. When passing `-f docker/docker-compose.yaml` from the repository root, add `-f docker/docker-compose.override.yml` as well, or the override is silently ignored.
|
||||||
|
|
||||||
|
An override file adds to and replaces individual settings. It cannot delete a key from `docker-compose.yaml`, and Compose concatenates rather than replaces `ports`, so removing a published port still requires editing `docker-compose.yaml`. The example below replaces the restart policy and adds a mount, leaving every other setting in place:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
codeman:
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- /srv/projects:/srv/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse-proxy host allowlist
|
||||||
|
|
||||||
|
Codeman rejects any request whose `Host` header is not on its own allowlist - a
|
||||||
|
DNS-rebinding guard, not a Compose or Docker concern. Loopback, any IP literal,
|
||||||
|
the configured `--host`, and a few tunnel-provider suffixes are allowed by
|
||||||
|
default; a reverse-proxied domain is not, and is rejected with
|
||||||
|
`403 Forbidden: host not allowed` before the request reaches any handler.
|
||||||
|
|
||||||
|
Add the domain with `CODEMAN_ALLOWED_HOSTS` in `.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CODEMAN_ALLOWED_HOSTS='codeman.example.com,.internal.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yaml` forwards it into the container (Compose only passes
|
||||||
|
through the environment keys it explicitly lists, and this is one of them, with
|
||||||
|
an empty default so the line is optional in `.env`).
|
||||||
|
|
||||||
|
See the application's own `docs/wiki/Remote-Access.md` for the full allowlist
|
||||||
|
format and the tunnel providers it accepts by default.
|
||||||
|
|
||||||
## Application data storage
|
## Application data storage
|
||||||
|
|
||||||
The default configuration uses a host-folder bind mount:
|
The default configuration uses a host-folder bind mount:
|
||||||
@@ -49,7 +89,7 @@ volumes:
|
|||||||
target: /home/${CODEMAN_RUNTIME_USER}
|
target: /home/${CODEMAN_RUNTIME_USER}
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`.
|
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`.
|
||||||
|
|
||||||
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
|
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
|
||||||
|
|
||||||
@@ -60,7 +100,7 @@ Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=
|
|||||||
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:
|
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
chown -R 99:100 /mnt/user/appdata/Coding/codeman
|
chown -R 99:100 /mnt/user/appdata/codeman
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `99:100` and the path with the values from your `.env` file.
|
Replace `99:100` and the path with the values from your `.env` file.
|
||||||
@@ -69,7 +109,7 @@ Do not replace this bind mount with a Docker-managed named volume when Docker ca
|
|||||||
|
|
||||||
## Static macvlan networking
|
## Static macvlan networking
|
||||||
|
|
||||||
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service:
|
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section from `docker-compose.yaml` and add the following to the `codeman` service. The service and network additions can instead be placed in `docker-compose.override.yml`, but the `ports:` removal cannot, as described under [Local customisation](#local-customisation):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
mac_address: ${CODEMAN_MAC_ADDRESS}
|
mac_address: ${CODEMAN_MAC_ADDRESS}
|
||||||
|
|||||||
+187
-7
@@ -12,11 +12,34 @@ if [[ ! -f "$env_file" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
|
# Naming a Compose file explicitly disables Compose's automatic discovery of
|
||||||
|
# the override file, so it has to be added back by hand. Without this, local
|
||||||
|
# customisation in docker-compose.override.yml is silently ignored. The
|
||||||
|
# candidates are checked in Compose's own precedence order - measured on
|
||||||
|
# Compose v5.5.0 with both present: it uses `.yml` and ignores `.yaml`.
|
||||||
|
override_yml="$script_dir/docker-compose.override.yml"
|
||||||
|
override_yaml="$script_dir/docker-compose.override.yaml"
|
||||||
|
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
|
||||||
|
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
|
||||||
|
"$override_yml" "$override_yaml" >&2
|
||||||
|
fi
|
||||||
|
compose_files=(-f "$compose_file")
|
||||||
|
for override_file in "$override_yml" "$override_yaml"; do
|
||||||
|
if [[ -f "$override_file" ]]; then
|
||||||
|
compose_files+=(-f "$override_file")
|
||||||
|
printf 'Using Compose override file: %s\n' "$override_file"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
||||||
appdata_path=$(
|
appdata_path=$(
|
||||||
"${compose_command[@]}" config --environment |
|
"${compose_command[@]}" config --environment |
|
||||||
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||||
)
|
)
|
||||||
|
cases_path=$(
|
||||||
|
"${compose_command[@]}" config --environment |
|
||||||
|
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||||
|
)
|
||||||
docker_socket=$(
|
docker_socket=$(
|
||||||
"${compose_command[@]}" config --environment |
|
"${compose_command[@]}" config --environment |
|
||||||
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
|
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
|
||||||
@@ -36,11 +59,18 @@ if [[ ! -d "$appdata_path" ]]; then
|
|||||||
mkdir -p -- "$appdata_path"
|
mkdir -p -- "$appdata_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
|
if [[ -z "$cases_path" ]]; then
|
||||||
:
|
printf 'Error: CODEMAN_CASES_PATH is not set in %s\n' "$env_file" >&2
|
||||||
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
|
exit 1
|
||||||
:
|
fi
|
||||||
else
|
|
||||||
|
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind sources live on the Docker
|
||||||
|
# host, so both need to work.
|
||||||
|
owner_of() {
|
||||||
|
stat -c '%u:%g' -- "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! owner_ids=$(owner_of "$appdata_path"); then
|
||||||
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -54,6 +84,34 @@ if [[ "$PUID" == '0' ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pre-creating this here, exactly like CODEMAN_APPDATA_PATH above, means Compose
|
||||||
|
# never has to materialise a missing bind source itself - which it does as
|
||||||
|
# root:root - so the in-container entrypoint's chown never has to run for this
|
||||||
|
# path at all. It happens AFTER PUID/PGID are known (they come from the appdata
|
||||||
|
# directory just above) so the new directory can be given that exact owner: a
|
||||||
|
# plain `mkdir -p` lands as the invoking user's uid and PRIMARY gid, and on a
|
||||||
|
# host set up the way the README suggests (`chown -R 99:100 <appdata>`) that gid
|
||||||
|
# is not PGID, which the container would then refuse to run on. Unlike appdata,
|
||||||
|
# an EXISTING cases directory is left exactly as it is: the README explicitly
|
||||||
|
# allows pointing this at a normal projects directory the host account already
|
||||||
|
# owns, and the container checks that it is WRITABLE as PUID:PGID rather than
|
||||||
|
# who owns it.
|
||||||
|
if [[ ! -d "$cases_path" ]]; then
|
||||||
|
mkdir -p -- "$cases_path"
|
||||||
|
if [[ "$(owner_of "$cases_path")" != "$PUID:$PGID" ]]; then
|
||||||
|
# As root this always succeeds; as a member of PGID a chgrp does; anyone
|
||||||
|
# else gets the clear error here, where the fix is obvious, rather than a
|
||||||
|
# restart loop from the container.
|
||||||
|
if ! chown -- "$PUID:$PGID" "$cases_path" 2>/dev/null; then
|
||||||
|
printf 'Error: created CODEMAN_CASES_PATH (%s) but could not make it %s:%s (the owner of CODEMAN_APPDATA_PATH).\n' \
|
||||||
|
"$cases_path" "$PUID" "$PGID" >&2
|
||||||
|
printf 'Run `chown %s:%s %s` as root, or create the directory as that account, then retry.\n' \
|
||||||
|
"$PUID" "$PGID" "$cases_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
||||||
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
|
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -92,6 +150,31 @@ if [[ ! -d "$repo_path/.git" ]]; then
|
|||||||
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Reads HEAD without requiring a `git` binary on the host — this script
|
||||||
|
# otherwise checks the checkout only by testing for `.git` as a directory, and
|
||||||
|
# resolving refs by hand keeps that the same "no host git needed" guarantee.
|
||||||
|
# ⚠️ A worktree checkout has `.git` as a FILE (`gitdir: <path>`), not a
|
||||||
|
# directory, so this returns nothing there and the volume-refresh check below
|
||||||
|
# silently no-ops — consistent with the `-d .git` test used everywhere else in
|
||||||
|
# this script, not a special case, but worth knowing if a worktree checkout
|
||||||
|
# stops picking up a stale-volume refresh it should have caught.
|
||||||
|
git_head_commit() {
|
||||||
|
local git_dir="$1/.git" head_ref ref_path
|
||||||
|
[[ -d "$git_dir" ]] || return 1
|
||||||
|
head_ref=$(cat -- "$git_dir/HEAD" 2>/dev/null) || return 1
|
||||||
|
if [[ "$head_ref" == ref:* ]]; then
|
||||||
|
ref_path="${head_ref#ref: }"
|
||||||
|
if [[ -f "$git_dir/$ref_path" ]]; then
|
||||||
|
cat -- "$git_dir/$ref_path"
|
||||||
|
else
|
||||||
|
# Packed after a `git gc`; the loose ref file above is gone.
|
||||||
|
awk -v ref="$ref_path" '$2 == ref { print $1; exit }' "$git_dir/packed-refs" 2>/dev/null
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf '%s' "$head_ref"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Record what the container is about to be built and created FROM. The in-app
|
# Record what the container is about to be built and created FROM. The in-app
|
||||||
# updater compares these against the release it wants to apply: a release that
|
# updater compares these against the release it wants to apply: a release that
|
||||||
# changes either file cannot be applied by the container restarting itself (a
|
# changes either file cannot be applied by the container restarting itself (a
|
||||||
@@ -126,4 +209,101 @@ else
|
|||||||
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
|
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
|
||||||
|
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
|
||||||
|
# behind old volume content until something clears it. The in-app self-updater
|
||||||
|
# never hits this — it rebuilds INSIDE the running container, into the very
|
||||||
|
# volume already in use — but a `docker compose build` triggered from outside
|
||||||
|
# it (this script, after a `git pull`) does: the container comes back up
|
||||||
|
# looking unchanged. Detect that here and clear just the affected volume(s) so
|
||||||
|
# the build below actually takes effect. Best-effort: with no sha256 tool this
|
||||||
|
# quietly does nothing, same as the environment-gate block above.
|
||||||
|
volumes_to_refresh=()
|
||||||
|
if [[ -n "$dockerfile_sha" ]]; then
|
||||||
|
repo_head=$(git_head_commit "$repo_path" || true)
|
||||||
|
lockfile_sha=$(sha256_of "$repo_path/package-lock.json" 2>/dev/null || true)
|
||||||
|
source_state_file="$state_dir/docker-build-source.json"
|
||||||
|
prev_head=''
|
||||||
|
prev_lockfile_sha=''
|
||||||
|
if [[ -f "$source_state_file" ]]; then
|
||||||
|
prev_head=$(sed -n 's/.*"headCommit": *"\([^"]*\)".*/\1/p' "$source_state_file")
|
||||||
|
prev_lockfile_sha=$(sed -n 's/.*"lockfileSha256": *"\([^"]*\)".*/\1/p' "$source_state_file")
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$repo_head" && "$repo_head" != "$prev_head" ]] && volumes_to_refresh+=('codeman-dist')
|
||||||
|
[[ -n "$lockfile_sha" && "$lockfile_sha" != "$prev_lockfile_sha" ]] && volumes_to_refresh+=('codeman-node-modules')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
|
||||||
|
exec "${compose_command[@]}" up --build -d
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Runs even on this script's very first invocation against an EXISTING
|
||||||
|
# deployment, deliberately: that deployment's volumes may already be stale
|
||||||
|
# (there was no earlier version of this check to have caught it), and clearing
|
||||||
|
# an already-empty or nonexistent volume is a harmless no-op, so there is no
|
||||||
|
# fresh-install case this needs to avoid.
|
||||||
|
printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_refresh[*]}"
|
||||||
|
|
||||||
|
# Build BEFORE taking the stack down: the image build is the slow part and needs
|
||||||
|
# no container stopped, so the deployment is offline only for the recreate.
|
||||||
|
"${compose_command[@]}" build
|
||||||
|
|
||||||
|
# `com.docker.compose.volume` is the volume KEY, not a project-qualified name -
|
||||||
|
# a second stack on the same host (a beta instance started with a different
|
||||||
|
# COMPOSE_PROJECT_NAME, say) that also declares a volume keyed `codeman-dist`
|
||||||
|
# shares that label, and `head -n1` would pick whichever the daemon happens to
|
||||||
|
# list first. Scope the lookup to THIS stack's own resolved project name so it
|
||||||
|
# can only ever match this stack's volume. The name is read from the resolved
|
||||||
|
# config's top-level `name` key, indentation-agnostic (the formatting is not a
|
||||||
|
# contract), and the FIRST `name` in the output is the project's: nested ones
|
||||||
|
# (a network's `name:`) come later. `--format json` needs Compose v2.3+.
|
||||||
|
project_name=$(
|
||||||
|
"${compose_command[@]}" config --format json 2>/dev/null |
|
||||||
|
sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1
|
||||||
|
)
|
||||||
|
|
||||||
|
"${compose_command[@]}" down
|
||||||
|
|
||||||
|
# Track whether the volumes were actually cleared. The marker below is written
|
||||||
|
# ONLY on success: with an unresolvable project name the label filter would
|
||||||
|
# match nothing, nothing would be removed, and a marker recording the new HEAD
|
||||||
|
# would stop this check from ever firing again while the stale volume kept
|
||||||
|
# serving old code. A failed removal likewise leaves the marker alone, so the
|
||||||
|
# next start retries, and the stack is brought back up regardless rather than
|
||||||
|
# left down.
|
||||||
|
refreshed=1
|
||||||
|
if [[ -z "$project_name" ]]; then
|
||||||
|
# The documented reset (docs/docker-self-update.md): both volumes re-seed from
|
||||||
|
# the image by a plain copy, so clearing the extra one costs a copy, not data.
|
||||||
|
printf 'Warning: could not resolve the Compose project name; clearing both build-artefact volumes with `down --volumes` instead.\n' >&2
|
||||||
|
"${compose_command[@]}" down --volumes || refreshed=0
|
||||||
|
else
|
||||||
|
for key in "${volumes_to_refresh[@]}"; do
|
||||||
|
volume_name=$(
|
||||||
|
docker volume ls -q \
|
||||||
|
--filter "label=com.docker.compose.volume=$key" \
|
||||||
|
--filter "label=com.docker.compose.project=$project_name" |
|
||||||
|
head -n1
|
||||||
|
)
|
||||||
|
if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then
|
||||||
|
printf 'Warning: could not remove volume %s; it will be retried on the next start.\n' "$volume_name" >&2
|
||||||
|
refreshed=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$refreshed" == '1' ]]; then
|
||||||
|
printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \
|
||||||
|
"$repo_head" "$lockfile_sha" >"$source_state_file.tmp"
|
||||||
|
mv -- "$source_state_file.tmp" "$source_state_file"
|
||||||
|
if [[ "$EUID" == '0' ]]; then
|
||||||
|
chown -- "$PUID:$PGID" "$source_state_file"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf 'Warning: the build-artefact volumes were NOT refreshed; the container may serve stale code until the next successful start.\n' >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Already built above, so no --build here: a second build would only re-check
|
||||||
|
# the cache.
|
||||||
|
exec "${compose_command[@]}" up -d
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ services:
|
|||||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||||
|
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||||
|
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||||
|
# defaults to empty rather than requiring a line in every .env.
|
||||||
|
CODEMAN_ALLOWED_HOSTS: ${CODEMAN_ALLOWED_HOSTS:-}
|
||||||
CODEMAN_HOST: ${CODEMAN_HOST}
|
CODEMAN_HOST: ${CODEMAN_HOST}
|
||||||
CODEMAN_PASSWORD: ${CODEMAN_PASSWORD}
|
CODEMAN_PASSWORD: ${CODEMAN_PASSWORD}
|
||||||
CODEMAN_PORT: ${CODEMAN_PORT}
|
CODEMAN_PORT: ${CODEMAN_PORT}
|
||||||
@@ -91,6 +95,23 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
# The entrypoint corrects bind-mount ownership as root before dropping to
|
||||||
|
# PUID:PGID. Everything not listed here remains dropped by cap_drop above.
|
||||||
|
# test/docker-entrypoint.test.ts pins this list against what the
|
||||||
|
# entrypoint and `init: true` actually need, so a capability cannot go
|
||||||
|
# missing silently again.
|
||||||
|
- CHOWN
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
# `init: true` makes tini PID 1, and tini stays ROOT while the entrypoint
|
||||||
|
# drops the server to PUID. Signalling a process of a different uid needs
|
||||||
|
# CAP_KILL; without it tini's SIGTERM forward fails ("Unexpected error
|
||||||
|
# when forwarding signal: 'Operation not permitted'"), tini dies, and the
|
||||||
|
# PID namespace teardown SIGKILLs the server instead of letting
|
||||||
|
# `server.stop()` flush state on every `docker compose down`/`restart`.
|
||||||
|
- KILL
|
||||||
|
- SETGID
|
||||||
|
- SETUID
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
|
|||||||
Executable
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Corrects ownership - host bind mounts, and the image-baked CLI prefix -
|
||||||
|
# then drops to PUID:PGID.
|
||||||
|
#
|
||||||
|
# Compose binds CODEMAN_APPDATA_PATH and CODEMAN_CASES_PATH from the host. When
|
||||||
|
# either path does not exist yet - a first run, a cleared application-data
|
||||||
|
# directory, a restored backup - the Docker daemon creates it owned by root,
|
||||||
|
# and an unprivileged server cannot then create its own state directory. The
|
||||||
|
# result is a container that restarts forever on:
|
||||||
|
#
|
||||||
|
# Failed to start web server: EACCES: permission denied, mkdir '/home/<user>/.codeman'
|
||||||
|
#
|
||||||
|
# Running this as root and dropping afterwards removes that failure mode without
|
||||||
|
# leaving the server privileged. The same root start also lets it re-assert
|
||||||
|
# /opt/codeman-cli's ownership on every start, not just at image build time -
|
||||||
|
# see the comment at that chown below for why that matters for anyone who
|
||||||
|
# runs the compose file directly rather than through Start-Codeman.sh.
|
||||||
|
#
|
||||||
|
# Capabilities this script needs against the compose file's `cap_drop: ALL`
|
||||||
|
# (test/docker-entrypoint.test.ts pins the list against docker-compose.yaml):
|
||||||
|
# CHOWN + DAC_OVERRIDE the chown of a root-owned bind source below
|
||||||
|
# SETUID + SETGID the setpriv drop itself
|
||||||
|
# KILL NOT used here, but required by the container: with
|
||||||
|
# `init: true` tini is PID 1 and runs as root while the
|
||||||
|
# server runs as PUID, and signalling a process of a
|
||||||
|
# different uid needs CAP_KILL. Without it every
|
||||||
|
# `docker compose down`/`restart` ends in tini dying with
|
||||||
|
# "Unexpected error when forwarding signal" and the
|
||||||
|
# server being SIGKILLed instead of stopping cleanly.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Honour an explicit `user:` in Compose: when the container was not started as
|
||||||
|
# root there is nothing to correct and no privilege to drop.
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Everything below runs as root and calls stat, chown, id, setpriv and friends
|
||||||
|
# by bare name, so the lookup path must not contain a directory the runtime
|
||||||
|
# account can write to. /opt/codeman-cli/bin is exactly that (it is chowned to
|
||||||
|
# PUID:PGID so sessions can update the agent CLIs in place), and the image
|
||||||
|
# appends it to PATH for the server's sake. Resolve root's commands through the
|
||||||
|
# system directories only, and hand the image's full PATH back to the server at
|
||||||
|
# the exec below, since Codeman resolves the agent CLIs through it.
|
||||||
|
runtime_path=$PATH
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
: "${PUID:=1000}"
|
||||||
|
: "${PGID:=1000}"
|
||||||
|
|
||||||
|
# The capabilities the compose file must grant, named in the diagnosis below so
|
||||||
|
# an out-of-tree compose file (Unraid's Compose Manager, a hand-written unit)
|
||||||
|
# fails with a one-line fix instead of a restart loop.
|
||||||
|
required_caps='CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID'
|
||||||
|
|
||||||
|
# Pre-flight the drop itself before touching anything. A container started with
|
||||||
|
# `cap_drop: ALL` and none of the additions above fails here, and would otherwise
|
||||||
|
# die at the final exec with a bare "setpriv: setresuid failed: Operation not
|
||||||
|
# permitted" after chown had already failed, or worse, misreport a perfectly
|
||||||
|
# writable directory as unwritable because the probe below could not drop
|
||||||
|
# privileges to test it.
|
||||||
|
if ! setpriv --reuid "$PUID" --regid "$PGID" --clear-groups true 2>/dev/null; then
|
||||||
|
printf 'entrypoint: cannot drop privileges to PUID:PGID (%s:%s).\n' "$PUID" "$PGID" >&2
|
||||||
|
printf 'entrypoint: this image starts as root and drops with setpriv, which needs\n' >&2
|
||||||
|
printf 'entrypoint: cap_add: [%s]\n' "$required_caps" >&2
|
||||||
|
printf 'entrypoint: on top of cap_drop: ALL (see docker/docker-compose.yaml). Add them to the\n' >&2
|
||||||
|
printf 'entrypoint: compose file that started this container, or set `user:` to skip the drop entirely.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preserve the supplementary groups Compose granted through group_add - that is
|
||||||
|
# how the Docker socket stays reachable - while discarding root's own group.
|
||||||
|
supplementary=$(id -G | tr ' ' '\n' | grep -vx 0 | paste -sd, -)
|
||||||
|
[ -n "$supplementary" ] || supplementary="$PGID"
|
||||||
|
|
||||||
|
# Writable as the account the server is about to become? A real probe, run as
|
||||||
|
# exactly the identity the final exec below produces (PUID, PGID, the same
|
||||||
|
# supplementary groups, capabilities dropped), rather than a comparison of
|
||||||
|
# owners: ownership is not writability. A group-writable tree owned by another
|
||||||
|
# account, an ACL, or a CIFS/NFS mount that reports some unrelated uid are all
|
||||||
|
# fine to run on and would all fail an owner check.
|
||||||
|
writable_as_runtime() {
|
||||||
|
setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" test -w "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in "${HOME:-}" "${CODEMAN_CASES_PATH:-}"; do
|
||||||
|
[ -n "$target" ] && [ -d "$target" ] || continue
|
||||||
|
owner=$(stat -c '%u:%g' "$target")
|
||||||
|
[ "$owner" = "${PUID}:${PGID}" ] && continue
|
||||||
|
|
||||||
|
# Only ever correct a directory the DAEMON created: root-owned, because
|
||||||
|
# neither PUID nor PGID existed yet when it materialised the missing bind
|
||||||
|
# source. Anything else - a host tree that legitimately belongs to some
|
||||||
|
# OTHER account, such as an existing CODEMAN_CASES_PATH the README already
|
||||||
|
# allows pointing at a normal project directory - is not this container's
|
||||||
|
# to reassign; recursively chowning it on every mismatch silently rewrote
|
||||||
|
# a credentials tree or a projects directory to PUID:PGID with one log
|
||||||
|
# line to explain it. Such a directory is left alone and only PROBED below.
|
||||||
|
#
|
||||||
|
# The chown is deliberately not fatal. A bind mount backed by NFS, CIFS or a
|
||||||
|
# rootless daemon can refuse chown while still being perfectly writable, and
|
||||||
|
# the probe below is what decides whether the server can run on it.
|
||||||
|
if [ "${owner%%:*}" = '0' ]; then
|
||||||
|
if chown -R "${PUID}:${PGID}" "$target" 2>/dev/null; then
|
||||||
|
printf 'entrypoint: corrected ownership of %s to %s:%s\n' "$target" "$PUID" "$PGID"
|
||||||
|
else
|
||||||
|
printf 'entrypoint: warning: cannot change ownership of %s to %s:%s; checking whether it is writable anyway\n' \
|
||||||
|
"$target" "$PUID" "$PGID" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if writable_as_runtime "$target"; then
|
||||||
|
if [ "${owner%%:*}" != '0' ]; then
|
||||||
|
printf 'entrypoint: %s is owned by %s, not %s:%s, but is writable as the runtime account; leaving its ownership alone\n' \
|
||||||
|
"$target" "$owner" "$PUID" "$PGID"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'entrypoint: %s is not writable as PUID:PGID (%s:%s); it is owned by %s.\n' \
|
||||||
|
"$target" "$PUID" "$PGID" "$owner" >&2
|
||||||
|
printf 'entrypoint: refusing to change ownership of a directory this container did not create.\n' >&2
|
||||||
|
printf 'entrypoint: either chown it on the host, make it writable to %s:%s, or set PUID/PGID to match its owner.\n' \
|
||||||
|
"$PUID" "$PGID" >&2
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# /opt/codeman-cli (the four agent CLIs) is chowned to PUID:PGID once, at
|
||||||
|
# image BUILD time, from the PUID/PGID build args - server.Dockerfile's own
|
||||||
|
# comment on that RUN step explains why it lives in its own prefix rather than
|
||||||
|
# /usr/local. Unlike HOME/CODEMAN_CASES_PATH above, that bake happens only
|
||||||
|
# when the image is actually rebuilt (`docker compose up --build`, which
|
||||||
|
# Start-Codeman.sh always does) - a deployment that instead runs the compose
|
||||||
|
# file directly (Unraid's Compose Manager, a native Debian systemd unit, any
|
||||||
|
# `docker compose up`/`restart` with no --build) can change PUID/PGID in .env
|
||||||
|
# and restart without ever rebuilding, at which point the container runs as
|
||||||
|
# the NEW uid while the CLI directory is still owned by the OLD one baked into
|
||||||
|
# the image layer - silently breaking the very "self-update a CLI in place"
|
||||||
|
# fix this directory exists for. Re-assert it here, every start, unconditionally:
|
||||||
|
# unlike the host bind mounts above, this is pure image content Codeman itself
|
||||||
|
# populated, never host data that might legitimately belong to someone else,
|
||||||
|
# so there is no ownership to be careful about - it is always correct for it
|
||||||
|
# to be owned by whoever this container is about to run as.
|
||||||
|
if [ -d /opt/codeman-cli ] && [ "$(stat -c '%u:%g' /opt/codeman-cli)" != "${PUID}:${PGID}" ]; then
|
||||||
|
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Discarding group 0 is right for root's own group, but it also discards a
|
||||||
|
# `group_add: 0` that was there to reach a Docker socket owned by root:root.
|
||||||
|
# The previous image ran as PUID with that group kept, so say so rather than
|
||||||
|
# letting Docker-case support vanish silently on such a host.
|
||||||
|
if [ -S /var/run/docker.sock ] && [ "$(stat -c '%g' /var/run/docker.sock)" = '0' ]; then
|
||||||
|
printf 'entrypoint: warning: /var/run/docker.sock is owned by group 0, which is dropped along with root;\n' >&2
|
||||||
|
printf 'entrypoint: warning: Docker cases will not work from this container. Give the socket a dedicated\n' >&2
|
||||||
|
printf 'entrypoint: warning: group on the host and set DOCKER_SOCKET_GID to it.\n' >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No `--bounding-set -all` here: it is a silent no-op without CAP_SETPCAP, which
|
||||||
|
# the compose file deliberately does not grant, and `no-new-privileges` already
|
||||||
|
# makes the bounding set moot. The reuid/regid drop leaves CapPrm/CapEff empty.
|
||||||
|
# The image's full PATH goes back to the server here; see the top of the file.
|
||||||
|
exec setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" \
|
||||||
|
env PATH="$runtime_path" "$@"
|
||||||
@@ -24,7 +24,7 @@ RUN npm ci \
|
|||||||
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
|
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
ARG CODEMAN_RUNTIME_USER=opencode
|
ARG CODEMAN_RUNTIME_USER=codeman
|
||||||
ARG PUID=1000
|
ARG PUID=1000
|
||||||
ARG PGID=1000
|
ARG PGID=1000
|
||||||
|
|
||||||
@@ -71,6 +71,24 @@ COPY --from=docker:29-cli \
|
|||||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||||
# through Codeman sessions, and the configured host bind mount retains state.
|
# through Codeman sessions, and the configured host bind mount retains state.
|
||||||
#
|
#
|
||||||
|
# Installed into a DEDICATED prefix, /opt/codeman-cli, not the base image's
|
||||||
|
# default /usr/local. A session needs write access to wherever these CLIs live
|
||||||
|
# so it can self-update one in place (observed via Codex's own
|
||||||
|
# `npm install -g @openai/codex`, which renames the old package directory
|
||||||
|
# aside before installing the new one — a rename needs write access to the
|
||||||
|
# PARENT directory, not just the target, so the runtime account needs that
|
||||||
|
# access at the directory level). Chowning /usr/local/bin and
|
||||||
|
# /usr/local/lib/node_modules directly to get it would ALSO hand away
|
||||||
|
# entrypoint.sh (COPY'd to /usr/local/bin below, root-owned, executed as root
|
||||||
|
# on every container start with CHOWN/DAC_OVERRIDE/SETUID/SETGID) and the node
|
||||||
|
# binary: owning the DIRECTORY is enough to rename it aside and drop a
|
||||||
|
# replacement, even though the file itself stays root-owned, which would let a
|
||||||
|
# compromised session arrange for its own script to run as root at the next
|
||||||
|
# restart — undoing the "the server itself never runs privileged" guarantee
|
||||||
|
# the entrypoint exists to provide. /opt/codeman-cli holds nothing else to
|
||||||
|
# escalate through, so owning it is exactly the CLI-update access it needs and
|
||||||
|
# no more.
|
||||||
|
#
|
||||||
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
|
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
|
||||||
# a function of WHEN their image was built, not of any commit — so a Codeman
|
# a function of WHEN their image was built, not of any commit — so a Codeman
|
||||||
# release that depends on newer CLI behaviour (the trust-dialog handling is
|
# release that depends on newer CLI behaviour (the trust-dialog handling is
|
||||||
@@ -82,6 +100,15 @@ COPY --from=docker:29-cli \
|
|||||||
#
|
#
|
||||||
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
||||||
# this layer when only the pins change upstream.
|
# this layer when only the pins change upstream.
|
||||||
|
# The prefix is APPENDED to PATH, never prepended: it is chowned to the runtime
|
||||||
|
# account below, and entrypoint.sh runs as root calling stat/chown/setpriv by
|
||||||
|
# bare name. A prefix ahead of /usr/bin would let a session drop a `setpriv`
|
||||||
|
# there and have it run as root at the next container start (measured with a
|
||||||
|
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||||
|
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||||
|
# system directories for the root part of the start.
|
||||||
|
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||||
|
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||||
RUN npm install --global \
|
RUN npm install --global \
|
||||||
@anthropic-ai/claude-code@2.1.258 \
|
@anthropic-ai/claude-code@2.1.258 \
|
||||||
@google/gemini-cli@0.58.0 \
|
@google/gemini-cli@0.58.0 \
|
||||||
@@ -93,6 +120,11 @@ RUN npm install --global \
|
|||||||
# PGID match the host-owned application-data directory mounted by Compose. The
|
# PGID match the host-owned application-data directory mounted by Compose. The
|
||||||
# requested GID may not exist in the base image, and a host UID such as 1000 may
|
# requested GID may not exist in the base image, and a host UID such as 1000 may
|
||||||
# already belong to the baked `node` account, so handle both cases explicitly.
|
# already belong to the baked `node` account, so handle both cases explicitly.
|
||||||
|
#
|
||||||
|
# The trailing chown hands the CLI prefix (/opt/codeman-cli, populated above)
|
||||||
|
# to that same account, so a session can self-update one of the CLIs in place.
|
||||||
|
# /usr/local stays root-owned throughout — see the comment on the npm install
|
||||||
|
# above for why that boundary matters.
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
|
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
|
||||||
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
|
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
|
||||||
@@ -120,7 +152,8 @@ RUN set -eux; \
|
|||||||
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
|
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
|
||||||
--shell /bin/bash \
|
--shell /bin/bash \
|
||||||
"${CODEMAN_RUNTIME_USER}"; \
|
"${CODEMAN_RUNTIME_USER}"; \
|
||||||
fi
|
fi; \
|
||||||
|
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
||||||
|
|
||||||
WORKDIR /opt/codeman
|
WORKDIR /opt/codeman
|
||||||
|
|
||||||
@@ -135,8 +168,19 @@ ENV CODEMAN_IN_CONTAINER=1 \
|
|||||||
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Runtime defaults for the entrypoint, matching the account created above.
|
||||||
|
ENV PGID=${PGID} PUID=${PUID}
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
USER ${CODEMAN_RUNTIME_USER}
|
# The container starts as root so the entrypoint can correct the ownership of
|
||||||
|
# the host bind mounts, which the daemon creates as root whenever they do not
|
||||||
|
# already exist. The entrypoint then drops to PUID:PGID with setpriv, so the
|
||||||
|
# server itself never runs privileged. Setting `user:` in Compose bypasses both
|
||||||
|
# steps, leaving the caller in full control.
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["node", "dist/index.js", "web"]
|
CMD ["node", "dist/index.js", "web"]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,363 @@
|
|||||||
|
# Custom Model Endpoint Profiles (all harnesses, local or cloud)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The author pays for Claude Code but also runs a capable local model behind an
|
||||||
|
OpenAI-compatible server (llama.cpp) — and wants the same mechanism to work
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Scope note**: this plan originally said "local LLM." It now covers any
|
||||||
|
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
|
||||||
|
vLLM) or cloud (Azure AI Foundry, OpenRouter, a company gateway). The
|
||||||
|
mechanism is identical (a base URL Codeman probes via `GET /v1/models`); the
|
||||||
|
only real differences are auth-header convention (cloud endpoints often want
|
||||||
|
an `api-key` header, e.g. Azure, rather than `Authorization: Bearer`) and
|
||||||
|
that a cloud "model" may actually be a deployment name distinct from the
|
||||||
|
underlying model family (Azure AI Foundry deployments) — both are called out
|
||||||
|
where they matter below. Naming throughout this plan is **"custom model
|
||||||
|
endpoint,"** not "local model," to keep that scope explicit.
|
||||||
|
|
||||||
|
### Additional use case: on-premises AI hardware
|
||||||
|
|
||||||
|
"Local" isn't limited to a desktop running llama.cpp — a growing category of
|
||||||
|
purpose-built, on-premises AI hardware exists specifically to run a serious
|
||||||
|
model on-site with an OpenAI-compatible server, and this feature is exactly
|
||||||
|
the on-ramp for pointing Codeman at one:
|
||||||
|
|
||||||
|
- **NVIDIA DGX Spark** (and the DGX Spark-class "Spark" mini-supercomputer
|
||||||
|
line) — a compact on-prem inference/training box aimed at running large
|
||||||
|
local models with an OpenAI-compatible API surface.
|
||||||
|
- **AMD "Strix Halo" (Ryzen AI Max)** on-prem AI mini-PCs — unified-memory
|
||||||
|
APU hardware marketed for local LLM inference, typically fronted by
|
||||||
|
llama.cpp/Ollama/vLLM the same way a home server would be.
|
||||||
|
|
||||||
|
Neither needs anything new from this design: both present a standard
|
||||||
|
`/v1/models` + `/v1/chat/completions` OpenAI-compatible surface once the
|
||||||
|
inference server is running, so they're just another `baseUrl` entry in the
|
||||||
|
custom-model-hosts store, same as llama.cpp or a cloud endpoint. The
|
||||||
|
justification for building this generically (rather than hardcoding "point
|
||||||
|
Claude at my llama.cpp box") is precisely this: **the same endpoint registry
|
||||||
|
and per-CLI injection mechanism should work unmodified for any current or
|
||||||
|
future OpenAI-compatible box or service** — a home GPU rig today, a Spark or
|
||||||
|
Strix Halo appliance tomorrow, a company's on-prem inference cluster after
|
||||||
|
that — without Codeman needing to know or care what's actually serving the
|
||||||
|
model on the other end of that URL.
|
||||||
|
|
||||||
|
A concrete example worth naming: **[Ark0N/Qwen5090](https://github.com/Ark0N/Qwen5090)**
|
||||||
|
(from the same GitHub account as this project's owner) is a one-click
|
||||||
|
Windows / one-command Linux installer that stands up Qwen3.8-27B locally on
|
||||||
|
an RTX 5090 (or another RTX 50-series card with ≥24GB) behind an
|
||||||
|
OpenAI-compatible API, served by any of vLLM, NInfer, or llama.cpp — MIT-
|
||||||
|
licensed tooling over Apache-2.0 Qwen weights. It's a direct, ready-made
|
||||||
|
target for this feature: point a custom-model-hosts entry at whichever
|
||||||
|
backend it's running, and it needs nothing further from Codeman's side. It's
|
||||||
|
also notable for already wiring up DeepSeek Harness and Claude Code as
|
||||||
|
coding agents against that local server itself, which is effectively the
|
||||||
|
same "point a Codeman-supported harness at a local endpoint" idea this
|
||||||
|
feature is generalizing — worth using as a real-world reference/test target
|
||||||
|
once chunk 5 (session integration) exists, alongside the author's own llama.cpp
|
||||||
|
box.
|
||||||
|
|
||||||
|
Each harness has its own (different-shaped) mechanism for pointing at a
|
||||||
|
custom OpenAI-compatible base URL + model — env vars for Claude, a JSON
|
||||||
|
config blob for opencode, a TOML file for Codex, etc. The author gave the
|
||||||
|
starting recipes for those three; the rest (Gemini, Pi, Grok, DeepSeek, OMP,
|
||||||
|
Antigravity) were researched for this plan and are flagged by confidence
|
||||||
|
below. A real end-to-end pass against the author's own llama-swap server
|
||||||
|
(`scripts/test-local-llm-harnesses.ts`, inside a `codeman/agent:llm-test`
|
||||||
|
Docker image with all 9 CLIs installed) then confirmed **claude and
|
||||||
|
opencode work end-to-end**, corrected a real Codex config.toml schema bug
|
||||||
|
the given recipe had (see the Codex row below), and surfaced that Codex's
|
||||||
|
_protocol_ — not just its config shape — does not work against a plain
|
||||||
|
OpenAI-Chat-Completions server like llama.cpp/llama-swap at all. Confidence
|
||||||
|
below reflects what was actually observed, not just what was planned.
|
||||||
|
|
||||||
|
The feature must be:
|
||||||
|
|
||||||
|
- **Off by default**, one settings toggle turns it on.
|
||||||
|
- Endpoint entry: user gives a base URL — a LAN address or a cloud URL —
|
||||||
|
plus an optional API key, and Codeman calls `GET <baseUrl>/v1/models` to
|
||||||
|
discover and store the available model (or deployment) list.
|
||||||
|
- A **new toolbar selector** (separate from the existing Run-mode menu, since
|
||||||
|
it's a modifier on top of whichever harness is already selected/running)
|
||||||
|
lets the user pick "Cloud (default)" — the harness's own native backend —
|
||||||
|
or a model discovered from one of the configured custom endpoints.
|
||||||
|
- Picking a custom-endpoint model for an **already-running session restarts
|
||||||
|
that session's CLI process** with the injected env/config pointed at that
|
||||||
|
endpoint (confirmed with the maintainer — these harnesses read endpoint config at
|
||||||
|
process start, not per-turn, so a live hot-swap isn't possible).
|
||||||
|
- **New sessions always default back to the harness's native cloud backend.**
|
||||||
|
A custom-endpoint selection is a per-session override, not a sticky global
|
||||||
|
default — starting a fresh CLI (any mode) always launches against its
|
||||||
|
native backend unless the user explicitly picks a custom endpoint for that
|
||||||
|
new session too. The toolbar selector is scoped to "this session," never
|
||||||
|
carried forward as the default for future sessions.
|
||||||
|
|
||||||
|
This follows the repo's existing data-driven CLI-registry philosophy
|
||||||
|
(`test/cli-registry-no-id-branching.test.ts`): per-CLI behavior is a
|
||||||
|
declared capability, never an `if (mode === 'claude')` branch.
|
||||||
|
|
||||||
|
## Per-CLI injection recipes (confidence-ranked)
|
||||||
|
|
||||||
|
| CLI | Mechanism | Confidence |
|
||||||
|
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `claude` | Env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_SONNET_MODEL`/`_HAIKU_MODEL`/`_OPUS_MODEL` (all set to the chosen model/deployment name) | **Verified end-to-end** against a real llama-swap server — a real "hello world" reply came back. ⚠️ Non-interactive (`-p`) invocations also fire an async session-title-generation call that reuses `ANTHROPIC_DEFAULT_HAIKU_MODEL` and validates it against Claude Code's OWN internal recognized-model list, printing `[claude-code:unrecognized_model]` and, in `-p` mode, hanging the whole invocation rather than just warning. `--settings '{"autoTitle":false}'` does NOT stop this (confirmed); `--bare` does (the warning still prints, but the real prompt runs) — but `--bare` ALSO disables hooks, LSP, plugin sync, and CLAUDE.md auto-discovery, so it is only safe for the standalone one-shot test script, NEVER for a real interactive Codeman session (which depends on hooks for idle detection, trust-dialog auto-accept, etc. — see the External CLI modes section of CLAUDE.md). Whether an INTERACTIVE claude session with a custom model hits the same hang (vs. just a background warning) is untested and should be checked before calling chunk 5/6 done for claude |
|
||||||
|
| `opencode` | `OPENCODE_CONFIG_CONTENT` env var (already a registry mechanism, `stock.ts:342`) holding a JSON blob: `{"provider":{"custom":{"options":{"baseURL":...,"apiKey":...},"models":{"<name>":{}}}},"model":"custom/<name>"}` | **Verified by user** |
|
||||||
|
| `codex` | TOML `config.toml`: top-level `model = "<id>"` + `[model_providers.custom]` (`base_url`, `env_key` naming an env var the real API key rides in — never a literal TOML field, since codex's schema has no such field). Written to an isolated dir via `CODEX_HOME` (`stock.ts:405-415`) so the user's own `~/.codex/config.toml` is never touched | **Config STRUCTURE verified** against a real codex binary (an earlier `[model].default` table shape was rejected: "invalid type: map, expected a string" — caught live). **Protocol CONFIRMED BROKEN against llama.cpp/llama-swap**: codex only speaks the Responses API (`wire_api = "responses"`, the only value it accepts since it dropped `"chat"` support in Feb 2026), and a real llama-swap server does not implement `/v1/responses` — a live run against it failed with repeated `Reconnecting...` then `high demand` errors. Codex support therefore needs a Responses-API-compatible endpoint (most local llama.cpp/Ollama/vLLM setups do not qualify); do not present this as working against a generic OpenAI-Chat-Completions box |
|
||||||
|
| `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`). 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 | **Confirmed reaching the server, but failing — unresolved.** A real run against llama-swap returns `dsh: HTTP_404: DeepSeek API error (HTTP 404)` consistently (confirmed the env vars are read: the request reaches the network rather than failing locally). Root cause not identified — plausible explanation by analogy with codex's Responses-API gap is that `dsh --profile headless` expects DeepSeek's official API response shape/path structure rather than a generic OpenAI-compatible `/v1/chat/completions` endpoint, but this was not confirmed by reading dsh's own bundled source (unlike pi/grok, where that grep resolved the question directly). Documented as best-effort/unknown, not shipped as verified working |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
Everything web-researched-but-unverified gets implemented but must be
|
||||||
|
smoke-tested against real installs of those CLIs before being called done —
|
||||||
|
call this out explicitly when implementing, don't just ship on faith.
|
||||||
|
|
||||||
|
**Cloud-endpoint specifics** to keep in mind per recipe above: an Azure AI
|
||||||
|
Foundry-style endpoint typically wants the API key in an `api-key` header
|
||||||
|
rather than (or in addition to) `Authorization: Bearer`, and its "model" is
|
||||||
|
often a deployment name rather than the underlying model family name — the
|
||||||
|
discovery step (`GET /v1/models`) still works the same way against Azure AI
|
||||||
|
Foundry's OpenAI-compatible endpoint shape, but a user may need to type the
|
||||||
|
deployment name manually if it isn't returned as expected.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Registry: new `capabilities.customModelInjection` field
|
||||||
|
|
||||||
|
Extend `src/config/cli-registry/types.ts` / `schema.ts` with a discriminated
|
||||||
|
union on each `CliEntry.capabilities`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CustomModelInjection =
|
||||||
|
| { kind: 'env'; baseUrlVar: string; apiKeyVar: string; modelVars: string[] }
|
||||||
|
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json' }
|
||||||
|
| {
|
||||||
|
kind: 'configDir';
|
||||||
|
dirEnvVar: string;
|
||||||
|
fileName: string;
|
||||||
|
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml';
|
||||||
|
}
|
||||||
|
| { kind: 'unsupported' };
|
||||||
|
```
|
||||||
|
|
||||||
|
Declared per stock.ts entry per the table above. A pure function in a new
|
||||||
|
`src/custom-model-injection.ts` (`buildCustomModelInjection(entry, endpoint, modelId)`)
|
||||||
|
turns `(CliEntry, endpoint, modelId)` into either an `envOverrides` object
|
||||||
|
(kind `env`/`configContentEnv`) or a `{ dirEnvVar, files: [{path, content}] }`
|
||||||
|
descriptor (kind `configDir`) — unit-testable with no IO, mirroring how
|
||||||
|
`session-cli-builder.ts` is pure. The `configDir` kind additionally needs an
|
||||||
|
IO wrapper that writes those files under
|
||||||
|
`dataPath('custom-model-configs/<sessionId>/')` (new dir, cleaned up on
|
||||||
|
session delete — same lifecycle as other per-session generated state).
|
||||||
|
|
||||||
|
### 2. Endpoint registry: `src/custom-model-hosts.ts`
|
||||||
|
|
||||||
|
Same read-array/write-array shape as `src/remote-hosts.ts` /
|
||||||
|
`src/webview-store.ts`: `~/.codeman/custom-model-hosts.json` holding
|
||||||
|
`CustomModelEndpoint[] = { id, label, baseUrl, apiKey?, authStyle?: 'bearer'|'api-key'|'both', models?: string[], lastDiscoveredAt? }`.
|
||||||
|
`authStyle` defaults to `'both'` (send both header conventions on the
|
||||||
|
discovery probe, same approach the smoke-test script below uses) so one
|
||||||
|
endpoint entry works whether it's llama.cpp or Azure without the user having
|
||||||
|
to know which header their box wants in advance.
|
||||||
|
|
||||||
|
New route file `src/web/routes/custom-model-routes.ts` (registered in the
|
||||||
|
routes barrel), mirroring `case-routes.ts`'s remote/docker-host CRUD
|
||||||
|
(`GET/POST/PUT/DELETE /api/model-endpoints`, admin-gated in multi-user mode
|
||||||
|
the same way) plus:
|
||||||
|
|
||||||
|
- `POST /api/model-endpoints/:id/discover-models` — fetches
|
||||||
|
`${baseUrl}/v1/models`, stores the `data[].id` list, returns it. Bounded
|
||||||
|
timeout, and run the target through the **same SSRF egress guard already
|
||||||
|
used for web tabs** (`webview-egress-policy.ts` — reject link-local/cloud
|
||||||
|
metadata addresses) — this still matters for a cloud URL too, since the
|
||||||
|
guard is about preventing a redirect to internal infra, not about
|
||||||
|
local-vs-cloud.
|
||||||
|
|
||||||
|
**Why discovery rather than a free-text model field**: it removes the one
|
||||||
|
piece of configuration most likely to trip a user up — hand-typing the
|
||||||
|
exact model identifier a given inference server expects, which varies by
|
||||||
|
server and is an easy source of a silent "model not found" failure with no
|
||||||
|
useful error surfaced back through a CLI's own startup. Discovery also
|
||||||
|
means this design is not limited to a single-model box: a **multi-model
|
||||||
|
gateway** such as **[llama-swap](https://github.com/mostlygeek/llama-swap)**
|
||||||
|
(hot-swaps between several loaded llama.cpp model configs behind one
|
||||||
|
OpenAI-compatible endpoint) or a vLLM/LiteLLM/Ollama instance serving
|
||||||
|
several models advertises ALL of them through the same `/v1/models` call —
|
||||||
|
so one endpoint entry surfaces every model that gateway can serve, with no
|
||||||
|
extra per-model configuration on Codeman's side at all.
|
||||||
|
|
||||||
|
### 3. Settings
|
||||||
|
|
||||||
|
- New synced boolean `customModelEndpointsEnabled` in `SettingsUpdateSchema`
|
||||||
|
(`src/web/schemas.ts`), default `false`, documented inline like
|
||||||
|
`readMyMindEnabled`/`workspaceHooksEnabled`.
|
||||||
|
- New `.set-group` "Custom Model Endpoints" inside the **Agents & CLIs**
|
||||||
|
section (`settings-clis`, `index.html:2150+`) with the enable toggle plus
|
||||||
|
a list-editor (add/refresh-models/delete rows) for endpoints — closest
|
||||||
|
existing precedent is the respawn-presets array editor
|
||||||
|
(`schemas.ts:1285-1305`, `index.html:1243-1244`) for add/apply/delete-by-id
|
||||||
|
semantics, backed by the new CRUD routes above.
|
||||||
|
|
||||||
|
### 4. Toolbar UI
|
||||||
|
|
||||||
|
- New header/toolbar button (e.g. `#customModelBtn`, `btn-toolbar
|
||||||
|
btn-custom-model`), marker-hidden by default (`btn-custom-model--hidden`)
|
||||||
|
and revealed by `applyHeaderVisibilitySettings()` only when
|
||||||
|
`customModelEndpointsEnabled` is on — same pattern as the File
|
||||||
|
Viewer/Cron buttons.
|
||||||
|
- Clicking opens a dropdown (`#customModelMenu`, same `.run-mode-menu`-style
|
||||||
|
markup as the existing Run-mode gear menu) listing "Cloud (default)" plus
|
||||||
|
every discovered model, grouped by endpoint. An entry is disabled with a
|
||||||
|
tooltip when the active session's CLI has `customModelInjection.kind ===
|
||||||
|
'unsupported'` (Antigravity) or none declared.
|
||||||
|
- Selecting an entry calls a new route:
|
||||||
|
`POST /api/sessions/:id/custom-model { endpointId, modelId } | { clear: true }`.
|
||||||
|
Server: resolve the CLI entry for `session.mode`, build the injection via
|
||||||
|
§1, persist it as a new `session.customModel` state field (surfaced in
|
||||||
|
`toState()`/SSE so the tab can show a small badge, e.g. "🖥 qwen3 (local)"
|
||||||
|
or "☁ gpt-4o-mini (azure)", and the choice survives reload), merge into
|
||||||
|
the session's `envOverrides`, and **respawn the pane's CLI process**
|
||||||
|
through the same respawn/interactive-restart path
|
||||||
|
`session.ts`/`tmux-manager.ts` already use for effort/model changes
|
||||||
|
(`_configureCliEnv()` + `applyEnvOverrides()` at spawn time) — reuse,
|
||||||
|
don't reinvent, the existing kill-and-relaunch-in-pane machinery.
|
||||||
|
- New-session creation deliberately does **not** inherit a prior custom-
|
||||||
|
endpoint choice: `buildEnvOverrides()` (session-ui.js) never carries the
|
||||||
|
toolbar selection forward to the next `run()` call. Every new session
|
||||||
|
starts on its native backend; picking a custom endpoint in the toolbar for
|
||||||
|
a session applies only to that session (and, if done before Run is
|
||||||
|
clicked, to the one session about to be created — not to sessions created
|
||||||
|
afterward).
|
||||||
|
|
||||||
|
### 5. Multi-user security clamp
|
||||||
|
|
||||||
|
Every new env var this feature introduces that can redirect a session's
|
||||||
|
traffic (and thus wherever its credentials go) — `ANTHROPIC_BASE_URL`,
|
||||||
|
`GOOGLE_GEMINI_BASE_URL`, `GROK_BASE_URL`, the `CODEX_HOME`/`PI_CONFIG_DIR`
|
||||||
|
dir-redirects, plus the already-privileged `DEEPSEEK_BASE_URL` — must be
|
||||||
|
added to each CLI's `capabilities.privilegedEnvKeys` so
|
||||||
|
`clampEnvOverridesForOwner()` strips them for a non-granted multi-user
|
||||||
|
owner, exactly the precedent already documented for `DEEPSEEK_BASE_URL`/
|
||||||
|
`OMP_AUTH_BROKER_URL`. This matters _more_, not less, now that endpoints can
|
||||||
|
be cloud URLs: redirecting a non-granted user's session to an attacker's
|
||||||
|
cloud endpoint is a credential-exfiltration path, not just a mischief
|
||||||
|
redirect to a LAN box. Endpoint CRUD itself stays admin-only in multi-user
|
||||||
|
mode, same as remote/docker hosts.
|
||||||
|
|
||||||
|
## Files touched (representative, not exhaustive)
|
||||||
|
|
||||||
|
- `src/config/cli-registry/types.ts`, `schema.ts`, `stock.ts` — new capability + per-entry declarations
|
||||||
|
- `src/custom-model-injection.ts` (new) — pure per-CLI descriptor builder + unit tests
|
||||||
|
- `src/custom-model-hosts.ts` (new) — endpoint store
|
||||||
|
- `src/web/routes/custom-model-routes.ts` (new) — CRUD + discovery route
|
||||||
|
- `src/web/routes/session-routes.ts` — `POST /api/sessions/:id/custom-model`, clamp wiring
|
||||||
|
- `src/web/schemas.ts` — `customModelEndpointsEnabled`, endpoint/discover payload schemas, privileged-key updates
|
||||||
|
- `src/session.ts` — `customModel` state field, `toState()` surface
|
||||||
|
- `src/web/public/index.html`, `settings-ui.js`, `session-ui.js`, `styles.css` — settings group, toolbar button/menu, badge, accent CSS
|
||||||
|
- `src/web/sse-events.ts` + `constants.js` — if a dedicated SSE event is warranted for the badge (or just ride existing session-update broadcasts)
|
||||||
|
- `test/fixtures/mock-openai-server.ts` (new) + `test/custom-model-injection-contract.test.ts` (new) — see Mock-server validation below
|
||||||
|
- `scripts/test-local-llm-harnesses.ts` (already added, this branch; run via `npx tsx`) — the standalone real-CLI-and-real-endpoint smoke test, supporting any `--base-url` (local or cloud). Dynamic: derives its harness list and every env var/config it injects from the live CLI registry + `buildCustomModelInjection()` rather than a second hand-maintained copy — only the one-shot invocation flags (`ONE_SHOT` table) are CLI-specific info the registry doesn't model and stay hand-maintained
|
||||||
|
- `docs/custom-model-endpoints.md` (new) + a CLAUDE.md pointer bullet under External CLI modes / envOverrides
|
||||||
|
|
||||||
|
## Mock-server validation strategy (CI-runnable, no real CLI binaries needed)
|
||||||
|
|
||||||
|
Spawning nine real CLI binaries in CI isn't realistic, and neither the author's
|
||||||
|
llama.cpp box nor a real cloud subscription can be a CI dependency. So the
|
||||||
|
injection _logic_ gets a tier of automated coverage that sits between the
|
||||||
|
pure unit tests and the live manual checks in Verification:
|
||||||
|
|
||||||
|
1. **`test/fixtures/mock-openai-server.ts`** — a small in-process HTTP
|
||||||
|
server (plain `http.createServer`, no external deps, port picked per the
|
||||||
|
existing `const PORT = 3150+` convention) that:
|
||||||
|
- Serves `GET /v1/models` → a fixed fake model list (`{data:[{id:'qwen3'},...]}`),
|
||||||
|
for testing the discovery route.
|
||||||
|
- Serves `POST /v1/chat/completions` (OpenAI shape) **and**
|
||||||
|
`POST /v1/messages` (Anthropic Messages-API shape, since that's what
|
||||||
|
`ANTHROPIC_BASE_URL` traffic looks like) and records every request it
|
||||||
|
receives (headers, body, path) into an array the test can assert on —
|
||||||
|
including which auth header style it saw, so the `authStyle: 'both'`
|
||||||
|
default and Azure's `api-key` convention both get real coverage.
|
||||||
|
- Returns a minimal valid completion so a client library doesn't choke
|
||||||
|
on the response shape.
|
||||||
|
|
||||||
|
2. **`test/custom-model-injection-contract.test.ts`** — for every CLI with a
|
||||||
|
`customModelInjection` capability (i.e. every row in the table above
|
||||||
|
except `antigravity`):
|
||||||
|
- Point a fixture `CustomModelEndpoint` at the mock server's URL.
|
||||||
|
- Call `buildCustomModelInjection(entry, endpoint, modelId)` (the pure
|
||||||
|
function from §1) to get the real env vars / config-file content that
|
||||||
|
would be injected into that CLI's session.
|
||||||
|
- Replay those exact values through a minimal HTTP request shaped the
|
||||||
|
way that CLI is documented to send it (Anthropic Messages shape for
|
||||||
|
claude; OpenAI chat-completions shape for opencode/codex/pi/grok/omp;
|
||||||
|
`GOOGLE_GEMINI_BASE_URL`'s OpenAI-compat shape for gemini; dsh's
|
||||||
|
provider call for deepseek) against the mock server.
|
||||||
|
- Assert the mock server received the request **at the injected
|
||||||
|
`baseUrl`**, with **the injected API key** in the expected header, and
|
||||||
|
**the injected model id** in the body/path — i.e. prove the values
|
||||||
|
Codeman computes are internally consistent and would reach the right
|
||||||
|
place with the right identifiers, end to end, in CI, on every push.
|
||||||
|
- Also cover the `configDir` kind (codex/pi/omp): assert the written
|
||||||
|
`config.toml`/`models.json`/`models.yml` file parses and contains the
|
||||||
|
same base URL/key/model, and that it's written under the isolated
|
||||||
|
per-session dir rather than the user's real config path.
|
||||||
|
|
||||||
|
3. **Explicit, stated limitation** (goes in the test file's `@fileoverview`
|
||||||
|
and in this doc, not left implicit): this proves _"if the CLI honors its
|
||||||
|
documented env/config contract, it will hit the right endpoint with the
|
||||||
|
right model."_ It does **not** prove the real CLI binary actually reads
|
||||||
|
that env var / config file the way its docs say — that's still the job
|
||||||
|
of the live manual checks in Verification step 4-5 below, and is exactly
|
||||||
|
why the confidence table above did not stop at "researched" — every CLI
|
||||||
|
except antigravity (no mechanism at all) has since been run against a
|
||||||
|
real llama-swap server via `scripts/test-local-llm-harnesses.ts`:
|
||||||
|
claude/opencode/pi/grok/omp are confirmed PASS end-to-end, codex is
|
||||||
|
confirmed FAIL for a real documented protocol reason (Responses-API-only
|
||||||
|
since Feb 2026), and gemini/deepseek are confirmed reaching the server
|
||||||
|
but failing for reasons not yet root-caused (see their table rows). The
|
||||||
|
mock-server suite catches regressions in Codeman's own logic; it cannot
|
||||||
|
catch a CLI changing its env-var name in a future release, or a real
|
||||||
|
cloud endpoint behaving differently from a local llama.cpp box.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `npm run typecheck && npm test` after each slice — this now includes the
|
||||||
|
mock-server contract suite from above, so injection-logic regressions
|
||||||
|
are caught automatically without touching real infrastructure.
|
||||||
|
2. Unit tests for `buildCustomModelInjection()` per CLI kind (pure, no IO).
|
||||||
|
3. Route tests (`app.inject`) for the new CRUD + discover-models endpoint
|
||||||
|
(mock `fetch` for `/v1/models`), and for the multi-user clamp on the new
|
||||||
|
privileged keys (mirror `test/routes/external-cli-bypass-clamp.test.ts`).
|
||||||
|
4. **Standalone real-binary smoke test**: `scripts/test-local-llm-harnesses.ts`
|
||||||
|
exercises every harness the CLI registry declares `customModelInjection`
|
||||||
|
support for against a real `--base-url` — local or cloud — outside of
|
||||||
|
Codeman's UI entirely, and is DYNAMIC (reads `enabledClis()` + calls the
|
||||||
|
real `buildCustomModelInjection()`, so a future registry change is picked
|
||||||
|
up automatically with zero edits to the script). Already run to
|
||||||
|
completion against the author's llama-swap server (a LAN address,
|
||||||
|
inside a `codeman/agent:llm-test` Docker image with all 9 CLI binaries):
|
||||||
|
claude/opencode/pi/grok/omp **PASS**, codex **FAILs as expected**
|
||||||
|
(Responses-API protocol gap, not a bug), gemini/deepseek **UNCONFIRMED**
|
||||||
|
(reach the server, fail for undiagnosed reasons — see their table rows),
|
||||||
|
antigravity **SKIP** (no mechanism). Re-run this against a real cloud
|
||||||
|
endpoint (e.g. an Azure AI Foundry deployment) once one is available, to
|
||||||
|
prove the `authStyle`/deployment-name handling holds up outside llama.cpp.
|
||||||
|
5. Once the full feature (not just the standalone script) is built: add an
|
||||||
|
endpoint via the real UI, hit discover-models, confirm the returned model
|
||||||
|
list, pick Claude + the model on a real session, confirm via
|
||||||
|
`tmux -L codeman capture-pane`/`tmux showenv -t <pane>` that
|
||||||
|
`ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY`/`ANTHROPIC_DEFAULT_*_MODEL` are
|
||||||
|
set post-restart, and confirm the endpoint's own logs show the next
|
||||||
|
prompt actually landing there. Repeat for opencode and Codex at minimum
|
||||||
|
before considering this shippable; spot-check the web-researched CLIs
|
||||||
|
and correct the plan's confidence table with what's actually observed.
|
||||||
|
6. `npm run lint && npm run format:check`.
|
||||||
|
7. Update `CHANGELOG.md`/changeset per the COM workflow when shipping.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Custom Model Endpoint Profiles
|
||||||
|
|
||||||
|
Point any Codeman-supported harness — Claude, opencode, Codex, Gemini, Pi,
|
||||||
|
Grok, DeepSeek, or OMP — at a custom OpenAI-compatible endpoint instead of
|
||||||
|
its native cloud backend, for a given session. "Custom endpoint" covers both
|
||||||
|
**local** hardware (llama.cpp, Ollama, vLLM, a home GPU rig, or purpose-built
|
||||||
|
boxes like NVIDIA DGX Spark or AMD Strix Halo mini-PCs) and **cloud**
|
||||||
|
services (Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
|
||||||
|
company gateway) — anything answering `GET /v1/models` and
|
||||||
|
`POST /v1/chat/completions` in the standard shape. Design doc, per-CLI
|
||||||
|
recipe confidence table, and security reasoning:
|
||||||
|
[`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md).
|
||||||
|
|
||||||
|
> **Status**: backend is implemented and tested (registry capability, the
|
||||||
|
> injection engine, the endpoint store + discovery route, the session
|
||||||
|
> restart route). The toolbar picker / settings UI described below as the
|
||||||
|
> intended surface is **not yet built** — until it lands, use the HTTP API
|
||||||
|
> directly (examples below). Antigravity has no known custom-endpoint
|
||||||
|
> mechanism and is not supported.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
App Settings → Agents & CLIs → **Custom Model Endpoints** (synced setting
|
||||||
|
`customModelEndpointsEnabled`, default **OFF**). Until the toolbar picker
|
||||||
|
lands, nothing reads this setting: the HTTP routes below work whether it is
|
||||||
|
on or off, and it exists now only so the picker has a switch to hang off
|
||||||
|
when it ships. The API equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X PUT https://localhost:3000/api/settings \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"customModelEndpointsEnabled": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding an endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST https://localhost:3000/api/model-endpoints \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"id": "llama-box", "label": "Home llama.cpp", "baseUrl": "http://192.168.1.50:8080"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`apiKey` is optional (most local servers don't check it). `authStyle`
|
||||||
|
(`bearer` | `api-key`, default `bearer`) controls which auth header
|
||||||
|
convention discovery uses: `bearer` is `Authorization: Bearer <key>`
|
||||||
|
(llama.cpp, OpenAI-compatible servers, most gateways), `api-key` is the
|
||||||
|
`api-key: <key>` header Azure AI Foundry wants. There is deliberately no
|
||||||
|
"send both" option: measured against a real llama-swap server, a request
|
||||||
|
carrying both headers hung indefinitely. `baseUrl` must be `http(s)`, carry
|
||||||
|
no embedded credentials, and may not point at a link-local or cloud-metadata
|
||||||
|
address; discovery re-checks the address the name actually resolves to.
|
||||||
|
|
||||||
|
Discover its available models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST https://localhost:3000/api/model-endpoints/llama-box/discover-models
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls the endpoint's own `GET /v1/models` and stores the returned list
|
||||||
|
on the endpoint record; `GET /api/model-endpoints` lists everything
|
||||||
|
configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
|
||||||
|
Endpoint management is admin-only in multi-user mode, same as remote/docker
|
||||||
|
hosts — these are machine-level infra, not per-user settings.
|
||||||
|
|
||||||
|
## Applying a model to a session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST https://localhost:3000/api/sessions/<sessionId>/custom-model \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"endpointId": "llama-box", "modelId": "qwen3"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This computes the CLI-specific env vars / config for that session's mode
|
||||||
|
(see the recipe table in `custom-model-endpoints-plan.md`) and **restarts the session's
|
||||||
|
CLI process in place** — same pane, same tmux session, fresh env. That
|
||||||
|
restart is necessary, not incidental: every supported harness reads its
|
||||||
|
endpoint config at process start, not per-turn, so there is no live
|
||||||
|
hot-swap. A Claude session is relaunched with `--resume <conversation> ||
|
||||||
|
--session-id <id>`, so it continues the conversation it was on; pi, omp and
|
||||||
|
grok are relaunched with the `--model` value that selects the injected
|
||||||
|
provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok),
|
||||||
|
since for those three the config file alone does not switch the model.
|
||||||
|
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
|
||||||
|
reattaches the durable remote/in-container tmux rather than relaunching the
|
||||||
|
agent, so the selection would report success and change nothing.
|
||||||
|
|
||||||
|
Clear back to the harness's native cloud default with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST https://localhost:3000/api/sessions/<sessionId>/custom-model \
|
||||||
|
-H 'Content-Type: application/json' -d '{"clear": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Clearing also removes the env vars the selection injected from the tmux
|
||||||
|
session (they persist there and would otherwise be inherited by the
|
||||||
|
relaunched CLI) and deletes the per-session config directory
|
||||||
|
(`~/.codeman/custom-model-configs/<sessionId>`, written 0600 because pi and
|
||||||
|
omp embed the API key in it). That directory is also removed when the
|
||||||
|
session is deleted. The selection survives a Codeman restart: the endpoint
|
||||||
|
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.
|
||||||
|
|
||||||
|
**New sessions always default back to the harness's native backend.** A
|
||||||
|
custom-endpoint selection is a per-session choice, never a sticky global
|
||||||
|
default — starting a fresh session doesn't inherit whatever the last one was
|
||||||
|
pointed at.
|
||||||
|
|
||||||
|
## Confidence per harness
|
||||||
|
|
||||||
|
Every harness except Antigravity has now been run end-to-end against a real
|
||||||
|
llama-swap server via `scripts/test-local-llm-harnesses.ts` (a dynamic
|
||||||
|
script that reads the live CLI registry, so a registry change is picked up
|
||||||
|
automatically). Results:
|
||||||
|
|
||||||
|
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
|
||||||
|
came back through the endpoint.
|
||||||
|
- **Codex** — the config is structurally correct, but Codex only speaks the
|
||||||
|
Responses API since Feb 2026, which llama.cpp/llama-swap don't implement.
|
||||||
|
This is a real protocol incompatibility, not a bug here; Codex support
|
||||||
|
needs a Responses-API-compatible endpoint.
|
||||||
|
- **Gemini** — fails with `Invalid auth method selected`, traced to an
|
||||||
|
undocumented `GATEWAY` auth path gemini-cli selects once
|
||||||
|
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
|
||||||
|
(several auth workarounds were tried and ruled out); do not rely on
|
||||||
|
Gemini support yet.
|
||||||
|
- **DeepSeek** — the request reaches the server (env vars are read) but
|
||||||
|
gets a consistent `HTTP_404`. Root cause not identified; best-effort only.
|
||||||
|
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
|
||||||
|
|
||||||
|
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
|
||||||
|
each result. `scripts/test-local-llm-harnesses.ts` is the standalone script
|
||||||
|
used to check a harness against a real endpoint outside the web UI
|
||||||
|
entirely; see its own `--help` for usage.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
Every env var this feature can set that redirects a session's traffic
|
||||||
|
(`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, etc.) is
|
||||||
|
listed in that CLI's `privilegedEnvKeys` in the CLI registry, so a
|
||||||
|
non-granted multi-user owner cannot set one directly via the generic
|
||||||
|
`envOverrides` API field — only through this feature's own route, which
|
||||||
|
computes the value from an admin-configured, SSRF-guarded endpoint rather
|
||||||
|
than trusting arbitrary client input. See the "Multi-user security
|
||||||
|
hardening" section of `custom-model-endpoints-plan.md` for the full reasoning; several
|
||||||
|
of these were reachable via the generic `envOverrides` field even before
|
||||||
|
this feature existed, and building this surfaced and closed that gap.
|
||||||
@@ -15,7 +15,7 @@ The application container mounts the Docker daemon socket so Codeman can create
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
|
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp docker/.env.example docker/.env
|
cp docker/.env.example docker/.env
|
||||||
@@ -33,12 +33,14 @@ On Linux, run the stack with the start script. It determines `PUID` and `PGID` f
|
|||||||
bash docker/Start-Codeman.sh
|
bash docker/Start-Codeman.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner.
|
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
|
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them.
|
||||||
|
|
||||||
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
|
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ unchanged. The container path is a new `SupervisorKind`, not a new updater.
|
|||||||
| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
|
| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
|
||||||
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
|
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
|
||||||
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
|
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
|
||||||
|
| `docker-build-source.json` | What HEAD/`package-lock.json` the build artefact volumes currently reflect. Written by both `Start-Codeman.sh` and this in-place update, so the two agree on whether those volumes are stale. |
|
||||||
|
|
||||||
### Why build artefacts are in named volumes
|
### Why build artefacts are in named volumes
|
||||||
|
|
||||||
@@ -72,6 +73,20 @@ Docker seeds an empty named volume from the image, so the first start inherits t
|
|||||||
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
|
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
|
||||||
`docker compose down -v` is the supported reset: the next start re-seeds them.
|
`docker compose down -v` is the supported reset: the next start re-seeds them.
|
||||||
|
|
||||||
|
That seeding-only-while-empty behaviour has a second, less obvious edge: it also
|
||||||
|
means a plain `docker compose build` triggered from OUTSIDE the container (for
|
||||||
|
example `Start-Codeman.sh`, after a `git pull` done by hand rather than through
|
||||||
|
this in-app updater) produces a fresh image whose freshly-built `dist`/
|
||||||
|
`node_modules` then sit unused behind the volumes' OLD content — the container
|
||||||
|
comes back up looking unchanged. `Start-Codeman.sh` detects this by comparing the
|
||||||
|
checkout's current HEAD and `package-lock.json` hash against `docker-build-source.json`,
|
||||||
|
and clears just the affected volume(s) before its own `--build` if they moved.
|
||||||
|
This in-place update writes that same file after a successful build precisely so
|
||||||
|
that comparison does not fire on stale information: without it, the next plain
|
||||||
|
`Start-Codeman.sh` run would see the HEAD this update just checked out, not
|
||||||
|
recognise it as already accounted for, and wipe the volumes this update just
|
||||||
|
correctly rebuilt right back to the OLDER image.
|
||||||
|
|
||||||
### Why the runtime image carries a build toolchain
|
### Why the runtime image carries a build toolchain
|
||||||
|
|
||||||
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
|
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
|
||||||
|
|||||||
@@ -333,9 +333,12 @@ Out of scope per the issue, and the current behavior already degrades correctly:
|
|||||||
- **Docker cases**: the workspace is a host directory bind-mounted at the same absolute path, so a host-side
|
- **Docker cases**: the workspace is a host directory bind-mounted at the same absolute path, so a host-side
|
||||||
write is visible in the container immediately. Edit mode works and needs nothing special. Worth one line
|
write is visible in the container immediately. Edit mode works and needs nothing special. Worth one line
|
||||||
in the docs.
|
in the docs.
|
||||||
- **Remote SSH cases**: `workingDir` is a path on the remote host. `validateSessionFilePath` realpaths it
|
- **Remote SSH cases**: `workingDir` is a path on the remote host, and the READ routes now
|
||||||
locally, which fails, so the write returns 404 exactly like the read routes do today. Confirm the viewer
|
resolve it over ssh (`src/remote-files.ts`, same `buildSshConnectionArgs` discipline as the
|
||||||
shows a clean empty/error state rather than an unexplained failure, and do not attempt an SFTP path.
|
launch path — #415). What stays unsupported is the WRITE side: an `edit=1` / `PUT` answers
|
||||||
|
`400` "editing is not supported for files in a remote (SSH) case", `editable` is always
|
||||||
|
`false`, office previews and generated thumbnails answer `400`, and no remote file is ever
|
||||||
|
copied to the server's disk. Do not attempt an SFTP write path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,17 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
|||||||
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
||||||
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
||||||
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
||||||
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
|
via `shouldApplyInput`. An applied frame is ACKed with `{t:'ia',seq}`; a duplicate is
|
||||||
client drops it). Untagged frames apply unconditionally (no behavior change).
|
ACKed as `{t:'ia',seq,dup:true,last:<watermark>}`, where `last` is the server's
|
||||||
|
highest applied seq for that `clientId` (`Session.lastInputSeq`). The client drops
|
||||||
|
the record either way, and on `dup` it lifts its own counter to `last` first and
|
||||||
|
re-sends a FIRST-attempt record (a retry being called a duplicate is the mechanism
|
||||||
|
working: the original landed). Without `last`, a tab killed between a send and the
|
||||||
|
persisted counter write came back counting BELOW the server's watermark, and every
|
||||||
|
later keystroke was dropped-but-ACKed: a silently dead terminal a reload could not
|
||||||
|
fix, since the stale counter was restored from localStorage too. The client now
|
||||||
|
persists the counter synchronously on every send for the same reason. Untagged
|
||||||
|
frames apply unconditionally (no behavior change).
|
||||||
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
||||||
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
||||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||||
|
|||||||
@@ -251,6 +251,107 @@ unreachable host answers "unknown", which also means do not revive. The answer
|
|||||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||||
|
|
||||||
|
## File access over SSH
|
||||||
|
|
||||||
|
A remote case's `workingDir` is an absolute path on the **remote** host
|
||||||
|
(`Session.workingDir = RemoteCase.remotePath`), so the file routes cannot use local
|
||||||
|
`fs`: a local `realpathSync` on a remote-only path fails by construction, which is why
|
||||||
|
previewing a file used to answer `404 File not found` for a case that was working
|
||||||
|
perfectly (#415). `src/remote-files.ts` is the one module that reads remote bytes,
|
||||||
|
and it follows the same rule as the launch path: every ssh command line comes from
|
||||||
|
`buildSshConnectionArgs()` — **never** a hand-built ssh line.
|
||||||
|
|
||||||
|
| Request | What happens |
|
||||||
|
|---------|--------------|
|
||||||
|
| `GET /api/sessions/:id/file-raw` | Streamed over `ssh` (`cat`, or `tail -c +N \| head -c L` for a `Range`); the same 200/206/416 contract as a local file, so `<video>`/`<audio>` seeking works |
|
||||||
|
| `GET /api/sessions/:id/file-content` | `cat` into memory, capped by the existing text limit; `edit=1` answers `400` (see below) and `editable` is always `false` |
|
||||||
|
| `PUT /api/sessions/:id/file-content` | `400` before any path is looked at: the guard sits AHEAD of the local path validation, because with a same-named directory on the Codeman host (an `sshfs` mount) the write would otherwise land on the local twin |
|
||||||
|
| `GET /api/sessions/:id/file-preview` | Non-office files redirect to `file-raw` (which works remotely); docx/pptx answer `400` |
|
||||||
|
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
|
||||||
|
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
|
||||||
|
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
|
||||||
|
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
|
||||||
|
|
||||||
|
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
|
||||||
|
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
|
||||||
|
the frontend's `_isExternalPreviewPath()` sends every absolute path that is not under
|
||||||
|
`workingDir` there, so fixing only `file-raw` would leave exactly that half broken.
|
||||||
|
|
||||||
|
Guard order is deliberately **the same as locally**, and the checks are not weakened
|
||||||
|
by the transport:
|
||||||
|
|
||||||
|
1. Ownership (`findSessionOrFail` / the scope helper) — unchanged.
|
||||||
|
2. Lexical containment of `workingDir + path` — a `../` escape is refused before any
|
||||||
|
connection is opened.
|
||||||
|
3. ONE ssh round trip that returns `realpath` **and** `stat` for the path **and** the
|
||||||
|
workspace root (`remoteProbePaths`). Resolving the root remotely is what keeps the
|
||||||
|
boundary honest for a symlinked `remotePath`. The probe uses `readlink -f` when
|
||||||
|
available; on a host without it (macOS before 12.3) a POSIX fallback canonicalizes
|
||||||
|
the directory chain with `cd -P`/`pwd -P` and then follows the LAST component with
|
||||||
|
plain `readlink` for a bounded number of hops. ⚠️ **The fallback fails closed**: a
|
||||||
|
path it cannot fully resolve (a loop, a `readlink` failure, the hop cap) is reported
|
||||||
|
as unresolvable and answers 404, never as its own unresolved string. An earlier
|
||||||
|
version resolved only the directory chain, so `ws/notes.txt -> ~/.ssh/id_rsa` passed
|
||||||
|
containment under the link's own path while `cat` followed it to the key.
|
||||||
|
Records come back NUL-separated and index-keyed (`<index>|kind|size|mtime|realPath`,
|
||||||
|
after a leading NUL that fences off any login banner), so a filename containing a
|
||||||
|
newline cannot shift the alignment.
|
||||||
|
4. Containment of the remote realpath against the remote root. The sensitive-path
|
||||||
|
blocklist then applies on whichever routes already apply it locally (`/api/download`,
|
||||||
|
attachment registration, edit mode — where resolving symlinks first is what makes it
|
||||||
|
meaningful); the remote branch neither drops a guard the local path has nor invents a
|
||||||
|
stricter one. One entry of that blocklist is host-bound by construction: the three
|
||||||
|
home-anchored members (`~/.claude.json`, `~/.claude/settings.json`,
|
||||||
|
`~/.claude/settings.local.json`) are compared against the **Codeman host's** home
|
||||||
|
directory, so they do not match a remote home at a different path. Everything else in
|
||||||
|
the list is depth-anchored (`/.ssh/`, `/.aws/credentials`, `/.claude/.credentials.json`,
|
||||||
|
`/etc/shadow`, ...) and applies to a remote path unchanged.
|
||||||
|
5. Size cap (`CODEMAN_MAX_DOWNLOAD_BYTES`) applied to the **remote** size, before the
|
||||||
|
body is requested.
|
||||||
|
|
||||||
|
The path arrives from the browser (`?path=`) and is interpolated as a single
|
||||||
|
`shellescape`-quoted token, in a command that is itself shellescaped into the ssh
|
||||||
|
line; `BatchMode=yes` means a host needing a passphrase fails fast instead of hanging.
|
||||||
|
A failed connection is reported as **502** with the remote reason — never a 404, which
|
||||||
|
used to make an unreachable host look like a typo in the agent's output. The reason is
|
||||||
|
the first stderr line, the timeout, or the exit code; never Node's `Command failed: …`
|
||||||
|
message, which would carry the identity-file path and the probe script into the body.
|
||||||
|
|
||||||
|
**Connections are bounded.** Every probe and buffered read runs through a small global
|
||||||
|
semaphore (`src/remote-ssh-limiter.ts`, default 4, `CODEMAN_MAX_REMOTE_FILE_SSH`), the
|
||||||
|
attachment-history list resolves its whole history in one batched probe instead of one
|
||||||
|
handshake per entry, and probes are chunked at 40 paths per round trip. Terminal output
|
||||||
|
in a remote session is written on the remote host, so a prompt-injected agent printing
|
||||||
|
hundreds of `codeman://attach` links used to make the server fork one `ssh` per link,
|
||||||
|
each holding a 20 s probe timeout, and a 100-entry history re-listed on every
|
||||||
|
`attachment:detected` event tripped OpenSSH's default `MaxStartups 10:30:100`. Streams
|
||||||
|
(`file-raw`, by-id `raw`) are not counted: one is held per browser request for the life
|
||||||
|
of a playback, and each is gated behind a counted probe anyway.
|
||||||
|
|
||||||
|
⚠️ **There is deliberately NO local fallback.** A remote case reads the remote bytes or
|
||||||
|
fails, even when a file with the same absolute name exists on the Codeman host — which
|
||||||
|
is the ordinary case for the documented stop-gap workaround, an `sshfs` mount of the
|
||||||
|
remote tree at the identical path. Serving the local twin instead would silently hand
|
||||||
|
back a DIFFERENT filesystem's bytes under a name the user believes is the remote file
|
||||||
|
(a stale mount, a different checkout, a leftover file), and the failure would be
|
||||||
|
invisible. An existing mount therefore stops being load-bearing for previews and
|
||||||
|
downloads but is harmless, and a missing remote file stays a 404 even if the mount
|
||||||
|
still has it.
|
||||||
|
|
||||||
|
**Not available over ssh (by choice, not by accident):** editing a file (writes would
|
||||||
|
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
|
||||||
|
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
|
||||||
|
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
|
||||||
|
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
|
||||||
|
the two views can only disagree when that mount is stale. Docker cases are unaffected:
|
||||||
|
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
|
||||||
|
|
||||||
|
⚠️ A remote record stores the **remote** path, and the same absolute path STRING means a
|
||||||
|
different file on each host. What decides which host to read is therefore never the
|
||||||
|
path but the SESSION (`session.remote`): a remote session never falls back to local
|
||||||
|
`fs`, and a local session never opens an ssh connection — including for attachment
|
||||||
|
records, which are keyed to the session that registered them.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
|
|||||||
`onRequest` hook) runs in this order:
|
`onRequest` hook) runs in this order:
|
||||||
|
|
||||||
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
||||||
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
|
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). The three
|
||||||
|
web‑tab exemptions (§10b: the capability in the path, the `Referer` form, and
|
||||||
|
the lost‑frame recovery page) sit in this same slot, ahead of the credential checks. While the
|
||||||
**managed tunnel is running**, the hook‑event exemption additionally requires
|
**managed tunnel is running**, the hook‑event exemption additionally requires
|
||||||
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
|
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
|
||||||
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
|
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
|
||||||
@@ -514,9 +516,10 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
|||||||
|
|
||||||
## 10b. Web tabs (dashboard proxy)
|
## 10b. Web tabs (dashboard proxy)
|
||||||
|
|
||||||
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Three properties carry the security weight:
|
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Four properties carry the security weight:
|
||||||
|
|
||||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||||
|
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
|
||||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||||
|
|
||||||
|
|||||||
+34
-4
@@ -159,6 +159,24 @@ layers cooperate so a dashboard talking to its own backend just works:
|
|||||||
using its `Referer` to identify the dashboard. This only fires for a request
|
using its `Referer` to identify the dashboard. This only fires for a request
|
||||||
that already missed every Codeman route, and never for one that resolves to a
|
that already missed every Codeman route, and never for one that resolves to a
|
||||||
real route, which is what keeps it from being an authentication bypass.
|
real route, which is what keeps it from being an authentication bypass.
|
||||||
|
5. The same script **masks the proxy prefix off the page's own URL** before any
|
||||||
|
of the page's code runs (`history.replaceState` to the path the page would see
|
||||||
|
on its own origin). A single-page app routes on `location.pathname` at boot,
|
||||||
|
and `/webview/<cap>/` is a path no app has a route for: without this, a React
|
||||||
|
Router / Vue Router / Next dev server painted its HTML and CSS and then replaced
|
||||||
|
them with its own "page not found" the moment its script ran. The page only
|
||||||
|
*reads* the masked path; every URL it emits still goes through the layers above.
|
||||||
|
6. A navigation the page starts **itself** after that — `location.reload()` (a dev
|
||||||
|
server's full-reload HMR), a root-absolute `location.href = '/login'` — now
|
||||||
|
targets Codeman's root with no capability anywhere on it. Codeman recognises
|
||||||
|
that request by shape (a top-level `<iframe>` navigation asking for HTML, for a
|
||||||
|
path it does not serve) and answers a static page that does nothing but tell
|
||||||
|
the owning tab which path was lost; the tab remounts the frame inside the
|
||||||
|
prefix at that path. It never counts as a failed login, so a dev server that
|
||||||
|
reloads on every save cannot rate-limit its user out of Codeman. The landing
|
||||||
|
page is the one served path that gets the same answer: it masks to exactly
|
||||||
|
`/`, and a reload there is admitted as long as the request carries no Codeman
|
||||||
|
credentials, which a sandboxed frame never does.
|
||||||
|
|
||||||
On top of that, the proxy answers those requests with CORS headers. That sounds
|
On top of that, the proxy answers those requests with CORS headers. That sounds
|
||||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||||
@@ -172,10 +190,22 @@ then every API call fails, which looks like the dashboard being broken.
|
|||||||
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
||||||
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
||||||
route can still slip through. Symptom: the page renders but a panel stays empty.
|
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||||
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
- **A root-absolute `url()` inside an inline `<style>` is not rescued.** Masking the
|
||||||
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
page's URL (layer 5) trades away the `Referer` safety net of layer 4 for
|
||||||
unforgeable and cannot be patched the way the other sinks are. A relative
|
requests the shim cannot see, and only HTML is rewritten server-side. An
|
||||||
`location.href = 'login'` is fine (`<base>` covers it).
|
external stylesheet is fine: a `url()` it references is fetched with the
|
||||||
|
stylesheet's own URL as `Referer`, which is still inside the prefix. A
|
||||||
|
root-absolute `url(/img.png)` written directly into a `<style>` block in the
|
||||||
|
document has the masked document as its `Referer`, so it 404s where the
|
||||||
|
fallback used to rescue it. Symptom: one background image missing while
|
||||||
|
everything else renders. Narrow, and a `url()` the page sets from script is
|
||||||
|
still covered by layer 3.
|
||||||
|
- **Root-absolute `location` navigation is recovered, not prevented.** `Location`
|
||||||
|
is unforgeable, so `location.href = '/login'` or `location.reload()` really does
|
||||||
|
leave the prefix; the frame comes back through the recovery hop in layer 6 above,
|
||||||
|
which needs a browser that sends `Sec-Fetch-Dest` (every current one; iOS Safari
|
||||||
|
since 16.4). Older browsers show Codeman's 404 in the frame; the tab's **Reload**
|
||||||
|
button puts it back.
|
||||||
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
|
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
|
||||||
host (an external SSO provider, say), the proxy hands the redirect back unchanged
|
host (an external SSO provider, say), the proxy hands the redirect back unchanged
|
||||||
rather than relaying it, because relaying would make this an open proxy. Use
|
rather than relaying it, because relaying would make this an open proxy. Use
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ every session or only the active tab.
|
|||||||
| Tall Tabs | Taller tab strip. |
|
| Tall Tabs | Taller tab strip. |
|
||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||||
|
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||||
| Overview Home Screen | The phone home screen. On by default. |
|
| Overview Home Screen | The phone home screen. On by default. |
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
Tabs can also be dragged to reorder.
|
||||||
|
|
||||||
|
### Automatic session names
|
||||||
|
|
||||||
|
Off by default. Turn on **Auto-name Sessions** (App Settings → Appearance → Tabs; synced
|
||||||
|
across devices) and a tab that still carries its generated name, such as `w3-myapp`, takes a
|
||||||
|
title from the first real prompt you submit, keeping the prefix: `w3-myapp: fix the login
|
||||||
|
redirect`. The strip shows the title and keeps the prefix in the tooltip, and the next
|
||||||
|
session in that case still counts up to `w4-myapp`. It happens once per session, only for
|
||||||
|
prompts you type or send through the input API (never a Ralph, respawn, cron or approval
|
||||||
|
answer), and never for shells. Slash commands such as `/clear` do not become titles; the
|
||||||
|
next prompt gets its turn. A name you set yourself, before or after, is never touched. The
|
||||||
|
title is derived locally from the prompt's first sentence; no text leaves the machine.
|
||||||
|
|
||||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.2",
|
"version": "1.29.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.2",
|
"version": "1.29.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.2",
|
"version": "1.29.1",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.28.2",
|
"version": "1.29.1",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Copy this file to local-llm-test.config.json (gitignored) and fill in your own values. CLI flags on scripts/test-local-llm-harnesses.mjs always override these. Any field can be omitted. apiKey is OPTIONAL — omit it entirely (or delete this line) for an endpoint like llama.cpp that doesn't check one; it defaults to a harmless placeholder either way.",
|
||||||
|
"baseUrl": "http://192.168.1.50:8080",
|
||||||
|
"model": "qwen3",
|
||||||
|
"apiKey": "",
|
||||||
|
"prompt": "Reply with exactly: hello world",
|
||||||
|
"timeout": 30000,
|
||||||
|
"only": []
|
||||||
|
}
|
||||||
@@ -193,6 +193,29 @@ run_step "installing" "Installing dependencies" npm install --no-fund --no-audit
|
|||||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||||
run_step "building" "Building" npm run build || rollback_and_fail "Build failed"
|
run_step "building" "Building" npm run build || rollback_and_fail "Build failed"
|
||||||
|
|
||||||
|
# Docker Compose only: record what HEAD/package-lock.json the freshly-built
|
||||||
|
# codeman-dist/codeman-node-modules volumes now reflect. `Start-Codeman.sh`
|
||||||
|
# reads this same file (`$appdata_path/.codeman/…`, i.e. this container's own
|
||||||
|
# $HOME/.codeman since that path IS the appdata bind mount) to detect source
|
||||||
|
# changes an EXTERNAL `docker compose build` made and refresh those volumes —
|
||||||
|
# without this, the next plain `Start-Codeman.sh` run would see the HEAD this
|
||||||
|
# update just checked out, not recognise it as already accounted for, and wipe
|
||||||
|
# the volumes this update just correctly rebuilt right back to the OLDER image.
|
||||||
|
if [[ "$SUPERVISOR" == "docker-compose" ]]; then
|
||||||
|
build_source_file="$HOME/.codeman/docker-build-source.json"
|
||||||
|
mkdir -p -- "$HOME/.codeman"
|
||||||
|
build_head=$(git rev-parse HEAD 2>/dev/null || true)
|
||||||
|
build_lockfile_sha=''
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
build_lockfile_sha=$(sha256sum -- package-lock.json 2>/dev/null | cut -d' ' -f1)
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
build_lockfile_sha=$(shasum -a 256 package-lock.json 2>/dev/null | cut -d' ' -f1)
|
||||||
|
fi
|
||||||
|
printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \
|
||||||
|
"$build_head" "$build_lockfile_sha" >"$build_source_file.tmp" \
|
||||||
|
&& mv -- "$build_source_file.tmp" "$build_source_file"
|
||||||
|
fi
|
||||||
|
|
||||||
# 6) Restart the service so the new code loads. Write the terminal pre-restart
|
# 6) Restart the service so the new code loads. Write the terminal pre-restart
|
||||||
# marker FIRST so the freshly-booted server can reconcile it deterministically.
|
# marker FIRST so the freshly-booted server can reconcile it deterministically.
|
||||||
write_status "restarting" "Restarting Codeman…"
|
write_status "restarting" "Restarting Codeman…"
|
||||||
|
|||||||
@@ -0,0 +1,699 @@
|
|||||||
|
#!/usr/bin/env -S npx tsx
|
||||||
|
/**
|
||||||
|
* Standalone smoke-test for pointing each Codeman-supported harness CLI at a
|
||||||
|
* custom OpenAI-compatible endpoint — local (llama.cpp, Ollama, vLLM, ...) or
|
||||||
|
* cloud (Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
|
||||||
|
* self-hosted gateway, ...). Anything that answers GET /v1/models and POST
|
||||||
|
* /v1/chat/completions in the standard shape qualifies; --base-url is not
|
||||||
|
* assumed to be a LAN address.
|
||||||
|
*
|
||||||
|
* This is intentionally OUTSIDE the npm test suite and outside Codeman's own
|
||||||
|
* session/tmux machinery: it spawns each real CLI binary directly, one-shot,
|
||||||
|
* with the env vars / config files that CLI's own docs say redirect it to a
|
||||||
|
* custom endpoint, and checks it can answer "hello world".
|
||||||
|
*
|
||||||
|
* DYNAMIC BY DESIGN: this file imports the SAME `enabledClis()` registry and
|
||||||
|
* `buildCustomModelInjection()` builder the production feature uses (see
|
||||||
|
* ../src/config/cli-registry/, ../src/custom-model-injection.ts,
|
||||||
|
* ../src/custom-model-injection-apply.ts) rather than keeping a second,
|
||||||
|
* hand-maintained copy of each CLI's env vars/config shape. A registry
|
||||||
|
* change (a new CLI, an edited env var name, a fixed config template) is
|
||||||
|
* picked up here automatically with zero edits to this file. Only the
|
||||||
|
* ONE-SHOT INVOCATION FLAGS (how to make each CLI answer one prompt and
|
||||||
|
* exit — information the registry doesn't model at all, since it only knows
|
||||||
|
* how to launch the interactive TUI) stay in the small ONE_SHOT table below;
|
||||||
|
* a CLI newly added to the registry with no ONE_SHOT entry is reported
|
||||||
|
* UNKNOWN rather than silently skipped or guessed at.
|
||||||
|
*
|
||||||
|
* Cloud endpoints often differ from a bare llama.cpp box in two ways this
|
||||||
|
* script accounts for: (1) auth may be an `api-key` header (Azure's
|
||||||
|
* convention) rather than `Authorization: Bearer` — see --auth-style below.
|
||||||
|
* (2) a cloud endpoint's "model" may actually be a deployment name distinct
|
||||||
|
* from the model family (Azure AI Foundry deployments) — always pass
|
||||||
|
* --model explicitly for those rather than relying on GET /v1/models
|
||||||
|
* discovery.
|
||||||
|
*
|
||||||
|
* IMPORTANT CONFIDENCE NOTE: claude and opencode are verified end-to-end
|
||||||
|
* against a real llama-swap server. codex's config STRUCTURE is verified,
|
||||||
|
* but it only speaks the Responses API (dropped Chat-Completions support
|
||||||
|
* Feb 2026) — expect it to fail against a plain OpenAI-compatible server,
|
||||||
|
* that's a real protocol gap, not a bug here. gemini/pi/grok/omp have their
|
||||||
|
* ONE-SHOT INVOCATION flags confirmed against real installed binaries'
|
||||||
|
* `--help` output, but their custom-endpoint env/config conventions remain
|
||||||
|
* web-researched, unverified. deepseek (dsh) is a profile launcher with no
|
||||||
|
* documented one-shot prompt flag at all — best-effort only. antigravity
|
||||||
|
* has no known CLI/env/config mechanism (GUI-only per public docs) — its
|
||||||
|
* registry entry declares `customModelInjection: { kind: 'unsupported' }`,
|
||||||
|
* which this script picks up dynamically and always skips.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/test-local-llm-harnesses.ts --base-url http://192.168.1.50:8080 [options]
|
||||||
|
* npx tsx scripts/test-local-llm-harnesses.ts --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --base-url <url> Required. Root URL of the OpenAI-compatible endpoint (local or cloud).
|
||||||
|
* --model <name> Model/deployment id to request. Default: first from GET /v1/models.
|
||||||
|
* --api-key <key> API key to send. Default: local-dummy-key (fine for llama.cpp; required for most cloud endpoints).
|
||||||
|
* --auth-style <style> "bearer" (default, Authorization: Bearer) or "api-key" (the
|
||||||
|
* `api-key` header some cloud gateways, e.g. Azure, want).
|
||||||
|
* NEVER send both — live-tested against a real server, doing
|
||||||
|
* so reliably HANGS the request indefinitely.
|
||||||
|
* --prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
|
||||||
|
* --only <id,id,...> Restrict to these harness ids (comma-separated).
|
||||||
|
* --timeout <ms> Per-harness spawn timeout. Default: 30000.
|
||||||
|
* --probe-help Instead of testing, resolve each installed binary and print --help.
|
||||||
|
* --keep-temp Don't delete generated per-harness config dirs afterward.
|
||||||
|
* --list Dry run: print the resolved plan per harness, execute nothing.
|
||||||
|
* -h, --help Show this help.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir, homedir } from 'node:os';
|
||||||
|
import { join, delimiter, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { enabledClis } from '../src/config/cli-registry/index.js';
|
||||||
|
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||||
|
import {
|
||||||
|
buildCustomModelInjection,
|
||||||
|
GROK_CUSTOM_MODEL_NAME,
|
||||||
|
type CustomModelEndpoint,
|
||||||
|
} from '../src/custom-model-injection.js';
|
||||||
|
import { applyConfigDirInjection } from '../src/custom-model-injection-apply.js';
|
||||||
|
|
||||||
|
const TAG = '[test-local-llm-harnesses]';
|
||||||
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CONFIG_PATH = join(SCRIPT_DIR, 'local-llm-test.config.json');
|
||||||
|
const CONFIG_EXAMPLE_PATH = join(SCRIPT_DIR, 'local-llm-test.config.example.json');
|
||||||
|
|
||||||
|
type AuthStyle = 'bearer' | 'api-key';
|
||||||
|
|
||||||
|
interface ConfigDefaults {
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
apiKey?: string;
|
||||||
|
authStyle?: AuthStyle;
|
||||||
|
prompt?: string;
|
||||||
|
only?: string[] | null;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads scripts/local-llm-test.config.json (gitignored — real IP/model/key,
|
||||||
|
* per-machine) if present, so you don't have to retype --base-url every run.
|
||||||
|
* See local-llm-test.config.example.json (tracked) for the shape. CLI flags
|
||||||
|
* always override whatever this file sets; this only supplies defaults.
|
||||||
|
*/
|
||||||
|
function loadConfigFile(): ConfigDefaults {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return {};
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||||
|
return {
|
||||||
|
baseUrl: raw.baseUrl ?? null,
|
||||||
|
model: raw.model ?? null,
|
||||||
|
apiKey: raw.apiKey || undefined, // empty string counts as "not set", not a real key
|
||||||
|
authStyle: raw.authStyle === 'api-key' ? 'api-key' : undefined, // never 'both'
|
||||||
|
prompt: raw.prompt ?? undefined,
|
||||||
|
only: Array.isArray(raw.only) && raw.only.length ? raw.only : null,
|
||||||
|
timeout: typeof raw.timeout === 'number' ? raw.timeout : undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${TAG} failed to parse ${CONFIG_PATH}: ${(err as Error).message} (ignoring it)`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Opts {
|
||||||
|
baseUrl: string | null;
|
||||||
|
model: string | null;
|
||||||
|
apiKey: string;
|
||||||
|
authStyle: AuthStyle;
|
||||||
|
prompt: string;
|
||||||
|
only: string[] | null;
|
||||||
|
timeout: number;
|
||||||
|
probeHelp: boolean;
|
||||||
|
keepTemp: boolean;
|
||||||
|
list: boolean;
|
||||||
|
help: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[], configDefaults: ConfigDefaults): Opts {
|
||||||
|
const opts: Opts = {
|
||||||
|
baseUrl: configDefaults.baseUrl ?? null,
|
||||||
|
model: configDefaults.model ?? null,
|
||||||
|
apiKey: configDefaults.apiKey ?? 'local-dummy-key',
|
||||||
|
authStyle: configDefaults.authStyle ?? 'bearer',
|
||||||
|
prompt: configDefaults.prompt ?? 'Reply with exactly: hello world',
|
||||||
|
only: configDefaults.only ?? null,
|
||||||
|
timeout: configDefaults.timeout ?? 30000,
|
||||||
|
probeHelp: false,
|
||||||
|
keepTemp: false,
|
||||||
|
list: false,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
switch (a) {
|
||||||
|
case '--base-url':
|
||||||
|
opts.baseUrl = argv[++i];
|
||||||
|
break;
|
||||||
|
case '--model':
|
||||||
|
opts.model = argv[++i];
|
||||||
|
break;
|
||||||
|
case '--api-key':
|
||||||
|
opts.apiKey = argv[++i];
|
||||||
|
break;
|
||||||
|
case '--auth-style':
|
||||||
|
opts.authStyle = argv[++i] as AuthStyle;
|
||||||
|
if (opts.authStyle !== 'bearer' && opts.authStyle !== 'api-key') {
|
||||||
|
console.error(`${TAG} --auth-style must be "bearer" or "api-key"`);
|
||||||
|
opts.help = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '--prompt':
|
||||||
|
opts.prompt = argv[++i];
|
||||||
|
break;
|
||||||
|
case '--only':
|
||||||
|
opts.only = argv[++i]
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
break;
|
||||||
|
case '--timeout':
|
||||||
|
opts.timeout = Number(argv[++i]);
|
||||||
|
break;
|
||||||
|
case '--probe-help':
|
||||||
|
opts.probeHelp = true;
|
||||||
|
break;
|
||||||
|
case '--keep-temp':
|
||||||
|
opts.keepTemp = true;
|
||||||
|
break;
|
||||||
|
case '--list':
|
||||||
|
opts.list = true;
|
||||||
|
break;
|
||||||
|
case '-h':
|
||||||
|
case '--help':
|
||||||
|
opts.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`${TAG} unknown argument: ${a}`);
|
||||||
|
opts.help = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log(`Usage: npx tsx scripts/test-local-llm-harnesses.ts [--base-url <url>] [options]
|
||||||
|
|
||||||
|
Reads defaults from scripts/local-llm-test.config.json if it exists (copy
|
||||||
|
scripts/local-llm-test.config.example.json to create it — gitignored, since
|
||||||
|
it holds a real IP/model/key). CLI flags always override the config file.
|
||||||
|
--base-url becomes optional once that file supplies one.
|
||||||
|
|
||||||
|
Works against any custom OpenAI-compatible endpoint, local or cloud
|
||||||
|
(llama.cpp, Ollama, vLLM, Azure AI Foundry, OpenRouter, a self-hosted
|
||||||
|
gateway, ...) — anything answering GET /v1/models and POST
|
||||||
|
/v1/chat/completions in the standard shape.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--base-url <url> Required. Root URL of the OpenAI-compatible endpoint.
|
||||||
|
--model <name> Model/deployment id to request. Default: first from GET /v1/models.
|
||||||
|
--api-key <key> API key to send. Default: local-dummy-key (required for most cloud endpoints).
|
||||||
|
--auth-style <style> "bearer" (default) or "api-key" (Azure-style). Never both — sending
|
||||||
|
both headers together reliably hangs some real servers.
|
||||||
|
--prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
|
||||||
|
--only <id,id,...> Restrict to these harness ids.
|
||||||
|
--timeout <ms> Per-harness spawn timeout. Default: 30000.
|
||||||
|
--probe-help Print each installed binary's --help instead of testing.
|
||||||
|
--keep-temp Keep generated per-harness config dirs afterward.
|
||||||
|
--list Dry run: print the resolved plan, execute nothing.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
Harness ids are read from the CLI registry at run time — pass an unknown
|
||||||
|
one and the error message lists what's actually enabled right now.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npx tsx scripts/test-local-llm-harnesses.ts --base-url http://192.168.1.50:8080
|
||||||
|
npx tsx scripts/test-local-llm-harnesses.ts --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOME = homedir();
|
||||||
|
|
||||||
|
/** Expands a leading `~` the way the CLI registry's own search dirs are written. */
|
||||||
|
function expandHome(p: string): string {
|
||||||
|
if (p === '~') return HOME;
|
||||||
|
if (p.startsWith('~/')) return join(HOME, p.slice(2));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathWithExtraDirs(extraDirs: string[]): string {
|
||||||
|
return [...extraDirs.map(expandHome), '/usr/local/bin', process.env.PATH ?? ''].join(delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a binary by trying `<bin> --version` with the CLI's own registry search dirs prefixed onto PATH. */
|
||||||
|
function resolveBinary(bin: string, searchDirs: string[]): string | null {
|
||||||
|
try {
|
||||||
|
execFileSync(bin, ['--version'], {
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, PATH: pathWithExtraDirs(searchDirs) },
|
||||||
|
});
|
||||||
|
return bin;
|
||||||
|
} catch (err) {
|
||||||
|
// Some CLIs (e.g. dsh) don't support --version cleanly for identity but
|
||||||
|
// still exist on PATH; a non-ENOENT failure still counts as "found".
|
||||||
|
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(bin: string, searchDirs: string[]): void {
|
||||||
|
try {
|
||||||
|
const out = execFileSync(bin, ['--help'], {
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, PATH: pathWithExtraDirs(searchDirs) },
|
||||||
|
});
|
||||||
|
console.log(out.toString());
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { stdout?: Buffer; message?: string };
|
||||||
|
console.log((e.stdout ?? e.message ?? String(err)).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- one-shot invocation table (NOT in the registry — genuinely separate info) ---
|
||||||
|
|
||||||
|
type Confidence = 'verified' | 'researched' | 'unknown';
|
||||||
|
|
||||||
|
interface OneShot {
|
||||||
|
/** `modelId` is the RAW model/deployment id (e.g. "qwen3.5-0.8b-...") — CLIs whose
|
||||||
|
* config wraps it under a provider/block name (pi/omp's "custom/<id>", grok's fixed
|
||||||
|
* block name) build the full `--model` value here, not in the injection layer. */
|
||||||
|
argv: (prompt: string, modelId: string) => string[];
|
||||||
|
confidence: Confidence;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to make each CLI answer ONE prompt and exit. The registry has no concept
|
||||||
|
* of this (it only knows the interactive TUI launch line), so this table is
|
||||||
|
* necessarily hand-maintained — but it is the ONLY hand-maintained part left;
|
||||||
|
* everything about WHERE the prompt goes (env vars, config files) comes from
|
||||||
|
* the real registry + `buildCustomModelInjection()` above.
|
||||||
|
*
|
||||||
|
* A CLI enabled in the registry with no entry here reports UNKNOWN rather
|
||||||
|
* than being silently skipped or guessed at — see `resolveOneShot()`.
|
||||||
|
*/
|
||||||
|
const ONE_SHOT: Record<string, OneShot> = {
|
||||||
|
claude: {
|
||||||
|
confidence: 'verified',
|
||||||
|
// Claude Code's async session-title-generation call also uses
|
||||||
|
// ANTHROPIC_DEFAULT_HAIKU_MODEL and validates it against Claude's OWN internal
|
||||||
|
// recognized-model list, printing [claude-code:unrecognized_model] to stderr for
|
||||||
|
// a local model name. Confirmed live: `--settings '{"autoTitle":false}'` does NOT
|
||||||
|
// stop it (still hung the whole run); `--bare` does — the warning still prints,
|
||||||
|
// but the actual prompt now runs and returns the real answer. Confirmed against
|
||||||
|
// a real llama-swap server. ⚠️ `--bare` also disables hooks/LSP/plugin sync/
|
||||||
|
// CLAUDE.md auto-discovery — fine for this ISOLATED one-shot test, never safe to
|
||||||
|
// apply to a real interactive Codeman session (which needs hooks).
|
||||||
|
argv: (prompt) => ['--dangerously-skip-permissions', '--bare', '-p', prompt],
|
||||||
|
},
|
||||||
|
opencode: { confidence: 'verified', argv: (prompt) => ['run', prompt] },
|
||||||
|
codex: {
|
||||||
|
confidence: 'verified',
|
||||||
|
note: 'config STRUCTURE verified; codex only speaks the Responses API (dropped Chat-Completions Feb 2026) — expect FAIL against a plain OpenAI-compatible server, that is a protocol gap, not a bug here.',
|
||||||
|
argv: (prompt) => ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt],
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
confidence: 'researched',
|
||||||
|
// --skip-trust: without it, an untrusted-folder check silently overrides
|
||||||
|
// --approval-mode yolo back to 'default' (confirmed live: "Approval mode
|
||||||
|
// overridden to 'default' because the current folder is not trusted").
|
||||||
|
argv: (prompt) => ['-p', prompt, '--approval-mode', 'yolo', '--skip-trust'],
|
||||||
|
},
|
||||||
|
pi: {
|
||||||
|
confidence: 'verified',
|
||||||
|
// --model custom/<id>: without an explicit --model, pi uses its own default
|
||||||
|
// provider (not our injected "custom" one) and fails with "No API key found
|
||||||
|
// for the selected model" — confirmed live. "custom" matches the provider name
|
||||||
|
// pi-models-json writes in custom-model-injection.ts. Verified end-to-end
|
||||||
|
// against a real llama-swap server after two real bugs were found and fixed:
|
||||||
|
// pi's `models` field must be an ARRAY of `{id}` objects (an object keyed by
|
||||||
|
// id silently loaded zero models), and PI_CONFIG_DIR does nothing for pi at
|
||||||
|
// all (grepped pi's own bundled source — not present anywhere); the actual
|
||||||
|
// working redirect is the CHILD PROCESS's `HOME` itself, since pi hardcodes
|
||||||
|
// `~/.pi/agent/models.json` with no dedicated override.
|
||||||
|
argv: (prompt, modelId) => ['--approve', '--model', `custom/${modelId}`, '-p', prompt],
|
||||||
|
},
|
||||||
|
grok: {
|
||||||
|
confidence: 'verified',
|
||||||
|
// -m <block name>: grok's config.toml (grok-toml template) declares the custom
|
||||||
|
// model under a fixed [model.<name>] block; GROK_CUSTOM_MODEL_NAME is that same
|
||||||
|
// name, imported from custom-model-injection.ts so the two can never drift apart.
|
||||||
|
// Verified end-to-end against a real llama-swap server after correcting the
|
||||||
|
// ORIGINAL recipe, which was wrong (env vars, not a config file — see the
|
||||||
|
// customModelInjection comment on grok's registry entry).
|
||||||
|
argv: (prompt) => ['--always-approve', '-m', GROK_CUSTOM_MODEL_NAME, '-p', prompt],
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
confidence: 'unknown',
|
||||||
|
note: 'dsh is a profile launcher, not a documented one-shot prompt flag. Best-effort only.',
|
||||||
|
argv: (prompt) => ['--profile', 'headless', prompt],
|
||||||
|
},
|
||||||
|
omp: {
|
||||||
|
confidence: 'verified',
|
||||||
|
// --model custom/<id>: same reasoning as pi — omp's own default model has no
|
||||||
|
// credential, so without an explicit --model it never reaches our injected
|
||||||
|
// provider at all. Verified end-to-end against a real llama-swap server after
|
||||||
|
// the same two fixes as pi (array-shaped `models`, HOME-redirect instead of
|
||||||
|
// PI_CONFIG_DIR — omp hardcodes `~/.omp/agent/models.yml`).
|
||||||
|
argv: (prompt, modelId) => ['--model', `custom/${modelId}`, '-p', prompt],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- baseline server check ---------------------------------------------------
|
||||||
|
|
||||||
|
async function baselineCheck(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
authStyle: AuthStyle,
|
||||||
|
model: string | null,
|
||||||
|
prompt: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<string> {
|
||||||
|
console.log(`\n=== Step 0: baseline check against ${baseUrl} (auth: ${authStyle}) ===`);
|
||||||
|
|
||||||
|
// Exactly ONE header, never both. An earlier version sent both auth conventions
|
||||||
|
// (Bearer + api-key) on the theory that an unused header is harmless — live-
|
||||||
|
// tested against a real llama-swap server, sending both reliably HUNG the
|
||||||
|
// request indefinitely (reproduced 3x: Bearer alone ~500ms, api-key alone
|
||||||
|
// ~600ms, both together no response inside a 15s timeout). Use --auth-style
|
||||||
|
// api-key for endpoints that specifically want that header (e.g. Azure AI
|
||||||
|
// Foundry); default 'bearer' covers everything else.
|
||||||
|
const authHeaders: Record<string, string> =
|
||||||
|
authStyle === 'api-key' ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` };
|
||||||
|
|
||||||
|
let discoveredModel = model;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||||
|
headers: authHeaders,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as { data?: Array<{ id: string }> };
|
||||||
|
const ids: string[] = (body.data ?? []).map((m) => m.id);
|
||||||
|
console.log(`GET /v1/models -> ${ids.length ? ids.join(', ') : '(empty list)'}`);
|
||||||
|
if (!discoveredModel && ids.length) discoveredModel = ids[0];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${TAG} GET /v1/models failed: ${(err as Error).message}`);
|
||||||
|
console.error(`${TAG} Is the server actually running at ${baseUrl}? Aborting.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discoveredModel) {
|
||||||
|
console.error(`${TAG} No --model given and none discovered from /v1/models. Aborting.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-tested against a real llama-swap server: a POST issued right after a GET on
|
||||||
|
// the same Node process reliably HANGS indefinitely (reproduced repeatedly — GET
|
||||||
|
// alone ~30ms, POST alone ~1-2s, GET-then-immediate-POST times out completely; a
|
||||||
|
// 2s pause between them fixed it every time). This looks like Node's fetch (undici)
|
||||||
|
// reusing a pooled keep-alive connection the server doesn't handle cleanly for a
|
||||||
|
// second request right behind a first. A short pause is the simplest portable fix
|
||||||
|
// (no extra deps, no need for undici's Agent/dispatcher API).
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: discoveredModel,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
const body = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
const reply: string = body.choices?.[0]?.message?.content ?? '';
|
||||||
|
if (!reply.trim()) throw new Error('empty reply');
|
||||||
|
console.log(`POST /v1/chat/completions -> "${reply.trim().slice(0, 200)}"`);
|
||||||
|
console.log('Server baseline: PASS\n');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${TAG} POST /v1/chat/completions failed: ${(err as Error).message}`);
|
||||||
|
console.error(`${TAG} Server responded to /v1/models but not to a chat request. Aborting.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return discoveredModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- per-harness run ----------------------------------------------------------
|
||||||
|
|
||||||
|
interface ChildResult {
|
||||||
|
code: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
timedOut: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runChild(bin: string, argv: string[], env: Record<string, string>, searchDirs: string[], timeoutMs: number) {
|
||||||
|
return new Promise<ChildResult>((resolve) => {
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
const child = spawn(bin, argv, {
|
||||||
|
env: { ...process.env, ...env, PATH: pathWithExtraDirs(searchDirs) },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
resolve({ code: null, stdout, stderr, timedOut: true });
|
||||||
|
}, timeoutMs);
|
||||||
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||||
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: null, stdout, stderr: `${stderr}\n${err.message}`, timedOut: false });
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code, stdout, stderr, timedOut: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HarnessResult {
|
||||||
|
id: string;
|
||||||
|
confidence: Confidence | 'unsupported' | 'no-one-shot-recipe';
|
||||||
|
status: 'PASS' | 'FAIL' | 'UNCONFIRMED' | 'SKIP' | 'LIST';
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHarness(
|
||||||
|
entry: CliEntry,
|
||||||
|
opts: Opts,
|
||||||
|
model: string,
|
||||||
|
endpoint: CustomModelEndpoint
|
||||||
|
): Promise<HarnessResult> {
|
||||||
|
const id = entry.id;
|
||||||
|
const injectionCap = entry.capabilities.customModelInjection;
|
||||||
|
|
||||||
|
// Dynamic: driven by the REGISTRY's own capability, not a hardcoded id check.
|
||||||
|
// A future CLI declared unsupported is skipped automatically, same as antigravity today.
|
||||||
|
if (injectionCap.kind === 'unsupported') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: 'unsupported',
|
||||||
|
status: 'SKIP',
|
||||||
|
detail: 'no known custom-model mechanism (registry: unsupported)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneShot = ONE_SHOT[id];
|
||||||
|
if (!oneShot) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: 'no-one-shot-recipe',
|
||||||
|
status: 'SKIP',
|
||||||
|
detail:
|
||||||
|
'registry supports custom-model injection for this CLI, but this script has no ONE_SHOT invocation entry yet — add one to test it',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = entry.discovery.binaries[0] ?? id;
|
||||||
|
const searchDirs = entry.discovery.searchDirs;
|
||||||
|
const resolved = resolveBinary(binary, searchDirs);
|
||||||
|
if (!resolved) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: oneShot.confidence,
|
||||||
|
status: 'SKIP',
|
||||||
|
detail: `binary "${binary}" not found on PATH or search dirs`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The REAL injection logic — same function the production route calls.
|
||||||
|
const injection = buildCustomModelInjection(entry, endpoint, model);
|
||||||
|
|
||||||
|
let env: Record<string, string> = {};
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
|
if (injection.kind === 'env') {
|
||||||
|
env = injection.envOverrides;
|
||||||
|
} else if (injection.kind === 'configDir') {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), `codeman-local-llm-test-${id}-`));
|
||||||
|
env = applyConfigDirInjection(tempDir, injection);
|
||||||
|
}
|
||||||
|
// injection.kind === 'unsupported' already handled via injectionCap above.
|
||||||
|
|
||||||
|
const argv = oneShot.argv(opts.prompt, model);
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
const detail = `${binary} ${argv.join(' ')} | env: ${Object.keys(env).join(', ')}${tempDir ? ` | configDir: ${tempDir}` : ''}`;
|
||||||
|
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
return { id, confidence: oneShot.confidence, status: 'LIST', detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, stdout, stderr, timedOut } = await runChild(binary, argv, env, searchDirs, opts.timeout);
|
||||||
|
|
||||||
|
let detailSuffix = '';
|
||||||
|
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
else if (tempDir) detailSuffix = ` [config kept at ${tempDir}]`;
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: oneShot.confidence,
|
||||||
|
status: 'FAIL',
|
||||||
|
detail: `timed out after ${opts.timeout}ms. stderr: ${stderr.slice(-300)}${detailSuffix}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = stdout.trim();
|
||||||
|
const matched = /hello/i.test(reply) && /world/i.test(reply);
|
||||||
|
const softStatus: HarnessResult['status'] = oneShot.confidence === 'verified' ? 'FAIL' : 'UNCONFIRMED';
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: oneShot.confidence,
|
||||||
|
status: softStatus,
|
||||||
|
detail: `exit ${code}. stderr: ${stderr.trim().slice(-300) || '(empty)'}${detailSuffix}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!reply) {
|
||||||
|
return { id, confidence: oneShot.confidence, status: softStatus, detail: `exit 0 but empty stdout${detailSuffix}` };
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
|
return { id, confidence: oneShot.confidence, status: 'PASS', detail: `${reply.slice(0, 200)}${detailSuffix}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
confidence: oneShot.confidence,
|
||||||
|
status: 'UNCONFIRMED',
|
||||||
|
detail: `reply didn't match heuristic, judge by eye: "${reply.slice(0, 300)}"${detailSuffix}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const configDefaults = loadConfigFile();
|
||||||
|
const opts = parseArgs(process.argv.slice(2), configDefaults);
|
||||||
|
if (opts.help) {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic: pulled from the live registry, not a hardcoded id list. `kind === 'agent'`
|
||||||
|
// excludes 'shell' (no model/endpoint concept). Antigravity stays in this list (it IS
|
||||||
|
// an enabled agent CLI) — it's the `unsupported` capability check in runHarness that
|
||||||
|
// skips it, not an exclusion here.
|
||||||
|
const allEntries = enabledClis().filter((e) => e.kind === 'agent');
|
||||||
|
const byId = new Map<string, CliEntry>(allEntries.map((e) => [e.id as string, e]));
|
||||||
|
const ids: string[] = opts.only ?? [...byId.keys()];
|
||||||
|
const unknownIds = ids.filter((id) => !byId.has(id));
|
||||||
|
if (unknownIds.length) {
|
||||||
|
console.error(`${TAG} unknown harness id(s): ${unknownIds.join(', ')}`);
|
||||||
|
console.error(`${TAG} known ids (from the live CLI registry): ${[...byId.keys()].join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const entries = ids.map((id) => byId.get(id)!);
|
||||||
|
|
||||||
|
// --probe-help never touches the network — no --base-url needed for it.
|
||||||
|
if (opts.probeHelp) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const binary = entry.discovery.binaries[0] ?? entry.id;
|
||||||
|
const resolved = resolveBinary(binary, entry.discovery.searchDirs);
|
||||||
|
console.log(`\n=== ${entry.id} (${binary}) ===`);
|
||||||
|
if (!resolved) {
|
||||||
|
console.log('(not found on PATH or search dirs)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printHelp(binary, entry.discovery.searchDirs);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.baseUrl) {
|
||||||
|
console.error(`${TAG} --base-url is required (pass it, or set "baseUrl" in ${CONFIG_PATH}).`);
|
||||||
|
console.error(`${TAG} See ${CONFIG_EXAMPLE_PATH} for the config file shape.\n`);
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
opts.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const endpoint: CustomModelEndpoint = {
|
||||||
|
id: 'standalone-test',
|
||||||
|
label: 'standalone test',
|
||||||
|
baseUrl: opts.baseUrl,
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --list is a pure dry run: never touch the network, even if --model was given.
|
||||||
|
let model: string;
|
||||||
|
if (opts.list) {
|
||||||
|
model = opts.model ?? 'local-model';
|
||||||
|
console.log(`\n=== Step 0 skipped (--list never hits the network; using placeholder "${model}") ===\n`);
|
||||||
|
} else {
|
||||||
|
model = await baselineCheck(opts.baseUrl, opts.apiKey, opts.authStyle, opts.model, opts.prompt, opts.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`=== Testing ${entries.length} harness(es) ===`);
|
||||||
|
const results: HarnessResult[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
process.stdout.write(`\n--- ${entry.id} ---\n`);
|
||||||
|
const result = await runHarness(entry, opts, model, endpoint);
|
||||||
|
results.push(result);
|
||||||
|
console.log(`${result.status}: ${result.detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Summary ===');
|
||||||
|
const width = Math.max(...results.map((r) => r.id.length)) + 2;
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`${r.id.padEnd(width)} [${r.confidence.padEnd(20)}] ${r.status.padEnd(11)} ${r.detail.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardFail = results.some((r) => r.status === 'FAIL' && r.confidence === 'verified');
|
||||||
|
if (hardFail) {
|
||||||
|
console.error(
|
||||||
|
`\n${TAG} at least one VERIFIED harness FAILed — that's a real regression, not just an unconfirmed guess.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`${TAG} unexpected error:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+124
-12
@@ -10,10 +10,12 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { realpathSync } from 'node:fs';
|
import { realpathSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { basename, extname, isAbsolute } from 'node:path';
|
import { basename, extname, isAbsolute } from 'node:path';
|
||||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||||
|
import { remoteProbePaths, RemoteFileAccessError, type RemoteProbe } from './remote-files.js';
|
||||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||||
|
import type { SessionRemote } from './types/session.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playable media extensions, single-sourced here because the WORKSPACE preview
|
* Playable media extensions, single-sourced here because the WORKSPACE preview
|
||||||
@@ -215,6 +217,106 @@ export interface RegisterExternalAttachmentOptions {
|
|||||||
* `codeman attach` CLI (which POSTs directly when a session id is known).
|
* `codeman attach` CLI (which POSTs directly when a session id is known).
|
||||||
*/
|
*/
|
||||||
forceWorkspaceConfinement?: boolean;
|
forceWorkspaceConfinement?: boolean;
|
||||||
|
/**
|
||||||
|
* Remote (SSH) case: the path exists on the REMOTE host, so it is resolved and
|
||||||
|
* stat'ed there (`remoteProbePaths`) instead of with local `realpathSync`/`fs.stat`,
|
||||||
|
* which cannot see it at all (#415). A file outside the case directory is
|
||||||
|
* unreachable exactly like a file inside it.
|
||||||
|
*
|
||||||
|
* `sessionWorkingDir` must then be the REMOTE path too, and the workspace
|
||||||
|
* confinement check (when active) compares against the remotely canonicalized root,
|
||||||
|
* so a symlinked `remotePath` does not refuse every registration.
|
||||||
|
*/
|
||||||
|
remote?: SessionRemote;
|
||||||
|
/**
|
||||||
|
* Remote only: `[file, workspaceRoot]` probes a caller already resolved in a BATCHED
|
||||||
|
* `remoteProbePaths` call (the attachment-history list does one round trip for the
|
||||||
|
* whole history). Skips this registration's own ssh probe; every guard below still
|
||||||
|
* runs on the same resolved path it would have produced itself.
|
||||||
|
*/
|
||||||
|
remoteProbes?: readonly [RemoteProbe | null, RemoteProbe | null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A path an attachment request resolved to, on whichever host it lives — the local
|
||||||
|
* filesystem or the remote host of a remote-SSH case. The rest of
|
||||||
|
* {@link registerExternalAttachment} (guards, extension allowlist, registry) is then
|
||||||
|
* host-agnostic: it only ever sees canonical absolute paths and numbers.
|
||||||
|
*/
|
||||||
|
interface ResolvedAttachmentFile {
|
||||||
|
resolvedPath: string;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
isFile: boolean;
|
||||||
|
extension: string;
|
||||||
|
/** Remote only: the workspace root, with symlinks resolved on the remote host. */
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `extension` the way the attachment registry defines it (no dot, lowercased). */
|
||||||
|
function attachmentExtensionOf(path: string): string {
|
||||||
|
return extname(path).toLowerCase().replace(/^\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local resolution: the historical realpath + stat. */
|
||||||
|
async function resolveLocalAttachment(requestedPath: string): Promise<ResolvedAttachmentFile> {
|
||||||
|
let resolvedPath: string;
|
||||||
|
try {
|
||||||
|
resolvedPath = realpathSync(requestedPath);
|
||||||
|
} catch {
|
||||||
|
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
return {
|
||||||
|
resolvedPath,
|
||||||
|
size: stat.size,
|
||||||
|
mtimeMs: stat.mtimeMs ?? 0,
|
||||||
|
isFile: typeof stat.isFile === 'function' ? stat.isFile() : true,
|
||||||
|
extension: attachmentExtensionOf(resolvedPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote resolution for a remote-SSH case: ONE ssh round trip returns the
|
||||||
|
* symlink-resolved path, the size/mtime and the kind, for the file AND (when a
|
||||||
|
* workspace is known) its root, which the confinement check compares against.
|
||||||
|
*/
|
||||||
|
async function resolveRemoteAttachment(
|
||||||
|
requestedPath: string,
|
||||||
|
remote: SessionRemote,
|
||||||
|
sessionWorkingDir?: string,
|
||||||
|
preResolved?: readonly [RemoteProbe | null, RemoteProbe | null]
|
||||||
|
): Promise<ResolvedAttachmentFile> {
|
||||||
|
const paths = sessionWorkingDir ? [requestedPath, sessionWorkingDir] : [requestedPath];
|
||||||
|
let probes: ReadonlyArray<RemoteProbe | null>;
|
||||||
|
if (preResolved) {
|
||||||
|
probes = preResolved;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
probes = await remoteProbePaths(remote, paths);
|
||||||
|
} catch (err) {
|
||||||
|
// 502 marks the TRANSPORT as the failure, distinct from the file's own 404/403,
|
||||||
|
// so a history listing can report the entry as unknown rather than missing.
|
||||||
|
throw new AttachmentRegistrationError(
|
||||||
|
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
|
||||||
|
502
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [probe, rootProbe] = probes;
|
||||||
|
if (!probe) {
|
||||||
|
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolvedPath: probe.realPath,
|
||||||
|
size: probe.size,
|
||||||
|
mtimeMs: probe.mtimeMs,
|
||||||
|
isFile: probe.kind === 'file',
|
||||||
|
extension: attachmentExtensionOf(probe.realPath),
|
||||||
|
workspaceRoot: rootProbe?.realPath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerExternalAttachment(
|
export async function registerExternalAttachment(
|
||||||
@@ -226,12 +328,9 @@ export async function registerExternalAttachment(
|
|||||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedPath: string;
|
const resolved = await (options.remote
|
||||||
try {
|
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
|
||||||
resolvedPath = realpathSync(requestedPath);
|
: resolveLocalAttachment(requestedPath));
|
||||||
} catch {
|
|
||||||
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||||
// path before doing anything else.
|
// path before doing anything else.
|
||||||
@@ -243,7 +342,10 @@ export async function registerExternalAttachment(
|
|||||||
// the caller forces it for this registration (the magic-link scanner — see
|
// the caller forces it for this registration (the magic-link scanner — see
|
||||||
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
|
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
|
||||||
const workingDir = options.sessionWorkingDir;
|
const workingDir = options.sessionWorkingDir;
|
||||||
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
|
const confined = options.remote
|
||||||
|
? !!workingDir && isUnderTree(resolved.resolvedPath, resolved.workspaceRoot ?? workingDir)
|
||||||
|
: !!workingDir && !!validateSessionFilePath(workingDir, resolved.resolvedPath);
|
||||||
|
if (!confined) {
|
||||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,20 +355,30 @@ export async function registerExternalAttachment(
|
|||||||
// operator-configured extra trees. Symlinks are already resolved above.
|
// operator-configured extra trees. Symlinks are already resolved above.
|
||||||
// Cross-workspace attachment of non-blocked files stays allowed, so
|
// Cross-workspace attachment of non-blocked files stays allowed, so
|
||||||
// codeman-publish and the ~/.codeman review loop keep working.
|
// codeman-publish and the ~/.codeman review loop keep working.
|
||||||
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
//
|
||||||
|
// The list is a pattern list over ABSOLUTE paths, so it is host-agnostic and holds
|
||||||
|
// for a remote path exactly as it does for a local one, with ONE exception worth
|
||||||
|
// knowing: `isSensitivePath`'s three home-anchored members (`~/.claude.json`,
|
||||||
|
// `~/.claude/settings.json`, `~/.claude/settings.local.json`) resolve against THIS
|
||||||
|
// host's `homedir()`, so on a remote host with a different home they do not match.
|
||||||
|
// Everything else in that list is depth-anchored (`/.ssh/`, `/.aws/credentials`,
|
||||||
|
// `/.claude/.credentials.json`, ...) and applies unchanged.
|
||||||
|
if (isBlockedAttachmentPath(resolved.resolvedPath, guard.blockedTrees)) {
|
||||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
|
const resolvedPath = resolved.resolvedPath;
|
||||||
|
const extension = resolved.extension;
|
||||||
if (!isSupportedAttachmentExtension(extension)) {
|
if (!isSupportedAttachmentExtension(extension)) {
|
||||||
throw new AttachmentRegistrationError('Unsupported attachment type');
|
throw new AttachmentRegistrationError('Unsupported attachment type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const stat = await fs.stat(resolvedPath);
|
if (!resolved.isFile) {
|
||||||
if (typeof stat.isFile === 'function' && !stat.isFile()) {
|
|
||||||
throw new AttachmentRegistrationError('Attachment path is not a file');
|
throw new AttachmentRegistrationError('Attachment path is not a file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stat = { size: resolved.size, mtimeMs: resolved.mtimeMs };
|
||||||
|
|
||||||
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
|
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.size = stat.size;
|
existing.size = stat.size;
|
||||||
|
|||||||
@@ -261,6 +261,19 @@ const echoSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `capabilities.customModelInjection.launchModel`: the `model` launch-param value that
|
||||||
|
* selects the injected provider, with `{modelId}` standing for the chosen id. Bounded to
|
||||||
|
* the characters the `model`/`model-pi` token patterns accept plus the placeholder braces,
|
||||||
|
* so a template can never smuggle a token the argv engine would have to quote.
|
||||||
|
*/
|
||||||
|
const launchModelTemplate = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(120)
|
||||||
|
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
|
||||||
|
.optional();
|
||||||
|
|
||||||
const capabilitiesSchema = z
|
const capabilitiesSchema = z
|
||||||
.object({
|
.object({
|
||||||
external: z.boolean(),
|
external: z.boolean(),
|
||||||
@@ -317,6 +330,37 @@ const capabilitiesSchema = z
|
|||||||
privilegedEnvKeys: z.array(envName).max(8),
|
privilegedEnvKeys: z.array(envName).max(8),
|
||||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||||
maxFrameBytes: z.number().int().positive().optional(),
|
maxFrameBytes: z.number().int().positive().optional(),
|
||||||
|
customModelInjection: z.discriminatedUnion('kind', [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
kind: z.literal('env'),
|
||||||
|
baseUrlVar: envName,
|
||||||
|
apiKeyVar: envName,
|
||||||
|
// Empty is valid: deepseek's model routing is a profile-composition concern, not
|
||||||
|
// an env var, so it declares baseUrl/apiKey injection with no model var at all.
|
||||||
|
modelVars: z.array(envName).max(8),
|
||||||
|
launchModel: launchModelTemplate,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
kind: z.literal('configContentEnv'),
|
||||||
|
envVar: envName,
|
||||||
|
template: z.literal('opencode-json'),
|
||||||
|
launchModel: launchModelTemplate,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
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']),
|
||||||
|
launchModel: launchModelTemplate,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z.object({ kind: z.literal('unsupported') }).strict(),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,12 @@ const CLAUDE: CliEntry = {
|
|||||||
unset: ['CLAUDECODE'],
|
unset: ['CLAUDECODE'],
|
||||||
tmuxSetenvKeys: [],
|
tmuxSetenvKeys: [],
|
||||||
dockerExecEnvNames: [],
|
dockerExecEnvNames: [],
|
||||||
|
// Deliberately excludes ANTHROPIC_* (base URL / API key / default-model overrides):
|
||||||
|
// custom-model-injection.ts's claude recipe uses those names, but they must reach a
|
||||||
|
// session ONLY through the admin-configured, SSRF-guarded custom-model route, never
|
||||||
|
// through a plain client-supplied envOverrides field. Widening this prefix would let
|
||||||
|
// any session-create caller redirect a session's Anthropic traffic and credentials to
|
||||||
|
// an arbitrary, unvalidated URL.
|
||||||
allowedPrefixes: ['CLAUDE_CODE_'],
|
allowedPrefixes: ['CLAUDE_CODE_'],
|
||||||
allowedKeys: ['CLAUDE_CONFIG_DIR'],
|
allowedKeys: ['CLAUDE_CONFIG_DIR'],
|
||||||
},
|
},
|
||||||
@@ -220,8 +226,28 @@ const CLAUDE: CliEntry = {
|
|||||||
statusLineTelemetry: true,
|
statusLineTelemetry: true,
|
||||||
model: { source: 'claude-settings-file' },
|
model: { source: 'claude-settings-file' },
|
||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
privilegedEnvKeys: [],
|
// ANTHROPIC_* is NOT in allowedPrefixes/allowedKeys above (deliberately — see the
|
||||||
|
// allowedPrefixes comment nearby), so these are unreachable via plain envOverrides
|
||||||
|
// today; listed here only so the dedicated custom-model route (docs/custom-model-endpoints-plan.md
|
||||||
|
// chunk 5) clamps them for a non-granted multi-user owner the same way every other
|
||||||
|
// CLI's injection vars are clamped, the day that route widens who can set them.
|
||||||
|
privilegedEnvKeys: [
|
||||||
|
'ANTHROPIC_BASE_URL',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
],
|
||||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||||
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||||
|
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
||||||
|
// respawn, never a live hot-swap.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'env',
|
||||||
|
baseUrlVar: 'ANTHROPIC_BASE_URL',
|
||||||
|
apiKeyVar: 'ANTHROPIC_API_KEY',
|
||||||
|
modelVars: ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// Mirrors the local default so the remote/in-container agent runs non-interactively
|
// Mirrors the local default so the remote/in-container agent runs non-interactively
|
||||||
@@ -287,6 +313,7 @@ const SHELL: CliEntry = {
|
|||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
privilegedEnvKeys: [],
|
privilegedEnvKeys: [],
|
||||||
gates: {},
|
gates: {},
|
||||||
|
customModelInjection: { kind: 'unsupported' }, // a raw shell has no "model" concept
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly
|
// No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly
|
||||||
@@ -366,6 +393,15 @@ const OPENCODE: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||||
|
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
||||||
|
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
||||||
|
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
||||||
|
customModelInjection: { kind: 'configContentEnv', envVar: 'OPENCODE_CONFIG_CONTENT', template: 'opencode-json' },
|
||||||
|
// OPENCODE_CONFIG_CONTENT already matches the OPENCODE_ allowedPrefix above, so it was
|
||||||
|
// ALREADY reachable via plain envOverrides before this feature existed — it replaces
|
||||||
|
// opencode's whole config, provider api keys included, so a non-granted multi-user owner
|
||||||
|
// sending it is a pre-existing credential-redirection gap, not one this feature opens.
|
||||||
|
privilegedEnvKeys: ['OPENCODE_CONFIG_CONTENT'],
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
credStore: { rel: '.config/opencode', seedWhole: true },
|
credStore: { rel: '.config/opencode', seedWhole: true },
|
||||||
@@ -455,6 +491,23 @@ const CODEX: CliEntry = {
|
|||||||
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
||||||
// regression; `schema.ts` now rejects a name that is not a declared param.
|
// regression; `schema.ts` now rejects a name that is not a declared param.
|
||||||
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
||||||
|
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
||||||
|
// so the user's real ~/.codex/config.toml is never touched.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'configDir',
|
||||||
|
dirEnvVar: 'CODEX_HOME',
|
||||||
|
fileName: 'config.toml',
|
||||||
|
template: 'codex-toml',
|
||||||
|
},
|
||||||
|
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||||
|
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||||
|
// MORE sensitive than a bare base-url var: a redirected CODEX_HOME points codex at a
|
||||||
|
// config.toml a non-granted owner fully controls, which can restate sandbox/approval
|
||||||
|
// policy INSIDE that file — a path the argv-level `bypassApprovals` clamp above
|
||||||
|
// cannot see or stop.
|
||||||
|
// CODEMAN_CUSTOM_MODEL_API_KEY: the credential config.toml's env_key references
|
||||||
|
// (see custom-model-injection.ts) — same reasoning as CODEX_HOME above.
|
||||||
|
privilegedEnvKeys: ['CODEX_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
credStore: {
|
credStore: {
|
||||||
@@ -538,6 +591,20 @@ const GEMINI: CliEntry = {
|
|||||||
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
||||||
// sends no geminiConfig at all would still get yolo for free.
|
// sends no geminiConfig at all would still get yolo for free.
|
||||||
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
||||||
|
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
||||||
|
// start). Confirm the exact model-override env var name against the installed
|
||||||
|
// gemini-cli version before shipping.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'env',
|
||||||
|
baseUrlVar: 'GOOGLE_GEMINI_BASE_URL',
|
||||||
|
apiKeyVar: 'GEMINI_API_KEY',
|
||||||
|
modelVars: ['GEMINI_MODEL'],
|
||||||
|
},
|
||||||
|
// All three already match the GEMINI_/GOOGLE_ allowedPrefixes above, so they were
|
||||||
|
// ALREADY reachable via plain envOverrides before this feature existed — a non-granted
|
||||||
|
// multi-user owner redirecting a gemini session's endpoint/credentials is a
|
||||||
|
// pre-existing gap this feature's analysis surfaced, not one it opens.
|
||||||
|
privilegedEnvKeys: ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_API_KEY', 'GEMINI_MODEL'],
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry
|
credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry
|
||||||
@@ -603,6 +670,10 @@ const ANTIGRAVITY: CliEntry = {
|
|||||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||||
// SENT config needs the flag forced off — nothing is materialized.
|
// SENT config needs the flag forced off — nothing is materialized.
|
||||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||||
|
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
||||||
|
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||||
|
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||||
|
customModelInjection: { kind: 'unsupported' },
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/,
|
// No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/,
|
||||||
@@ -693,6 +764,34 @@ const PI: CliEntry = {
|
|||||||
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
|
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
|
||||||
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
||||||
privilegedParams: [{ param: 'approveProjectTrust', clampTo: false, materializeWhenAbsent: true }],
|
privilegedParams: [{ param: 'approveProjectTrust', clampTo: false, materializeWhenAbsent: true }],
|
||||||
|
// CORRECTED after live-testing: `PI_CONFIG_DIR` does NOT exist anywhere in pi's own
|
||||||
|
// bundled source (grepped the installed package directly) — it does nothing for pi
|
||||||
|
// itself, despite being a real Codeman env var that OTHER things (omp) read. The
|
||||||
|
// confirmed working redirect is `HOME` itself: pi hardcodes `~/.pi/agent/models.json`
|
||||||
|
// with no dedicated override, so redirecting the CHILD PROCESS's HOME is what
|
||||||
|
// actually relocates it (verified: a model written under an isolated HOME's
|
||||||
|
// `.pi/agent/models.json` shows up in `pi --list-models` and answers a real prompt
|
||||||
|
// against a real llama-swap server; PI_CONFIG_DIR alone left it silently unable to
|
||||||
|
// see any provider). ⚠️ This is a bigger blast radius than a dedicated config-dir
|
||||||
|
// var: it also redirects pi's real sessions/auth/extensions for the DURATION of a
|
||||||
|
// custom-model session, not just its provider config — document this trade-off
|
||||||
|
// wherever this capability is surfaced.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'configDir',
|
||||||
|
dirEnvVar: 'HOME',
|
||||||
|
fileName: '.pi/agent/models.json',
|
||||||
|
template: 'pi-models-json',
|
||||||
|
// Writing models.json is not enough: without `--model custom/<id>` pi stays on its
|
||||||
|
// own default provider and fails with "No API key found for the selected model"
|
||||||
|
// (confirmed live). `custom` is the provider name pi-models-json declares.
|
||||||
|
launchModel: 'custom/{modelId}',
|
||||||
|
},
|
||||||
|
// HOME is not `PI_`-prefixed, so unlike the old (wrong) PI_CONFIG_DIR guess this was
|
||||||
|
// never reachable via the generic envOverrides allowlist at all — listed here anyway,
|
||||||
|
// matching the documented pattern for every other CLI's dir-redirect var, since a
|
||||||
|
// redirected HOME is at least as sensitive as CODEX_HOME/GROK_HOME (pi executes
|
||||||
|
// repo-local .pi/extensions TypeScript — see the External CLI modes note in CLAUDE.md).
|
||||||
|
privilegedEnvKeys: ['HOME'],
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
credStore: {
|
credStore: {
|
||||||
@@ -788,6 +887,28 @@ const GROK: CliEntry = {
|
|||||||
// already its safe interactive ask-mode, so the multi-user clamp only needs to force an
|
// already its safe interactive ask-mode, so the multi-user clamp only needs to force an
|
||||||
// EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent.
|
// EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent.
|
||||||
privilegedParams: [{ param: 'alwaysApprove', clampTo: false }],
|
privilegedParams: [{ param: 'alwaysApprove', clampTo: false }],
|
||||||
|
// CORRECTED after live-testing against a real grok binary: the original `env` kind
|
||||||
|
// (GROK_BASE_URL/GROK_MODEL/XAI_API_KEY) produced "Not signed in" — those env vars
|
||||||
|
// are NOT grok's real custom-endpoint mechanism. The real one (verified against
|
||||||
|
// xAI's own docs) is a `[model.<name>]` block in a config.toml under GROK_HOME,
|
||||||
|
// the same configDir shape as codex/pi/omp. `api_backend = "chat_completions"` is
|
||||||
|
// explicitly supported (unlike codex, which dropped it) — grok CAN talk to a plain
|
||||||
|
// OpenAI Chat-Completions server directly.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'configDir',
|
||||||
|
dirEnvVar: 'GROK_HOME',
|
||||||
|
fileName: 'config.toml',
|
||||||
|
template: 'grok-toml',
|
||||||
|
// The `[model.<name>]` block the grok-toml template writes; `--model <name>` is what
|
||||||
|
// selects it (GROK_CUSTOM_MODEL_NAME in custom-model-injection.ts, pinned equal by
|
||||||
|
// test/custom-model-injection.test.ts so the two cannot drift).
|
||||||
|
launchModel: 'codeman-custom',
|
||||||
|
},
|
||||||
|
// GROK_HOME already matches the GROK_ allowedPrefix above, so it was ALREADY
|
||||||
|
// reachable via plain envOverrides before this feature existed — same reasoning
|
||||||
|
// as CODEX_HOME: a redirected config dir can restate policy the argv-level
|
||||||
|
// `alwaysApprove` clamp above cannot see.
|
||||||
|
privilegedEnvKeys: ['GROK_HOME'],
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/,
|
// ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/,
|
||||||
@@ -943,7 +1064,23 @@ const DEEPSEEK: CliEntry = {
|
|||||||
// The half no other CLI needs. `DSH_*` is an allowlisted envOverrides prefix and
|
// The half no other CLI needs. `DSH_*` is an allowlisted envOverrides prefix and
|
||||||
// applyEnvOverrides() runs LAST, so without this a non-granted owner could send
|
// applyEnvOverrides() runs LAST, so without this a non-granted owner could send
|
||||||
// DSH_PERMISSION_MODE on the same request and land after the config clamp.
|
// DSH_PERMISSION_MODE on the same request and land after the config clamp.
|
||||||
|
// ⚠️ DEEPSEEK_API_KEY deliberately stays OUT of this list (see the docstring on
|
||||||
|
// clampEnvOverridesForOwner() in session-routes.ts): _configureCliEnv() forwards the
|
||||||
|
// 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'],
|
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||||
|
// Web-researched, unverified, partial: 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.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'env',
|
||||||
|
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||||
|
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||||
|
modelVars: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// No credStore: dsh keeps everything under $DSH_HOME (default ~/.dsh), which is
|
// No credStore: dsh keeps everything under $DSH_HOME (default ~/.dsh), which is
|
||||||
@@ -1045,7 +1182,25 @@ const OMP: CliEntry = {
|
|||||||
// Where omp resolves its auth from. No known concrete exfiltration path today (omp
|
// Where omp resolves its auth from. No known concrete exfiltration path today (omp
|
||||||
// forwards no operator-held key into a pane), but a non-granted owner redirecting where
|
// forwards no operator-held key into a pane), but a non-granted owner redirecting where
|
||||||
// a shared multi-tenant deployment resolves auth is not something to allow silently.
|
// a shared multi-tenant deployment resolves auth is not something to allow silently.
|
||||||
privilegedEnvKeys: ['OMP_AUTH_BROKER_URL', 'OMP_AUTH_BROKER_TOKEN'],
|
// HOME added for custom-model-injection.ts's omp recipe (see below). Unlike pi,
|
||||||
|
// PI_CONFIG_DIR genuinely IS one of the env vars omp reads (per the DeepSeek/OMP
|
||||||
|
// note in CLAUDE.md) — but live-testing this feature found it did NOT relocate
|
||||||
|
// omp's model config the way expected, while redirecting HOME itself (like pi)
|
||||||
|
// worked immediately (verified end-to-end: a real "hello world" reply came back).
|
||||||
|
privilegedEnvKeys: ['OMP_AUTH_BROKER_URL', 'OMP_AUTH_BROKER_TOKEN', 'HOME'],
|
||||||
|
// Verified end-to-end against a real llama-swap server (live-tested, not just
|
||||||
|
// researched — a real "hello world" reply came back). Same HOME-redirect mechanism
|
||||||
|
// as pi (see its customModelInjection comment for the full reasoning) — omp hardcodes
|
||||||
|
// `~/.omp/agent/models.yml` with no dedicated config-dir override either.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'configDir',
|
||||||
|
dirEnvVar: 'HOME',
|
||||||
|
fileName: '.omp/agent/models.yml',
|
||||||
|
template: 'omp-models-yml',
|
||||||
|
// Same as pi: omp's own default model has no credential, so without an explicit
|
||||||
|
// `--model custom/<id>` it never reaches the injected provider at all.
|
||||||
|
launchModel: 'custom/{modelId}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
// `~/.omp/agent` also holds agent.db/history.db/models.db (SQLite caches) and
|
// `~/.omp/agent` also holds agent.db/history.db/models.db (SQLite caches) and
|
||||||
|
|||||||
@@ -457,6 +457,57 @@ export interface CliCapabilities {
|
|||||||
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
||||||
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
||||||
maxFrameBytes?: number;
|
maxFrameBytes?: number;
|
||||||
|
/**
|
||||||
|
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||||
|
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||||
|
* Custom Model Endpoint Profiles feature (`docs/custom-model-endpoints-plan.md`). Declared
|
||||||
|
* per entry, never branched on id, same as every other capability here.
|
||||||
|
*
|
||||||
|
* `env`: plain env vars (claude's `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY`/
|
||||||
|
* `ANTHROPIC_DEFAULT_*_MODEL`). `configContentEnv`: a full config blob
|
||||||
|
* carried in one env var (opencode's `OPENCODE_CONFIG_CONTENT`).
|
||||||
|
* `configDir`: a generated config file under an isolated, dir-redirect-env-
|
||||||
|
* pointed directory so the user's real CLI config is never touched
|
||||||
|
* (codex's `CODEX_HOME`/`config.toml`, pi/omp's `PI_CONFIG_DIR`, grok's
|
||||||
|
* `GROK_HOME`/`config.toml`). `unsupported`: no known mechanism
|
||||||
|
* (antigravity) — the toolbar entry stays disabled for this CLI.
|
||||||
|
*
|
||||||
|
* ⚠️ grok was ORIGINALLY declared as `env` kind (`GROK_BASE_URL`/
|
||||||
|
* `GROK_MODEL`/`XAI_API_KEY`) — that recipe was WRONG, not just unverified:
|
||||||
|
* live-tested against a real grok binary, it produced "Not signed in",
|
||||||
|
* because those env vars are not grok's real custom-endpoint mechanism at
|
||||||
|
* all. The real one is a `[model.<name>]` block in a `config.toml` under
|
||||||
|
* `GROK_HOME` (verified against xAI's own docs), same shape as codex/pi/
|
||||||
|
* omp — this is why the confidence table in docs/custom-model-endpoints-plan.md exists:
|
||||||
|
* "researched" web docs can still be plausible-sounding and wrong.
|
||||||
|
*
|
||||||
|
* Every env var name this introduces that can redirect a session's
|
||||||
|
* traffic MUST also appear in `privilegedEnvKeys` above, exactly like
|
||||||
|
* `DEEPSEEK_BASE_URL` — a non-granted multi-user owner redirecting a
|
||||||
|
* session to their own endpoint is a credential-exfiltration path, not
|
||||||
|
* just a mischief redirect.
|
||||||
|
*
|
||||||
|
* `launchModel` is the value the entry's own `model` launch param must carry
|
||||||
|
* for the CLI to SELECT the injected provider, as a template where
|
||||||
|
* `{modelId}` is the chosen model id. Writing the config file is not enough
|
||||||
|
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
|
||||||
|
* provider and reports "No API key found for the selected model") or for
|
||||||
|
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
|
||||||
|
* declares). Absent = the config alone selects the model (claude's env vars,
|
||||||
|
* opencode's blob, codex's top-level `model` key). Applied by the session's
|
||||||
|
* respawn options through the entry's `legacyConfigField`, never by id.
|
||||||
|
*/
|
||||||
|
customModelInjection:
|
||||||
|
| { kind: 'env'; baseUrlVar: string; apiKeyVar: string; modelVars: string[]; launchModel?: string }
|
||||||
|
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
|
||||||
|
| {
|
||||||
|
kind: 'configDir';
|
||||||
|
dirEnvVar: string;
|
||||||
|
fileName: string;
|
||||||
|
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'unsupported' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Read/write-array store for user-configured custom OpenAI-compatible
|
||||||
|
* model endpoints (local or cloud — docs/custom-model-endpoints-plan.md). Same
|
||||||
|
* shape as `remote-hosts.ts` / `webview-store.ts`: `~/.codeman/custom-model-hosts.json`
|
||||||
|
* holding a plain array, read/written whole. The file can hold API keys, so it is
|
||||||
|
* written 0600 via tmp+rename like `intents.json` (`mode` on `writeFile` applies only
|
||||||
|
* to a file being created; the rename is what keeps an existing file's bytes and
|
||||||
|
* mode from ever being observable half-written or world-readable).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const CUSTOM_MODEL_HOSTS_FILE = 'custom-model-hosts.json';
|
||||||
|
|
||||||
|
export type CustomModelAuthStyle = 'bearer' | 'api-key';
|
||||||
|
|
||||||
|
export interface CustomModelHost {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Root URL, local or cloud — e.g. "http://192.168.1.50:8080" or an Azure AI Foundry URL. */
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
/**
|
||||||
|
* Defaults to 'bearer' (the common `Authorization: Bearer` convention — matches
|
||||||
|
* llama.cpp, OpenAI-compatible servers, and most gateways). Pick 'api-key' for
|
||||||
|
* endpoints that specifically want the `api-key` header, e.g. Azure AI Foundry.
|
||||||
|
*
|
||||||
|
* ⚠️ There is deliberately NO 'both' option. An earlier design sent BOTH headers
|
||||||
|
* on every discovery request on the theory that an unused header is harmless —
|
||||||
|
* live-tested against a real llama-swap server, sending both reliably HUNG the
|
||||||
|
* request indefinitely (reproduced 3× — Bearer alone: ~500ms, api-key alone:
|
||||||
|
* ~600ms, both together: no response inside a 15s timeout). Whatever auth
|
||||||
|
* middleware some servers run apparently does not handle two simultaneous
|
||||||
|
* credential conventions gracefully, so "send everything and let the server
|
||||||
|
* ignore what it doesn't need" is not a safe default — it can silently turn a
|
||||||
|
* working endpoint into one that always times out.
|
||||||
|
*/
|
||||||
|
authStyle?: CustomModelAuthStyle;
|
||||||
|
models?: string[];
|
||||||
|
lastDiscoveredAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customModelHostsPath(configDir: string): string {
|
||||||
|
return join(configDir, CUSTOM_MODEL_HOSTS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCustomModelHosts(configDir: string): Promise<CustomModelHost[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(customModelHostsPath(configDir), 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as CustomModelHost[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeCustomModelHosts(configDir: string, hosts: CustomModelHost[]): Promise<void> {
|
||||||
|
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||||
|
const target = customModelHostsPath(configDir);
|
||||||
|
const tmp = `${target}.${process.pid}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(hosts, null, 2), { mode: 0o600 });
|
||||||
|
await fs.rename(tmp, target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The one IO wrapper around `custom-model-injection.ts`'s pure
|
||||||
|
* `ConfigDirInjection` output — deliberately split out so that file, the
|
||||||
|
* discovery routes, and `scripts/test-local-llm-harnesses.ts` (via tsx) can
|
||||||
|
* all share EXACTLY one "write these files, merge this env" implementation.
|
||||||
|
* Before this existed, the route and the standalone script each carried
|
||||||
|
* their own copy of this logic, which is exactly the kind of drift the CLI
|
||||||
|
* registry's "declare once, consume everywhere" design exists to prevent —
|
||||||
|
* see docs/custom-model-endpoints-plan.md and the "dynamic to support
|
||||||
|
* cli-registry changes" requirement it was written against.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chmodSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
import type { CliEntry } from './config/cli-registry/types.js';
|
||||||
|
import {
|
||||||
|
buildCustomModelInjection,
|
||||||
|
type ConfigDirInjection,
|
||||||
|
type CustomModelEndpoint,
|
||||||
|
} from './custom-model-injection.js';
|
||||||
|
|
||||||
|
/** Where a session's isolated `configDir`-kind files live: never the user's real CLI config path. */
|
||||||
|
export function customModelConfigDir(sessionId: string): string {
|
||||||
|
return join(dataPath('custom-model-configs'), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.).
|
||||||
|
*
|
||||||
|
* 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> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
|
||||||
|
export function removeConfigDir(dir: string | undefined): void {
|
||||||
|
if (!dir) return;
|
||||||
|
try {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What applying an endpoint to a session yields, ready for `Session.setCustomModel()`. */
|
||||||
|
export interface AppliedCustomModel {
|
||||||
|
envOverrides: Record<string, string>;
|
||||||
|
envKeys: string[];
|
||||||
|
configDir?: string;
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute (and for the `configDir` kind, write) everything a session needs to run
|
||||||
|
* against `endpoint`/`modelId`. Returns undefined for a CLI with no mechanism.
|
||||||
|
*
|
||||||
|
* Idempotent on purpose: the boot-recovery path calls it again for a session that
|
||||||
|
* was already pointed at an endpoint, so the config files are rewritten in place
|
||||||
|
* (same content) and the env values, which are never persisted because they carry
|
||||||
|
* the API key, are re-derived from the endpoint store instead.
|
||||||
|
*/
|
||||||
|
export function applyCustomModelInjection(
|
||||||
|
entry: Pick<CliEntry, 'capabilities'>,
|
||||||
|
endpoint: CustomModelEndpoint,
|
||||||
|
modelId: string,
|
||||||
|
sessionId: string
|
||||||
|
): AppliedCustomModel | undefined {
|
||||||
|
const injection = buildCustomModelInjection(entry, endpoint, modelId);
|
||||||
|
if (injection.kind === 'unsupported') return undefined;
|
||||||
|
if (injection.kind === 'env') {
|
||||||
|
return {
|
||||||
|
envOverrides: injection.envOverrides,
|
||||||
|
envKeys: Object.keys(injection.envOverrides),
|
||||||
|
launchModel: injection.launchModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const configDir = customModelConfigDir(sessionId);
|
||||||
|
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||||
|
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pure builder for the Custom Model Endpoint Profiles feature
|
||||||
|
* (docs/custom-model-endpoints-plan.md): turns a CLI registry entry's
|
||||||
|
* `capabilities.customModelInjection` declaration, a configured endpoint,
|
||||||
|
* and a chosen model id into the concrete env vars / config-file content
|
||||||
|
* that would redirect that CLI's session at the endpoint.
|
||||||
|
*
|
||||||
|
* No IO here on purpose (mirrors `session-cli-builder.ts`) — a caller
|
||||||
|
* writes `ConfigDirInjection.files` to disk under an isolated per-session
|
||||||
|
* directory and points `dirEnvVar` at it; this module only computes what
|
||||||
|
* those files/env vars should contain.
|
||||||
|
*
|
||||||
|
* Confidence: `claude` and `opencode` are verified end-to-end against a real
|
||||||
|
* llama-swap server (a real "hello world" reply came back). `codex`'s
|
||||||
|
* config.toml STRUCTURE is now verified (an earlier `[model].default` table
|
||||||
|
* shape was rejected by a real codex binary with "invalid type: map,
|
||||||
|
* expected a string" — caught by `scripts/test-local-llm-harnesses.ts`),
|
||||||
|
* but `wire_api = "responses"` is the only value codex still accepts
|
||||||
|
* (support for `"chat"` was dropped in Feb 2026), and a plain OpenAI
|
||||||
|
* Chat-Completions server (llama.cpp, llama-swap, most local setups) does
|
||||||
|
* NOT implement the Responses API — so codex may still fail at the
|
||||||
|
* PROTOCOL level even with a correctly-shaped config file. That gap is
|
||||||
|
* real and current, not a stale warning; see docs/custom-model-endpoints-plan.md. The rest
|
||||||
|
* (gemini/pi/grok/deepseek/omp) have their ONE-SHOT INVOCATION flags
|
||||||
|
* confirmed against real installed binaries' own `--help` output, but
|
||||||
|
* their custom-endpoint env/config conventions remain web-researched,
|
||||||
|
* unverified.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CliEntry } from './config/cli-registry/types.js';
|
||||||
|
|
||||||
|
export interface CustomModelEndpoint {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Root URL, no trailing slash required — e.g. "http://192.168.1.50:8080" or an Azure AI Foundry URL. */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Falls back to a harmless placeholder for endpoints (llama.cpp) that don't check it. */
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvInjection {
|
||||||
|
kind: 'env';
|
||||||
|
/** Ready to merge into a session's envOverrides. */
|
||||||
|
envOverrides: Record<string, string>;
|
||||||
|
/** See {@link ConfigDirInjection.launchModel}. */
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDirInjection {
|
||||||
|
kind: 'configDir';
|
||||||
|
/** Env var that must be set to the directory the caller writes `files` under. */
|
||||||
|
dirEnvVar: string;
|
||||||
|
files: Array<{ relPath: string; content: string }>;
|
||||||
|
/**
|
||||||
|
* Env vars the written config file REFERENCES by name rather than embedding a
|
||||||
|
* literal value (codex's `env_key = "..."` convention: config.toml never carries
|
||||||
|
* the API key itself, only the name of an env var codex reads it from). Merge
|
||||||
|
* these into the session's envOverrides alongside `dirEnvVar` — never skip them,
|
||||||
|
* or the config points at a credential that was never actually set.
|
||||||
|
*/
|
||||||
|
extraEnv?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* The value the CLI's `model` launch param must carry for it to SELECT the injected
|
||||||
|
* provider (pi/omp: `custom/<modelId>`; grok: the `[model.<name>]` block name). Absent
|
||||||
|
* when the config alone selects the model. Rendered from the registry entry's
|
||||||
|
* `customModelInjection.launchModel` template, never hand-built per CLI.
|
||||||
|
*/
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsupportedInjection {
|
||||||
|
kind: 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomModelInjectionResult = EnvInjection | ConfigDirInjection | UnsupportedInjection;
|
||||||
|
|
||||||
|
const DEFAULT_API_KEY = 'local-dummy-key';
|
||||||
|
|
||||||
|
/** Normalizes a base URL to end in exactly one trailing `/v1`, for CLIs whose config expects the OpenAI-style suffix. */
|
||||||
|
export function withV1Suffix(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, '');
|
||||||
|
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-escapes a string for embedding in a TOML/YAML double-quoted scalar — a safe superset of both grammars' basic escapes. */
|
||||||
|
function quoted(value: string): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomModelInjection(
|
||||||
|
entry: Pick<CliEntry, 'capabilities'>,
|
||||||
|
endpoint: CustomModelEndpoint,
|
||||||
|
modelId: string
|
||||||
|
): CustomModelInjectionResult {
|
||||||
|
const cap = entry.capabilities.customModelInjection;
|
||||||
|
const apiKey = endpoint.apiKey?.trim() || DEFAULT_API_KEY;
|
||||||
|
|
||||||
|
switch (cap.kind) {
|
||||||
|
case 'env': {
|
||||||
|
const envOverrides: Record<string, string> = {
|
||||||
|
[cap.baseUrlVar]: endpoint.baseUrl,
|
||||||
|
[cap.apiKeyVar]: apiKey,
|
||||||
|
};
|
||||||
|
for (const modelVar of cap.modelVars) envOverrides[modelVar] = modelId;
|
||||||
|
return withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'configContentEnv': {
|
||||||
|
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||||
|
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'configDir': {
|
||||||
|
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||||
|
return withLaunchModel(
|
||||||
|
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||||
|
cap.launchModel,
|
||||||
|
modelId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unsupported':
|
||||||
|
return { kind: 'unsupported' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a `launchModel` template (`{modelId}` = the chosen id) onto an injection result. */
|
||||||
|
function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||||
|
result: T,
|
||||||
|
template: string | undefined,
|
||||||
|
modelId: string
|
||||||
|
): T {
|
||||||
|
if (!template) return result;
|
||||||
|
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfigContent(
|
||||||
|
template: 'opencode-json',
|
||||||
|
endpoint: CustomModelEndpoint,
|
||||||
|
modelId: string,
|
||||||
|
apiKey: string
|
||||||
|
): string {
|
||||||
|
switch (template) {
|
||||||
|
case 'opencode-json':
|
||||||
|
return JSON.stringify({
|
||||||
|
$schema: 'https://opencode.ai/config.json',
|
||||||
|
provider: {
|
||||||
|
custom: {
|
||||||
|
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||||
|
models: { [modelId]: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: `custom/${modelId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEX_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',
|
||||||
|
endpoint: CustomModelEndpoint,
|
||||||
|
modelId: string,
|
||||||
|
apiKey: string
|
||||||
|
): { content: string; extraEnv?: Record<string, string> } {
|
||||||
|
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||||
|
switch (template) {
|
||||||
|
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
|
||||||
|
// a string" (caught by scripts/test-local-llm-harnesses.ts against a real llama-swap
|
||||||
|
// server). The API key is NEVER a literal TOML field: codex's schema only supports
|
||||||
|
// `env_key`, the NAME of an env var it reads the credential from at runtime, so the
|
||||||
|
// actual value must ride along as an extra env var, never embedded in the file.
|
||||||
|
// ⚠️ `wire_api = "responses"` is the only value codex still accepts (it dropped
|
||||||
|
// `"chat"` support in Feb 2026) — a plain OpenAI Chat-Completions server (llama.cpp,
|
||||||
|
// llama-swap, most local setups) does NOT implement the Responses API, so this
|
||||||
|
// recipe may still fail at the PROTOCOL level even though the file now parses
|
||||||
|
// correctly. That is a real, currently-unresolved compatibility gap, not a syntax
|
||||||
|
// bug — track it before calling codex support done.
|
||||||
|
const content = [
|
||||||
|
`model = ${quoted(modelId)}`,
|
||||||
|
`model_provider = "custom"`,
|
||||||
|
'',
|
||||||
|
'[model_providers.custom]',
|
||||||
|
`name = "Custom Endpoint"`,
|
||||||
|
`base_url = ${quoted(baseUrl)}`,
|
||||||
|
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
|
||||||
|
`wire_api = "responses"`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
|
||||||
|
}
|
||||||
|
case 'pi-models-json':
|
||||||
|
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
|
||||||
|
// `{id: "..."}` objects, NOT an object keyed by model id — the earlier shape here
|
||||||
|
// silently loaded zero models ("No models available"), confirmed live. `authHeader:
|
||||||
|
// true` is required too: pi does not automatically send `Authorization: Bearer
|
||||||
|
// <apiKey>` just because `apiKey` is set (per the same doc) — without it, a real
|
||||||
|
// (non-llama.cpp) endpoint that actually checks the key would reject every request.
|
||||||
|
return {
|
||||||
|
content: JSON.stringify(
|
||||||
|
{
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
api: 'openai-completions',
|
||||||
|
authHeader: true,
|
||||||
|
models: [{ id: modelId }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'omp-models-yml':
|
||||||
|
// Mirrors the pi-models-json fix above (omp shares pi's config lineage per
|
||||||
|
// CLAUDE.md — it reads several of pi's own env vars): a flat list of bare model
|
||||||
|
// name strings under `models` is UNCONFIRMED against real omp docs (none are
|
||||||
|
// bundled with the binary) — this now matches pi's `{id: "..."}` object-list
|
||||||
|
// shape and adds `authHeader: true` on the same reasoning, but has not itself
|
||||||
|
// been live-tested the way pi's fix was. Verify before raising its confidence.
|
||||||
|
return {
|
||||||
|
content: `providers:\n custom:\n baseUrl: ${quoted(baseUrl)}\n apiKey: ${quoted(apiKey)}\n api: openai-completions\n authHeader: true\n models:\n - id: ${quoted(modelId)}\n`,
|
||||||
|
};
|
||||||
|
case 'grok-toml': {
|
||||||
|
// Verified against xAI's own docs (docs.x.ai/build/settings/reference): a
|
||||||
|
// `[model.<name>]` block, NOT plain env vars — an earlier `env`-kind recipe for
|
||||||
|
// grok was wrong, not just unverified (see the customModelInjection doc comment
|
||||||
|
// in cli-registry/types.ts). `api_backend = "chat_completions"` is explicitly
|
||||||
|
// supported (unlike codex, which dropped it after Feb 2026), so this one CAN
|
||||||
|
// talk to a plain OpenAI-compatible server directly. `env_key` reuses grok's own
|
||||||
|
// documented fallback var name (XAI_API_KEY) rather than inventing a new one.
|
||||||
|
const content = [
|
||||||
|
`[model.${GROK_CUSTOM_MODEL_NAME}]`,
|
||||||
|
`model = ${quoted(modelId)}`,
|
||||||
|
`base_url = ${quoted(baseUrl)}`,
|
||||||
|
`name = "Custom Endpoint"`,
|
||||||
|
`env_key = "XAI_API_KEY"`,
|
||||||
|
`api_backend = "chat_completions"`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
return { content, extraEnv: { XAI_API_KEY: apiKey } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,64 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which existing case, if any, blocks adopting `container` at `containerWorkdir`.
|
||||||
|
*
|
||||||
|
* One container may back SEVERAL adopted cases, each pointing at a different
|
||||||
|
* directory inside it — that is the whole reason to adopt the same container
|
||||||
|
* twice, and it is safe because the in-container tmux session is named per
|
||||||
|
* SESSION (`dockerTmuxSessionName`, `codeman-dkr-<id8>`) and not per case, so a
|
||||||
|
* session teardown kills exactly one session and its siblings on the shared
|
||||||
|
* in-container tmux server are untouched. Nothing else reaches an adopted
|
||||||
|
* container's lifecycle either: stop/remove throw at the builder, recreate
|
||||||
|
* refuses `owned === false`, and the orphan reaper filters on the
|
||||||
|
* `codeman.managed=1` label that only Codeman-created containers carry.
|
||||||
|
*
|
||||||
|
* So the conflicts that remain are NOT about the tmux server:
|
||||||
|
* - `owned-case` the container backs a case Codeman CREATED, whose lifecycle
|
||||||
|
* it owns; a recreate or delete there would destroy the
|
||||||
|
* adopted case's container out from under it.
|
||||||
|
* - `other-owner` already adopted by a different user. Adoption hands out a
|
||||||
|
* shell inside someone else's container, so it stays scoped.
|
||||||
|
* - `duplicate` same container AND same directory: the second case would
|
||||||
|
* behave identically to the first, so name the first instead
|
||||||
|
* of silently creating a twin. A DIFFERENT directory is the
|
||||||
|
* supported case and returns null.
|
||||||
|
*/
|
||||||
|
export type AdoptContainerConflict =
|
||||||
|
| { kind: 'owned-case'; caseName: string }
|
||||||
|
| { kind: 'other-owner'; caseName: string }
|
||||||
|
| { kind: 'duplicate'; caseName: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function classifyAdoptContainerConflict(params: {
|
||||||
|
container: string;
|
||||||
|
/** Directory inside the container this adoption targets (already defaulted). */
|
||||||
|
containerWorkdir: string;
|
||||||
|
existing: ReadonlyArray<
|
||||||
|
Pick<DockerCase, 'name' | 'container' | 'containerWorkdir' | 'hostWorkspacePath' | 'owned' | 'owner'>
|
||||||
|
>;
|
||||||
|
/** Owner visibility test (canAccessOwned bound to the caller). */
|
||||||
|
canAccess: (owner?: string) => boolean;
|
||||||
|
}): AdoptContainerConflict {
|
||||||
|
const { container, containerWorkdir, existing, canAccess } = params;
|
||||||
|
const sharing = existing.filter((item) => (item.container ?? dockerContainerName(item.name)) === container);
|
||||||
|
if (sharing.length === 0) return null;
|
||||||
|
|
||||||
|
// `owned` is optional and an ABSENT flag means owned (legacy cases predate the
|
||||||
|
// field), so this must test `!== false` rather than truthiness.
|
||||||
|
const owned = sharing.find((item) => item.owned !== false);
|
||||||
|
if (owned) return { kind: 'owned-case', caseName: owned.name };
|
||||||
|
|
||||||
|
const foreign = sharing.find((item) => !canAccess(item.owner));
|
||||||
|
if (foreign) return { kind: 'other-owner', caseName: foreign.name };
|
||||||
|
|
||||||
|
const twin = sharing.find((item) => (item.containerWorkdir ?? item.hostWorkspacePath) === containerWorkdir);
|
||||||
|
if (twin) return { kind: 'duplicate', caseName: twin.name };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An ADOPTED container is one the user built and runs themselves. Codeman may
|
* An ADOPTED container is one the user built and runs themselves. Codeman may
|
||||||
* only exec into it; it must never create, start, stop, restart or remove it.
|
* only exec into it; it must never create, start, stop, restart or remove it.
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import { realpathSync } from 'node:fs';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, normalize, sep } from 'node:path';
|
import { join, normalize, sep } from 'node:path';
|
||||||
import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js';
|
import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js';
|
||||||
|
import type { SessionRemote } from './types/session.js';
|
||||||
|
|
||||||
export interface GeneratedArtifactRegistrationOptions {
|
export interface GeneratedArtifactRegistrationOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
sessionWorkingDir: string;
|
sessionWorkingDir: string;
|
||||||
|
/** Remote (SSH) case: the path lives on the remote host (see attachment-registry). */
|
||||||
|
remote?: SessionRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerGeneratedArtifactAttachment(
|
export async function registerGeneratedArtifactAttachment(
|
||||||
@@ -26,19 +29,30 @@ export async function registerGeneratedArtifactAttachment(
|
|||||||
// Decide trust on the symlink-resolved path. If it can't be resolved, fall
|
// Decide trust on the symlink-resolved path. If it can't be resolved, fall
|
||||||
// back to the strict force-confined policy (registration will 404 a missing
|
// back to the strict force-confined policy (registration will 404 a missing
|
||||||
// file anyway).
|
// file anyway).
|
||||||
let forceWorkspaceConfinement = true;
|
//
|
||||||
try {
|
// A remote case keeps that strict policy unconditionally: the well-known Codex
|
||||||
const resolvedPath = realpathSync(options.filePath);
|
// artifact directories are anchored at THIS host's home, which says nothing about
|
||||||
forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
// a remote home, so only a file inside the remote workspace is trusted here.
|
||||||
} catch {
|
const resolvedPath = options.remote ? undefined : tryRealpath(options.filePath);
|
||||||
// Keep force confinement.
|
const forceWorkspaceConfinement = !resolvedPath
|
||||||
}
|
? true
|
||||||
|
: !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
||||||
return registerExternalAttachment(options.sessionId, options.filePath, {
|
return registerExternalAttachment(options.sessionId, options.filePath, {
|
||||||
sessionWorkingDir: options.sessionWorkingDir,
|
sessionWorkingDir: options.sessionWorkingDir,
|
||||||
forceWorkspaceConfinement,
|
forceWorkspaceConfinement,
|
||||||
|
remote: options.remote,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `realpathSync` without the throw — undefined when the path does not resolve. */
|
||||||
|
function tryRealpath(path: string): string | undefined {
|
||||||
|
try {
|
||||||
|
return realpathSync(path);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Well-known Codex generated-artifact directories, anchored at the user's home. */
|
/** Well-known Codex generated-artifact directories, anchored at the user's home. */
|
||||||
function codexGeneratedDirs(): string[] {
|
function codexGeneratedDirs(): string[] {
|
||||||
const home = homedir();
|
const home = homedir();
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ export interface RespawnPaneOptions {
|
|||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Env vars to REMOVE from the tmux session (`setenv -u`) before `envOverrides` is
|
||||||
|
* applied. `setenv` persists at the tmux-session level and is inherited by
|
||||||
|
* `respawn-pane`, so a key that merely disappears from `envOverrides` stays set
|
||||||
|
* for the relaunched CLI; clearing a custom-model selection has to name it.
|
||||||
|
*/
|
||||||
|
unsetEnvKeys?: string[];
|
||||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Remote (SSH) file access for remote-SSH cases.
|
||||||
|
*
|
||||||
|
* A remote case's `workingDir` is an absolute path on ANOTHER host
|
||||||
|
* (`Session.workingDir = RemoteCase.remotePath`, see docs/remote-sessions.md). Every
|
||||||
|
* file route used to read it with local `fs`, which cannot work: the local
|
||||||
|
* `realpathSync` in `validateSessionFilePath` fails first, so the request died as a
|
||||||
|
* 404 "File not found" before a byte was read (#415). This module is the ONE place
|
||||||
|
* that reads remote bytes, mirroring how `remote-hosts.ts` is the one place that
|
||||||
|
* builds an ssh command line.
|
||||||
|
*
|
||||||
|
* Connection options come from `buildSshConnectionArgs()` — never a hand-built ssh
|
||||||
|
* line (the COD-107 discipline in docs/remote-sessions.md) — so a proxied,
|
||||||
|
* custom-port or jump-hosted case reaches its files with exactly the credentials the
|
||||||
|
* launch used, and `BatchMode=yes` means a host that needs a passphrase fails fast
|
||||||
|
* instead of hanging on a prompt nothing can answer.
|
||||||
|
*
|
||||||
|
* ⚠️ The path is the injection surface: it arrives from the browser (`?path=`). It is
|
||||||
|
* always interpolated as a single `shellescape`d token, and the whole remote command
|
||||||
|
* is itself shellescaped into the ssh line, so the local shell and the remote shell
|
||||||
|
* each see one opaque argument. Never build a command here by concatenating a raw
|
||||||
|
* path into the string.
|
||||||
|
*
|
||||||
|
* Read-only by design: previews, text reads and streaming. Writing to a remote file
|
||||||
|
* is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the
|
||||||
|
* office-conversion/thumbnail paths that would need the bytes on the server's disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec, spawn } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { PassThrough, type Readable } from 'node:stream';
|
||||||
|
import type { SessionRemote } from './types/session.js';
|
||||||
|
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||||
|
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bound on the probe (realpath + stat) round trip. The connect itself is already
|
||||||
|
* bounded by `buildSshConnectionArgs`'s default `-o ConnectTimeout=10`; this covers
|
||||||
|
* a host that accepts the TCP connection and then never answers.
|
||||||
|
*/
|
||||||
|
const REMOTE_PROBE_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** Bound on a buffered remote read (`cat`), on top of the caller's own size cap. */
|
||||||
|
const REMOTE_READ_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Slack over the caller's byte cap so a file exactly at the limit still fits. */
|
||||||
|
const READ_BUFFER_SLACK_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
/** Marker a probe prints when the path does not exist on the remote host. */
|
||||||
|
const NOT_FOUND_MARKER = 'n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker a probe prints when the path exists but could NOT be canonicalized (no
|
||||||
|
* `readlink -f`, and the portable fallback hit its hop cap or a `readlink` failure).
|
||||||
|
* Parsed as `null`, i.e. 404: a path whose real target is unknown must never be
|
||||||
|
* served, because every containment and blocklist check runs on the resolved path.
|
||||||
|
*/
|
||||||
|
const UNRESOLVABLE_MARKER = 'x';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths per ssh round trip. The whole remote script is ONE shellescaped argument,
|
||||||
|
* and Linux caps a single argv string at 128 KiB, so a 100-entry attachment history
|
||||||
|
* of long paths is split rather than risking `E2BIG` on the local `sh`.
|
||||||
|
*/
|
||||||
|
const REMOTE_PROBE_CHUNK_SIZE = 40;
|
||||||
|
|
||||||
|
/** Symlink hops the portable resolver follows before giving up (Linux uses 40). */
|
||||||
|
const REMOTE_SYMLINK_MAX_HOPS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under vitest no real ssh connection may ever be opened (mirrors
|
||||||
|
* `checkRemoteTmuxAvailable` and friends in remote-hosts.ts). The route tests mock
|
||||||
|
* this module, so nothing reaches here today; this is what keeps the NEXT
|
||||||
|
* remote-session test that touches a file route from opening a connection from CI.
|
||||||
|
* A clear 502-shaped error, never a fake success: there are no fake bytes to return.
|
||||||
|
*/
|
||||||
|
function assertNotUnderTest(): void {
|
||||||
|
if (process.env.VITEST) {
|
||||||
|
throw new RemoteFileAccessError('remote file access is disabled under test');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a remote path turned out to be. `other` = symlink/socket/fifo/device. */
|
||||||
|
export type RemotePathKind = 'file' | 'directory' | 'other';
|
||||||
|
|
||||||
|
export interface RemoteProbe {
|
||||||
|
/** The path with symlinks resolved on the REMOTE host. */
|
||||||
|
realPath: string;
|
||||||
|
kind: RemotePathKind;
|
||||||
|
/** Size in bytes (0 for anything that is not a regular file). */
|
||||||
|
size: number;
|
||||||
|
/** mtime in ms since epoch (0 when the remote `stat` reported none). */
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A remote file access failed for a reason that is NOT "the file is missing" —
|
||||||
|
* unreachable host, timeout, ssh error, unexpected probe output. Callers map this to
|
||||||
|
* a 5xx with the remote reason in the message; a missing file is reported separately
|
||||||
|
* as `null`/404 so the two cannot be confused.
|
||||||
|
*/
|
||||||
|
export class RemoteFileAccessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'RemoteFileAccessError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a remote shell command in the shared, shellescaped ssh line.
|
||||||
|
*
|
||||||
|
* The single entry point for "run this on the remote host": connection args (port,
|
||||||
|
* identity, jump host, SOCKS ProxyCommand, extra `-o`) all come from
|
||||||
|
* `buildSshConnectionArgs`, and the command is ONE shellescaped token, so a path with
|
||||||
|
* spaces, quotes or `$(…)` cannot escape into the ssh command line.
|
||||||
|
*/
|
||||||
|
export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: string): string {
|
||||||
|
return [...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(shellCommand)].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `realpath + stat + existence` for one or more paths, in a SINGLE ssh round trip.
|
||||||
|
*
|
||||||
|
* One call instead of three matters: without a shared connection (no ControlMaster)
|
||||||
|
* every extra `ssh` is a fresh handshake, and the file routes need the path AND the
|
||||||
|
* workspace root canonicalized to compare them.
|
||||||
|
*
|
||||||
|
* Output format: the script first prints a lone NUL, then one NUL-terminated record
|
||||||
|
* per path, `<index>|n` (missing), `<index>|x` (exists but cannot be canonicalized) or
|
||||||
|
* `<index>|kind|size|mtime|realPath`. Records are keyed by INDEX and separated by NUL
|
||||||
|
* rather than newline so that a remote filename containing a newline cannot shift the
|
||||||
|
* alignment, and the leading NUL is what separates a login banner or an eager rc-file
|
||||||
|
* `echo` (which land before the script runs) from the records without any "last N
|
||||||
|
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
||||||
|
*
|
||||||
|
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
||||||
|
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
||||||
|
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
||||||
|
* (which the systems lacking `-f` do have) for a bounded number of hops. A path the
|
||||||
|
* fallback cannot resolve prints `x`, never the unresolved string: every containment
|
||||||
|
* and blocklist check downstream runs on `realPath`, and an earlier version of this
|
||||||
|
* fallback returned the directory-resolved path with the final symlink still in it,
|
||||||
|
* so `ws/notes.txt -> ~/.ssh/id_rsa` passed containment while `cat` served the key.
|
||||||
|
*/
|
||||||
|
export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||||
|
const probes = paths.map((path, index) => `probe ${index} ${shellescape(path)}`).join('\n');
|
||||||
|
return [
|
||||||
|
'resolve_last() {',
|
||||||
|
' q=$1',
|
||||||
|
' hops=0',
|
||||||
|
' while :; do',
|
||||||
|
' d=$(cd -P "$(dirname "$q")" 2>/dev/null && pwd -P) || return 1',
|
||||||
|
' q=$d/$(basename "$q")',
|
||||||
|
' [ -L "$q" ] || break',
|
||||||
|
' hops=$((hops + 1))',
|
||||||
|
` [ "$hops" -le ${REMOTE_SYMLINK_MAX_HOPS} ] || return 1`,
|
||||||
|
' l=$(readlink "$q" 2>/dev/null) || return 1',
|
||||||
|
' [ -n "$l" ] || return 1',
|
||||||
|
' case $l in /*) q=$l ;; *) q=$d/$l ;; esac',
|
||||||
|
' done',
|
||||||
|
' if [ -d "$q" ]; then q=$(cd -P "$q" 2>/dev/null && pwd -P) || return 1; fi',
|
||||||
|
' printf %s "$q"',
|
||||||
|
'}',
|
||||||
|
'probe() {',
|
||||||
|
' i=$1',
|
||||||
|
' p=$2',
|
||||||
|
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
||||||
|
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||||
|
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||||
|
' if [ -d "$r" ]; then t=d; elif [ -f "$r" ]; then t=f; else t=o; fi',
|
||||||
|
' s=0',
|
||||||
|
' if [ "$t" = f ]; then s=$(stat -c %s "$r" 2>/dev/null || stat -f %z "$r" 2>/dev/null); [ -n "$s" ] || s=0; fi',
|
||||||
|
' m=$(stat -c %Y "$r" 2>/dev/null || stat -f %m "$r" 2>/dev/null || printf 0)',
|
||||||
|
` printf '%s|%s|%s|%s|%s\\0' "$i" "$t" "$s" "$m" "$r"`,
|
||||||
|
'}',
|
||||||
|
"printf '\\0'",
|
||||||
|
probes,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one probe record (index prefix already stripped). `null` for the not-found
|
||||||
|
* and unresolvable markers or anything malformed.
|
||||||
|
*/
|
||||||
|
export function parseRemoteProbeRecord(record: string): RemoteProbe | null {
|
||||||
|
if (!record || record === NOT_FOUND_MARKER || record === UNRESOLVABLE_MARKER) return null;
|
||||||
|
|
||||||
|
const parts = record.split('|');
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
|
const [kindRaw, sizeRaw, mtimeRaw] = parts;
|
||||||
|
const kind: RemotePathKind | null =
|
||||||
|
kindRaw === 'f' ? 'file' : kindRaw === 'd' ? 'directory' : kindRaw === 'o' ? 'other' : null;
|
||||||
|
if (!kind) return null;
|
||||||
|
|
||||||
|
const realPath = parts.slice(3).join('|');
|
||||||
|
if (!realPath) return null;
|
||||||
|
|
||||||
|
const size = Number.parseInt(sizeRaw, 10);
|
||||||
|
const mtimeSeconds = Number.parseInt(mtimeRaw, 10);
|
||||||
|
return {
|
||||||
|
realPath,
|
||||||
|
kind,
|
||||||
|
size: Number.isFinite(size) && size > 0 ? size : 0,
|
||||||
|
mtimeMs: Number.isFinite(mtimeSeconds) && mtimeSeconds > 0 ? mtimeSeconds * 1000 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the output of {@link buildRemoteProbeCommand} into one entry per requested
|
||||||
|
* path, in order. Throws when a path's record is missing: that means the transport
|
||||||
|
* or the remote shell did something unexpected, and silently treating it as "not
|
||||||
|
* found" would turn an infrastructure failure into a wrong 404.
|
||||||
|
*
|
||||||
|
* Everything before the first NUL is the remote shell's own chatter (banner, rc-file
|
||||||
|
* output) and is discarded; records are matched by their index prefix, so neither
|
||||||
|
* extra output nor a newline inside a filename can shift the mapping.
|
||||||
|
*/
|
||||||
|
export function parseRemoteProbeOutput(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
|
||||||
|
const records = stdout.split('\0').slice(1);
|
||||||
|
const byIndex = new Map<number, string>();
|
||||||
|
for (const record of records) {
|
||||||
|
const match = /^(\d+)\|([\s\S]*)$/.exec(record);
|
||||||
|
if (!match) continue;
|
||||||
|
const index = Number.parseInt(match[1], 10);
|
||||||
|
if (!byIndex.has(index)) byIndex.set(index, match[2]);
|
||||||
|
}
|
||||||
|
return paths.map((_, index) => {
|
||||||
|
const record = byIndex.get(index);
|
||||||
|
if (record === undefined) {
|
||||||
|
throw new RemoteFileAccessError('remote host returned no usable file information');
|
||||||
|
}
|
||||||
|
return parseRemoteProbeRecord(record);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe one or more remote paths. Entry is `null` for a path that does not exist (or
|
||||||
|
* could not be canonicalized, which is refused the same way).
|
||||||
|
*
|
||||||
|
* Large batches are split into round trips of {@link REMOTE_PROBE_CHUNK_SIZE}, each
|
||||||
|
* counted against the global ssh limiter, so an attachment history of 100 entries
|
||||||
|
* costs three connections in sequence rather than 100 at once.
|
||||||
|
*/
|
||||||
|
export async function remoteProbePaths(
|
||||||
|
remote: SessionRemote,
|
||||||
|
paths: readonly string[]
|
||||||
|
): Promise<Array<RemoteProbe | null>> {
|
||||||
|
assertNotUnderTest();
|
||||||
|
const results: Array<RemoteProbe | null> = [];
|
||||||
|
for (let offset = 0; offset < paths.length; offset += REMOTE_PROBE_CHUNK_SIZE) {
|
||||||
|
const chunk = paths.slice(offset, offset + REMOTE_PROBE_CHUNK_SIZE);
|
||||||
|
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(chunk));
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
const result = await runWithRemoteSshLimit(() =>
|
||||||
|
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 256 * 1024 })
|
||||||
|
);
|
||||||
|
stdout = result.stdout;
|
||||||
|
} catch (err) {
|
||||||
|
throw new RemoteFileAccessError(
|
||||||
|
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push(...parseRemoteProbeOutput(stdout, chunk));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||||
|
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||||
|
assertNotUnderTest();
|
||||||
|
const command = buildRemoteFileCommand(remote, `cat ${shellescape(remotePath)}`);
|
||||||
|
try {
|
||||||
|
const result = await runWithRemoteSshLimit(() =>
|
||||||
|
execAsync(command, {
|
||||||
|
timeout: REMOTE_READ_TIMEOUT_MS,
|
||||||
|
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
|
||||||
|
encoding: 'buffer',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
||||||
|
} catch (err) {
|
||||||
|
throw new RemoteFileAccessError(`failed to read remote file: ${describeExecError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command that writes a remote file's bytes to stdout.
|
||||||
|
*
|
||||||
|
* ⚠️ Range reads use `tail -c +N | head -c L` (both POSIX, constant memory) because
|
||||||
|
* the alternative — `dd bs=1` — issues one read syscall per byte and would make video
|
||||||
|
* seeking unusable. The trade-off is that a `tail` failure (the file vanished
|
||||||
|
* mid-request) reports `head`'s exit status, i.e. a short body on an already-sent
|
||||||
|
* 206; the client retries. The uncompressed path (`cat`) reports its own failure
|
||||||
|
* correctly, so the streaming error path is still covered by the normal case.
|
||||||
|
*/
|
||||||
|
export function buildRemoteReadCommand(remotePath: string, range?: { start: number; end: number }): string {
|
||||||
|
const quoted = shellescape(remotePath);
|
||||||
|
if (!range) return `cat ${quoted}`;
|
||||||
|
const length = range.end - range.start + 1;
|
||||||
|
return `tail -c +${range.start + 1} ${quoted} | head -c ${length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteFileStream {
|
||||||
|
/** The remote file's bytes, streamed from the ssh child's stdout. */
|
||||||
|
stream: Readable;
|
||||||
|
/**
|
||||||
|
* Abort the transfer and reap the ssh child. The caller MUST call this when the
|
||||||
|
* HTTP request ends — especially on a client disconnect — or the `ssh` process
|
||||||
|
* keeps running (and holding a connection open) after nobody is reading it.
|
||||||
|
*/
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a remote file (optionally a byte range) as a Node Readable.
|
||||||
|
*
|
||||||
|
* Nothing is buffered in server memory: the bytes go from `ssh`'s stdout straight to
|
||||||
|
* the HTTP response, which is what makes a multi-GB remote video cost one pipe.
|
||||||
|
*/
|
||||||
|
export function remoteCreateReadStream(
|
||||||
|
remote: SessionRemote,
|
||||||
|
remotePath: string,
|
||||||
|
range?: { start: number; end: number }
|
||||||
|
): RemoteFileStream {
|
||||||
|
if (process.env.VITEST) {
|
||||||
|
// Same rule as the buffered calls, in stream form: the consumer sees the error
|
||||||
|
// through the stream's normal failure path instead of a connection attempt.
|
||||||
|
const stream = new PassThrough();
|
||||||
|
process.nextTick(() => stream.destroy(new RemoteFileAccessError('remote file access is disabled under test')));
|
||||||
|
return { stream, close: () => stream.destroy() };
|
||||||
|
}
|
||||||
|
const command = buildRemoteFileCommand(remote, buildRemoteReadCommand(remotePath, range));
|
||||||
|
const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
if (stderr.length < 2000) stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = child.stdout;
|
||||||
|
let ended = false;
|
||||||
|
stream.on('end', () => {
|
||||||
|
ended = true;
|
||||||
|
});
|
||||||
|
stream.on('error', () => {
|
||||||
|
ended = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err: Error) => {
|
||||||
|
stream.destroy(err);
|
||||||
|
});
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
// Only a truncated transfer is an error. A non-zero exit AFTER the body finished
|
||||||
|
// (e.g. a signal delivered as the last byte was flushed) must not destroy an
|
||||||
|
// already-complete response, or the browser reports a broken body for a file it
|
||||||
|
// received in full.
|
||||||
|
if (ended || code === 0 || code === null) return;
|
||||||
|
const detail = stderr.trim().split('\n')[0];
|
||||||
|
stream.destroy(new RemoteFileAccessError(`remote read failed (ssh exit ${code})${detail ? `: ${detail}` : ''}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
close(): void {
|
||||||
|
if (!stream.destroyed) stream.destroy();
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First useful line of an exec/stderr error, for a user-facing message.
|
||||||
|
*
|
||||||
|
* ⚠️ Never Node's `err.message`: for a failed `exec` it is `Command failed: <the whole
|
||||||
|
* ssh line>`, which carries the identity-file path and the probe script, and this
|
||||||
|
* string goes out in a 502 body. stderr, the timeout flag and the exit/spawn code are
|
||||||
|
* everything a user can act on.
|
||||||
|
*/
|
||||||
|
function describeExecError(err: unknown): string {
|
||||||
|
if (typeof err === 'object' && err !== null) {
|
||||||
|
const record = err as { stderr?: unknown; code?: unknown; killed?: unknown };
|
||||||
|
const stderr =
|
||||||
|
typeof record.stderr === 'string' ? record.stderr : Buffer.isBuffer(record.stderr) ? String(record.stderr) : '';
|
||||||
|
const line = stderr
|
||||||
|
.split('\n')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find((entry) => entry.length > 0);
|
||||||
|
if (line) return line.slice(0, 300);
|
||||||
|
if (record.killed) return 'timed out';
|
||||||
|
if (typeof record.code === 'number') return `ssh exit ${record.code}`;
|
||||||
|
if (typeof record.code === 'string') return `ssh could not be started (${record.code})`;
|
||||||
|
}
|
||||||
|
return 'unknown error';
|
||||||
|
}
|
||||||
+7
-1
@@ -147,8 +147,14 @@ export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): st
|
|||||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||||
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
|
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
|
||||||
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
|
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
|
||||||
|
*
|
||||||
|
* EXPORTED for `remote-files.ts` (#415, remote file access): that module wraps a
|
||||||
|
* remote shell command in the ssh line built by `buildSshConnectionArgs()`, so it
|
||||||
|
* needs the same escaping discipline for the remote command itself and for every
|
||||||
|
* path interpolated into it. A third private copy of this function is exactly how
|
||||||
|
* two escaping implementations drift apart.
|
||||||
*/
|
*/
|
||||||
function shellescape(str: string): string {
|
export function shellescape(str: string): string {
|
||||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
|
||||||
|
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
|
||||||
|
* the buffered text read).
|
||||||
|
*
|
||||||
|
* Two paths can fan those out without a human behind each one:
|
||||||
|
*
|
||||||
|
* - `GET /api/sessions/:id/attachments` resolves every history entry (up to
|
||||||
|
* `ATTACHMENT_HISTORY_LIMIT`, 100), and the attachments drawer re-runs it on every
|
||||||
|
* `attachment:detected` event while it is open, which is exactly when an agent is
|
||||||
|
* writing files. The route now batches the probes, but a burst of drawers is still
|
||||||
|
* a burst.
|
||||||
|
* - A `codeman://attach?path=` magic link in terminal output registers the path
|
||||||
|
* fire-and-forget, once per distinct link per PTY chunk. In a remote session that
|
||||||
|
* output is written by a process on the remote host, so a prompt-injected agent can
|
||||||
|
* print hundreds of links and have the server fork one `ssh` per link, each holding
|
||||||
|
* a 20s probe timeout.
|
||||||
|
*
|
||||||
|
* Without a cap that is the fork-bomb shape `document-conversion-limiter.ts` exists to
|
||||||
|
* prevent, and it also trips OpenSSH's default `MaxStartups 10:30:100`, which starts
|
||||||
|
* dropping connections at ten unauthenticated handshakes. This is that limiter for
|
||||||
|
* ssh: a small fixed pool, FIFO queueing, and a slot handed straight to the next
|
||||||
|
* waiter on release so the active count can never exceed the cap under interleaved
|
||||||
|
* async resumption.
|
||||||
|
*
|
||||||
|
* Streams (`remoteCreateReadStream`) are deliberately NOT counted: one is opened per
|
||||||
|
* browser request and held for the life of a media playback, so four open videos
|
||||||
|
* would otherwise block every preview and the history list. They are already gated
|
||||||
|
* behind a counted probe (the guard re-probe runs first), so their spawn RATE is
|
||||||
|
* bounded here even though their concurrency is bounded by the browser.
|
||||||
|
*
|
||||||
|
* NOT re-entrant: never acquire from inside a task already holding a slot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max remote probes/reads allowed to run concurrently across the whole process.
|
||||||
|
* Override with CODEMAN_MAX_REMOTE_FILE_SSH (clamped to >= 1). Four keeps a burst
|
||||||
|
* well under OpenSSH's ten-handshake default.
|
||||||
|
*/
|
||||||
|
const MAX_CONCURRENT_REMOTE_SSH = (() => {
|
||||||
|
const raw = Number(process.env.CODEMAN_MAX_REMOTE_FILE_SSH);
|
||||||
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 4;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
/** Test/diagnostic hook: remote calls currently holding a slot. */
|
||||||
|
export function getActiveRemoteSshCount(): number {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/diagnostic hook: remote calls queued behind the cap. */
|
||||||
|
export function getQueuedRemoteSshCount(): number {
|
||||||
|
return waiters.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configured cap, so a test can assert against the real number. */
|
||||||
|
export function getRemoteSshLimit(): number {
|
||||||
|
return MAX_CONCURRENT_REMOTE_SSH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquire(): Promise<void> {
|
||||||
|
if (active < MAX_CONCURRENT_REMOTE_SSH) {
|
||||||
|
active++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(): void {
|
||||||
|
const next = waiters.shift();
|
||||||
|
if (next) {
|
||||||
|
// Hand the slot straight to the next waiter; `active` stays at the cap.
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
active--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
|
||||||
|
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
await acquire();
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Automatic session names from the first prompt.
|
||||||
|
*
|
||||||
|
* A new tab is born as `w3-myapp`, which says where it runs and nothing about
|
||||||
|
* what it is doing. Once the user submits a real prompt the tab can carry a
|
||||||
|
* title derived from it (`w3-myapp: fix the login redirect`), and this module
|
||||||
|
* holds the three pure pieces of that: a tracker that reconstructs the composer
|
||||||
|
* text from the keystrokes Codeman forwards, the title heuristic, and the
|
||||||
|
* prefix-preserving composition.
|
||||||
|
*
|
||||||
|
* Deliberately no LLM: the prompt already passes through the input boundary,
|
||||||
|
* so a local title is private, deterministic and identical for every CLI.
|
||||||
|
*
|
||||||
|
* ⚠️ The tracker sits on the raw keystroke stream, which carries far more than
|
||||||
|
* the prompt: cursor keys, mouse reports Codeman forwards to the CLI, bracketed
|
||||||
|
* pastes, Alt chords, the bare Esc that interrupts a turn. Every one of those
|
||||||
|
* once named a tab something wrong (a lone Esc ate the next prompt's first
|
||||||
|
* character; a wheel tick mid-word dropped the first half of the prompt), so
|
||||||
|
* the rules below are explicit per key. The model is a best-effort transcript:
|
||||||
|
* keys whose effect on the composer is knowable are mirrored, keys that leave
|
||||||
|
* the text alone are ignored, and keys that replace it with something the
|
||||||
|
* tracker cannot see (history recall) TAINT the draft so that Enter submits
|
||||||
|
* nothing rather than a fragment. A prompt that yields no title leaves the
|
||||||
|
* session eligible for the next one.
|
||||||
|
*
|
||||||
|
* Only user-originated input is fed here; the Session decides that. Ralph
|
||||||
|
* kick-starts, respawn `/clear`s, cron launches and approval answers all go
|
||||||
|
* through the same write paths and must never become a tab title.
|
||||||
|
*
|
||||||
|
* @module session-auto-name
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MAX_SESSION_NAME_LENGTH } from './config/terminal-limits.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest composer draft kept, in code points. The title is cut from the HEAD
|
||||||
|
* of the prompt, so once the cap is reached further text is counted rather
|
||||||
|
* than kept (backspaces consume that count first). Keeping the tail instead
|
||||||
|
* would turn a long paste into a title made of its last line.
|
||||||
|
*/
|
||||||
|
const MAX_PROMPT_BUFFER_CODE_POINTS = 8_192;
|
||||||
|
|
||||||
|
/** Longest escape sequence collected before the tracker gives up on it. */
|
||||||
|
const MAX_ESCAPE_SEQUENCE_LENGTH = 64;
|
||||||
|
|
||||||
|
/** Longest title, in code points, before it is cut with an ellipsis. */
|
||||||
|
const MAX_AUTO_NAME_CODE_POINTS = 72;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sentence boundary is only honoured this far into the prompt, or "e.g. fix
|
||||||
|
* this now" becomes "e.g." and "Ok. Fix the bug" becomes "Ok". Short enough
|
||||||
|
* that a CJK sentence (a dozen code points is a full request) still cuts.
|
||||||
|
*/
|
||||||
|
const MIN_SENTENCE_CODE_POINTS = 8;
|
||||||
|
|
||||||
|
/** A CSI sequence ends at its first byte in this range. */
|
||||||
|
const CSI_FINAL_BYTE = /[\x40-\x7e]/;
|
||||||
|
/** CSI parameter and intermediate bytes; anything else mid-sequence is malformed. */
|
||||||
|
const CSI_BODY_BYTE = /[\x20-\x3f]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/clear`, `/model opus`, `/ralph-loop:ralph-loop`: a slash followed by a
|
||||||
|
* command word and then whitespace or the end. A path (`/home/me/notes.txt
|
||||||
|
* what is this`) has a second slash where the whitespace should be and so is a
|
||||||
|
* prompt.
|
||||||
|
*/
|
||||||
|
const SLASH_COMMAND_PATTERN = /^\/[a-z][a-z0-9_:-]*(?:\s|$)/i;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CSI_SEQUENCE_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/g;
|
||||||
|
const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '。', '!', '?']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs the composer draft from forwarded keystrokes and reports each
|
||||||
|
* submitted prompt. Input arrives in arbitrary chunks (one keystroke, a paste,
|
||||||
|
* an agent's whole prompt plus Enter), so all state lives across calls.
|
||||||
|
*/
|
||||||
|
export class SubmittedPromptTracker {
|
||||||
|
private buffer = '';
|
||||||
|
private bufferCodePoints = 0;
|
||||||
|
/** Code points typed past the cap; backspaces eat these before real text. */
|
||||||
|
private overflow = 0;
|
||||||
|
/** Escape sequence in progress; a lone ESC means "just saw ESC". */
|
||||||
|
private sequence = '';
|
||||||
|
private inPaste = false;
|
||||||
|
/** The composer holds text the tracker never saw (history recall); Enter submits nothing. */
|
||||||
|
private tainted = false;
|
||||||
|
|
||||||
|
feed(data: string): string[] {
|
||||||
|
const submitted: string[] = [];
|
||||||
|
for (const ch of data) {
|
||||||
|
if (this.sequence) {
|
||||||
|
this.continueSequence(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\x1b') {
|
||||||
|
this.sequence = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.handleKey(ch, submitted);
|
||||||
|
}
|
||||||
|
// A chunk that ENDS in a lone ESC is the Esc key, not the start of a
|
||||||
|
// sequence: xterm hands each key's whole sequence to one write, and the
|
||||||
|
// programmatic senders (an approval deny sends exactly `\x1b`) send it
|
||||||
|
// alone. Leaving it pending would make the next prompt's first character
|
||||||
|
// look like an Alt chord and swallow it.
|
||||||
|
if (this.sequence === '\x1b') this.sequence = '';
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private continueSequence(ch: string): void {
|
||||||
|
if (this.sequence === '\x1b') {
|
||||||
|
if (ch === '[' || ch === 'O' || ch === ']' || ch === 'P') {
|
||||||
|
this.sequence += ch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sequence = ch === '\x1b' ? ch : '';
|
||||||
|
// Alt+Enter inserts a newline in the composer; every other Alt chord
|
||||||
|
// (word movement, Alt+B/F) leaves the text alone.
|
||||||
|
if (ch === '\r' || ch === '\n') this.appendSeparator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sequence += ch;
|
||||||
|
if (this.sequence.length > MAX_ESCAPE_SEQUENCE_LENGTH) {
|
||||||
|
// Not a sequence any terminal sends; what follows is unknowable, so the
|
||||||
|
// draft is tainted rather than titled after the tail of the garbage.
|
||||||
|
this.sequence = '';
|
||||||
|
this.tainted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = this.sequence[1];
|
||||||
|
if (kind === '[') {
|
||||||
|
if (CSI_FINAL_BYTE.test(ch)) {
|
||||||
|
const sequence = this.sequence;
|
||||||
|
this.sequence = '';
|
||||||
|
this.handleCsi(sequence);
|
||||||
|
} else if (!CSI_BODY_BYTE.test(ch)) {
|
||||||
|
// Malformed (an ESC [ followed by text): drop the sequence and let the
|
||||||
|
// character count as typed rather than swallowing up to 64 of them.
|
||||||
|
this.sequence = '';
|
||||||
|
this.handleKeyOrEscape(ch);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'O') {
|
||||||
|
// SS3 carries exactly one byte (application-mode cursor keys).
|
||||||
|
this.sequence = '';
|
||||||
|
if (ch === 'A' || ch === 'B') this.tainted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// OSC / DCS run to BEL or ST (ESC \).
|
||||||
|
if (ch === '\x07' || this.sequence.endsWith('\x1b\\')) this.sequence = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyOrEscape(ch: string): void {
|
||||||
|
if (ch === '\x1b') {
|
||||||
|
this.sequence = ch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only reached mid-chunk from a malformed sequence, where no submission can
|
||||||
|
// be reported; a stray Enter there resets the draft like any other Enter.
|
||||||
|
this.handleKey(ch, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCsi(sequence: string): void {
|
||||||
|
if (sequence === '\x1b[200~') {
|
||||||
|
this.inPaste = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sequence === '\x1b[201~') {
|
||||||
|
this.inPaste = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const final = sequence[sequence.length - 1];
|
||||||
|
// Up/Down (with or without modifiers) recall history: the composer now
|
||||||
|
// holds a line this tracker never saw. Everything else leaves the text as
|
||||||
|
// it is: Left/Right/Home/End, Delete (`3~`), Shift+Tab (`Z`), SGR mouse
|
||||||
|
// reports (`<…M`/`m`, forwarded on every wheel tick), focus reports.
|
||||||
|
if (final === 'A' || final === 'B') this.tainted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKey(ch: string, submitted: string[]): void {
|
||||||
|
const codePoint = ch.codePointAt(0) ?? 0;
|
||||||
|
if (this.inPaste) {
|
||||||
|
// Pasted newlines are newlines IN the composer, never Enter; they and
|
||||||
|
// the other controls (tabs) become a single separator.
|
||||||
|
if (codePoint < 0x20 || codePoint === 0x7f) this.appendSeparator();
|
||||||
|
else this.append(ch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (ch) {
|
||||||
|
case '\r': {
|
||||||
|
const prompt = this.tainted ? '' : this.buffer.trim();
|
||||||
|
if (prompt) submitted.push(prompt);
|
||||||
|
this.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case '\n':
|
||||||
|
// Ctrl+J, and the line feed the send-key route injects for Shift+Enter:
|
||||||
|
// a newline inside the composer, so the lines join with a separator.
|
||||||
|
this.appendSeparator();
|
||||||
|
return;
|
||||||
|
case '\x7f':
|
||||||
|
case '\x08':
|
||||||
|
this.backspace();
|
||||||
|
return;
|
||||||
|
case '\x17': // Ctrl+W: word rubout
|
||||||
|
this.killWord();
|
||||||
|
return;
|
||||||
|
case '\x15': // Ctrl+U: line discard
|
||||||
|
case '\x03': // Ctrl+C: clears the composer (or, empty, arms an exit)
|
||||||
|
this.reset();
|
||||||
|
return;
|
||||||
|
case '\x10': // Ctrl+P
|
||||||
|
case '\x0e': // Ctrl+N
|
||||||
|
case '\x12': // Ctrl+R: history search
|
||||||
|
case '\x1f': // Ctrl+_: undo
|
||||||
|
this.tainted = true;
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
// Tab (the @-mention completer, which only ever extends the token),
|
||||||
|
// cursor chords (Ctrl+A/E/B/F) and the rest of C0 leave the text alone.
|
||||||
|
if (codePoint < 0x20 || codePoint === 0x7f) return;
|
||||||
|
this.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One space between lines, never a run of them, and none at the start. */
|
||||||
|
private appendSeparator(): void {
|
||||||
|
if (this.overflow > 0) return;
|
||||||
|
if (!this.buffer || /\s$/.test(this.buffer)) return;
|
||||||
|
this.append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private append(ch: string): void {
|
||||||
|
if (this.bufferCodePoints >= MAX_PROMPT_BUFFER_CODE_POINTS) {
|
||||||
|
this.overflow += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffer += ch;
|
||||||
|
this.bufferCodePoints += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private backspace(): void {
|
||||||
|
if (this.overflow > 0) {
|
||||||
|
this.overflow -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.buffer) return;
|
||||||
|
const last = this.buffer.charCodeAt(this.buffer.length - 1);
|
||||||
|
const units = last >= 0xdc00 && last <= 0xdfff && this.buffer.length >= 2 ? 2 : 1;
|
||||||
|
this.buffer = this.buffer.slice(0, -units);
|
||||||
|
this.bufferCodePoints -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private killWord(): void {
|
||||||
|
this.overflow = 0;
|
||||||
|
this.buffer = this.buffer.replace(/\S+\s*$/u, '');
|
||||||
|
this.bufferCodePoints = Array.from(this.buffer).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
this.buffer = '';
|
||||||
|
this.bufferCodePoints = 0;
|
||||||
|
this.overflow = 0;
|
||||||
|
this.tainted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a submitted prompt into a title, or null when the prompt is not a task:
|
||||||
|
* empty, a slash command (`/clear`, `/model`), or a `!` shell escape.
|
||||||
|
*/
|
||||||
|
export function deriveAutoSessionName(prompt: string): string | null {
|
||||||
|
const text = prompt.replace(CSI_SEQUENCE_PATTERN, '').replace(CONTROL_CHAR_PATTERN, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!text || text.startsWith('!') || SLASH_COMMAND_PATTERN.test(text)) return null;
|
||||||
|
return truncateCodePoints(firstSentence(text), MAX_AUTO_NAME_CODE_POINTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first sentence, provided it is long enough to be one; a trailing full
|
||||||
|
* stop is dropped because a tab title is not a sentence.
|
||||||
|
*/
|
||||||
|
function firstSentence(text: string): string {
|
||||||
|
const codePoints = Array.from(text);
|
||||||
|
for (let i = MIN_SENTENCE_CODE_POINTS - 1; i < codePoints.length; i++) {
|
||||||
|
if (!SENTENCE_TERMINATORS.has(codePoints[i])) continue;
|
||||||
|
const next = codePoints[i + 1];
|
||||||
|
if (next !== undefined && !/\s/.test(next)) continue;
|
||||||
|
return codePoints
|
||||||
|
.slice(0, i + 1)
|
||||||
|
.join('')
|
||||||
|
.replace(/[.。]+$/, '');
|
||||||
|
}
|
||||||
|
return text.replace(/[.。]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cuts to `max` code points with an ellipsis, on a word boundary when one is near the end. */
|
||||||
|
function truncateCodePoints(text: string, max: number): string {
|
||||||
|
const codePoints = Array.from(text);
|
||||||
|
if (codePoints.length <= max) return text;
|
||||||
|
let cut = codePoints.slice(0, max - 1).join('');
|
||||||
|
const lastSpace = cut.lastIndexOf(' ');
|
||||||
|
if (lastSpace >= Math.floor(cut.length / 2)) cut = cut.slice(0, lastSpace);
|
||||||
|
return `${cut.trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a placeholder becomes: `<prefix>: <title>`, so the tab keeps its
|
||||||
|
* case identity and its `w<n>` counter (the tab strip already renders that
|
||||||
|
* form as the title alone, prefix in the tooltip, and the next-session counter
|
||||||
|
* still matches it). A session with no name at all just takes the title. The
|
||||||
|
* result honours `maxLength` in UTF-16 units, the unit the rename route caps.
|
||||||
|
*/
|
||||||
|
export function composeAutoSessionName(
|
||||||
|
currentName: string,
|
||||||
|
title: string,
|
||||||
|
maxLength = MAX_SESSION_NAME_LENGTH
|
||||||
|
): string {
|
||||||
|
const prefix = currentName.trim();
|
||||||
|
if (!prefix) return fitTitle(title, maxLength);
|
||||||
|
const room = maxLength - prefix.length - 2;
|
||||||
|
if (room <= 0) return prefix;
|
||||||
|
return `${prefix}: ${fitTitle(title, room)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fits a title into `maxUnits` UTF-16 units, ellipsis included. */
|
||||||
|
function fitTitle(title: string, maxUnits: number): string {
|
||||||
|
if (title.length <= maxUnits) return title;
|
||||||
|
let units = 0;
|
||||||
|
let keep = 0;
|
||||||
|
for (const codePoint of Array.from(title)) {
|
||||||
|
if (units + codePoint.length > maxUnits - 1) break;
|
||||||
|
units += codePoint.length;
|
||||||
|
keep += 1;
|
||||||
|
}
|
||||||
|
return truncateCodePoints(title, keep + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codeman's own `w<n>-<case>` / `s<n>-<case>` placeholders, the only names auto-naming replaces. */
|
||||||
|
export function isGeneratedSessionName(name: string): boolean {
|
||||||
|
return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
|
||||||
|
}
|
||||||
@@ -254,7 +254,14 @@ export class SessionManager extends EventEmitter {
|
|||||||
// future reader of state.json.
|
// future reader of state.json.
|
||||||
const state = session.toState();
|
const state = session.toState();
|
||||||
const envOverrides = session.getEnvOverridesForPersist();
|
const envOverrides = session.getEnvOverridesForPersist();
|
||||||
const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state;
|
// __customModel: same convention, the disk-only bookkeeping of a custom-model
|
||||||
|
// selection (env KEYS, config dir, launch model; never the injected values).
|
||||||
|
const customModel = session.getCustomModelForPersist();
|
||||||
|
const toStore = {
|
||||||
|
...state,
|
||||||
|
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
||||||
|
...(customModel ? { __customModel: customModel } : {}),
|
||||||
|
};
|
||||||
this.store.setSession(session.id, toStore as SessionState);
|
this.store.setSession(session.id, toStore as SessionState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+254
-9
@@ -23,7 +23,7 @@
|
|||||||
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
||||||
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
||||||
* @consumedby session-manager, web/server, respawn-controller
|
* @consumedby session-manager, web/server, respawn-controller
|
||||||
* @emits session:terminal, session:idle, session:working, session:completion, session:exit
|
* @emits session:terminal, session:idle, session:working, session:completion, session:promptSubmitted, session:exit
|
||||||
*
|
*
|
||||||
* @module session
|
* @module session
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +48,8 @@ import {
|
|||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
|
type CustomModelBookkeeping,
|
||||||
|
type CustomModelSelection,
|
||||||
type GeminiConfig,
|
type GeminiConfig,
|
||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
@@ -56,6 +58,8 @@ import {
|
|||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
|
type SessionNameSource,
|
||||||
|
type SessionWriteOptions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||||
@@ -119,6 +123,7 @@ import { SessionAutoOps } from './session-auto-ops.js';
|
|||||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||||
import { SessionTaskCache } from './session-task-cache.js';
|
import { SessionTaskCache } from './session-task-cache.js';
|
||||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||||
|
import { isGeneratedSessionName, SubmittedPromptTracker } from './session-auto-name.js';
|
||||||
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
||||||
import {
|
import {
|
||||||
sanitizeAttachmentHistory,
|
sanitizeAttachmentHistory,
|
||||||
@@ -421,6 +426,15 @@ export class Session extends EventEmitter {
|
|||||||
private _taskCache = new SessionTaskCache();
|
private _taskCache = new SessionTaskCache();
|
||||||
|
|
||||||
private _name: string;
|
private _name: string;
|
||||||
|
private _nameSource: SessionNameSource;
|
||||||
|
/**
|
||||||
|
* Reconstructs the composer draft from USER keystrokes so the first real
|
||||||
|
* prompt can name the tab. Fed only when a write says `fromUser`, and never
|
||||||
|
* for a CLI whose Enter runs a command rather than submitting a prompt
|
||||||
|
* (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`.
|
||||||
|
*/
|
||||||
|
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
|
||||||
|
private readonly _acceptsPrompts: boolean;
|
||||||
private ptyProcess: pty.IPty | null = null;
|
private ptyProcess: pty.IPty | null = null;
|
||||||
private _pid: number | null = null;
|
private _pid: number | null = null;
|
||||||
private _status: SessionStatus = 'idle';
|
private _status: SessionStatus = 'idle';
|
||||||
@@ -577,6 +591,20 @@ export class Session extends EventEmitter {
|
|||||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||||
private _effort: EffortLevel | undefined;
|
private _effort: EffortLevel | undefined;
|
||||||
|
|
||||||
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
|
||||||
|
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
|
||||||
|
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
|
||||||
|
// previous injection (remove exactly the env keys it added, delete a previous isolated
|
||||||
|
// config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`).
|
||||||
|
private _customModel: CustomModelBookkeeping | undefined;
|
||||||
|
|
||||||
|
// Env keys a retired custom-model selection injected that the NEXT respawn must
|
||||||
|
// `tmux setenv -u`. Deleting a key from `_envOverrides` alone does nothing to the
|
||||||
|
// tmux session, which keeps every `setenv` and hands it to `respawn-pane`, so the
|
||||||
|
// relaunched CLI would come back still pointed at the old endpoint (measured, see
|
||||||
|
// TmuxManager.applyEnvOverrides). Drained after a successful respawn.
|
||||||
|
private _pendingEnvUnsets = new Set<string>();
|
||||||
|
|
||||||
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
||||||
private readonly _tmuxHistoryLimit: number;
|
private readonly _tmuxHistoryLimit: number;
|
||||||
|
|
||||||
@@ -638,6 +666,12 @@ export class Session extends EventEmitter {
|
|||||||
workingDir: string;
|
workingDir: string;
|
||||||
mode?: SessionMode;
|
mode?: SessionMode;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Who owns the name (see `SessionNameSource`). Omitted, it is inferred
|
||||||
|
* from the name: Codeman's own `w<n>-<case>` placeholders (or no name)
|
||||||
|
* stay eligible for auto-naming, anything else counts as the user's.
|
||||||
|
*/
|
||||||
|
nameSource?: SessionNameSource;
|
||||||
/** Terminal multiplexer instance (tmux) */
|
/** Terminal multiplexer instance (tmux) */
|
||||||
mux?: TerminalMultiplexer;
|
mux?: TerminalMultiplexer;
|
||||||
/** Whether to use multiplexer wrapping */
|
/** Whether to use multiplexer wrapping */
|
||||||
@@ -707,6 +741,9 @@ export class Session extends EventEmitter {
|
|||||||
this.createdAt = config.createdAt || Date.now();
|
this.createdAt = config.createdAt || Date.now();
|
||||||
this.mode = config.mode || 'claude';
|
this.mode = config.mode || 'claude';
|
||||||
this._name = config.name || '';
|
this._name = config.name || '';
|
||||||
|
this._nameSource =
|
||||||
|
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual');
|
||||||
|
this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell';
|
||||||
this._resumeSessionId = config.resumeSessionId;
|
this._resumeSessionId = config.resumeSessionId;
|
||||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||||
// days-old tmux session, and seeding last-activity from it would report a
|
// days-old tmux session, and seeding last-activity from it would report a
|
||||||
@@ -1238,6 +1275,65 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — public-safe
|
||||||
|
// subset only (never envKeys/configDir/launchModel, the bookkeeping for setCustomModel).
|
||||||
|
get customModel(): CustomModelSelection | undefined {
|
||||||
|
if (!this._customModel) return undefined;
|
||||||
|
const { endpointId, modelId, label } = this._customModel;
|
||||||
|
return { endpointId, modelId, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
|
||||||
|
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), 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).
|
||||||
|
*
|
||||||
|
* Keys the previous selection injected that the new one does not re-set are queued
|
||||||
|
* for `tmux setenv -u` on the next respawn (`_pendingEnvUnsets`, threaded through
|
||||||
|
* `_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.
|
||||||
|
*/
|
||||||
|
setCustomModel(
|
||||||
|
next: CustomModelBookkeeping | undefined,
|
||||||
|
envOverrides?: Record<string, string>
|
||||||
|
): { removedEnvKeys: string[]; previousConfigDir: string | undefined } {
|
||||||
|
const previousConfigDir = this._customModel?.configDir;
|
||||||
|
const removedEnvKeys: string[] = [];
|
||||||
|
if (this._customModel) {
|
||||||
|
for (const key of this._customModel.envKeys) {
|
||||||
|
if (this._envOverrides) delete this._envOverrides[key];
|
||||||
|
removedEnvKeys.push(key);
|
||||||
|
this._pendingEnvUnsets.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// would set it right back anyway); keep the list to what actually goes away.
|
||||||
|
for (const key of Object.keys(envOverrides)) this._pendingEnvUnsets.delete(key);
|
||||||
|
}
|
||||||
|
return { removedEnvKeys, previousConfigDir };
|
||||||
|
}
|
||||||
|
|
||||||
// Token tracking getters and setters
|
// Token tracking getters and setters
|
||||||
get totalTokens(): number {
|
get totalTokens(): number {
|
||||||
return this._totalInputTokens + this._totalOutputTokens;
|
return this._totalInputTokens + this._totalOutputTokens;
|
||||||
@@ -1295,8 +1391,31 @@ export class Session extends EventEmitter {
|
|||||||
return this._name;
|
return this._name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An explicit rename: the name is the user's from here on and auto-naming never touches it. */
|
||||||
set name(value: string) {
|
set name(value: string) {
|
||||||
this._name = value;
|
this._name = value;
|
||||||
|
this._nameSource = 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the tab after its first prompt. Only a placeholder is eligible, and
|
||||||
|
* the session stops being one whether or not the string changed: "first
|
||||||
|
* prompt" means the first, not "every prompt until a rename". Returns
|
||||||
|
* whether the name changed, so the caller knows whether to persist and
|
||||||
|
* broadcast.
|
||||||
|
*/
|
||||||
|
applyAutoName(value: string): boolean {
|
||||||
|
if (this._nameSource !== 'placeholder') return false;
|
||||||
|
const name = value.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
this._nameSource = 'auto';
|
||||||
|
if (this._name === name) return false;
|
||||||
|
this._name = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get nameSource(): SessionNameSource {
|
||||||
|
return this._nameSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||||
@@ -1440,6 +1559,7 @@ export class Session extends EventEmitter {
|
|||||||
// attach repaint, so the home screens' quiet ordering survives a restart.
|
// attach repaint, so the home screens' quiet ordering survives a restart.
|
||||||
lastActivityAt: this._wireActivityAt,
|
lastActivityAt: this._wireActivityAt,
|
||||||
name: this._name,
|
name: this._name,
|
||||||
|
nameSource: this._nameSource,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
autoClearEnabled: this._autoOps.autoClearEnabled,
|
autoClearEnabled: this._autoOps.autoClearEnabled,
|
||||||
autoClearThreshold: this._autoOps.autoClearThreshold,
|
autoClearThreshold: this._autoOps.autoClearThreshold,
|
||||||
@@ -1478,6 +1598,7 @@ export class Session extends EventEmitter {
|
|||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
customModel: this.customModel,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||||
@@ -1617,6 +1738,7 @@ export class Session extends EventEmitter {
|
|||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
|
this._pendingEnvUnsets.clear();
|
||||||
// Wait a moment for the respawned process to fully start
|
// Wait a moment for the respawned process to fully start
|
||||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
@@ -1710,14 +1832,68 @@ export class Session extends EventEmitter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill and relaunch this session's CLI process IN PLACE — same pane, same tmux
|
||||||
|
* session, fresh env/args from current state. Custom Model Endpoint Profiles
|
||||||
|
* (docs/custom-model-endpoints-plan.md) is the first caller: after `setCustomModel()` merges new
|
||||||
|
* env vars into `_envOverrides`, the running CLI process still has the OLD env
|
||||||
|
* (inherited at its own process start, not live-reloaded), so switching a
|
||||||
|
* session's model/endpoint requires this restart to actually take effect.
|
||||||
|
*
|
||||||
|
* A GENERALIZED {@link reattachRemote} with the `!this._remote` guard dropped —
|
||||||
|
* `_buildRespawnPaneOptions()` already passes `remote: this._remote` through
|
||||||
|
* unconditionally, so `mux.respawnPane()` builds the right command either way
|
||||||
|
* (a local session gets `respawn-pane -k` + the real launch line, which is the
|
||||||
|
* kill-and-relaunch this method exists for; a remote session gets the existing
|
||||||
|
* reattach-to-durable-tmux behavior). Deliberately does NOT check `isBusy()` —
|
||||||
|
* that's the caller's job (mirrors `/interactive`'s guard), since a raw restart
|
||||||
|
* primitive shouldn't itself decide when it's safe to use.
|
||||||
|
*
|
||||||
|
* @returns true if the pane was respawned, false otherwise (no mux session, or
|
||||||
|
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
||||||
|
*/
|
||||||
|
async restartCli(): Promise<boolean> {
|
||||||
|
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||||
|
const mux = this._mux;
|
||||||
|
|
||||||
|
if (!mux.muxSessionExists(this._muxSession.muxName)) {
|
||||||
|
console.log('[Session] restartCli: mux session gone, skipping:', this._muxSession.muxName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pinOmpRespawnId();
|
||||||
|
const options = this._buildRespawnPaneOptions();
|
||||||
|
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
||||||
|
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
||||||
|
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
||||||
|
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
||||||
|
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
||||||
|
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
||||||
|
// already use, so pin the live conversation id for THIS respawn only. The registry
|
||||||
|
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
||||||
|
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
||||||
|
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
||||||
|
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
||||||
|
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
||||||
|
}
|
||||||
|
const newPid = await mux.respawnPane(options);
|
||||||
|
if (!newPid) {
|
||||||
|
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._pendingEnvUnsets.clear();
|
||||||
|
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
|
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
|
||||||
* truth shared by interactive start, shell start (via their inline copies),
|
* truth shared by interactive start, shell start (via their inline copies),
|
||||||
* and {@link reattachRemote} so the remote reattach path can never drift from
|
* {@link reattachRemote}, and {@link restartCli} so no respawn path can drift from
|
||||||
* the spawn path.
|
* the spawn path.
|
||||||
*/
|
*/
|
||||||
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
||||||
return {
|
const options: import('./mux-interface.js').RespawnPaneOptions = {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
@@ -1745,12 +1921,37 @@ export class Session extends EventEmitter {
|
|||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
|
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
owner: this._owner,
|
owner: this._owner,
|
||||||
};
|
};
|
||||||
|
return this._withCustomModelLaunchModel(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||||
|
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||||
|
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
|
||||||
|
* ...) or the top-level `model` for an entry that declares none — so this stays a
|
||||||
|
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
|
||||||
|
* `<Mode>Config` keeps whatever model the user chose at create, which is exactly what a
|
||||||
|
* later clear must fall back to.
|
||||||
|
*/
|
||||||
|
private _withCustomModelLaunchModel(
|
||||||
|
options: import('./mux-interface.js').RespawnPaneOptions
|
||||||
|
): import('./mux-interface.js').RespawnPaneOptions {
|
||||||
|
const launchModel = this._customModel?.launchModel;
|
||||||
|
if (!launchModel) return options;
|
||||||
|
const entry = getCli(this.mode);
|
||||||
|
if (!entry) return options;
|
||||||
|
const field = entry.launch.legacyConfigField;
|
||||||
|
if (!field) return { ...options, model: launchModel };
|
||||||
|
const bag = options as unknown as Record<string, unknown>;
|
||||||
|
const existing = (bag[field] ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...options, [field]: { ...existing, model: launchModel } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3357,10 +3558,11 @@ export class Session extends EventEmitter {
|
|||||||
* discards the data, but it used to do so with no signal at all — which is how
|
* discards the data, but it used to do so with no signal at all — which is how
|
||||||
* input could disappear while the caller believed it had been delivered.
|
* input could disappear while the caller believed it had been delivered.
|
||||||
*/
|
*/
|
||||||
write(data: string): boolean {
|
write(data: string, options: SessionWriteOptions = {}): boolean {
|
||||||
this._trackSubmit(data);
|
const submittedPrompt = this._trackSubmit(data, options);
|
||||||
if (!this.ptyProcess) return false;
|
if (!this.ptyProcess) return false;
|
||||||
this.ptyProcess.write(data);
|
this.ptyProcess.write(data);
|
||||||
|
this._emitSubmittedPrompt(submittedPrompt);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3377,10 +3579,35 @@ export class Session extends EventEmitter {
|
|||||||
return this._lastSubmitAt;
|
return this._lastSubmitAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _trackSubmit(data: string): void {
|
/**
|
||||||
|
* Stamps the pane's last Enter for EVERY write, and feeds the auto-name
|
||||||
|
* tracker only for user-originated input on a prompt-taking CLI. Ralph
|
||||||
|
* kick-starts, respawn `/clear`s, cron launches, approval answers and the
|
||||||
|
* trust-dialog keys all arrive without `fromUser` and so can never name a tab.
|
||||||
|
*/
|
||||||
|
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||||
|
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||||
if (data.includes('\r') || data.includes('\n')) {
|
if (data.includes('\r') || data.includes('\n')) {
|
||||||
this._lastSubmitAt = Date.now();
|
this._lastSubmitAt = Date.now();
|
||||||
}
|
}
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds user input that reaches the pane AROUND the write paths: the
|
||||||
|
* send-key route injects Shift+Enter's line feed through `tmux send-keys -H`
|
||||||
|
* directly, and without this the two lines of a prompt joined with no
|
||||||
|
* separator. Reports submissions like a write would (a line feed never is one).
|
||||||
|
*/
|
||||||
|
trackUserInput(data: string): void {
|
||||||
|
if (!this._acceptsPrompts) return;
|
||||||
|
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _emitSubmittedPrompt(prompts: string[]): void {
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
this.emit('promptSubmitted', prompt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3416,6 +3643,21 @@ export class Session extends EventEmitter {
|
|||||||
* half-open socket silently drops frames with no error) would type a prompt
|
* half-open socket silently drops frames with no error) would type a prompt
|
||||||
* twice whenever an ACK is lost after the write landed.
|
* twice whenever an ACK is lost after the write landed.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The highest input seq recorded for `clientId`, or 0 when this session has
|
||||||
|
* never seen it.
|
||||||
|
*
|
||||||
|
* Reported back on a REJECTED (duplicate) frame so the client can lift its own
|
||||||
|
* counter above this watermark. Without that number a client whose persisted
|
||||||
|
* counter fell behind ours has no way to find its way out: every fresh
|
||||||
|
* keystroke it sends lands at or below the watermark, is dropped as a
|
||||||
|
* duplicate, and is ACKed anyway — so the UI looks healthy while nothing is
|
||||||
|
* delivered, and a reload restores the same stale counter from localStorage.
|
||||||
|
*/
|
||||||
|
lastInputSeq(clientId: string): number {
|
||||||
|
return this._appliedInputSeq.get(clientId) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||||
const last = this._appliedInputSeq.get(clientId);
|
const last = this._appliedInputSeq.get(clientId);
|
||||||
if (last !== undefined && seq <= last) return false;
|
if (last !== undefined && seq <= last) return false;
|
||||||
@@ -3462,14 +3704,17 @@ export class Session extends EventEmitter {
|
|||||||
* session.writeViaMux('/init\r'); // Send /init command
|
* session.writeViaMux('/init\r'); // Send /init command
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async writeViaMux(data: string): Promise<boolean> {
|
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||||
this._trackSubmit(data);
|
const submittedPrompt = this._trackSubmit(data, options);
|
||||||
if (this._mux && this._muxSession) {
|
if (this._mux && this._muxSession) {
|
||||||
return this._mux.sendInput(this.id, data);
|
const sent = await this._mux.sendInput(this.id, data);
|
||||||
|
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||||
|
return sent;
|
||||||
}
|
}
|
||||||
// Fallback to PTY write
|
// Fallback to PTY write
|
||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
this.ptyProcess.write(data);
|
this.ptyProcess.write(data);
|
||||||
|
this._emitSubmittedPrompt(submittedPrompt);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+26
-11
@@ -1738,21 +1738,34 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
|
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
|
||||||
* shell-metachar injection even if upstream schema check is bypassed.
|
* shell-metachar injection even if upstream schema check is bypassed.
|
||||||
*/
|
*/
|
||||||
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
|
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>, unsetKeys?: string[]): void {
|
||||||
|
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
|
||||||
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
|
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
|
||||||
// on the tmux session and hard-locks /effort switching in every respawned pane.
|
// on the tmux session and hard-locks /effort switching in every respawned pane.
|
||||||
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
|
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
|
||||||
// so unconditionally unset the stale var before applying current overrides.
|
// so unconditionally unset the stale var before applying current overrides.
|
||||||
try {
|
//
|
||||||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
|
// The caller's own unsets ride the same path, and run BEFORE the overrides are
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
// (re)applied: a key that is both unset and present in `envOverrides` ends up set,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
// so a stale unset can never clobber a live value. Removing a key from the map is
|
||||||
});
|
// not enough on its own — `setenv` persists at the tmux-session level and is
|
||||||
} catch {
|
// inherited by `respawn-pane`, measured: `setenv FOO bar` survived two successive
|
||||||
/* Non-critical — var may not exist */
|
// `respawn-pane -k`. Clearing a custom-model selection is what needs this.
|
||||||
|
for (const key of ['CLAUDE_CODE_EFFORT_LEVEL', ...(unsetKeys ?? [])]) {
|
||||||
|
if (!VALID_KEY.test(key)) {
|
||||||
|
console.warn(`[TmuxManager] Skipping invalid env unset key: ${JSON.stringify(key)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u ${key}`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Non-critical — var may not exist */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!envOverrides) return;
|
if (!envOverrides) return;
|
||||||
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
|
|
||||||
for (const [key, value] of Object.entries(envOverrides)) {
|
for (const [key, value] of Object.entries(envOverrides)) {
|
||||||
if (!value) continue; // Skip empty — nothing to set
|
if (!value) continue; // Skip empty — nothing to set
|
||||||
if (!VALID_KEY.test(key)) {
|
if (!VALID_KEY.test(key)) {
|
||||||
@@ -2208,6 +2221,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
|
unsetEnvKeys,
|
||||||
effort,
|
effort,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
@@ -2269,8 +2283,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
);
|
);
|
||||||
this._configureStatusLineUserCommand(muxName, userStatusLineCommand);
|
this._configureStatusLineUserCommand(muxName, userStatusLineCommand);
|
||||||
|
|
||||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
// Re-apply user env overrides before respawn so the new shell inherits them,
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
// dropping the ones the caller retired first (see applyEnvOverrides).
|
||||||
|
this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
|
||||||
|
|
||||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ export interface CaseInfo {
|
|||||||
container: string;
|
container: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Directory INSIDE the container (defaults to `path` when unset). */
|
||||||
|
containerWorkdir?: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
/**
|
/**
|
||||||
* CLIs available INSIDE the container. A container case runs its agents in
|
* CLIs available INSIDE the container. A container case runs its agents in
|
||||||
@@ -201,6 +203,10 @@ export interface CaseInfo {
|
|||||||
* first session: the container is created on demand by the launch chain, so treating
|
* first session: the container is created on demand by the launch chain, so treating
|
||||||
* "not found" as a fault there hid every agent mode behind an error telling the user
|
* "not found" as a fault there hid every agent mode behind an error telling the user
|
||||||
* to start a container Codeman was about to create itself.
|
* to start a container Codeman was about to create itself.
|
||||||
|
*
|
||||||
|
* It also gates the Add Case panel's "copy an existing case" picker: only an ADOPTED
|
||||||
|
* container may back several cases at once (`classifyAdoptContainerConflict`), since an
|
||||||
|
* owned container's lifecycle belongs to its one case.
|
||||||
*/
|
*/
|
||||||
owned?: boolean;
|
owned?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ export type SessionMode =
|
|||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
| 'omp';
|
| 'omp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||||
|
* name at all), still eligible for auto-naming. `auto`: titled after its first
|
||||||
|
* prompt (`w<n>-<case>: <title>`), which happens once. `manual`: set by a
|
||||||
|
* person; auto-naming never touches it.
|
||||||
|
*/
|
||||||
|
export type SessionNameSource = 'placeholder' | 'auto' | 'manual';
|
||||||
|
|
||||||
|
/** Options for `Session.write()` / `Session.writeViaMux()`. */
|
||||||
|
export interface SessionWriteOptions {
|
||||||
|
/**
|
||||||
|
* The bytes were typed by a person, or sent by an agent on their behalf
|
||||||
|
* (browser keystrokes, `POST /api/sessions/:id/input`). Only such input can
|
||||||
|
* name a tab; Ralph, respawn, cron and approval writes leave this unset.
|
||||||
|
*/
|
||||||
|
fromUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type RemoteCommandMode = Extract<
|
export type RemoteCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||||
@@ -558,6 +576,29 @@ export interface SessionAttachmentHistoryItem {
|
|||||||
/**
|
/**
|
||||||
* Current state of a session
|
* Current state of a session
|
||||||
*/
|
*/
|
||||||
|
/** The public half of a session's custom-model selection (on the wire, in `SessionState`). */
|
||||||
|
export interface CustomModelSelection {
|
||||||
|
endpointId: string;
|
||||||
|
modelId: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full custom-model selection a session keeps: the public selection plus the
|
||||||
|
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||||
|
* it once wrote. Persisted to state.json only as the disk-only `__customModel` field
|
||||||
|
* (never broadcast); the injected env VALUES are not in here at all, since they carry
|
||||||
|
* the endpoint's API key, and are re-derived from the endpoint store on recovery.
|
||||||
|
*/
|
||||||
|
export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||||
|
/** Env keys the selection injected into the session's envOverrides / tmux session. */
|
||||||
|
envKeys: string[];
|
||||||
|
/** Isolated per-session config directory written for a `configDir`-kind CLI. */
|
||||||
|
configDir?: string;
|
||||||
|
/** Value forced onto the CLI's `model` launch param (pi/omp `custom/<id>`, grok's block name). */
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
/** Unique session identifier */
|
/** Unique session identifier */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -591,6 +632,8 @@ export interface SessionState {
|
|||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
/** Session display name */
|
/** Session display name */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Who owns the name (see `SessionNameSource`); absent on states persisted before auto-naming existed. */
|
||||||
|
nameSource?: SessionNameSource;
|
||||||
/** Session mode */
|
/** Session mode */
|
||||||
mode?: SessionMode;
|
mode?: SessionMode;
|
||||||
/** Auto-clear enabled */
|
/** Auto-clear enabled */
|
||||||
@@ -677,6 +720,15 @@ export interface SessionState {
|
|||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/**
|
||||||
|
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
|
||||||
|
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
|
||||||
|
* at, if any. Undefined = the harness's native cloud default. No secrets here — the
|
||||||
|
* endpoint's base URL/api key live only in Session._envOverrides, never in this public
|
||||||
|
* state. The internal half (which env keys were injected, which config dir was
|
||||||
|
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||||
|
*/
|
||||||
|
customModel?: CustomModelSelection;
|
||||||
/** Sanitized per-session attachment history. */
|
/** Sanitized per-session attachment history. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
|||||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||||
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
|
import {
|
||||||
|
capabilityFromProxyPath,
|
||||||
|
capabilityFromReferer,
|
||||||
|
carriesAuthCredentials,
|
||||||
|
isLostWebviewFrameNavigation,
|
||||||
|
lostWebviewFramePage,
|
||||||
|
LOST_FRAME_PAGE_CSP,
|
||||||
|
} from '../webview-proxy.js';
|
||||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||||
|
|
||||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||||
@@ -176,6 +183,65 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
|
|||||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A web-tab frame that navigated itself off the proxy prefix (see
|
||||||
|
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
|
||||||
|
* no capability left in the URL. Answer with the static recovery page here, BEFORE
|
||||||
|
* the credential checks, so the reload of a proxied dashboard neither shows a
|
||||||
|
* login challenge inside the tab nor counts as a failed attempt against the
|
||||||
|
* caller's IP — a dev server that full-reloads on every save would otherwise
|
||||||
|
* rate-limit its own user out of Codeman. Fenced like the Referer exemption: a
|
||||||
|
* path that resolves to a real route (/api, /q, a registered handler) is never
|
||||||
|
* answered this way, so a genuine unauthenticated navigation still gets the 401.
|
||||||
|
*
|
||||||
|
* `/` is the one registered route that IS answered here, and only when the
|
||||||
|
* request carries neither the session cookie nor an Authorization header. The
|
||||||
|
* shim maps `/webview/<cap>/` to exactly `/`, so a dashboard that reloads on its
|
||||||
|
* landing page (a Vite dev server on a config change) asks for Codeman's root
|
||||||
|
* as an iframe navigation; answering that with the app shell put Codeman inside
|
||||||
|
* its own web tab, and with a password it was a 401 in the frame. Nothing in
|
||||||
|
* Codeman frames its own root and the sandboxed frame has no credentials, so the
|
||||||
|
* credential-free form can only be that frame; a framed `/` WITH credentials is
|
||||||
|
* still the shell. Property worth knowing: a non-browser client can set these
|
||||||
|
* headers too, so an unauthenticated caller can tell a registered route (401)
|
||||||
|
* from a non-route (200) and enumerate the route table. Accepted, because the
|
||||||
|
* routes are public in docs/api-reference.md.
|
||||||
|
*
|
||||||
|
* @returns true when the reply was sent.
|
||||||
|
*/
|
||||||
|
function serveLostWebviewFrame(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||||
|
if (!isLostWebviewFrameNavigation(req)) return false;
|
||||||
|
const url = (req.url ?? '').split('?')[0];
|
||||||
|
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||||
|
if (url === '/') {
|
||||||
|
if (carriesAuthCredentials(req.headers, AUTH_COOKIE_NAME)) return false;
|
||||||
|
} else if (matchesRegisteredRoute(req, url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sendLostWebviewFramePage(reply);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The landing-page case of serveLostWebviewFrame, for the index route. Without
|
||||||
|
* CODEMAN_PASSWORD no auth hook runs at all, so a lost frame's reload of `/`
|
||||||
|
* reaches `GET /` directly and the route asks this before rendering the shell.
|
||||||
|
* Under a password the hook has already answered a credential-free lost frame,
|
||||||
|
* so here it only ever sees the credentialed form, which stays the shell.
|
||||||
|
*/
|
||||||
|
export function isLostWebviewRootFrame(req: FastifyRequest): boolean {
|
||||||
|
if (!isLostWebviewFrameNavigation(req)) return false;
|
||||||
|
if ((req.url ?? '').split('?')[0] !== '/') return false;
|
||||||
|
return !carriesAuthCredentials(req.headers, AUTH_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the static recovery page (lostWebviewFramePage) with its own CSP, uncached. */
|
||||||
|
export function sendLostWebviewFramePage(reply: FastifyReply): FastifyReply {
|
||||||
|
reply.header('content-security-policy', LOST_FRAME_PAGE_CSP);
|
||||||
|
reply.header('cache-control', 'no-store');
|
||||||
|
return reply.type('text/html; charset=utf-8').send(lostWebviewFramePage());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether `url` resolves to a route Codeman actually registered.
|
* Whether `url` resolves to a route Codeman actually registered.
|
||||||
*
|
*
|
||||||
@@ -302,6 +368,8 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
|||||||
done();
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||||
|
if (serveLostWebviewFrame(req, reply)) return;
|
||||||
|
|
||||||
const clientIp = req.ip;
|
const clientIp = req.ip;
|
||||||
|
|
||||||
@@ -439,6 +507,8 @@ function registerMultiUserAuthHook(
|
|||||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||||
// than re-deriving it from a request that carries no credentials.
|
// than re-deriving it from a request that carries no credentials.
|
||||||
if (hasValidWebviewCapability(req, basePath)) return;
|
if (hasValidWebviewCapability(req, basePath)) return;
|
||||||
|
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||||
|
if (serveLostWebviewFrame(req, reply)) return;
|
||||||
|
|
||||||
const clientIp = req.ip;
|
const clientIp = req.ip;
|
||||||
|
|
||||||
|
|||||||
+88
-18
@@ -2895,7 +2895,7 @@ class CodemanApp {
|
|||||||
} else if (msg.t === 'ia') {
|
} else if (msg.t === 'ia') {
|
||||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||||
// the durable queue so it can never be re-delivered/lost.
|
// the durable queue so it can never be re-delivered/lost.
|
||||||
this._onWsInputAck(msg.seq);
|
this._onWsInputAck(msg.seq, msg);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed messages
|
// Ignore malformed messages
|
||||||
@@ -3073,7 +3073,11 @@ class CodemanApp {
|
|||||||
this._pendingDeliveries.set(sessionId, list);
|
this._pendingDeliveries.set(sessionId, list);
|
||||||
}
|
}
|
||||||
list.push(rec);
|
list.push(rec);
|
||||||
this._persistReliableState();
|
// ⚠️ SYNCHRONOUS, not the debounced writer: the seq counter is precisely the
|
||||||
|
// thing that must survive a crash, and a debounce puts it on the path most
|
||||||
|
// likely to be lost. A counter that comes back BELOW the server's watermark
|
||||||
|
// makes every later keystroke a silently-dropped duplicate (see _onWsInputAck).
|
||||||
|
this._persistReliableNow();
|
||||||
this._updateConnectionIndicator();
|
this._updateConnectionIndicator();
|
||||||
this._drainSession(sessionId);
|
this._drainSession(sessionId);
|
||||||
}
|
}
|
||||||
@@ -3179,9 +3183,40 @@ class CodemanApp {
|
|||||||
this.markIdleAlertSeen?.(sessionId);
|
this.markIdleAlertSeen?.(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
|
/**
|
||||||
_onWsInputAck(seq) {
|
* Server input-ACK frame ({t:'ia',seq}) over the WebSocket.
|
||||||
if (this._wsSessionId && Number.isInteger(seq)) this._ackDelivery(this._wsSessionId, seq);
|
*
|
||||||
|
* `dup:true` means the server REJECTED the frame as already-seen rather than
|
||||||
|
* applying it, and `last` is its watermark for this clientId. That combination
|
||||||
|
* is the escape hatch from a rolled-back counter: our seqs persist on a
|
||||||
|
* debounced write, so a tab killed between a send and that write comes back
|
||||||
|
* counting from BELOW the server's watermark, and from then on every keystroke
|
||||||
|
* is dropped-but-ACKed — a silently dead terminal that a reload cannot fix,
|
||||||
|
* because the stale counter is restored from localStorage too.
|
||||||
|
*
|
||||||
|
* ⚠️ Only a FIRST-attempt record is re-queued. A retry (`tries > 1`) being
|
||||||
|
* called a duplicate is the mechanism working as designed — the original did
|
||||||
|
* land — and re-sending it would type the same thing twice.
|
||||||
|
*/
|
||||||
|
_onWsInputAck(seq, msg) {
|
||||||
|
const sessionId = this._wsSessionId;
|
||||||
|
if (!sessionId || !Number.isInteger(seq)) return;
|
||||||
|
if (msg && msg.dup) {
|
||||||
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
|
const rec = list && list.find((r) => r.seq === seq);
|
||||||
|
const watermark = Number.isInteger(msg.last) ? msg.last : seq;
|
||||||
|
// Lift the counter clear of the server's watermark before anything else, so
|
||||||
|
// the re-queue below (and every later keystroke) gets an acceptable seq.
|
||||||
|
if ((this._seqCounters.get(sessionId) || 0) <= watermark) {
|
||||||
|
this._seqCounters.set(sessionId, watermark);
|
||||||
|
this._persistReliableNow();
|
||||||
|
}
|
||||||
|
const lost = rec && rec.tries <= 1 ? rec.data : null;
|
||||||
|
this._ackDelivery(sessionId, seq);
|
||||||
|
if (lost !== null) this._reliableSend(sessionId, lost, rec.useMux);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._ackDelivery(sessionId, seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from ws.onopen — flush everything pending over the fresh socket. */
|
/** Called from ws.onopen — flush everything pending over the fresh socket. */
|
||||||
@@ -3601,13 +3636,20 @@ class CodemanApp {
|
|||||||
* Reset all app state maps, timers, and handlers to a clean baseline.
|
* Reset all app state maps, timers, and handlers to a clean baseline.
|
||||||
* Called by handleInit() on SSE reconnect / page reload to prevent
|
* Called by handleInit() on SSE reconnect / page reload to prevent
|
||||||
* memory leaks and stale data.
|
* memory leaks and stale data.
|
||||||
|
*
|
||||||
|
* @param {boolean} [preserveTerminal] Keep the terminal caches. Set when an SSE
|
||||||
|
* RECONNECT lands back on the session already on screen: the buffers still
|
||||||
|
* describe that session, and dropping them forces a full refetch + xterm
|
||||||
|
* reset that throws away the user's scroll position (see handleInit).
|
||||||
*/
|
*/
|
||||||
_resetAllAppState() {
|
_resetAllAppState(preserveTerminal = false) {
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.ralphStates.clear();
|
this.ralphStates.clear();
|
||||||
this.terminalBuffers.clear();
|
if (!preserveTerminal) {
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBuffers.clear();
|
||||||
this._xtermSnapshots?.clear();
|
this.terminalBufferCache.clear();
|
||||||
|
this._xtermSnapshots?.clear();
|
||||||
|
}
|
||||||
this.projectInsights.clear();
|
this.projectInsights.clear();
|
||||||
this.teams.clear();
|
this.teams.clear();
|
||||||
this.teamTasks.clear();
|
this.teamTasks.clear();
|
||||||
@@ -3734,7 +3776,23 @@ class CodemanApp {
|
|||||||
// Stop any active voice recording on reconnect
|
// Stop any active voice recording on reconnect
|
||||||
VoiceInput.cleanup();
|
VoiceInput.cleanup();
|
||||||
|
|
||||||
this._resetAllAppState();
|
// A RECONNECT that lands back on the same session must not become a full
|
||||||
|
// reload. This used to clear the terminal caches and re-run selectSession()
|
||||||
|
// unconditionally, so every SSE reconnect refetched the buffer (up to 1 MiB)
|
||||||
|
// and reset+rewrote xterm. On a link that drops a connection about once a
|
||||||
|
// minute that reads as the page refreshing itself and losing your place.
|
||||||
|
// Keep the caches and the active id here; the restore block below resyncs
|
||||||
|
// through _onSessionNeedsRefresh(), which still reloads the buffer (so
|
||||||
|
// output produced during the outage is not lost) but preserves the reading
|
||||||
|
// position.
|
||||||
|
const activeBefore = this.activeSessionId;
|
||||||
|
const keepTerminal =
|
||||||
|
gen > 1 &&
|
||||||
|
!!activeBefore &&
|
||||||
|
Array.isArray(data.sessions) &&
|
||||||
|
data.sessions.some((s) => s.id === activeBefore);
|
||||||
|
|
||||||
|
this._resetAllAppState(keepTerminal);
|
||||||
|
|
||||||
data.sessions.forEach(s => {
|
data.sessions.forEach(s => {
|
||||||
this.sessions.set(s.id, s);
|
this.sessions.set(s.id, s);
|
||||||
@@ -3864,20 +3922,32 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousActiveId = this.activeSessionId;
|
const previousActiveId = this.activeSessionId;
|
||||||
this.activeSessionId = null;
|
if (this.sessionOrder.length === 0) {
|
||||||
if (this.sessionOrder.length > 0) {
|
this.activeSessionId = null;
|
||||||
|
} else {
|
||||||
// Priority: current active > localStorage > first session
|
// Priority: current active > localStorage > first session
|
||||||
let restoreId = previousActiveId;
|
let restoreId = previousActiveId;
|
||||||
if (!restoreId || !this.sessions.has(restoreId)) {
|
if (!restoreId || !this.sessions.has(restoreId)) {
|
||||||
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
|
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
|
||||||
}
|
}
|
||||||
// `auto`: the app is restoring a session on load, not a human opening
|
if (keepTerminal && restoreId === previousActiveId && this.sessions.has(restoreId)) {
|
||||||
// one, so a pending idle alert on that tab stays armed until it is
|
// Reconnect onto the session already on screen. renderSessionTabs() ran
|
||||||
// actually tapped (see the userInitiated note in selectSession).
|
// above and activeSessionId never changed, so the tab strip is already
|
||||||
if (restoreId && this.sessions.has(restoreId)) {
|
// correct; only the buffer needs to catch up. The WS has its own
|
||||||
this.selectSession(restoreId, { auto: true });
|
// backoff reconnect, but if it is not on this session (dead socket, or
|
||||||
|
// a give-up) nothing else would re-establish it from here.
|
||||||
|
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
||||||
|
void this._onSessionNeedsRefresh({ id: restoreId });
|
||||||
} else {
|
} else {
|
||||||
this.selectSession(this.sessionOrder[0], { auto: true });
|
this.activeSessionId = null;
|
||||||
|
// `auto`: the app is restoring a session on load, not a human opening
|
||||||
|
// one, so a pending idle alert on that tab stays armed until it is
|
||||||
|
// actually tapped (see the userInitiated note in selectSession).
|
||||||
|
if (restoreId && this.sessions.has(restoreId)) {
|
||||||
|
this.selectSession(restoreId, { auto: true });
|
||||||
|
} else {
|
||||||
|
this.selectSession(this.sessionOrder[0], { auto: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@
|
|||||||
'Ultracode Agents': 'Ultracode 智能体',
|
'Ultracode Agents': 'Ultracode 智能体',
|
||||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||||
'Approvals Inbox': '审批收件箱',
|
'Approvals Inbox': '审批收件箱',
|
||||||
|
'Auto-name Sessions': '自动命名会话',
|
||||||
Approvals: '审批',
|
Approvals: '审批',
|
||||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||||
'No pending approvals': '没有待处理的审批',
|
'No pending approvals': '没有待处理的审批',
|
||||||
|
|||||||
@@ -2047,6 +2047,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" id="appSettingsAutoNameSessionsItem" data-search="auto name session title first prompt tab rename">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Auto-name Sessions <span class="set-tag">synced</span></span>
|
||||||
|
<span class="set-row-desc">Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||||
@@ -2906,6 +2913,13 @@
|
|||||||
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
|
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
|
||||||
<span class="form-hint">On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.</span>
|
<span class="form-hint">On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row docker-adopt-only" id="dockerAdoptCloneRow" hidden>
|
||||||
|
<label>Duplicate an Existing Case</label>
|
||||||
|
<select id="dockerAdoptCloneFrom" onchange="app.applyDockerCloneSource()">
|
||||||
|
<option value="">Start from scratch</option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">Same container, another directory inside it. Picks up the container, host and workspace below — you only set a new name and container workdir. Adopted containers only: an owned container's lifecycle belongs to its one case.</span>
|
||||||
|
</div>
|
||||||
<div class="form-row docker-adopt-only">
|
<div class="form-row docker-adopt-only">
|
||||||
<label>Container Name</label>
|
<label>Container Name</label>
|
||||||
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
|||||||
@@ -120,6 +120,41 @@ const CjkInput = (() => {
|
|||||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** CSI final byte per navigation key, for the modifier-carrying forms below. */
|
||||||
|
const CSI_NAV_FINAL = {
|
||||||
|
ArrowUp: 'A',
|
||||||
|
ArrowDown: 'B',
|
||||||
|
ArrowRight: 'C',
|
||||||
|
ArrowLeft: 'D',
|
||||||
|
End: 'F',
|
||||||
|
Home: 'H',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `CSI 1 ; <mod> <final>` form for a Ctrl/Alt-modified navigation key, or
|
||||||
|
* null when this key is not one.
|
||||||
|
*
|
||||||
|
* A modified navigation key is a terminal COMMAND, not text editing — claude's
|
||||||
|
* own "Jump to bottom (ctrl+End)" is one. PASSTHROUGH_KEYS carries only the
|
||||||
|
* plain forms, so Ctrl+End used to fail in BOTH directions: with an empty
|
||||||
|
* field it was sent as a bare `\x1b[F` (the modifier silently dropped, so the
|
||||||
|
* CLI saw a plain End), and with any text in the field it was not forwarded at
|
||||||
|
* all and the browser's default moved the caret to the end of the composer,
|
||||||
|
* which is what the user sees as "the shortcut does something to the input box
|
||||||
|
* instead".
|
||||||
|
*
|
||||||
|
* ⚠️ Shift ALONE is deliberately excluded: Shift+arrow selects text inside the
|
||||||
|
* composer, which is a real editing gesture worth keeping local. Shift is still
|
||||||
|
* encoded when it accompanies Ctrl or Alt.
|
||||||
|
*/
|
||||||
|
function _modifiedNavSequence(e) {
|
||||||
|
const final = CSI_NAV_FINAL[e.key];
|
||||||
|
if (!final) return null;
|
||||||
|
if (!e.ctrlKey && !e.altKey) return null;
|
||||||
|
const mod = 1 + (e.shiftKey ? 1 : 0) + (e.altKey ? 2 : 0) + (e.ctrlKey ? 4 : 0);
|
||||||
|
return `\x1b[1;${mod}${final}`;
|
||||||
|
}
|
||||||
|
|
||||||
function _strip(str) {
|
function _strip(str) {
|
||||||
return str.replace(//g, '');
|
return str.replace(//g, '');
|
||||||
}
|
}
|
||||||
@@ -321,6 +356,17 @@ const CjkInput = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Alt-modified navigation keys go to the PTY REGARDLESS of whether
|
||||||
|
// the field has text: they are commands for the CLI, and the composer has
|
||||||
|
// no editing behaviour for them worth preserving (plain Home/End still
|
||||||
|
// edit locally through the table below).
|
||||||
|
const modNav = _modifiedNavSequence(e);
|
||||||
|
if (modNav) {
|
||||||
|
e.preventDefault();
|
||||||
|
_send(modNav);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Arrow/function keys: forward to PTY when no real text
|
// Arrow/function keys: forward to PTY when no real text
|
||||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
*
|
*
|
||||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
||||||
* bar adds /clear, /compact, Shift+Tab and more). Tab flushes any locally-buffered
|
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
||||||
|
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
||||||
|
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
||||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||||
* The paste button opens a dialog that handles both text paste and image attach
|
* The paste button opens a dialog that handles both text paste and image attach
|
||||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||||
@@ -661,6 +663,8 @@ const KeyboardAccessoryBar = {
|
|||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||||
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||||
@@ -746,6 +750,8 @@ const KeyboardAccessoryBar = {
|
|||||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||||
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||||
@@ -772,6 +778,9 @@ const KeyboardAccessoryBar = {
|
|||||||
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
|
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
|
||||||
// templates but stays display:none until the bar carries the marker class.
|
// templates but stays display:none until the bar carries the marker class.
|
||||||
this.syncReadMyMind();
|
this.syncReadMyMind();
|
||||||
|
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
||||||
|
// session's mode instead of a setting.
|
||||||
|
this.syncCodexKeys();
|
||||||
|
|
||||||
// Add click handlers — preventDefault stops event from reaching terminal
|
// Add click handlers — preventDefault stops event from reaching terminal
|
||||||
this.element.addEventListener('click', (e) => {
|
this.element.addEventListener('click', (e) => {
|
||||||
@@ -784,7 +793,7 @@ const KeyboardAccessoryBar = {
|
|||||||
this.handleAction(action, btn);
|
this.handleAction(action, btn);
|
||||||
|
|
||||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||||
if (refocusActions.has(action) ||
|
if (refocusActions.has(action) ||
|
||||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
@@ -815,6 +824,7 @@ const KeyboardAccessoryBar = {
|
|||||||
refreshForActiveSession() {
|
refreshForActiveSession() {
|
||||||
this.clearCtrl();
|
this.clearCtrl();
|
||||||
this._applyLayout(this._resolveMode());
|
this._applyLayout(this._resolveMode());
|
||||||
|
this.syncCodexKeys();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Which layout the current state calls for. */
|
/** Which layout the current state calls for. */
|
||||||
@@ -827,6 +837,11 @@ const KeyboardAccessoryBar = {
|
|||||||
return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
|
return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_isCodexSession() {
|
||||||
|
if (typeof app === 'undefined' || !app.activeSessionId) return false;
|
||||||
|
return app.sessions?.get(app.activeSessionId)?.mode === 'codex';
|
||||||
|
},
|
||||||
|
|
||||||
/** Swap the button set in the DOM. */
|
/** Swap the button set in the DOM. */
|
||||||
_applyLayout(mode) {
|
_applyLayout(mode) {
|
||||||
if (!this.element || mode === this._mode) return;
|
if (!this.element || mode === this._mode) return;
|
||||||
@@ -913,6 +928,12 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'arrow-right':
|
case 'arrow-right':
|
||||||
this.sendNavKey('\x1b[C');
|
this.sendNavKey('\x1b[C');
|
||||||
break;
|
break;
|
||||||
|
case 'shift-left':
|
||||||
|
this.sendNavKey('\x1b[1;2D');
|
||||||
|
break;
|
||||||
|
case 'shift-right':
|
||||||
|
this.sendNavKey('\x1b[1;2C');
|
||||||
|
break;
|
||||||
case 'esc':
|
case 'esc':
|
||||||
this.sendKey('\x1b');
|
this.sendKey('\x1b');
|
||||||
break;
|
break;
|
||||||
@@ -1009,6 +1030,20 @@ const KeyboardAccessoryBar = {
|
|||||||
this.element.classList.toggle('rmm-enabled', enabled === true);
|
this.element.classList.toggle('rmm-enabled', enabled === true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Reveal the ⇧←/⇧→ keys only while the active session runs Codex. They are
|
||||||
|
* Codex bindings (edit the last queued message / prompt stack back) and do
|
||||||
|
* nothing in any other CLI, yet a tap still goes through sendNavKey(), which
|
||||||
|
* hands the session to plain PTY echo for the rest of the prompt, so on a
|
||||||
|
* phone a dead key would also switch off local echo. Same marker-class
|
||||||
|
* shape as syncReadMyMind(): the class lives on the BAR because setMode()
|
||||||
|
* rebuilds the buttons' innerHTML. Synced at init and on every session
|
||||||
|
* switch (refreshForActiveSession); a session's mode is fixed at create, so
|
||||||
|
* no other event can change the answer. */
|
||||||
|
syncCodexKeys() {
|
||||||
|
if (!this.element) return;
|
||||||
|
this.element.classList.toggle('codex-enabled', this._isCodexSession());
|
||||||
|
},
|
||||||
|
|
||||||
/** Send a slash command to the active session.
|
/** Send a slash command to the active session.
|
||||||
* Sends text and Enter separately so Ink processes them as distinct events. */
|
* Sends text and Enter separately so Ink processes them as distinct events. */
|
||||||
sendCommand(command) {
|
sendCommand(command) {
|
||||||
@@ -1044,7 +1079,7 @@ const KeyboardAccessoryBar = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A composer nav key (the four arrows) from the bar, under the SAME contract
|
* A composer nav key (arrows, including Shift+Left/Right) from the bar, under the SAME contract
|
||||||
* as pressing one on a hardware keyboard (the `isComposerNavKey` branch of
|
* as pressing one on a hardware keyboard (the `isComposerNavKey` branch of
|
||||||
* terminal-ui.js's onData): flush the unsent draft so the key edits the real
|
* terminal-ui.js's onData): flush the unsent draft so the key edits the real
|
||||||
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
|
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
|
||||||
|
|||||||
@@ -3153,7 +3153,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
||||||
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
||||||
else modal.removeAttribute('data-docker-adopt');
|
else modal.removeAttribute('data-docker-adopt');
|
||||||
if (adopting) void this._loadDockerContainerOptions();
|
if (adopting) {
|
||||||
|
void this._loadDockerContainerOptions();
|
||||||
|
void this._loadDockerCloneOptions();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3165,6 +3168,120 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
||||||
* and an empty list simply leaves the field as plain text input.
|
* and an empty list simply leaves the field as plain text input.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Fill the "Duplicate an Existing Case" picker with the ADOPTED docker cases.
|
||||||
|
*
|
||||||
|
* One adopted container can back several cases, each pointing at a different
|
||||||
|
* directory inside it (classifyAdoptContainerConflict) — but re-typing the
|
||||||
|
* container, host and workspace by hand for every directory is exactly the
|
||||||
|
* friction that makes the capability go unused. Picking a case here fills those
|
||||||
|
* three and leaves only the two fields that MUST differ: the case name and the
|
||||||
|
* container workdir.
|
||||||
|
*
|
||||||
|
* ⚠️ Adopted cases only (`docker.owned === false`). An owned container's
|
||||||
|
* lifecycle belongs to its one case — a second case on it would be destroyed
|
||||||
|
* out from under itself by that case's recreate or delete — and the server
|
||||||
|
* refuses it, so offering it here would only produce a confusing error.
|
||||||
|
*/
|
||||||
|
async _loadDockerCloneOptions() {
|
||||||
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
||||||
|
const row = document.getElementById('dockerAdoptCloneRow');
|
||||||
|
if (!select || !row) return;
|
||||||
|
let cases = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cases');
|
||||||
|
const data = await res.json();
|
||||||
|
cases = (Array.isArray(data) ? data : data?.data || []).filter(
|
||||||
|
(c) => c?.docker && c.docker.owned === false
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cases = [];
|
||||||
|
}
|
||||||
|
select.textContent = '';
|
||||||
|
const blank = document.createElement('option');
|
||||||
|
blank.value = '';
|
||||||
|
blank.textContent = 'Start from scratch';
|
||||||
|
select.appendChild(blank);
|
||||||
|
for (const c of cases) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = c.name;
|
||||||
|
// Server-supplied strings: textContent, never markup.
|
||||||
|
option.textContent = `${c.name} — ${c.docker.container}:${c.docker.containerWorkdir || c.docker.path}`;
|
||||||
|
option.dataset.container = c.docker.container;
|
||||||
|
option.dataset.hostId = c.docker.hostId;
|
||||||
|
option.dataset.path = c.docker.path;
|
||||||
|
option.dataset.workdir = c.docker.containerWorkdir || c.docker.path;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
// Nothing to duplicate yet: an empty picker is noise on the first adoption.
|
||||||
|
row.hidden = cases.length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the picked case: carry over what STAYS the same, clear what must not.
|
||||||
|
*
|
||||||
|
* The two cleared fields are the point of the feature — a duplicate that kept
|
||||||
|
* the original's name would be rejected as an existing case, and one that kept
|
||||||
|
* its container workdir would be rejected as an exact twin (both by the server,
|
||||||
|
* with a clear message, but a form that pre-fills a value it knows will be
|
||||||
|
* refused is just a trap).
|
||||||
|
*/
|
||||||
|
applyDockerCloneSource() {
|
||||||
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
||||||
|
const option = select?.selectedOptions?.[0];
|
||||||
|
if (!option || !option.value) return;
|
||||||
|
const set = (id, value) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = value || '';
|
||||||
|
};
|
||||||
|
set('dockerContainerName', option.dataset.container);
|
||||||
|
set('dockerHostId', option.dataset.hostId);
|
||||||
|
set('dockerWorkspacePath', option.dataset.path);
|
||||||
|
// Pre-filled, NOT cleared: these two must differ from the source, but editing
|
||||||
|
// `/srv/app/api` into `/srv/app/web` beats retyping a long path, and the same
|
||||||
|
// goes for the name. What keeps a duplicate from being submitted unchanged is
|
||||||
|
// the guard below (dockerCloneGuard), which is a better trade than an empty
|
||||||
|
// field: the form stays a starting point instead of a blank form with three
|
||||||
|
// fields mysteriously filled in.
|
||||||
|
set('dockerCaseName', option.value);
|
||||||
|
set('dockerAdoptWorkdir', option.dataset.workdir);
|
||||||
|
// Remembered so the guard can tell "unchanged" from "happens to look similar".
|
||||||
|
select.dataset.appliedName = option.value;
|
||||||
|
select.dataset.appliedWorkdir = option.dataset.workdir || '';
|
||||||
|
const workdir = document.getElementById('dockerAdoptWorkdir');
|
||||||
|
workdir?.focus();
|
||||||
|
// Caret at the end: the tail is the part that changes.
|
||||||
|
if (workdir) workdir.setSelectionRange(workdir.value.length, workdir.value.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse a duplicate that still carries the source case's name or directory.
|
||||||
|
*
|
||||||
|
* Both are pre-filled so they can be EDITED, which means both can also be left
|
||||||
|
* alone by accident. The server refuses either (an existing case name, or an
|
||||||
|
* exact same-container-same-directory twin) with a clear message, but a
|
||||||
|
* round-trip to be told "you forgot to change the field you were looking at" is
|
||||||
|
* worse than saying so here, next to the field, before anything is sent.
|
||||||
|
*
|
||||||
|
* Returns the offending element, or null when the form is fine.
|
||||||
|
*/
|
||||||
|
dockerCloneGuard() {
|
||||||
|
const select = document.getElementById('dockerAdoptCloneFrom');
|
||||||
|
if (!select || !select.value) return null;
|
||||||
|
const name = document.getElementById('dockerCaseName');
|
||||||
|
const workdir = document.getElementById('dockerAdoptWorkdir');
|
||||||
|
if (name && name.value.trim() === (select.dataset.appliedName || '')) {
|
||||||
|
return { el: name, message: `"${name.value.trim()}" is the case you copied from — give this one a new name.` };
|
||||||
|
}
|
||||||
|
if (workdir && workdir.value.trim() === (select.dataset.appliedWorkdir || '')) {
|
||||||
|
return {
|
||||||
|
el: workdir,
|
||||||
|
message: 'Same container and same directory as the case you copied from — point this one at another directory.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
async _loadDockerContainerOptions() {
|
async _loadDockerContainerOptions() {
|
||||||
const list = document.getElementById('dockerContainerList');
|
const list = document.getElementById('dockerContainerList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -3265,6 +3382,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.showToast('Enter the name of the running container to attach to', 'error');
|
this.showToast('Enter the name of the running container to attach to', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A duplicate that still carries the source's name or directory: say so here,
|
||||||
|
// beside the field, rather than sending a request certain to come back refused.
|
||||||
|
const cloneIssue = adopting ? this.dockerCloneGuard() : null;
|
||||||
|
if (cloneIssue) {
|
||||||
|
this.showToast(cloneIssue.message, 'error');
|
||||||
|
const statusEl = document.getElementById('dockerLinkStatus');
|
||||||
|
if (statusEl) statusEl.textContent = cloneIssue.message;
|
||||||
|
cloneIssue.el.focus();
|
||||||
|
cloneIssue.el.select?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
|
|||||||
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||||
// changes nothing. Default ON — only an explicit false turns it off.
|
// changes nothing. Default ON — only an explicit false turns it off.
|
||||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||||
|
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||||
|
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||||
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||||
|
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
|
|||||||
@@ -10495,14 +10495,19 @@ kbd {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--modal-backdrop);
|
background: var(--modal-backdrop);
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
z-index: 5100;
|
z-index: 5100;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same stale-hit-test reasoning as .offline-overlay above: this one is also a
|
||||||
|
persistent full-screen fixed element, shown by adding `.visible`. */
|
||||||
|
.file-preview-overlay.visible {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
.file-preview-overlay.visible {
|
.file-preview-overlay.visible {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -11969,6 +11974,18 @@ kbd {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard-accessory ⇧←/⇧→ keys: Codex bindings (edit the last queued
|
||||||
|
message / prompt stack back), so they are revealed only while the active
|
||||||
|
session runs Codex. Same marker-class shape as the 🧠 key above, for the
|
||||||
|
same reason (the bar's innerHTML is rebuilt on every layout switch); the
|
||||||
|
class is synced from the active session's mode (keyboard-accessory.js). */
|
||||||
|
.keyboard-accessory-bar .accessory-btn-codex {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.keyboard-accessory-bar.codex-enabled .accessory-btn-codex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.approvals-badge {
|
.approvals-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -15304,9 +15321,26 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
padding-top: calc(20px + var(--safe-area-top));
|
padding-top: calc(20px + var(--safe-area-top));
|
||||||
padding-bottom: calc(20px + var(--safe-area-bottom));
|
padding-bottom: calc(20px + var(--safe-area-bottom));
|
||||||
background: rgba(6, 8, 12, 0.93);
|
background: rgba(6, 8, 12, 0.93);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⚠️ `backdrop-filter` is applied ONLY while the overlay is actually shown.
|
||||||
|
It promotes the element to its own compositing layer, and a full-screen
|
||||||
|
`position: fixed` layer that is created and then hidden has been observed to
|
||||||
|
leave a STALE HIT-TEST REGION behind in Chrome: the page keeps rendering
|
||||||
|
correctly while every pointer event over the viewport lands on nothing.
|
||||||
|
Symptom (reported on a long-lived tab against a remote server, where a
|
||||||
|
connection blip shows and then hides #offlineOverlay): the terminal stops
|
||||||
|
scrolling AND unrelated click-to-expand controls stop responding at the same
|
||||||
|
time, while a freshly opened tab is fine — and a console one-liner that only
|
||||||
|
READS layout (getComputedStyle + elementFromPoint, both of which force a
|
||||||
|
hit-test recompute) restores it. Two unrelated features dying together, and a
|
||||||
|
read-only command curing them, is what points at hit-testing rather than at
|
||||||
|
either feature. Keeping the property off the hidden state means the layer is
|
||||||
|
never created while invisible. */
|
||||||
|
.offline-overlay:not([hidden]) {
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-overlay[hidden] {
|
.offline-overlay[hidden] {
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
this.terminal.open(container);
|
this.terminal.open(container);
|
||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
|
this._installShiftDragSelection();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
|
||||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
@@ -911,7 +912,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.addEventListener('contextmenu', (ev) => {
|
container.addEventListener('contextmenu', (ev) => {
|
||||||
if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) {
|
if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Right-click COPIES the selection, the mintty/PuTTY convention, because
|
||||||
|
// the browser's own menu structurally cannot offer it here: xterm paints
|
||||||
|
// glyphs into a canvas, so a terminal selection is not a DOM selection
|
||||||
|
// and the native "Copy" item has nothing to act on (it is absent or
|
||||||
|
// inert). This is the second half of the habit users bring from a native
|
||||||
|
// terminal running a mouse-tracking TUI — Shift+drag to select (see
|
||||||
|
// _installShiftDragSelection), right-click to copy — and without it that
|
||||||
|
// gesture dead-ends after the selection is made.
|
||||||
|
//
|
||||||
|
// With NOTHING selected the native menu is left alone: it still carries
|
||||||
|
// the browser-level items (reload, inspect) and suppressing it there
|
||||||
|
// would take them away to offer nothing in return.
|
||||||
|
if (!this.terminal?.hasSelection?.()) return;
|
||||||
|
const selection = this.terminal.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
void this.copyTerminalSelection(selection);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
@@ -4853,6 +4872,51 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
|
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make Shift+drag START a selection instead of trying to extend one.
|
||||||
|
*
|
||||||
|
* In a native terminal running a mouse-tracking TUI (claude, codex), Shift is
|
||||||
|
* the "let me select text" modifier: it bypasses the app's mouse reporting so
|
||||||
|
* the emulator selects locally. Users bring that habit here, and here it did
|
||||||
|
* NOTHING — Shift+drag selected no text at all (measured).
|
||||||
|
*
|
||||||
|
* The reason is that the habit and xterm's Shift mean different things once
|
||||||
|
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
|
||||||
|
* the app actually has mouse tracking on; the server strips those DECSETs for
|
||||||
|
* claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is
|
||||||
|
* permanently `none`, that branch is unreachable, and Shift instead falls into
|
||||||
|
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
|
||||||
|
* when `selectionStart` is null, so the drag never anchors and no selection is
|
||||||
|
* ever built (this is why nothing gets cleared: there was nothing to clear).
|
||||||
|
*
|
||||||
|
* So plant the anchor xterm is missing. Runs in the CAPTURE phase on the
|
||||||
|
* `.xterm` root, an ancestor of the `.xterm-screen` element SelectionService
|
||||||
|
* binds to, so it lands before xterm's own mousedown; xterm's incremental
|
||||||
|
* handler then extends from our anchor and the drag behaves like a plain one.
|
||||||
|
* A Shift+drag with a selection ALREADY up is left alone — that is a genuine
|
||||||
|
* extend gesture and xterm already does it right.
|
||||||
|
*/
|
||||||
|
_installShiftDragSelection() {
|
||||||
|
const el = this.terminal?.element;
|
||||||
|
if (!el || el._codemanShiftDragInstalled) return;
|
||||||
|
el._codemanShiftDragInstalled = true;
|
||||||
|
el.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(ev) => {
|
||||||
|
if (!ev.isTrusted || ev.button !== 0 || !ev.shiftKey) return;
|
||||||
|
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||||
|
if (this.terminal?.hasSelection?.()) return;
|
||||||
|
const pos = this._clientPointToCell(ev.clientX, ev.clientY);
|
||||||
|
if (!pos) return;
|
||||||
|
// _clientPointToCell is 1-based and viewport-relative; select() takes a
|
||||||
|
// 0-based column and an ABSOLUTE buffer row.
|
||||||
|
const viewportY = this.terminal.buffer?.active?.viewportY ?? 0;
|
||||||
|
this.terminal.select(pos.col - 1, pos.row - 1 + viewportY, 0);
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
_installMobileTapMouseGuard() {
|
_installMobileTapMouseGuard() {
|
||||||
const el = this.terminal?.element;
|
const el = this.terminal?.element;
|
||||||
if (!el || el._codemanTapMouseGuardInstalled) return;
|
if (!el || el._codemanTapMouseGuardInstalled) return;
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._webviewFrameLru = this._webviewFrameLru || [];
|
this._webviewFrameLru = this._webviewFrameLru || [];
|
||||||
|
|
||||||
await this.refreshWebviews();
|
await this.refreshWebviews();
|
||||||
|
this._installWebviewLostListener();
|
||||||
|
|
||||||
// Restore the previously open web tabs (per device: which dashboards you keep
|
// Restore the previously open web tabs (per device: which dashboards you keep
|
||||||
// open is a workspace-layout choice, not something to sync across machines).
|
// open is a workspace-layout choice, not something to sync across machines).
|
||||||
@@ -231,6 +232,55 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take back a frame that navigated itself off its proxy prefix.
|
||||||
|
*
|
||||||
|
* The proxy's runtime shim masks `/webview/<cap>/` off the document URL so a
|
||||||
|
* single-page app routes on the path it expects. A navigation the page then
|
||||||
|
* starts itself — `location.reload()` (a dev server's full-reload HMR), a
|
||||||
|
* root-absolute `location.href = '/login'` — lands on Codeman's root with no
|
||||||
|
* capability, where the server answers a static page that does nothing but
|
||||||
|
* post `{type:'codeman:webview-lost', path}` here. The frame is identified by
|
||||||
|
* `event.source` against the iframes this tab mounted (never by the payload),
|
||||||
|
* and remounted inside the prefix at that path. Bounded per frame so a page
|
||||||
|
* that reloads itself on every boot cannot spin.
|
||||||
|
*/
|
||||||
|
_installWebviewLostListener() {
|
||||||
|
if (this._webviewLostListener) return;
|
||||||
|
this._webviewLostListener = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || typeof data !== 'object' || data.type !== 'codeman:webview-lost') return;
|
||||||
|
if (typeof data.path !== 'string' || !event.source) return;
|
||||||
|
const layer = document.getElementById('webviewLayer');
|
||||||
|
if (!layer) return;
|
||||||
|
for (const wrap of layer.querySelectorAll('.webview-frame')) {
|
||||||
|
const frame = wrap.querySelector('iframe');
|
||||||
|
if (!frame || frame.contentWindow !== event.source) continue;
|
||||||
|
const id = wrap.dataset.webviewId;
|
||||||
|
if (!id || !this.webviews?.has(id)) return;
|
||||||
|
const now = Date.now();
|
||||||
|
this._webviewRecoveries = this._webviewRecoveries || new Map();
|
||||||
|
const recent = (this._webviewRecoveries.get(id) || []).filter((at) => now - at < 60000);
|
||||||
|
if (recent.length >= 5) return;
|
||||||
|
recent.push(now);
|
||||||
|
this._webviewRecoveries.set(id, recent);
|
||||||
|
// Path only, never an origin. Three spellings would resolve to a foreign
|
||||||
|
// origin (in direct mode `new URL(path, src)` is the frame's src, so the
|
||||||
|
// frame would remount there): the protocol-relative `//host/x`; a
|
||||||
|
// backslash, which the WHATWG parser treats as `/` for http(s), so
|
||||||
|
// `/\host/x` too; and an ASCII tab or newline, which the parser deletes
|
||||||
|
// before it looks at anything, so `/<tab>/host/x` IS `//host/x` by the time
|
||||||
|
// it resolves. Drop the invisible ones, collapse the leading separators to
|
||||||
|
// one `/`, and refuse whatever still opens a second one. The proxied form
|
||||||
|
// is refused server-side as well (resolveUpstreamUrl).
|
||||||
|
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
||||||
|
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', this._webviewLostListener);
|
||||||
|
},
|
||||||
|
|
||||||
_persistWebviewOrder() {
|
_persistWebviewOrder() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
||||||
@@ -328,13 +378,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (data.webview) this.webviews.set(id, data.webview);
|
if (data.webview) this.webviews.set(id, data.webview);
|
||||||
|
|
||||||
let src = data.embedUrl || data.webview?.url || webview.url;
|
let src = data.embedUrl || data.webview?.url || webview.url;
|
||||||
const path = typeof options.path === 'string' ? options.path : '';
|
// A string `path` (even '') means "go there": the proxy prefix is
|
||||||
|
// `/webview/<cap>/` and the wildcard rides after it; in direct mode the deep
|
||||||
|
// link resolves against the dashboard's own origin. No `path` means "show
|
||||||
|
// the tab", leaving a mounted frame on whatever page it reached.
|
||||||
|
const path = typeof options.path === 'string' ? options.path : null;
|
||||||
if (path) {
|
if (path) {
|
||||||
// The proxy prefix is `/webview/<cap>/`; a wildcard rides after it. In
|
|
||||||
// direct mode the deep link resolves against the dashboard's own origin.
|
|
||||||
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
||||||
}
|
}
|
||||||
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
|
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: path !== null });
|
||||||
this.activeWebviewId = id;
|
this.activeWebviewId = id;
|
||||||
this.hideWelcome?.();
|
this.hideWelcome?.();
|
||||||
document.querySelector('.main')?.classList.add('webview-active');
|
document.querySelector('.main')?.classList.add('webview-active');
|
||||||
|
|||||||
@@ -88,11 +88,43 @@ export function validateSessionFilePath(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const relativePath = relative(resolvedWorkingDir, resolvedPath);
|
return confineToRoot(resolvedWorkingDir, resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
|
||||||
|
* WITHOUT touching the filesystem.
|
||||||
|
*
|
||||||
|
* Needed for remote-SSH cases (`src/remote-files.ts`), where `workingDir` is an
|
||||||
|
* absolute path on the REMOTE host and any local `realpathSync` fails by
|
||||||
|
* construction — which is how every file-raw/file-content request in a remote case
|
||||||
|
* used to end up as a 404 before a single byte was read. The caller follows this
|
||||||
|
* pre-check with a remote realpath + the same containment rule, so escapes are
|
||||||
|
* refused exactly as they are locally; what changes is only WHICH filesystem
|
||||||
|
* resolves the symlinks.
|
||||||
|
*
|
||||||
|
* A lexical check alone would follow nothing, so it must never be the last word for
|
||||||
|
* a path that can contain a symlink — it is the cheap reject in front of the real
|
||||||
|
* (local or remote) resolution, not a replacement for it.
|
||||||
|
*/
|
||||||
|
export function validateSessionFilePathLexical(
|
||||||
|
sessionWorkingDir: string,
|
||||||
|
filePath: string
|
||||||
|
): { resolvedPath: string; relativePath: string } | null {
|
||||||
|
return confineToRoot(resolve(sessionWorkingDir), resolve(sessionWorkingDir, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared containment rule: `candidate` must sit inside `root` (both already
|
||||||
|
* canonical for their filesystem). `relative()` is the whole test — a `..` or an
|
||||||
|
* absolute result means the candidate escaped.
|
||||||
|
*/
|
||||||
|
function confineToRoot(root: string, candidate: string): { resolvedPath: string; relativePath: string } | null {
|
||||||
|
const relativePath = relative(root, candidate);
|
||||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { resolvedPath, relativePath };
|
return { resolvedPath: candidate, relativePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import {
|
|||||||
dockerContainerName,
|
dockerContainerName,
|
||||||
dockerDisplayPath,
|
dockerDisplayPath,
|
||||||
probeAdoptableContainer,
|
probeAdoptableContainer,
|
||||||
|
classifyAdoptContainerConflict,
|
||||||
listDockerContainers,
|
listDockerContainers,
|
||||||
browseInContainer,
|
browseInContainer,
|
||||||
dockerAdoptProbeModes,
|
dockerAdoptProbeModes,
|
||||||
@@ -327,6 +328,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
container,
|
container,
|
||||||
image: host.image,
|
image: host.image,
|
||||||
path: dockerCase.hostWorkspacePath,
|
path: dockerCase.hostWorkspacePath,
|
||||||
|
containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath,
|
||||||
network: host.network ?? 'bridge',
|
network: host.network ?? 'bridge',
|
||||||
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
|
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
|
||||||
...(dockerCase.owned === false ? { owned: false } : {}),
|
...(dockerCase.owned === false ? { owned: false } : {}),
|
||||||
@@ -906,12 +908,35 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
) {
|
) {
|
||||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||||
}
|
}
|
||||||
// Two cases must never share one adopted container: session close kills the
|
// One container may back SEVERAL adopted cases, each pointing at a different
|
||||||
// in-container tmux by session id, but a shared adoption would let one case's
|
// directory inside it. What still blocks it, and why, lives in
|
||||||
// teardown and another's launch race over the same tmux server.
|
// classifyAdoptContainerConflict — note that none of it is about the shared
|
||||||
|
// in-container tmux server, which is safe precisely because sessions there
|
||||||
|
// are named per SESSION id (`codeman-dkr-<id8>`), never per case.
|
||||||
const container = dockerCase.container;
|
const container = dockerCase.container;
|
||||||
if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) {
|
const conflict = classifyAdoptContainerConflict({
|
||||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`);
|
container,
|
||||||
|
containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath,
|
||||||
|
existing: dockerCases,
|
||||||
|
canAccess: (owner) => canAccessOwned(getAuthUser(req), owner),
|
||||||
|
});
|
||||||
|
if (conflict?.kind === 'owned-case') {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.ALREADY_EXISTS,
|
||||||
|
`Container "${container}" belongs to case "${conflict.caseName}", which Codeman created and whose lifecycle it manages. Adopt a container you started yourself, or open that case directly.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conflict?.kind === 'other-owner') {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.FORBIDDEN,
|
||||||
|
`Container "${container}" is already adopted by another user.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conflict?.kind === 'duplicate') {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.ALREADY_EXISTS,
|
||||||
|
`Case "${conflict.caseName}" already adopts "${container}" at that same directory. Point this one at another directory inside the container.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||||
@@ -1583,7 +1608,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
container,
|
container,
|
||||||
image: host.image,
|
image: host.image,
|
||||||
path: dockerCase.hostWorkspacePath,
|
path: dockerCase.hostWorkspacePath,
|
||||||
|
containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath,
|
||||||
network: host.network ?? 'bridge',
|
network: host.network ?? 'bridge',
|
||||||
|
...(dockerCase.owned === false ? { owned: false } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Custom Model Endpoint Profiles CRUD + discovery
|
||||||
|
* (docs/custom-model-endpoints-plan.md). Endpoints are machine-level infra,
|
||||||
|
* like remote/docker hosts, so writes are admin-only in multi-user mode
|
||||||
|
* (`case-routes.ts`'s `/api/remote-hosts` is the pattern this mirrors).
|
||||||
|
*
|
||||||
|
* Discovery (`POST /:id/discover-models`) fetches `${baseUrl}/v1/models`
|
||||||
|
* through `webviewFetch()` (`webview-egress.ts`), the same guarded dispatcher
|
||||||
|
* the web-tab proxy uses: `baseUrl` is refused at save time by the schema's
|
||||||
|
* hostname check (link-local / cloud-metadata literals and names), and the
|
||||||
|
* undici lookup hook refuses a name that RESOLVES into one of those ranges at
|
||||||
|
* connect time, redirects included — a save-time hostname check alone would
|
||||||
|
* let `models.example` resolve to 169.254.169.254 later. The endpoint is
|
||||||
|
* admin-configured, so this is defence in depth rather than the only gate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||||
|
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||||
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||||
|
import { getDataDir } from '../../config/instance.js';
|
||||||
|
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
|
||||||
|
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
|
||||||
|
import { CustomModelHostSchema } from '../schemas.js';
|
||||||
|
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
|
||||||
|
|
||||||
|
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||||
|
const DISCOVER_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null {
|
||||||
|
if (!isMultiUserMode() || isAdmin(req)) return null;
|
||||||
|
reply.code(403);
|
||||||
|
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverModels(host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>): Promise<string[]> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const apiKey = host.apiKey?.trim();
|
||||||
|
// Exactly ONE header, never both — see custom-model-hosts.ts's CustomModelAuthStyle
|
||||||
|
// doc comment for why: sending both reliably HANGS some real servers.
|
||||||
|
const style = host.authStyle ?? 'bearer';
|
||||||
|
if (apiKey && style === 'bearer') headers.Authorization = `Bearer ${apiKey}`;
|
||||||
|
if (apiKey && style === 'api-key') headers['api-key'] = apiKey;
|
||||||
|
|
||||||
|
const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/v1/models`), {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as { data?: Array<{ id?: unknown }> };
|
||||||
|
return (body.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* undici reports every network failure as `TypeError('fetch failed', { cause })`, with the
|
||||||
|
* useful part (`connect ECONNREFUSED 127.0.0.1:8080`) one level down; surface the deepest
|
||||||
|
* message so the user sees the refused connection, not the wrapper.
|
||||||
|
*/
|
||||||
|
function describeFetchError(err: unknown): string {
|
||||||
|
let message = err instanceof Error ? err.message : String(err);
|
||||||
|
let current: unknown = err;
|
||||||
|
for (let depth = 0; depth < 5 && current instanceof Error && current.cause !== undefined; depth++) {
|
||||||
|
current = current.cause;
|
||||||
|
if (current instanceof Error && current.message) message = current.message;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/model-endpoints', async (req) =>
|
||||||
|
isMultiUserMode() && !isAdmin(req) ? [] : readCustomModelHosts(CODEMAN_CONFIG_DIR)
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/api/model-endpoints', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||||
|
const denied = adminOnly(req, reply);
|
||||||
|
if (denied) return denied;
|
||||||
|
const host = parseBody(CustomModelHostSchema, req.body);
|
||||||
|
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||||
|
}
|
||||||
|
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||||
|
if (hosts.some((item) => item.id === host.id)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
|
||||||
|
}
|
||||||
|
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||||
|
return { success: true, data: { host } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||||
|
const denied = adminOnly(req, reply);
|
||||||
|
if (denied) return denied;
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const host = parseBody(CustomModelHostSchema, { ...(req.body as object), id });
|
||||||
|
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||||
|
}
|
||||||
|
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||||
|
const index = hosts.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||||
|
const next = [...hosts];
|
||||||
|
next[index] = host;
|
||||||
|
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||||
|
return { success: true, data: { host } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||||
|
const denied = adminOnly(req, reply);
|
||||||
|
if (denied) return denied;
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||||
|
await writeCustomModelHosts(
|
||||||
|
CODEMAN_CONFIG_DIR,
|
||||||
|
hosts.filter((item) => item.id !== id)
|
||||||
|
);
|
||||||
|
return { success: true, data: { id } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/model-endpoints/:id/discover-models',
|
||||||
|
async (req, reply): Promise<ApiResponse<{ models: string[] }>> => {
|
||||||
|
const denied = adminOnly(req, reply);
|
||||||
|
if (denied) return denied;
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||||
|
const index = hosts.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||||
|
const host = hosts[index];
|
||||||
|
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const models = await discoverModels(host);
|
||||||
|
const next = [...hosts];
|
||||||
|
next[index] = { ...host, models, lastDiscoveredAt: new Date().toISOString() };
|
||||||
|
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||||
|
return { success: true, data: { models } };
|
||||||
|
} catch (err) {
|
||||||
|
const blocked = egressBlockedReason(err);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
blocked ? `Endpoint refused: ${blocked}` : `Could not reach endpoint: ${describeFetchError(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+670
-105
File diff suppressed because it is too large
Load Diff
@@ -27,3 +27,4 @@ export { registerWsRoutes } from './ws-routes.js';
|
|||||||
export { registerVoiceRoutes } from './voice-routes.js';
|
export { registerVoiceRoutes } from './voice-routes.js';
|
||||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||||
|
export { registerCustomModelRoutes } from './custom-model-routes.js';
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ import {
|
|||||||
SessionOrderUpdateSchema,
|
SessionOrderUpdateSchema,
|
||||||
SessionWaitQuerySchema,
|
SessionWaitQuerySchema,
|
||||||
SessionWaitOutputQuerySchema,
|
SessionWaitOutputQuerySchema,
|
||||||
|
CustomModelSelectionSchema,
|
||||||
} from '../schemas.js';
|
} from '../schemas.js';
|
||||||
|
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
||||||
|
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
||||||
|
import { matchesPattern } from '../../config/cli-registry/patterns.js';
|
||||||
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
||||||
import { TabLayoutValidationError } from '../../tab-layout.js';
|
import { TabLayoutValidationError } from '../../tab-layout.js';
|
||||||
import {
|
import {
|
||||||
@@ -1152,6 +1156,102 @@ export function registerSessionRoutes(
|
|||||||
return { color: session.color };
|
return { color: session.color };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) ==========
|
||||||
|
//
|
||||||
|
// Applies (or clears) a session's custom OpenAI-compatible endpoint selection and
|
||||||
|
// RESTARTS the pane's CLI process — these harnesses read endpoint config at process
|
||||||
|
// start, not per-turn, so a live hot-swap isn't possible (confirmed with the
|
||||||
|
// maintainer). Endpoints come from the admin-configured custom-model-hosts store
|
||||||
|
// (chunk 3's CRUD routes), never raw client-supplied env — that's what keeps this
|
||||||
|
// route safe to let any session owner call for their own session, unlike the
|
||||||
|
// generic envOverrides field the privilegedEnvKeys clamp exists to guard.
|
||||||
|
//
|
||||||
|
// ⚠️ Local sessions only for now. A remote session's `restartCli()` renders
|
||||||
|
// `ssh ... tmux new-session -A`, which reattaches the durable remote tmux rather than
|
||||||
|
// restarting the agent, and the env lands on the LOCAL pane running ssh, which
|
||||||
|
// forwards nothing; docker is the same attach-or-create shape. Both used to answer
|
||||||
|
// `restarted: true` and change nothing, so they are refused until those paths are
|
||||||
|
// plumbed (the env would have to ride the remote/in-container launch command).
|
||||||
|
app.post('/api/sessions/:id/custom-model', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = parseBody(CustomModelSelectionSchema, req.body, 'Invalid request body');
|
||||||
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
|
if (session.remote || session.docker) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (session.isBusy()) {
|
||||||
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('clear' in body) {
|
||||||
|
const { previousConfigDir } = session.setCustomModel(undefined);
|
||||||
|
removeConfigDir(previousConfigDir);
|
||||||
|
const restarted = await session.restartCli();
|
||||||
|
persistAndBroadcastSession(ctx, session);
|
||||||
|
return { customModel: session.customModel, restarted };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getCli(session.mode);
|
||||||
|
if (!entry) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `No CLI registry entry for mode ${session.mode}`);
|
||||||
|
}
|
||||||
|
if (entry.capabilities.customModelInjection.kind === 'unsupported') {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = await readCustomModelHosts(getDataDir());
|
||||||
|
const endpoint = hosts.find((h) => h.id === body.endpointId);
|
||||||
|
if (!endpoint) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CLI whose config alone cannot select the model also gets its `model` launch param
|
||||||
|
// forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that
|
||||||
|
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
||||||
|
// own default provider again, so refuse an id the pattern cannot carry up front.
|
||||||
|
const modelSpec = entry.launch.params.model;
|
||||||
|
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id);
|
||||||
|
if (!applied) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
applied.launchModel !== undefined &&
|
||||||
|
modelSpec?.type === 'token' &&
|
||||||
|
!matchesPattern(modelSpec.pattern, applied.launchModel)
|
||||||
|
) {
|
||||||
|
removeConfigDir(applied.configDir);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previousConfigDir } = session.setCustomModel(
|
||||||
|
{
|
||||||
|
endpointId: endpoint.id,
|
||||||
|
modelId: body.modelId,
|
||||||
|
label: endpoint.label,
|
||||||
|
envKeys: applied.envKeys,
|
||||||
|
configDir: applied.configDir,
|
||||||
|
launchModel: applied.launchModel,
|
||||||
|
},
|
||||||
|
applied.envOverrides
|
||||||
|
);
|
||||||
|
// Clean up the OLD config dir on disk, unless the new one happens to reuse the same
|
||||||
|
// path (same session, configDir kind again) — never delete the dir we just wrote.
|
||||||
|
if (previousConfigDir && previousConfigDir !== applied.configDir) {
|
||||||
|
removeConfigDir(previousConfigDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restarted = await session.restartCli();
|
||||||
|
persistAndBroadcastSession(ctx, session);
|
||||||
|
return { customModel: session.customModel, restarted };
|
||||||
|
});
|
||||||
|
|
||||||
// ========== Delete Session ==========
|
// ========== Delete Session ==========
|
||||||
|
|
||||||
app.delete('/api/sessions/:id', async (req) => {
|
app.delete('/api/sessions/:id', async (req) => {
|
||||||
@@ -1495,6 +1595,9 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||||
|
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
||||||
|
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
||||||
|
// approvals write through the session directly and never say so).
|
||||||
//
|
//
|
||||||
// Because the response has already been sent by then, a failure there is the
|
// Because the response has already been sent by then, a failure there is the
|
||||||
// one case the caller can never learn about — so the dedup bookkeeping is
|
// one case the caller can never learn about — so the dedup bookkeeping is
|
||||||
@@ -1517,32 +1620,32 @@ export function registerSessionRoutes(
|
|||||||
} else if (useMux && waitPromise) {
|
} else if (useMux && waitPromise) {
|
||||||
// The response is already staying open for the wait, so the tmux write can be
|
// The response is already staying open for the wait, so the tmux write can be
|
||||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
delivered = true;
|
delivered = true;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||||
delivered = session.write(inputStr);
|
delivered = session.write(inputStr, { fromUser: true });
|
||||||
if (!delivered) undoOnFailure();
|
if (!delivered) undoOnFailure();
|
||||||
}
|
}
|
||||||
} else if (useMux) {
|
} else if (useMux) {
|
||||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||||
session
|
session
|
||||||
.writeViaMux(inputStr)
|
.writeViaMux(inputStr, { fromUser: true })
|
||||||
.then((ok) => {
|
.then((ok) => {
|
||||||
if (ok) return;
|
if (ok) return;
|
||||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||||
if (!session.write(inputStr)) undoOnFailure();
|
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!session.write(inputStr)) undoOnFailure();
|
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Same rollback. NOT an error response, deliberately: a session can
|
// Same rollback. NOT an error response, deliberately: a session can
|
||||||
// legitimately have no PTY yet (created but not started), and callers have
|
// legitimately have no PTY yet (created but not started), and callers have
|
||||||
// always been able to write to one without a 4xx.
|
// always been able to write to one without a 4xx.
|
||||||
delivered = session.write(inputStr);
|
delivered = session.write(inputStr, { fromUser: true });
|
||||||
if (!delivered && tagged) {
|
if (!delivered && tagged) {
|
||||||
session.forgetInputSeq(clientId as string, seq as number);
|
session.forgetInputSeq(clientId as string, seq as number);
|
||||||
}
|
}
|
||||||
@@ -1791,6 +1894,9 @@ export function registerSessionRoutes(
|
|||||||
console.error('[Server] send-key failed:', err);
|
console.error('[Server] send-key failed:', err);
|
||||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||||
}
|
}
|
||||||
|
// The bytes bypassed the session's write path, so tell the auto-name
|
||||||
|
// tracker about them or the two lines of a prompt join with no separator.
|
||||||
|
session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,15 +185,39 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
|||||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||||
// and re-asserts the desktop layout after a mobile override.
|
// and re-asserts the desktop layout after a mobile override.
|
||||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||||
delivered = session.write(msg.d);
|
// Browser keystrokes are the user's own, so they may name the tab.
|
||||||
|
delivered = session.write(msg.d, { fromUser: true });
|
||||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||||
// the client to drop the frame from its durable queue and left the seq
|
// the client to drop the frame from its durable queue and left the seq
|
||||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||||
// a duplicate: the input was lost for good.
|
// a duplicate: the input was lost for good.
|
||||||
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
|
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
|
||||||
}
|
}
|
||||||
if (delivered && seq !== null && socket.readyState === 1) {
|
if (seq !== null && socket.readyState === 1) {
|
||||||
socket.send(`{"t":"ia","seq":${seq}}`);
|
if (apply) {
|
||||||
|
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
|
||||||
|
} else {
|
||||||
|
// REJECTED as a duplicate. ACK it — the client must still drop it
|
||||||
|
// from its durable queue — but say so, and hand back our watermark.
|
||||||
|
//
|
||||||
|
// A plain ACK here is indistinguishable from "applied", which is
|
||||||
|
// what made a client with a rolled-back counter unrecoverable: its
|
||||||
|
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
|
||||||
|
// between a send and that write comes back with a counter BELOW this
|
||||||
|
// watermark, every later keystroke lands at or under it, and each one
|
||||||
|
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
|
||||||
|
// a reload restores the same stale counter. `last` is what lets the
|
||||||
|
// client lift itself out.
|
||||||
|
// ⚠️ Defensive: the session arrives through a structural port, and an
|
||||||
|
// implementation without this method must not take the whole input
|
||||||
|
// path down with it — a throw here aborts the message handler and the
|
||||||
|
// frame is never ACKed at all, which strands it in the client's queue.
|
||||||
|
const watermark =
|
||||||
|
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
|
||||||
|
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
|
||||||
|
: seq;
|
||||||
|
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
msg.t === 'z' &&
|
msg.t === 'z' &&
|
||||||
|
|||||||
@@ -1230,6 +1230,13 @@ export const SettingsUpdateSchema = z
|
|||||||
* already pending immediately.
|
* already pending immediately.
|
||||||
*/
|
*/
|
||||||
approvalsInboxEnabled: z.boolean().optional(),
|
approvalsInboxEnabled: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
|
||||||
|
* prompt as a title (`w3-case: fix the login redirect`). Synced, default
|
||||||
|
* OFF: the prompt lands in mux-sessions.json, every session:updated
|
||||||
|
* broadcast and /api/search, which is the user's choice to make.
|
||||||
|
*/
|
||||||
|
autoNameSessions: z.boolean().optional(),
|
||||||
/**
|
/**
|
||||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||||
@@ -1237,6 +1244,13 @@ export const SettingsUpdateSchema = z
|
|||||||
* stored profiles stay until DELETE /api/sessions/:id/intent.
|
* stored profiles stay until DELETE /api/sessions/:id/intent.
|
||||||
*/
|
*/
|
||||||
readMyMindEnabled: z.boolean().optional(),
|
readMyMindEnabled: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the toolbar picker that lets a
|
||||||
|
* session point at a user-configured custom OpenAI-compatible endpoint (local or
|
||||||
|
* cloud) instead of its native cloud backend. SYNCED, default OFF — endpoint entry,
|
||||||
|
* discovery, and the extra toolbar surface are all opt-in.
|
||||||
|
*/
|
||||||
|
customModelEndpointsEnabled: z.boolean().optional(),
|
||||||
/**
|
/**
|
||||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||||
* default (opus: prediction quality is the product and it runs only on an
|
* default (opus: prediction quality is the product and it runs only on an
|
||||||
@@ -1888,3 +1902,29 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
|||||||
|
|
||||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||||
|
|
||||||
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — a
|
||||||
|
// user-configured custom OpenAI-compatible endpoint, local (llama.cpp) or cloud
|
||||||
|
// (Azure AI Foundry, etc.). Lives below `webviewUrlSchema` because `baseUrl` IS that
|
||||||
|
// schema: http(s) only, a real hostname, no embedded credentials, and the link-local /
|
||||||
|
// cloud-metadata refusal, the same bar a saved dashboard URL has to clear.
|
||||||
|
export const CustomModelHostSchema = z.object({
|
||||||
|
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid endpoint id'),
|
||||||
|
label: z.string().min(1).max(100),
|
||||||
|
baseUrl: webviewUrlSchema,
|
||||||
|
apiKey: z.string().max(4096).optional(),
|
||||||
|
// No 'both': live-tested against a real server, sending both auth header
|
||||||
|
// conventions on one request reliably HANGS it — see custom-model-hosts.ts.
|
||||||
|
authStyle: z.enum(['bearer', 'api-key']).optional(),
|
||||||
|
models: z.array(z.string().max(200)).max(200).optional(),
|
||||||
|
lastDiscoveredAt: z.string().max(64).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
|
||||||
|
export const CustomModelSelectionSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
endpointId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid endpoint id'),
|
||||||
|
modelId: z.string().min(1).max(200),
|
||||||
|
}),
|
||||||
|
z.object({ clear: z.literal(true) }),
|
||||||
|
]);
|
||||||
|
|||||||
+80
-2
@@ -67,6 +67,10 @@ import {
|
|||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||||
|
import { getCli } from '../config/cli-registry/registry.js';
|
||||||
|
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||||
|
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
|
||||||
|
import type { CustomModelBookkeeping } from '../types/session.js';
|
||||||
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
|
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
|
||||||
import {
|
import {
|
||||||
buildDetectedAttachmentHistoryItem,
|
buildDetectedAttachmentHistoryItem,
|
||||||
@@ -148,7 +152,13 @@ import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest
|
|||||||
import { telemetrySignature } from '../usage-telemetry.js';
|
import { telemetrySignature } from '../usage-telemetry.js';
|
||||||
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
||||||
import type { ScheduledRun } from './ports/index.js';
|
import type { ScheduledRun } from './ports/index.js';
|
||||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
import {
|
||||||
|
registerAuthMiddleware,
|
||||||
|
registerSecurityHeaders,
|
||||||
|
registerHostGuard,
|
||||||
|
isLostWebviewRootFrame,
|
||||||
|
sendLostWebviewFramePage,
|
||||||
|
} from './middleware/auth.js';
|
||||||
import { isMultiUserMode } from '../config/multiuser.js';
|
import { isMultiUserMode } from '../config/multiuser.js';
|
||||||
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
||||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||||
@@ -179,8 +189,10 @@ import {
|
|||||||
registerVoiceRoutes,
|
registerVoiceRoutes,
|
||||||
registerWebviewRoutes,
|
registerWebviewRoutes,
|
||||||
registerTabLayoutRoutes,
|
registerTabLayoutRoutes,
|
||||||
|
registerCustomModelRoutes,
|
||||||
tryWebviewRefererFallback,
|
tryWebviewRefererFallback,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
|
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||||
import { CronService } from '../cron/cron-service.js';
|
import { CronService } from '../cron/cron-service.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -804,7 +816,14 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Security headers + CORS
|
// Security headers + CORS
|
||||||
registerSecurityHeaders(this.app, this.https, this.basePath);
|
registerSecurityHeaders(this.app, this.https, this.basePath);
|
||||||
this.app.get('/', async (_req, reply) => {
|
this.app.get('/', async (req, reply) => {
|
||||||
|
// A web-tab frame that reloaded on its dashboard's landing page. The proxy's
|
||||||
|
// runtime shim maps `/webview/<cap>/` to exactly `/`, so that reload asks for
|
||||||
|
// Codeman's own root as an iframe navigation, and it used to get the app
|
||||||
|
// shell rendered inside the web tab. Only the credential-free form is taken
|
||||||
|
// (nothing in Codeman frames its root; the sandboxed frame has no cookie and
|
||||||
|
// no Authorization); under a password the auth hook has answered it already.
|
||||||
|
if (isLostWebviewRootFrame(req)) return sendLostWebviewFramePage(reply);
|
||||||
return reply
|
return reply
|
||||||
.header('Cache-Control', 'no-cache')
|
.header('Cache-Control', 'no-cache')
|
||||||
.type('text/html; charset=utf-8')
|
.type('text/html; charset=utf-8')
|
||||||
@@ -976,6 +995,11 @@ export class WebServer extends EventEmitter {
|
|||||||
// and the relay declines unless the Referer carries a live capability, so
|
// and the relay declines unless the Referer carries a live capability, so
|
||||||
// genuinely unknown `/api` paths still get the envelope below.
|
// genuinely unknown `/api` paths still get the envelope below.
|
||||||
if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply;
|
if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply;
|
||||||
|
// An authenticated web-tab frame (Basic auth, or trusted mode with a cookie)
|
||||||
|
// that navigated itself off its proxy prefix: the runtime shim masks the
|
||||||
|
// prefix so the page's router sees its own path, and a reload of that page
|
||||||
|
// lands here. The unauthenticated form is answered in the auth middleware.
|
||||||
|
if (!req.url.startsWith('/api') && isLostWebviewFrameNavigation(req)) return sendLostWebviewFramePage(reply);
|
||||||
if (req.url.startsWith('/api')) {
|
if (req.url.startsWith('/api')) {
|
||||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1075,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerOrchestratorRoutes(this.app, ctx);
|
registerOrchestratorRoutes(this.app, ctx);
|
||||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||||
registerTabLayoutRoutes(this.app, ctx);
|
registerTabLayoutRoutes(this.app, ctx);
|
||||||
|
registerCustomModelRoutes(this.app);
|
||||||
|
|
||||||
// Cron: build the service from the same context, recompute
|
// Cron: build the service from the same context, recompute
|
||||||
// due times for any persisted jobs, then expose it to its routes.
|
// due times for any persisted jobs, then expose it to its routes.
|
||||||
@@ -1160,6 +1185,34 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery half of Custom Model Endpoint Profiles: the env values a selection injects
|
||||||
|
* are never persisted (they carry the API key), so they are computed again from the
|
||||||
|
* endpoint store, through the SAME apply path the route uses. Undefined when the
|
||||||
|
* endpoint is gone or the CLI is unregistered: the bookkeeping is still restored so
|
||||||
|
* the selection can be cleared, and the pane keeps running on tmux's retained env.
|
||||||
|
*/
|
||||||
|
private async _rebuildCustomModelEnv(
|
||||||
|
session: Session,
|
||||||
|
saved: CustomModelBookkeeping
|
||||||
|
): Promise<Record<string, string> | undefined> {
|
||||||
|
const entry = getCli(session.mode);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId);
|
||||||
|
if (!endpoint) {
|
||||||
|
console.warn(
|
||||||
|
`[WebServer] custom-model endpoint ${saved.endpointId} no longer exists; selection kept for clearing`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id)?.envOverrides;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Persists full session state including respawn config to state.json */
|
/** Persists full session state including respawn config to state.json */
|
||||||
private _persistSessionStateNow(session: Session): void {
|
private _persistSessionStateNow(session: Session): void {
|
||||||
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
|
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
|
||||||
@@ -1169,10 +1222,14 @@ export class WebServer extends EventEmitter {
|
|||||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||||
// separate from the sanitized public attachmentHistory in toState().
|
// separate from the sanitized public attachmentHistory in toState().
|
||||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||||
|
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
|
||||||
|
// launch model; never the values) so recovery can restore and later clear it.
|
||||||
|
const customModel = session.getCustomModelForPersist();
|
||||||
const state = {
|
const state = {
|
||||||
...base,
|
...base,
|
||||||
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
||||||
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
|
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
|
||||||
|
...(customModel ? { __customModel: customModel } : {}),
|
||||||
} as SessionState;
|
} as SessionState;
|
||||||
const controller = this.respawnControllers.get(session.id);
|
const controller = this.respawnControllers.get(session.id);
|
||||||
if (controller) {
|
if (controller) {
|
||||||
@@ -1375,6 +1432,9 @@ export class WebServer extends EventEmitter {
|
|||||||
// come back to a loader whose file we deleted.
|
// come back to a loader whose file we deleted.
|
||||||
if (killMux) {
|
if (killMux) {
|
||||||
void removeAgentSessionPreamble(sessionId);
|
void removeAgentSessionPreamble(sessionId);
|
||||||
|
// The per-session custom-model config dir carries the endpoint's API key (pi and
|
||||||
|
// omp embed it literally); it must not outlive the session it was written for.
|
||||||
|
removeConfigDir(customModelConfigDir(sessionId));
|
||||||
}
|
}
|
||||||
await session.stop(killMux);
|
await session.stop(killMux);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
@@ -1668,6 +1728,10 @@ export class WebServer extends EventEmitter {
|
|||||||
getStore: () => this.store,
|
getStore: () => this.store,
|
||||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||||
this.registerAttachment(id, filePath, source),
|
this.registerAttachment(id, filePath, source),
|
||||||
|
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
|
||||||
|
// Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
|
||||||
|
// session:updated broadcast and /api/search, so it is a choice, not a default.
|
||||||
|
isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,10 +1764,12 @@ export class WebServer extends EventEmitter {
|
|||||||
sessionId,
|
sessionId,
|
||||||
filePath,
|
filePath,
|
||||||
sessionWorkingDir: session.workingDir,
|
sessionWorkingDir: session.workingDir,
|
||||||
|
remote: session.remote,
|
||||||
})
|
})
|
||||||
: await registerExternalAttachment(sessionId, filePath, {
|
: await registerExternalAttachment(sessionId, filePath, {
|
||||||
sessionWorkingDir: session.workingDir,
|
sessionWorkingDir: session.workingDir,
|
||||||
forceWorkspaceConfinement: true,
|
forceWorkspaceConfinement: true,
|
||||||
|
remote: session.remote,
|
||||||
});
|
});
|
||||||
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
||||||
if (record) {
|
if (record) {
|
||||||
@@ -2830,6 +2896,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
||||||
// by the Session constructor (env var would hard-lock /effort switching).
|
// by the Session constructor (env var would hard-lock /effort switching).
|
||||||
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
|
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
|
||||||
|
const savedCustomModel = (savedState as { __customModel?: CustomModelBookkeeping })?.__customModel;
|
||||||
// Prefer the private (externalPath-bearing) history; fall back to the
|
// Prefer the private (externalPath-bearing) history; fall back to the
|
||||||
// sanitized public copy for sessions persisted before that split.
|
// sanitized public copy for sessions persisted before that split.
|
||||||
const savedAttachmentHistory =
|
const savedAttachmentHistory =
|
||||||
@@ -2840,6 +2907,7 @@ export class WebServer extends EventEmitter {
|
|||||||
workingDir: muxSession.workingDir,
|
workingDir: muxSession.workingDir,
|
||||||
mode: muxSession.mode,
|
mode: muxSession.mode,
|
||||||
name: sessionName,
|
name: sessionName,
|
||||||
|
nameSource: savedState?.nameSource,
|
||||||
// When the session FIRST started, not when this server booted.
|
// When the session FIRST started, not when this server booted.
|
||||||
// Without it every recovered session was restamped `Date.now()` on
|
// Without it every recovered session was restamped `Date.now()` on
|
||||||
// each restart, so a week-old pane read as "created 2m ago" on the
|
// each restart, so a week-old pane read as "created 2m ago" on the
|
||||||
@@ -2892,6 +2960,16 @@ export class WebServer extends EventEmitter {
|
|||||||
parentSessionId: savedState?.parentSessionId,
|
parentSessionId: savedState?.parentSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom-model selection survives the restart. The tmux session still carries
|
||||||
|
// the injected `setenv`s (that is what kept the pane on the endpoint across the
|
||||||
|
// restart), but `_envOverrides` is rebuilt from a persist that deliberately
|
||||||
|
// excludes them, so re-derive the values from the endpoint store and re-write
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||||
if (savedState?.name && muxSession.name !== savedState.name) {
|
if (savedState?.name && muxSession.name !== savedState.name) {
|
||||||
this.mux.updateSessionName(muxSession.sessionId, savedState.name);
|
this.mux.updateSessionName(muxSession.sessionId, savedState.name);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Extracted from server.ts for modularity. Provides:
|
* Extracted from server.ts for modularity. Provides:
|
||||||
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
||||||
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
|
* - `createSessionListeners()` — builds all session listener handlers via dependency injection
|
||||||
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
||||||
*
|
*
|
||||||
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
||||||
@@ -29,6 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
|||||||
import { fileStreamManager } from '../file-stream-manager.js';
|
import { fileStreamManager } from '../file-stream-manager.js';
|
||||||
import { sessionWaits } from './session-wait-registry.js';
|
import { sessionWaits } from './session-wait-registry.js';
|
||||||
import { approvalInbox } from './approval-inbox.js';
|
import { approvalInbox } from './approval-inbox.js';
|
||||||
|
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||||
|
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||||
|
|
||||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||||
export interface SessionListenerRefs {
|
export interface SessionListenerRefs {
|
||||||
@@ -63,6 +65,7 @@ export interface SessionListenerRefs {
|
|||||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
||||||
|
promptSubmitted: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||||
@@ -83,10 +86,13 @@ interface SessionListenerDeps {
|
|||||||
cleanupRespawnOnExit(sessionId: string): void;
|
cleanupRespawnOnExit(sessionId: string): void;
|
||||||
getStore(): import('../state-store.js').StateStore;
|
getStore(): import('../state-store.js').StateStore;
|
||||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||||
|
updateSessionName(sessionId: string, name: string): boolean;
|
||||||
|
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||||
|
isAutoNameEnabled(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all 26 session listener handlers, capturing dependencies via closure.
|
* Creates all session listener handlers, capturing dependencies via closure.
|
||||||
* Call `attachSessionListeners()` after to wire them to the session.
|
* Call `attachSessionListeners()` after to wire them to the session.
|
||||||
*/
|
*/
|
||||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||||
@@ -451,6 +457,30 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names a placeholder tab after its first real prompt (`w3-case: fix the
|
||||||
|
* login redirect`), behind the synced `autoNameSessions` setting. The
|
||||||
|
* eligibility check comes first so the settings read costs nothing on the
|
||||||
|
* prompts of an already-named session; a prompt that yields no title (a
|
||||||
|
* slash command) leaves the session eligible for the next one.
|
||||||
|
*/
|
||||||
|
promptSubmitted: (prompt: string) => {
|
||||||
|
if (session.nameSource !== 'placeholder') return;
|
||||||
|
const title = deriveAutoSessionName(prompt);
|
||||||
|
if (!title) return;
|
||||||
|
void deps
|
||||||
|
.isAutoNameEnabled()
|
||||||
|
.then((enabled) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
|
||||||
|
if (!session.applyAutoName(name)) return;
|
||||||
|
deps.updateSessionName(session.id, session.name);
|
||||||
|
deps.persistSessionState(session);
|
||||||
|
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +517,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.on('bashToolEnd', refs.bashToolEnd);
|
session.on('bashToolEnd', refs.bashToolEnd);
|
||||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
session.on('attachmentRequested', refs.attachmentRequested);
|
session.on('attachmentRequested', refs.attachmentRequested);
|
||||||
|
session.on('promptSubmitted', refs.promptSubmitted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||||
@@ -522,4 +553,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.off('bashToolEnd', refs.bashToolEnd);
|
session.off('bashToolEnd', refs.bashToolEnd);
|
||||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
session.off('attachmentRequested', refs.attachmentRequested);
|
session.off('attachmentRequested', refs.attachmentRequested);
|
||||||
|
session.off('promptSubmitted', refs.promptSubmitted);
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-1
@@ -38,6 +38,7 @@
|
|||||||
* that everything else here works to preserve.
|
* that everything else here works to preserve.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||||
import { stripBasePath } from '../config/base-path.js';
|
import { stripBasePath } from '../config/base-path.js';
|
||||||
|
|
||||||
@@ -425,6 +426,21 @@ export function runtimeUrlShim(prefix: string): string {
|
|||||||
// and a throw here would break the dashboard rather than fix it.
|
// and a throw here would break the dashboard rather than fix it.
|
||||||
return `<script>(function(){try{
|
return `<script>(function(){try{
|
||||||
var P=${JSON.stringify(prefix)};
|
var P=${JSON.stringify(prefix)};
|
||||||
|
// Route masking. A single-page app reads location.pathname on boot and routes
|
||||||
|
// on it; through the proxy that path starts with /webview/<cap>/, which no app
|
||||||
|
// has a route for, so it rendered its own "page not found" the moment its
|
||||||
|
// script ran — after the HTML and CSS had already painted. Replace the entry
|
||||||
|
// with the path the page would see on its own origin. The base element still resolves
|
||||||
|
// relative URLs inside the prefix, and every root-absolute sink below is
|
||||||
|
// rewritten back into it, so only what the page READS changes. The parent
|
||||||
|
// tab remounts the frame if the page ever navigates itself off the prefix
|
||||||
|
// (see lostWebviewFramePage), which is what makes a masked reload survivable.
|
||||||
|
try{
|
||||||
|
var L=location.pathname;
|
||||||
|
if(L.indexOf(P)===0&&window.history&&typeof history.replaceState==='function'){
|
||||||
|
history.replaceState(history.state,'',L.slice(P.length-1)+location.search+location.hash);
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
function rw(u){
|
function rw(u){
|
||||||
try{
|
try{
|
||||||
if(u==null)return u;
|
if(u==null)return u;
|
||||||
@@ -457,13 +473,24 @@ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
|||||||
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
['WebSocket','EventSource'].forEach(function(k){
|
['WebSocket','EventSource','Worker','SharedWorker'].forEach(function(k){
|
||||||
var C=window[k];if(!C)return;
|
var C=window[k];if(!C)return;
|
||||||
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
||||||
W.prototype=C.prototype;
|
W.prototype=C.prototype;
|
||||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
||||||
window[k]=W;
|
window[k]=W;
|
||||||
});
|
});
|
||||||
|
// With the document URL masked, a request the shim misses can no longer be
|
||||||
|
// rescued by its Referer (that carried the prefix), so the remaining
|
||||||
|
// URL-taking entry points are covered here rather than left to the fallback.
|
||||||
|
if(window.navigator&&typeof navigator.sendBeacon==='function'){
|
||||||
|
var ob=navigator.sendBeacon;
|
||||||
|
navigator.sendBeacon=function(u,d){return ob.call(navigator,rw(u),d);};
|
||||||
|
}
|
||||||
|
if(typeof window.open==='function'){
|
||||||
|
var ow=window.open;
|
||||||
|
window.open=function(u){var a=[].slice.call(arguments);a[0]=rw(u);return ow.apply(this,a);};
|
||||||
|
}
|
||||||
var A=['src','href','action','poster','data','formaction','srcset'];
|
var A=['src','href','action','poster','data','formaction','srcset'];
|
||||||
function rwSet(v){
|
function rwSet(v){
|
||||||
try{
|
try{
|
||||||
@@ -680,3 +707,87 @@ export function upstreamWebSocketUrl(target: URL): string {
|
|||||||
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return ws.href;
|
return ws.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── Lost-frame recovery ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The script the recovery page runs. Kept as a constant so its CSP hash below
|
||||||
|
* is computed from the exact bytes that are served.
|
||||||
|
*/
|
||||||
|
const LOST_FRAME_SCRIPT = `(function(){try{
|
||||||
|
var path=location.pathname+location.search+location.hash;
|
||||||
|
if(window.parent&&window.parent!==window){window.parent.postMessage({type:'codeman:webview-lost',path:path},'*');}
|
||||||
|
}catch(e){}})();`;
|
||||||
|
|
||||||
|
const LOST_FRAME_SCRIPT_HASH = createHash('sha256').update(LOST_FRAME_SCRIPT, 'utf8').digest('base64');
|
||||||
|
|
||||||
|
/** CSP for the recovery page: nothing but its own hashed inline script. */
|
||||||
|
export const LOST_FRAME_PAGE_CSP = `default-src 'none'; script-src 'sha256-${LOST_FRAME_SCRIPT_HASH}'; style-src 'unsafe-inline'`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this request is a web-tab frame that has navigated off its proxy prefix.
|
||||||
|
*
|
||||||
|
* The runtime shim masks `/webview/<cap>/` off the document URL so a single-page
|
||||||
|
* app routes on the path it expects. The price is that a navigation the page
|
||||||
|
* starts ITSELF — `location.reload()` (a dev server's full-reload HMR), a
|
||||||
|
* root-absolute `location.href = '/login'` — now targets Codeman's own root with
|
||||||
|
* no capability anywhere on it: no prefix in the path, no cookie in an
|
||||||
|
* opaque-origin frame, and a Referer that names the masked page. Such a request
|
||||||
|
* is recognisable by shape alone: a top-level navigation of an `<iframe>`
|
||||||
|
* (`Sec-Fetch-Dest`), asking for HTML, for a path Codeman does not serve. The
|
||||||
|
* one served path that still qualifies is `/` itself, which the callers admit
|
||||||
|
* only when the request carries no credentials (see carriesAuthCredentials).
|
||||||
|
*
|
||||||
|
* The answer is `lostWebviewFramePage()`, a static page whose only content is a
|
||||||
|
* `postMessage` to the parent naming the path; the Codeman tab that owns the
|
||||||
|
* frame remounts it inside the prefix at that path. Nothing is exempted from
|
||||||
|
* auth by this except that static page, which carries no data.
|
||||||
|
*/
|
||||||
|
export function isLostWebviewFrameNavigation(req: {
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
}): boolean {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||||
|
const dest = req.headers['sec-fetch-dest'];
|
||||||
|
if (dest !== 'iframe' && dest !== 'frame') return false;
|
||||||
|
const mode = req.headers['sec-fetch-mode'];
|
||||||
|
if (mode !== undefined && mode !== 'navigate') return false;
|
||||||
|
const accept = req.headers.accept;
|
||||||
|
return typeof accept === 'string' && accept.includes('text/html');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request carries something Codeman's auth would recognise: the
|
||||||
|
* session cookie, or an `Authorization` header (Basic auth, which a browser
|
||||||
|
* re-sends on every request to the realm once it has been accepted).
|
||||||
|
*
|
||||||
|
* `/` is the one lost-frame path a registered route also serves (the app shell),
|
||||||
|
* so the route table cannot tell a landing-page reload of a proxied dashboard
|
||||||
|
* (the runtime shim maps `/webview/<cap>/` to exactly `/`) from a genuine
|
||||||
|
* navigation. Credentials can: nothing in Codeman frames its own root, and a
|
||||||
|
* sandboxed web-tab frame is opaque-origin and carries neither, so an `<iframe>`
|
||||||
|
* navigation of `/` with NEITHER credential can only be that frame. A framed
|
||||||
|
* `/` that does carry credentials is left to the shell.
|
||||||
|
*/
|
||||||
|
export function carriesAuthCredentials(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
sessionCookieName: string
|
||||||
|
): boolean {
|
||||||
|
const authorization = headers.authorization;
|
||||||
|
if (Array.isArray(authorization) ? authorization.length > 0 : (authorization ?? '').trim() !== '') return true;
|
||||||
|
const cookie = headers.cookie;
|
||||||
|
const cookies = Array.isArray(cookie) ? cookie.join('; ') : cookie;
|
||||||
|
if (typeof cookies !== 'string' || cookies === '') return false;
|
||||||
|
return cookies.split(';').some((part) => part.trim().startsWith(`${sessionCookieName}=`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The static page that hands a lost frame back to its owning tab. */
|
||||||
|
export function lostWebviewFramePage(): string {
|
||||||
|
return (
|
||||||
|
'<!doctype html><html><head><meta charset="utf-8"><title>Reconnecting</title>' +
|
||||||
|
'<meta name="referrer" content="no-referrer"></head>' +
|
||||||
|
'<body style="margin:0;font:14px system-ui,sans-serif;color:#888;padding:16px">' +
|
||||||
|
'Reconnecting this web tab…' +
|
||||||
|
`<script>${LOST_FRAME_SCRIPT}</script></body></html>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ function expectRejected(mutate: (entry: Record<string, unknown>) => void, becaus
|
|||||||
expect(result.success, `expected rejection: ${because}`).toBe(false);
|
expect(result.success, `expected rejection: ${because}`).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('customModelInjection.launchModel', () => {
|
||||||
|
it('rejects a template with characters the argv engine would have to quote', () => {
|
||||||
|
expectRejected((e) => {
|
||||||
|
const caps = e.capabilities as Record<string, unknown>;
|
||||||
|
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId} --yolo' };
|
||||||
|
}, 'a space in the launch-model template');
|
||||||
|
expectRejected((e) => {
|
||||||
|
const caps = e.capabilities as Record<string, unknown>;
|
||||||
|
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: '' };
|
||||||
|
}, 'an empty launch-model template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the placeholder form the stock entries use', () => {
|
||||||
|
const entry = baseEntry();
|
||||||
|
const caps = entry.capabilities as Record<string, unknown>;
|
||||||
|
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId}' };
|
||||||
|
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the shipped catalog', () => {
|
describe('the shipped catalog', () => {
|
||||||
it('validates every stock entry exactly as shipped', () => {
|
it('validates every stock entry exactly as shipped', () => {
|
||||||
// If this fails, the catalog cannot load at all — every other test here is downstream.
|
// If this fails, the catalog cannot load at all — every other test here is downstream.
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Contract tests for Custom Model Endpoint Profiles
|
||||||
|
* (docs/custom-model-endpoints-plan.md chunk 7): for every CLI with a `customModelInjection`
|
||||||
|
* capability, build the real injection via `buildCustomModelInjection()`,
|
||||||
|
* then replay those exact values through an HTTP request shaped the way that
|
||||||
|
* CLI is documented to send it, against the in-process mock server
|
||||||
|
* (`test/fixtures/mock-openai-server.ts`). Asserts the mock received the
|
||||||
|
* request at the injected base URL, with the injected API key in the
|
||||||
|
* expected header, and the injected model id in the body.
|
||||||
|
*
|
||||||
|
* LIMITATION (stated here and in docs/custom-model-endpoints-plan.md, not left implicit): this
|
||||||
|
* proves "if the CLI honors its documented env/config contract, it will hit
|
||||||
|
* the right endpoint with the right model." It does NOT prove the real CLI
|
||||||
|
* binary actually reads that env var / config file the way its docs say —
|
||||||
|
* that's still the job of `scripts/test-local-llm-harnesses.ts` against a
|
||||||
|
* real endpoint and real binaries. This suite catches regressions in
|
||||||
|
* Codeman's own injection logic; it cannot catch a CLI changing its env-var
|
||||||
|
* name in a future release.
|
||||||
|
*
|
||||||
|
* Port: N/A (mock server binds a random free port, not a fixed one)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { getCli } from '../src/config/cli-registry/index.js';
|
||||||
|
import { buildCustomModelInjection, type CustomModelEndpoint } from '../src/custom-model-injection.js';
|
||||||
|
import { startMockOpenAiServer, type MockOpenAiServer } from './fixtures/mock-openai-server.js';
|
||||||
|
|
||||||
|
let mock: MockOpenAiServer;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mock = await startMockOpenAiServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await mock.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function entryOrThrow(id: string) {
|
||||||
|
const entry = getCli(id);
|
||||||
|
if (!entry) throw new Error(`missing CLI registry entry: ${id}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointFor(mock: MockOpenAiServer): CustomModelEndpoint {
|
||||||
|
return { id: 'ep1', label: 'mock', baseUrl: mock.baseUrl, apiKey: 'contract-test-key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replays an OpenAI-shaped chat-completions call using the given base URL/key/model. */
|
||||||
|
async function callOpenAiCompat(baseUrl: string, apiKey: string, model: string) {
|
||||||
|
return fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'hello world' }] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('custom-model-injection contract (mock server)', () => {
|
||||||
|
it('claude: ANTHROPIC_BASE_URL/API_KEY reach a real Anthropic-shaped /v1/messages call', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('claude'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'env') throw new Error('unreachable');
|
||||||
|
|
||||||
|
await fetch(`${injection.envOverrides.ANTHROPIC_BASE_URL}/v1/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-api-key': injection.envOverrides.ANTHROPIC_API_KEY },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: injection.envOverrides.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||||
|
messages: [{ role: 'user', content: 'hello world' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mock.requests).toHaveLength(1);
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/messages');
|
||||||
|
expect(mock.requests[0].headers['x-api-key']).toBe('contract-test-key');
|
||||||
|
expect((mock.requests[0].body as { model: string }).model).toBe('qwen3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: OPENCODE_CONFIG_CONTENT decodes to a baseURL/apiKey that reach the mock', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('opencode'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'env') throw new Error('unreachable');
|
||||||
|
const config = JSON.parse(injection.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||||
|
const { baseURL, apiKey } = config.provider.custom.options;
|
||||||
|
expect(baseURL).toBe(`${mock.baseUrl}/v1`);
|
||||||
|
|
||||||
|
await callOpenAiCompat(baseURL, apiKey, 'qwen3');
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
expect((mock.requests[0].body as { model: string }).model).toBe('qwen3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('codex: config.toml decodes to a base_url/model, and env_key/extraEnv reach the mock over /v1/responses', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('codex'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
const toml = injection.files[0].content;
|
||||||
|
const baseUrl = /base_url = "([^"]+)"/.exec(toml)?.[1];
|
||||||
|
const model = /^model = "([^"]+)"/m.exec(toml)?.[1];
|
||||||
|
const envKeyName = /env_key = "([^"]+)"/.exec(toml)?.[1];
|
||||||
|
expect(baseUrl).toBe(`${mock.baseUrl}/v1`);
|
||||||
|
expect(model).toBe('qwen3');
|
||||||
|
expect(toml).toContain('wire_api = "responses"');
|
||||||
|
expect(toml).not.toContain('api_key ='); // never a literal TOML field
|
||||||
|
expect(envKeyName).toBe('CODEMAN_CUSTOM_MODEL_API_KEY');
|
||||||
|
expect(injection.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'contract-test-key' });
|
||||||
|
|
||||||
|
// The real credential rides as an env var (env_key names it) — replay it, not a
|
||||||
|
// value read from the file, since the file itself never carries the secret.
|
||||||
|
const apiKey = injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY;
|
||||||
|
await fetch(`${baseUrl}/responses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({ model, input: 'hello world' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/responses');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi: models.json decodes to a baseUrl/apiKey that reach the mock', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('pi'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
const parsed = JSON.parse(injection.files[0].content);
|
||||||
|
const { baseUrl, apiKey } = parsed.providers.custom;
|
||||||
|
expect(baseUrl).toBe(`${mock.baseUrl}/v1`);
|
||||||
|
|
||||||
|
await callOpenAiCompat(baseUrl, apiKey, 'qwen3');
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omp: models.yml decodes to a baseUrl/apiKey that reach the mock', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('omp'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
const yml = injection.files[0].content;
|
||||||
|
const baseUrl = JSON.parse(/baseUrl: (".*")\n/.exec(yml)![1]);
|
||||||
|
const apiKey = JSON.parse(/apiKey: (".*")\n/.exec(yml)![1]);
|
||||||
|
expect(baseUrl).toBe(`${mock.baseUrl}/v1`);
|
||||||
|
|
||||||
|
await callOpenAiCompat(baseUrl, apiKey, 'qwen3');
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
// gemini/deepseek's `env` kind passes the base URL through UNCHANGED (unlike
|
||||||
|
// opencode/codex/pi/omp/grok, which build a structured config and explicitly append
|
||||||
|
// /v1) — matching Anthropic's own convention for claude's ANTHROPIC_BASE_URL, where the
|
||||||
|
// SDK appends the path itself. Whether each of these TWO CLIs' own OpenAI-compatible
|
||||||
|
// client expects the var to already include /v1 (the common OpenAI-SDK convention) or
|
||||||
|
// appends it itself is genuinely CLI-specific and UNVERIFIED (see the confidence table
|
||||||
|
// in docs/custom-model-endpoints-plan.md) — these tests model the common OpenAI-SDK convention (base_url
|
||||||
|
// ends in /v1) since that's the more likely behavior for an OpenAI-compatible client,
|
||||||
|
// but that assumption should be corrected here the moment it's checked against a real
|
||||||
|
// binary. (grok WAS in this group too, until live-testing showed the whole `env` recipe
|
||||||
|
// was wrong for it — see its own test below.)
|
||||||
|
|
||||||
|
it('gemini: GOOGLE_GEMINI_BASE_URL/GEMINI_API_KEY reach the mock', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('gemini'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'env') throw new Error('unreachable');
|
||||||
|
|
||||||
|
await callOpenAiCompat(
|
||||||
|
`${injection.envOverrides.GOOGLE_GEMINI_BASE_URL}/v1`,
|
||||||
|
injection.envOverrides.GEMINI_API_KEY,
|
||||||
|
injection.envOverrides.GEMINI_MODEL
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
expect((mock.requests[0].body as { model: string }).model).toBe('qwen3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grok: config.toml [model.<name>] block base_url/env_key + extraEnv reach the mock over /v1/chat/completions', async () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('grok'), endpointFor(mock), 'qwen3');
|
||||||
|
if (injection.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
const toml = injection.files[0].content;
|
||||||
|
const baseUrl = /base_url = "([^"]+)"/.exec(toml)?.[1];
|
||||||
|
const model = /^model = "([^"]+)"/m.exec(toml)?.[1];
|
||||||
|
expect(baseUrl).toBe(`${mock.baseUrl}/v1`);
|
||||||
|
expect(model).toBe('qwen3');
|
||||||
|
expect(toml).toContain('api_backend = "chat_completions"');
|
||||||
|
expect(injection.extraEnv).toEqual({ XAI_API_KEY: 'contract-test-key' });
|
||||||
|
|
||||||
|
await callOpenAiCompat(baseUrl!, injection.extraEnv!.XAI_API_KEY, model!);
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deepseek: DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY reach the mock (base URL/key only, no model var)', async () => {
|
||||||
|
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']);
|
||||||
|
|
||||||
|
await callOpenAiCompat(
|
||||||
|
`${injection.envOverrides.DEEPSEEK_BASE_URL}/v1`,
|
||||||
|
injection.envOverrides.DEEPSEEK_API_KEY,
|
||||||
|
'qwen3'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||||
|
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: unsupported, never reaches the mock', () => {
|
||||||
|
const injection = buildCustomModelInjection(entryOrThrow('antigravity'), endpointFor(mock), 'qwen3');
|
||||||
|
expect(injection).toEqual({ kind: 'unsupported' });
|
||||||
|
expect(mock.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mock server also answers GET /v1/models for the discovery route', async () => {
|
||||||
|
const res = await fetch(`${mock.baseUrl}/v1/models`);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.map((m: { id: string }) => m.id)).toEqual(['qwen3', 'llama3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the Custom Model Endpoint Profiles pure builder.
|
||||||
|
* Uses the real CLI registry entries (getCli) rather than hand-rolled
|
||||||
|
* fixtures, so a change to a real entry's customModelInjection declaration
|
||||||
|
* is exercised here automatically instead of silently diverging.
|
||||||
|
*
|
||||||
|
* Port: N/A (no server needed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getCli } from '../src/config/cli-registry/index.js';
|
||||||
|
import {
|
||||||
|
buildCustomModelInjection,
|
||||||
|
withV1Suffix,
|
||||||
|
GROK_CUSTOM_MODEL_NAME,
|
||||||
|
type CustomModelEndpoint,
|
||||||
|
} from '../src/custom-model-injection.js';
|
||||||
|
|
||||||
|
const endpoint: CustomModelEndpoint = {
|
||||||
|
id: 'ep1',
|
||||||
|
label: 'llama.cpp box',
|
||||||
|
baseUrl: 'http://192.168.1.50:8080',
|
||||||
|
apiKey: 'my-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
function entryOrThrow(id: string) {
|
||||||
|
const entry = getCli(id);
|
||||||
|
if (!entry) throw new Error(`missing CLI registry entry: ${id}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('withV1Suffix', () => {
|
||||||
|
it('appends /v1 when missing', () => {
|
||||||
|
expect(withV1Suffix('http://host:8080')).toBe('http://host:8080/v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent when already present', () => {
|
||||||
|
expect(withV1Suffix('http://host:8080/v1')).toBe('http://host:8080/v1');
|
||||||
|
expect(withV1Suffix('http://host:8080/v1/')).toBe('http://host:8080/v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a trailing slash with no /v1', () => {
|
||||||
|
expect(withV1Suffix('http://host:8080/')).toBe('http://host:8080/v1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCustomModelInjection', () => {
|
||||||
|
it('claude: env kind sets base URL, api key, and all three tier model vars', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
|
||||||
|
expect(result.kind).toBe('env');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
expect(result.envOverrides).toEqual({
|
||||||
|
ANTHROPIC_BASE_URL: 'http://192.168.1.50:8080',
|
||||||
|
ANTHROPIC_API_KEY: 'my-key',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: falls back to a dummy key when the endpoint has none', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
expect(result.envOverrides.ANTHROPIC_API_KEY).toBe('local-dummy-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: configContentEnv carries a JSON blob in OPENCODE_CONFIG_CONTENT', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3');
|
||||||
|
expect(result.kind).toBe('env');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||||
|
expect(parsed.model).toBe('custom/qwen3');
|
||||||
|
expect(parsed.provider.custom.options.baseURL).toBe('http://192.168.1.50:8080/v1');
|
||||||
|
expect(parsed.provider.custom.options.apiKey).toBe('my-key');
|
||||||
|
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
|
||||||
|
expect(result.kind).toBe('configDir');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.dirEnvVar).toBe('CODEX_HOME');
|
||||||
|
expect(result.files).toHaveLength(1);
|
||||||
|
expect(result.files[0].relPath).toBe('config.toml');
|
||||||
|
expect(result.files[0].content).toContain('model = "qwen3"');
|
||||||
|
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
|
||||||
|
expect(result.files[0].content).toContain('wire_api = "responses"');
|
||||||
|
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
|
||||||
|
expect(result.files[0].content).toContain('env_key = "CODEMAN_CUSTOM_MODEL_API_KEY"');
|
||||||
|
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('codex: escapes a quote in the model id so it cannot break out of the TOML string', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'weird"model');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.files[0].content).toContain('model = "weird\\"model"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.dirEnvVar).toBe('HOME');
|
||||||
|
expect(result.files[0].relPath).toBe('.pi/agent/models.json');
|
||||||
|
const parsed = JSON.parse(result.files[0].content);
|
||||||
|
expect(parsed.providers.custom.baseUrl).toBe('http://192.168.1.50:8080/v1');
|
||||||
|
expect(parsed.providers.custom.authHeader).toBe(true);
|
||||||
|
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.dirEnvVar).toBe('HOME');
|
||||||
|
expect(result.files[0].relPath).toBe('.omp/agent/models.yml');
|
||||||
|
expect(result.files[0].content).toContain('baseUrl: "http://192.168.1.50:8080/v1"');
|
||||||
|
expect(result.files[0].content).toContain('authHeader: true');
|
||||||
|
expect(result.files[0].content).toContain('- id: "qwen3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemini: env kind sets GOOGLE_GEMINI_BASE_URL/GEMINI_API_KEY/GEMINI_MODEL', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('gemini'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
expect(result.envOverrides).toEqual({
|
||||||
|
GOOGLE_GEMINI_BASE_URL: 'http://192.168.1.50:8080',
|
||||||
|
GEMINI_API_KEY: 'my-key',
|
||||||
|
GEMINI_MODEL: 'qwen3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grok: configDir writes a config.toml [model.<name>] block, key rides as extraEnv (XAI_API_KEY)', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
|
||||||
|
expect(result.kind).toBe('configDir');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.dirEnvVar).toBe('GROK_HOME');
|
||||||
|
expect(result.files).toHaveLength(1);
|
||||||
|
expect(result.files[0].relPath).toBe('config.toml');
|
||||||
|
expect(result.files[0].content).toContain('model = "qwen3"');
|
||||||
|
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
|
||||||
|
expect(result.files[0].content).toContain('api_backend = "chat_completions"');
|
||||||
|
expect(result.files[0].content).toContain('env_key = "XAI_API_KEY"');
|
||||||
|
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
|
||||||
|
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deepseek: env kind sets base URL/key only, no model var', () => {
|
||||||
|
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',
|
||||||
|
DEEPSEEK_API_KEY: 'my-key',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antigravity: unsupported', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('antigravity'), endpoint, 'qwen3');
|
||||||
|
expect(result).toEqual({ kind: 'unsupported' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shell: unsupported', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('shell'), endpoint, 'qwen3');
|
||||||
|
expect(result).toEqual({ kind: 'unsupported' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('launchModel (the model launch param that selects the injected provider)', () => {
|
||||||
|
it('pi and omp get --model custom/<modelId>: the config file alone leaves them on their default provider', () => {
|
||||||
|
for (const id of ['pi', 'omp']) {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3.5-0.8b');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.launchModel, id).toBe('custom/qwen3.5-0.8b');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grok gets the [model.<name>] block name, pinned to the constant the config template writes', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||||
|
expect(result.launchModel).toBe(GROK_CUSTOM_MODEL_NAME);
|
||||||
|
expect(result.files[0].content).toContain(`[model.${GROK_CUSTOM_MODEL_NAME}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CLIs whose config selects the model on its own declare none', () => {
|
||||||
|
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
|
||||||
|
if (result.kind === 'unsupported') throw new Error('unreachable');
|
||||||
|
expect(result.launchModel, id).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -372,7 +372,10 @@ describe('DeepSeek multi-user clamp: the env-var half', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
|
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
|
||||||
const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
|
// CODEX_HOME is a poor "unrelated" example here — it is itself a privileged key
|
||||||
|
// (codex's own registry entry), so a genuinely non-privileged one is needed to
|
||||||
|
// prove the identity-return fast path, not just that DEEPSEEK_API_KEY is exempt.
|
||||||
|
const input = { DEEPSEEK_API_KEY: 'sk-test', OPENCODE_LOG_LEVEL: 'debug' };
|
||||||
const out = await _clampEnvOverridesForOwner('nobody', input);
|
const out = await _clampEnvOverridesForOwner('nobody', input);
|
||||||
expect(out).toBe(input);
|
expect(out).toBe(input);
|
||||||
expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined();
|
expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined();
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview "Duplicate an existing case" in the container-adoption form.
|
||||||
|
*
|
||||||
|
* The server already allows one ADOPTED container to back several cases, each
|
||||||
|
* pointing at a different directory inside it (classifyAdoptContainerConflict).
|
||||||
|
* Re-typing the container, host and workspace by hand for every directory is the
|
||||||
|
* friction that would leave that capability unused, so the form carries them over
|
||||||
|
* and clears only the two fields that MUST differ.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||||
|
const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
const routes = readFileSync(resolve(import.meta.dirname, '../src/web/routes/case-routes.ts'), 'utf8');
|
||||||
|
const apiTypes = readFileSync(resolve(import.meta.dirname, '../src/types/api.ts'), 'utf8');
|
||||||
|
|
||||||
|
describe('the API exposes what the picker needs', () => {
|
||||||
|
it('reports each docker case s directory inside the container', () => {
|
||||||
|
// Without it the picker cannot show WHICH directory a case already uses, which
|
||||||
|
// is the one thing the user needs to see before choosing a different one.
|
||||||
|
expect(apiTypes).toMatch(/containerWorkdir\?: string;/);
|
||||||
|
expect(routes).toContain('containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports whether the container is owned, on EVERY case-shaped response', () => {
|
||||||
|
// Two sites build a docker CaseInfo (the list and the single-case lookup);
|
||||||
|
// filling only one leaves the picker blind depending on which the UI read.
|
||||||
|
expect(routes.match(/\.\.\.\(dockerCase\.owned === false \? \{ owned: false \} : \{\}\),/g) ?? []).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an ABSENT owned flag as owned, so legacy cases are not offered', () => {
|
||||||
|
// `owned` is optional and predates this field; the wire carries it ONLY when
|
||||||
|
// false (absent = owned, the shape master already used), so the picker must
|
||||||
|
// test `=== false` rather than truthiness, or a legacy case would read as
|
||||||
|
// adopted and be offered a duplicate the server then refuses.
|
||||||
|
expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
|
||||||
|
expect(routes).not.toContain('owned: dockerCase.owned !== false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the picker only offers what the server would accept', () => {
|
||||||
|
const fn = ui.slice(ui.indexOf('async _loadDockerCloneOptions()'), ui.indexOf('applyDockerCloneSource()'));
|
||||||
|
|
||||||
|
it('filters to ADOPTED cases only', () => {
|
||||||
|
expect(fn).toMatch(/c\.docker\.owned === false/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the row entirely when there is nothing to duplicate', () => {
|
||||||
|
expect(fn).toMatch(/row\.hidden = cases\.length === 0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds options with textContent, never markup', () => {
|
||||||
|
// Case names and container names are user- and engine-supplied strings.
|
||||||
|
expect(fn).toContain('option.textContent =');
|
||||||
|
expect(fn).not.toContain('innerHTML');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applying a source fills every field, including the two that must differ', () => {
|
||||||
|
const fn = ui.slice(ui.indexOf('applyDockerCloneSource()'), ui.indexOf('dockerCloneGuard()'));
|
||||||
|
|
||||||
|
it('carries over container, host and workspace', () => {
|
||||||
|
for (const id of ['dockerContainerName', 'dockerHostId', 'dockerWorkspacePath']) {
|
||||||
|
expect(fn).toContain(`set('${id}', option.dataset.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PRE-FILLS the case name and container workdir rather than clearing them', () => {
|
||||||
|
// Editing `/srv/app/api` into `/srv/app/web` beats retyping a long path, and a
|
||||||
|
// form with three fields mysteriously filled and two blank reads as broken.
|
||||||
|
// What stops an unchanged submit is the guard, not an empty field.
|
||||||
|
expect(fn).toContain("set('dockerCaseName', option.value)");
|
||||||
|
expect(fn).toContain("set('dockerAdoptWorkdir', option.dataset.workdir)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers what it applied, so the guard can tell unchanged from similar', () => {
|
||||||
|
expect(fn).toContain('select.dataset.appliedName = option.value');
|
||||||
|
expect(fn).toContain('select.dataset.appliedWorkdir =');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the workdir with the caret at the END, where the edit happens', () => {
|
||||||
|
expect(fn).toMatch(/setSelectionRange\(workdir\.value\.length, workdir\.value\.length\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for the blank "start from scratch" option', () => {
|
||||||
|
expect(fn).toMatch(/if \(!option \|\| !option\.value\) return;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the guard refuses a duplicate that was never edited', () => {
|
||||||
|
const fn = ui.slice(ui.indexOf('dockerCloneGuard()'), ui.indexOf('dockerCloneGuard()') + 1400);
|
||||||
|
|
||||||
|
it('flags an unchanged case name', () => {
|
||||||
|
expect(fn).toMatch(/appliedName/);
|
||||||
|
expect(fn).toContain('give this one a new name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an unchanged container workdir', () => {
|
||||||
|
expect(fn).toMatch(/appliedWorkdir/);
|
||||||
|
expect(fn).toContain('another directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent when no source was picked', () => {
|
||||||
|
// Typing a fresh adoption by hand must not be second-guessed.
|
||||||
|
expect(fn).toMatch(/if \(!select \|\| !select\.value\) return null;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs BEFORE the request, and focuses the offending field', () => {
|
||||||
|
const submit = ui.slice(ui.indexOf('const cloneIssue'), ui.indexOf('const cloneIssue') + 500);
|
||||||
|
expect(submit).toContain('cloneIssue.el.focus()');
|
||||||
|
expect(submit).toContain('return;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports into a status element that actually exists', () => {
|
||||||
|
// A dead id would silently drop the explanation next to the field.
|
||||||
|
const submit = ui.slice(ui.indexOf('const cloneIssue'), ui.indexOf('const cloneIssue') + 500);
|
||||||
|
const id = /getElementById\('([^']+)'\)/.exec(submit)?.[1];
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(html).toContain(`id="${id}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the row is wired into the adoption panel', () => {
|
||||||
|
it('lives in the adopt-only block and starts hidden', () => {
|
||||||
|
expect(html).toMatch(/id="dockerAdoptCloneRow"[^>]*hidden/);
|
||||||
|
expect(html).toMatch(/class="form-row docker-adopt-only" id="dockerAdoptCloneRow"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads its options whenever adopt mode turns on', () => {
|
||||||
|
// Slice from the DEFINITION, not the first call site.
|
||||||
|
const start = ui.indexOf('_syncDockerAdoptMode() {');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const sync = ui.slice(start, start + 900);
|
||||||
|
expect(sync).toContain('_loadDockerCloneOptions()');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
checkDockerConfigDrift,
|
checkDockerConfigDrift,
|
||||||
dockerConfigHash,
|
dockerConfigHash,
|
||||||
dockerAdoptProbeModes,
|
dockerAdoptProbeModes,
|
||||||
|
classifyAdoptContainerConflict,
|
||||||
|
dockerContainerName,
|
||||||
} from '../src/docker-hosts.js';
|
} from '../src/docker-hosts.js';
|
||||||
import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
|
import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -451,3 +453,113 @@ describe('adopted container: a missing container means different things per owne
|
|||||||
expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
|
expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('adopted container: one container may back several cases', () => {
|
||||||
|
const base = {
|
||||||
|
type: 'docker' as const,
|
||||||
|
hostId: 'h1',
|
||||||
|
hostWorkspacePath: '/srv/work',
|
||||||
|
};
|
||||||
|
const mk = (over: Record<string, unknown>) => ({ ...base, ...over }) as never;
|
||||||
|
const mine = () => true;
|
||||||
|
|
||||||
|
it('allows a second adoption of the same container at a DIFFERENT directory', () => {
|
||||||
|
// The whole point of the feature: one container, two folders, two cases.
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'devbox',
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '/app/web', owned: false })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an exact twin (same container AND same directory) and names the first case', () => {
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'devbox',
|
||||||
|
containerWorkdir: '/app/web',
|
||||||
|
existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '/app/web', owned: false })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to hostWorkspacePath when containerWorkdir is absent on either side', () => {
|
||||||
|
// containerWorkdir defaults to hostWorkspacePath, so an absent field on the
|
||||||
|
// stored case must compare equal to an incoming adoption that omits it too —
|
||||||
|
// otherwise the twin check silently stops firing for the default case.
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'devbox',
|
||||||
|
containerWorkdir: '/srv/work',
|
||||||
|
existing: [mk({ name: 'web', container: 'devbox', owned: false })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a container backing a case Codeman CREATED', () => {
|
||||||
|
// Codeman owns that container's lifecycle: a recreate or case-delete there
|
||||||
|
// would destroy the adopted case's container out from under it.
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'codeman-case-web',
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [mk({ name: 'web', container: 'codeman-case-web', owned: true })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an ABSENT owned flag as owned, so legacy cases keep the old refusal', () => {
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'legacy',
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [mk({ name: 'old', container: 'legacy' })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'old' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the container name from the case name when the field is absent', () => {
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: dockerContainerName('web'),
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [mk({ name: 'web', owned: true })],
|
||||||
|
canAccess: mine,
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a container another user already adopted', () => {
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'devbox',
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' })],
|
||||||
|
canAccess: (owner) => owner === 'alice',
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'other-owner', caseName: 'theirs' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an owned case outranks a foreign adoption, so the message names the real blocker', () => {
|
||||||
|
const conflict = classifyAdoptContainerConflict({
|
||||||
|
container: 'devbox',
|
||||||
|
containerWorkdir: '/app/api',
|
||||||
|
existing: [
|
||||||
|
mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' }),
|
||||||
|
mk({ name: 'built', container: 'devbox', owned: true }),
|
||||||
|
],
|
||||||
|
canAccess: (owner) => owner === 'alice',
|
||||||
|
});
|
||||||
|
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'built' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unrelated container alone', () => {
|
||||||
|
expect(
|
||||||
|
classifyAdoptContainerConflict({
|
||||||
|
container: 'fresh',
|
||||||
|
containerWorkdir: '/app',
|
||||||
|
existing: [mk({ name: 'web', container: 'devbox', owned: false })],
|
||||||
|
canAccess: mine,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static and fixture checks for the Docker Compose deployment's
|
||||||
|
* privilege handling: `docker/entrypoint.sh` starts as root, corrects bind-mount
|
||||||
|
* ownership and drops to PUID:PGID, which only works while three files agree.
|
||||||
|
*
|
||||||
|
* 1. The capabilities `docker-compose.yaml` adds back on top of `cap_drop: ALL`
|
||||||
|
* must be exactly what the entrypoint and `init: true` need. This is the
|
||||||
|
* drift that shipped once already: the `USER` instruction became a root
|
||||||
|
* entrypoint, tini stayed root while the server became PUID, and with no
|
||||||
|
* CAP_KILL every `docker compose down` ended in tini failing to forward
|
||||||
|
* SIGTERM and the server being SIGKILLed. The list is derived here from what
|
||||||
|
* the scripts actually do, not copied.
|
||||||
|
* 2. The runtime-owned CLI prefix must never sit ahead of the system
|
||||||
|
* directories on the PATH the root entrypoint resolves commands through: a
|
||||||
|
* planted `setpriv` in a PUID-writable prefix ran as uid 0 (measured with a
|
||||||
|
* minimal image of the same shape).
|
||||||
|
* 3. `Start-Codeman.sh` derives PUID/PGID BEFORE it creates
|
||||||
|
* `CODEMAN_CASES_PATH`, so the directory it creates has the owner the
|
||||||
|
* container will accept, and its `git_head_commit` helper (a pure function
|
||||||
|
* over `.git`) resolves the three ref layouts a checkout can have.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf-8');
|
||||||
|
|
||||||
|
const compose = read('docker/docker-compose.yaml');
|
||||||
|
const entrypoint = read('docker/entrypoint.sh');
|
||||||
|
const dockerfile = read('docker/server.Dockerfile');
|
||||||
|
const startScript = read('docker/Start-Codeman.sh');
|
||||||
|
|
||||||
|
/** The `- NAME` entries under `cap_add:` (the block ends at the next key at the same indent). */
|
||||||
|
function composeCapAdd(text: string): string[] {
|
||||||
|
const m = text.match(/^(\s*)cap_add:\n((?:\1\s+.*\n)*)/m);
|
||||||
|
if (!m) return [];
|
||||||
|
return m[2]
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.startsWith('- '))
|
||||||
|
.map((l) => l.slice(2).trim())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the deployment needs, derived from the scripts. Each rule names the
|
||||||
|
* line that needs it, so a capability cannot be added or removed here without
|
||||||
|
* the reason changing too.
|
||||||
|
*/
|
||||||
|
function requiredCaps(): string[] {
|
||||||
|
const caps = new Set<string>();
|
||||||
|
if (/\bchown\b/.test(entrypoint)) {
|
||||||
|
// chown of a root-owned bind source, and traversing trees root cannot
|
||||||
|
// otherwise read on a mount with restrictive modes.
|
||||||
|
caps.add('CHOWN');
|
||||||
|
caps.add('DAC_OVERRIDE');
|
||||||
|
}
|
||||||
|
if (/setpriv .*--reuid/.test(entrypoint)) caps.add('SETUID');
|
||||||
|
if (/setpriv .*--(regid|groups|clear-groups)/.test(entrypoint)) caps.add('SETGID');
|
||||||
|
const dropsUid = /setpriv .*--reuid/.test(entrypoint);
|
||||||
|
if (/^\s*init:\s*true\s*$/m.test(compose) && dropsUid) {
|
||||||
|
// tini is PID 1 and stays root; signalling the PUID server needs CAP_KILL.
|
||||||
|
caps.add('KILL');
|
||||||
|
}
|
||||||
|
return [...caps].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker-compose.yaml cap_add covers what entrypoint.sh and init:true need', () => {
|
||||||
|
it('the compose file adds back exactly the derived capability set', () => {
|
||||||
|
expect(composeCapAdd(compose)).toEqual(requiredCaps());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cap_drop: ALL is still the baseline', () => {
|
||||||
|
expect(compose).toMatch(/^\s*cap_drop:\n\s*- ALL\s*$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the entrypoint's own diagnosis names the same list, so a missing cap gets a one-line fix", () => {
|
||||||
|
const m = entrypoint.match(/^required_caps='([^']+)'/m);
|
||||||
|
expect(m, 'entrypoint.sh must declare required_caps').not.toBeNull();
|
||||||
|
const named = m![1]
|
||||||
|
.split(',')
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.sort();
|
||||||
|
expect(named).toEqual(composeCapAdd(compose));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the user-facing docs quote the same cap_add list', () => {
|
||||||
|
for (const rel of ['docker/README.md', 'CLAUDE.md']) {
|
||||||
|
const text = read(rel);
|
||||||
|
const quoted = [...text.matchAll(/cap_add: \[([^\]]+)\]/g)].map((m) =>
|
||||||
|
m[1]
|
||||||
|
.split(',')
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.sort()
|
||||||
|
);
|
||||||
|
expect(quoted.length, `${rel} should quote the cap_add list at least once`).toBeGreaterThan(0);
|
||||||
|
for (const list of quoted) expect(list, rel).toEqual(composeCapAdd(compose));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the runtime-owned CLI prefix never shadows root commands', () => {
|
||||||
|
it('server.Dockerfile appends /opt/codeman-cli/bin to PATH rather than prepending it', () => {
|
||||||
|
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
|
||||||
|
expect(pathLines.length).toBeGreaterThan(0);
|
||||||
|
for (const line of pathLines) {
|
||||||
|
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').not.toMatch(
|
||||||
|
/^ENV PATH=\/opt\/codeman-cli/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
|
||||||
|
const lines = entrypoint.split('\n');
|
||||||
|
const pinIdx = lines.findIndex((l) => l === 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin');
|
||||||
|
expect(pinIdx, 'the PATH pin must exist').toBeGreaterThan(-1);
|
||||||
|
const firstToolIdx = lines.findIndex((l) => !l.trim().startsWith('#') && /\b(setpriv|chown|stat)\b/.test(l));
|
||||||
|
expect(firstToolIdx).toBeGreaterThan(pinIdx);
|
||||||
|
// The only thing allowed before the pin is the `user:` short-circuit.
|
||||||
|
const before = lines
|
||||||
|
.slice(0, pinIdx)
|
||||||
|
.filter((l) => l.trim() && !l.trim().startsWith('#') && !/^(set -eu|runtime_path=\$PATH)$/.test(l.trim()));
|
||||||
|
expect(before).toEqual(['if [ "$(id -u)" -ne 0 ]; then', ' exec "$@"', 'fi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entrypoint.sh hands the image's full PATH back to the server at the drop", () => {
|
||||||
|
expect(entrypoint).toMatch(/exec setpriv [^\n]*\\\n\s*env PATH="\$runtime_path" "\$@"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entrypoint.sh no longer passes --bounding-set (a silent no-op without CAP_SETPCAP)', () => {
|
||||||
|
const code = entrypoint
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => !l.trim().startsWith('#'))
|
||||||
|
.join('\n');
|
||||||
|
expect(code).not.toMatch(/--bounding-set/);
|
||||||
|
expect(composeCapAdd(compose)).not.toContain('SETPCAP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Start-Codeman.sh', () => {
|
||||||
|
it('parses under bash -n', () => {
|
||||||
|
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
|
||||||
|
execFileSync('sh', ['-n', join(ROOT, 'docker/entrypoint.sh')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives PUID/PGID before creating CODEMAN_CASES_PATH, so the new directory gets that owner', () => {
|
||||||
|
const puid = startScript.indexOf('export PUID=');
|
||||||
|
const mkdirCases = startScript.indexOf('mkdir -p -- "$cases_path"');
|
||||||
|
expect(puid).toBeGreaterThan(-1);
|
||||||
|
expect(mkdirCases).toBeGreaterThan(puid);
|
||||||
|
expect(startScript).toMatch(/chown -- "\$PUID:\$PGID" "\$cases_path"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds before taking the stack down, and writes the source marker only after a refresh', () => {
|
||||||
|
const build = startScript.indexOf('"${compose_command[@]}" build');
|
||||||
|
const down = startScript.indexOf('"${compose_command[@]}" down');
|
||||||
|
const marker = startScript.indexOf('>"$source_state_file.tmp"');
|
||||||
|
expect(build).toBeGreaterThan(-1);
|
||||||
|
expect(down).toBeGreaterThan(build);
|
||||||
|
expect(marker).toBeGreaterThan(down);
|
||||||
|
expect(startScript).toMatch(/if \[\[ "\$refreshed" == '1' \]\]; then\n\s*printf '\{\\n {2}"headCommit"/);
|
||||||
|
// A failed volume removal must not abort under set -e with the stack down.
|
||||||
|
expect(startScript).not.toMatch(/\[\[ -n "\$volume_name" \]\] && docker volume rm/);
|
||||||
|
expect(startScript).toMatch(/&& ! docker volume rm -- "\$volume_name"; then/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to `down --volumes` when the Compose project name cannot be resolved', () => {
|
||||||
|
expect(startScript).toMatch(/if \[\[ -z "\$project_name" \]\]; then[\s\S]*down --volumes/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git_head_commit resolves every ref layout a checkout can have', () => {
|
||||||
|
let base: string;
|
||||||
|
const git = (cwd: string, ...args: string[]) =>
|
||||||
|
execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 't',
|
||||||
|
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||||
|
GIT_COMMITTER_NAME: 't',
|
||||||
|
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||||
|
},
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
/** Runs the function exactly as the script defines it, extracted by its own delimiters. */
|
||||||
|
const headCommit = (repo: string): { out: string; status: number } => {
|
||||||
|
const script = [`eval "$(sed -n '/^git_head_commit() {/,/^}/p' "$1")"`, 'git_head_commit "$2"'].join('\n');
|
||||||
|
try {
|
||||||
|
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), repo], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
return { out: out.trim(), status: 0 };
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { stdout?: string; status?: number };
|
||||||
|
return { out: (e.stdout ?? '').trim(), status: e.status ?? 1 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRepo = (name: string): string => {
|
||||||
|
const dir = join(base, name);
|
||||||
|
git(base, 'init', '-q', '-b', 'master', dir);
|
||||||
|
writeFileSync(join(dir, 'f'), 'x');
|
||||||
|
git(dir, 'add', 'f');
|
||||||
|
git(dir, 'commit', '-q', '-m', 'one');
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
base = mkdtempSync(join(tmpdir(), 'codeman-head-commit-'));
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('symbolic ref with a loose ref file', () => {
|
||||||
|
const dir = makeRepo('loose');
|
||||||
|
expect(headCommit(dir)).toEqual({ out: git(dir, 'rev-parse', 'HEAD'), status: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detached HEAD', () => {
|
||||||
|
const dir = makeRepo('detached');
|
||||||
|
const sha = git(dir, 'rev-parse', 'HEAD');
|
||||||
|
git(dir, 'checkout', '-q', '--detach', sha);
|
||||||
|
expect(headCommit(dir)).toEqual({ out: sha, status: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packed refs after gc', () => {
|
||||||
|
const dir = makeRepo('packed');
|
||||||
|
const sha = git(dir, 'rev-parse', 'HEAD');
|
||||||
|
git(dir, 'pack-refs', '--all');
|
||||||
|
expect(readFileSync(join(dir, '.git/packed-refs'), 'utf-8')).toContain('refs/heads/master');
|
||||||
|
expect(headCommit(dir)).toEqual({ out: sha, status: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a linked worktree (.git is a file) resolves nothing rather than something wrong', () => {
|
||||||
|
const dir = makeRepo('main');
|
||||||
|
const wt = join(base, 'wt');
|
||||||
|
git(dir, 'worktree', 'add', '-q', wt);
|
||||||
|
const result = headCommit(wt);
|
||||||
|
expect(result.out).toBe('');
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a directory that is not a checkout fails', () => {
|
||||||
|
const result = headCommit(base);
|
||||||
|
expect(result.out).toBe('');
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+118
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview In-process fake OpenAI/Anthropic-compatible HTTP server for the
|
||||||
|
* Custom Model Endpoint Profiles contract tests (docs/custom-model-endpoints-plan.md chunk 7).
|
||||||
|
*
|
||||||
|
* No external deps — plain `node:http`. Captures every request it receives
|
||||||
|
* (method, path, headers, parsed JSON body) so a test can assert the injected
|
||||||
|
* base URL / API key / model actually reached the right place, with the right
|
||||||
|
* auth header, in the shape a real llama.cpp/Azure/etc. endpoint would see it.
|
||||||
|
*
|
||||||
|
* Serves the request shapes this feature's recipes produce: OpenAI-style
|
||||||
|
* `POST /v1/chat/completions` (opencode/pi/grok/omp/gemini's compat
|
||||||
|
* endpoint), Anthropic-style `POST /v1/messages` (claude's ANTHROPIC_BASE_URL
|
||||||
|
* traffic), OpenAI's newer `POST /v1/responses` (codex's actual wire protocol
|
||||||
|
* as of Feb 2026 — it dropped chat-completions support), plus `GET /v1/models`
|
||||||
|
* for the discovery route's own tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||||
|
import { AddressInfo } from 'node:net';
|
||||||
|
|
||||||
|
export interface CapturedRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockOpenAiServer {
|
||||||
|
baseUrl: string;
|
||||||
|
requests: CapturedRequest[];
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (!raw) return resolve(undefined);
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
resolve(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the mock server on a random free port and resolves once it's listening. */
|
||||||
|
export async function startMockOpenAiServer(): Promise<MockOpenAiServer> {
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
|
||||||
|
const server: Server = createServer((req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const path = (req.url ?? '').split('?')[0];
|
||||||
|
requests.push({ method: req.method ?? 'GET', path, headers: req.headers, body });
|
||||||
|
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
|
||||||
|
if (path === '/v1/models' && req.method === 'GET') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/v1/chat/completions' && req.method === 'POST') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'mock-completion',
|
||||||
|
choices: [{ index: 0, message: { role: 'assistant', content: 'hello world' } }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/v1/messages' && req.method === 'POST') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'mock-message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'hello world' }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex's real wire protocol (verified against a live binary: it dropped
|
||||||
|
// wire_api="chat" support in Feb 2026, so its config.toml always says
|
||||||
|
// wire_api="responses") — a different shape from OpenAI's chat-completions.
|
||||||
|
if (path === '/v1/responses' && req.method === 'POST') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'mock-response',
|
||||||
|
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hello world' }] }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end(JSON.stringify({ error: 'not found in mock server', path }));
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -146,6 +146,72 @@ describe('CJK input module', () => {
|
|||||||
expect(sent).toEqual(['中文', ...committed]);
|
expect(sent).toEqual(['中文', ...committed]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards Ctrl/Alt-modified navigation keys to the PTY, modifier intact', () => {
|
||||||
|
// claude prints "Jump to bottom (ctrl+End)" and the shortcut has to REACH it.
|
||||||
|
// PASSTHROUGH_KEYS carries only the plain forms, so Ctrl+End used to fail in
|
||||||
|
// both directions: with an empty field it went out as a bare `\x1b[F` (a
|
||||||
|
// plain End), and with any text in the field it was not forwarded at all —
|
||||||
|
// the browser default then moved the caret to the end of the composer, which
|
||||||
|
// is what the user sees as "the shortcut acts on the input box instead".
|
||||||
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
textarea.fire('keydown', {
|
||||||
|
key: 'End',
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault,
|
||||||
|
});
|
||||||
|
expect(preventDefault).toHaveBeenCalled();
|
||||||
|
expect(sent).toEqual(['\x1b[1;5F']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards a modified navigation key even when the composer has text', () => {
|
||||||
|
// The empty-field rule belongs to PLAIN navigation (which really is local
|
||||||
|
// editing); a Ctrl-modified one is a command for the CLI either way.
|
||||||
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
textarea.value = PHANTOM + '未发送的草稿';
|
||||||
|
textarea.fire('keydown', {
|
||||||
|
key: 'Home',
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(sent).toEqual(['\x1b[1;5H']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes the modifier bitmask, Shift included when it rides along', () => {
|
||||||
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
textarea.fire('keydown', {
|
||||||
|
key: 'ArrowUp',
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(sent).toEqual(['\x1b[1;6A']); // 1 + shift(1) + ctrl(4)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Shift-ALONE navigation local, so selecting in the composer still works', () => {
|
||||||
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
textarea.value = PHANTOM + '草稿';
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
textarea.fire('keydown', {
|
||||||
|
key: 'ArrowLeft',
|
||||||
|
shiftKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault,
|
||||||
|
});
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers committed text when compositionend never fires (stuck composition)', () => {
|
it('recovers committed text when compositionend never fires (stuck composition)', () => {
|
||||||
const { textarea, sent } = loadCjkHarness();
|
const { textarea, sent } = loadCjkHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -597,6 +597,34 @@ describe('composer nav keys from the bar', () => {
|
|||||||
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
||||||
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
|
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
|
||||||
|
|
||||||
|
it.each(['simple', 'extended'])('exposes Shift arrows in the %s agent layout', (mode) => {
|
||||||
|
const { bar, barElement } = loadBar('codex');
|
||||||
|
bar.setMode(mode);
|
||||||
|
expect(barElement.actions).toContain('shift-left');
|
||||||
|
expect(barElement.actions).toContain('shift-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['shift-left', '\x1b[1;2D'],
|
||||||
|
['shift-right', '\x1b[1;2C'],
|
||||||
|
])('%s flushes the draft before navigation and hands editing to the PTY', (action, sequence) => {
|
||||||
|
const { bar, app, overlay, fetchMock } = barWithDraft('unfinished follow-up');
|
||||||
|
const events: string[] = [];
|
||||||
|
app.sendInput = vi.fn(() => events.push('draft'));
|
||||||
|
fetchMock.mockImplementation(() => {
|
||||||
|
events.push('key');
|
||||||
|
return Promise.resolve({ ok: true, catch: () => {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
bar.handleAction(action);
|
||||||
|
|
||||||
|
expect(app.sendInput).toHaveBeenCalledWith('unfinished follow-up');
|
||||||
|
expect(events).toEqual(['draft', 'key']);
|
||||||
|
expect(sentKeys(fetchMock)).toEqual([sequence]);
|
||||||
|
expect(overlay.pendingText).toBe('');
|
||||||
|
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
|
||||||
|
});
|
||||||
|
|
||||||
it('flushes the unsent draft before sending the arrow', () => {
|
it('flushes the unsent draft before sending the arrow', () => {
|
||||||
// On a phone the typed text lives in the overlay and has NEVER reached the
|
// On a phone the typed text lives in the overlay and has NEVER reached the
|
||||||
// PTY, so an arrow sent on its own arrives at a composer the CLI still
|
// PTY, so an arrow sent on its own arrives at a composer the CLI still
|
||||||
@@ -640,3 +668,75 @@ describe('composer nav keys from the bar', () => {
|
|||||||
expect(app.sendInput).not.toHaveBeenCalled();
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Codex shift-arrow keys are gated on the active session', () => {
|
||||||
|
// ⇧←/⇧→ are Codex bindings. They ship in both agent templates, but a tap in
|
||||||
|
// any other CLI would do nothing AND hand the session to PTY echo
|
||||||
|
// (sendNavKey adds it to _echoPassthroughSessions), so on a phone a dead key
|
||||||
|
// would also switch local echo off for the rest of the prompt. The reveal
|
||||||
|
// follows the 🧠 key's shape: a marker class on the BAR element, because
|
||||||
|
// setMode() rebuilds the buttons' innerHTML on every layout switch.
|
||||||
|
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
|
||||||
|
|
||||||
|
it('marks both shift keys in both agent templates so one CSS rule can hide them', () => {
|
||||||
|
const simple = keyboardSource.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
||||||
|
const extended = keyboardSource.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
||||||
|
for (const template of [simple, extended]) {
|
||||||
|
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-left"/);
|
||||||
|
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-right"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the keys in styles.css until the bar carries codex-enabled', () => {
|
||||||
|
expect(stylesSource).toMatch(/\.keyboard-accessory-bar \.accessory-btn-codex \{\s*display: none;/);
|
||||||
|
expect(stylesSource).toMatch(
|
||||||
|
/\.keyboard-accessory-bar\.codex-enabled \.accessory-btn-codex \{\s*display: inline-flex;/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['simple', 'extended'])('carries codex-enabled for a codex session in the %s layout', (mode) => {
|
||||||
|
const { bar, barElement } = loadBar('codex');
|
||||||
|
bar.setMode(mode);
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
||||||
|
// The buttons themselves are still in the DOM; the class is what reveals them.
|
||||||
|
expect(barElement.actions).toContain('shift-left');
|
||||||
|
expect(barElement.actions).toContain('shift-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'shell', 'pi', 'omp', 'deepseek'])(
|
||||||
|
'does not carry codex-enabled for a %s session',
|
||||||
|
(sessionMode) => {
|
||||||
|
const { bar, barElement } = loadBar(sessionMode);
|
||||||
|
bar.setMode('extended');
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('re-syncs the class on a session switch, in both directions', () => {
|
||||||
|
const { app, bar, barElement } = loadBar('codex');
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
||||||
|
|
||||||
|
app.sessions.set('session-2', { mode: 'claude' });
|
||||||
|
app.activeSessionId = 'session-2';
|
||||||
|
bar.refreshForActiveSession();
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
||||||
|
|
||||||
|
app.activeSessionId = 'session-1';
|
||||||
|
bar.refreshForActiveSession();
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the class when no session is active (welcome screen)', () => {
|
||||||
|
const { app, bar, barElement } = loadBar('codex');
|
||||||
|
app.activeSessionId = '';
|
||||||
|
bar.refreshForActiveSession();
|
||||||
|
expect(barElement.classList.contains('codex-enabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired at init and on every session switch, like the 🧠 key', () => {
|
||||||
|
const initBody = keyboardSource.match(/\n init\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
|
||||||
|
const refreshBody = keyboardSource.match(/\n refreshForActiveSession\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
|
||||||
|
expect(initBody).toContain('this.syncCodexKeys();');
|
||||||
|
expect(refreshBody).toContain('this.syncCodexKeys();');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -471,7 +471,18 @@ describe('Virtual Keyboard', () => {
|
|||||||
});
|
});
|
||||||
// Tab replaced /clear in the simple bar; /clear and /compact live in the
|
// Tab replaced /clear in the simple bar; /clear and /compact live in the
|
||||||
// extended bar only.
|
// extended bar only.
|
||||||
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'tab', 'paste', 'esc', 'dismiss']);
|
expect(actions).toEqual([
|
||||||
|
'scroll-up',
|
||||||
|
'scroll-down',
|
||||||
|
'init',
|
||||||
|
'tab',
|
||||||
|
'shift-left',
|
||||||
|
'shift-right',
|
||||||
|
'paste',
|
||||||
|
'readmymind',
|
||||||
|
'esc',
|
||||||
|
'dismiss',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('double-tap confirm on /clear button', async () => {
|
it('double-tap confirm on /clear button', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import type { SessionStatus } from '../../src/types.js';
|
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced mock session for testing RespawnController.
|
* Enhanced mock session for testing RespawnController.
|
||||||
@@ -13,6 +13,18 @@ import type { SessionStatus } from '../../src/types.js';
|
|||||||
export class MockSession extends EventEmitter {
|
export class MockSession extends EventEmitter {
|
||||||
id: string;
|
id: string;
|
||||||
workingDir: string = '/tmp/test-workdir';
|
workingDir: string = '/tmp/test-workdir';
|
||||||
|
/**
|
||||||
|
* Mirrors `Session.remote` — set to a `SessionRemote` to model a remote-SSH case,
|
||||||
|
* whose `workingDir` is an absolute path on ANOTHER host. File routes must read it
|
||||||
|
* over ssh instead of with local `fs` (#415).
|
||||||
|
*/
|
||||||
|
remote?: SessionRemote;
|
||||||
|
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
|
||||||
|
attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||||
|
/** Mirrors Session.getAttachmentHistoryForPersist(). */
|
||||||
|
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] {
|
||||||
|
return this.attachmentHistory;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
|
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
|
||||||
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
|
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
|
||||||
@@ -56,6 +68,9 @@ export class MockSession extends EventEmitter {
|
|||||||
this.lastSubmitAt = Date.now();
|
this.lastSubmitAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
|
||||||
|
trackUserInput(_data: string): void {}
|
||||||
|
|
||||||
private _muxName: string | null = null;
|
private _muxName: string | null = null;
|
||||||
|
|
||||||
constructor(id: string = 'mock-session-id') {
|
constructor(id: string = 'mock-session-id') {
|
||||||
@@ -318,6 +333,43 @@ export class MockSession extends EventEmitter {
|
|||||||
this.color = c;
|
this.color = c;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) */
|
||||||
|
customModel: { endpointId: string; modelId: string; label?: string } | undefined = undefined;
|
||||||
|
remote: unknown = undefined;
|
||||||
|
docker: unknown = undefined;
|
||||||
|
private _mockCustomModel:
|
||||||
|
| {
|
||||||
|
endpointId: string;
|
||||||
|
modelId: string;
|
||||||
|
label?: string;
|
||||||
|
envKeys: string[];
|
||||||
|
configDir?: string;
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
setCustomModel = vi.fn(
|
||||||
|
(
|
||||||
|
next:
|
||||||
|
| {
|
||||||
|
endpointId: string;
|
||||||
|
modelId: string;
|
||||||
|
label?: string;
|
||||||
|
envKeys: string[];
|
||||||
|
configDir?: string;
|
||||||
|
launchModel?: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
_envOverrides?: Record<string, string>
|
||||||
|
): { removedEnvKeys: string[]; previousConfigDir: string | undefined } => {
|
||||||
|
const previous = this._mockCustomModel;
|
||||||
|
this._mockCustomModel = next;
|
||||||
|
this.customModel = next ? { endpointId: next.endpointId, modelId: next.modelId, label: next.label } : undefined;
|
||||||
|
return { removedEnvKeys: previous?.envKeys ?? [], previousConfigDir: previous?.configDir };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
restartCli = vi.fn(async () => true);
|
||||||
|
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
||||||
|
|
||||||
/** Stub for sendInput */
|
/** Stub for sendInput */
|
||||||
sendInput = vi.fn();
|
sendInput = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* A persistent full-screen overlay must not carry `backdrop-filter` while it is
|
||||||
|
* hidden.
|
||||||
|
*
|
||||||
|
* The property promotes the element to its own compositing layer, and a
|
||||||
|
* full-screen `position: fixed` layer that is created and then hidden has been
|
||||||
|
* observed to leave a stale HIT-TEST region behind in Chrome: the page renders
|
||||||
|
* correctly while pointer events over the viewport land on nothing. Reported on
|
||||||
|
* a long-lived tab against a remote server (where a connection blip shows and
|
||||||
|
* then hides #offlineOverlay): terminal scrolling AND unrelated click-to-expand
|
||||||
|
* controls died together, a freshly opened tab was fine, and a console
|
||||||
|
* one-liner doing nothing but READING layout restored it.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
|
||||||
|
function ruleBody(selector: string): string {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const m = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^}]*)\\}`, 'm').exec(css);
|
||||||
|
if (!m) throw new Error(`no rule for ${selector}`);
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent, full-screen, fixed overlays and the selector that shows each. */
|
||||||
|
const PERSISTENT_OVERLAYS: Array<{ base: string; shown: string }> = [
|
||||||
|
{ base: '.offline-overlay', shown: '.offline-overlay:not([hidden])' },
|
||||||
|
{ base: '.file-preview-overlay', shown: '.file-preview-overlay.visible' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('persistent full-screen overlays do not composite while hidden', () => {
|
||||||
|
for (const { base, shown } of PERSISTENT_OVERLAYS) {
|
||||||
|
it(`${base} keeps backdrop-filter off its base rule`, () => {
|
||||||
|
expect(ruleBody(base)).not.toMatch(/backdrop-filter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${base} still blurs once shown, via ${shown}`, () => {
|
||||||
|
// Moving the property must not silently DELETE the effect: the overlay is
|
||||||
|
// meant to blur what is behind it while it is up.
|
||||||
|
const body = ruleBody(shown);
|
||||||
|
expect(body).toMatch(/(^|\s)backdrop-filter:\s*blur\(/m);
|
||||||
|
expect(body).toMatch(/-webkit-backdrop-filter:\s*blur\(/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('the offline overlay still forces display:none when hidden', () => {
|
||||||
|
// The base rule is `display: flex`, so [hidden] alone would not hide it —
|
||||||
|
// this is the guard that rule stays put while the block is edited.
|
||||||
|
expect(ruleBody('.offline-overlay[hidden]')).toMatch(/display:\s*none\s*!important/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,3 +71,39 @@ describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
|
|||||||
expect(s.shouldApplyInput('recent', 2)).toBe(true);
|
expect(s.shouldApplyInput('recent', 2)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Session.lastInputSeq (the watermark a stuck client needs)', () => {
|
||||||
|
it('reports 0 for a client it has never seen', () => {
|
||||||
|
expect(makeSession().lastInputSeq('c-new')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the highest seq applied for that client', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
s.shouldApplyInput('c-1', 7);
|
||||||
|
expect(s.lastInputSeq('c-1')).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is what a rolled-back client must clear to be heard again', () => {
|
||||||
|
// The failure this exists for: the client's seq counter persists on a
|
||||||
|
// DEBOUNCED write, so a tab killed between a send and that write comes back
|
||||||
|
// counting from below the watermark. Every later keystroke then lands at or
|
||||||
|
// under it and is rejected — silently, because a rejected frame is ACKed too.
|
||||||
|
const s = makeSession();
|
||||||
|
for (let i = 1; i <= 40; i++) s.shouldApplyInput('c-1', i);
|
||||||
|
// Restored counter starts over at 1: dropped, and every subsequent one too.
|
||||||
|
expect(s.shouldApplyInput('c-1', 1)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('c-1', 2)).toBe(false);
|
||||||
|
// The watermark it is handed back is exactly what makes it recoverable.
|
||||||
|
const watermark = s.lastInputSeq('c-1');
|
||||||
|
expect(watermark).toBe(40);
|
||||||
|
expect(s.shouldApplyInput('c-1', watermark + 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resurrect a seq that forgetInputSeq rolled back', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
s.shouldApplyInput('c-1', 5);
|
||||||
|
s.forgetInputSeq('c-1', 5);
|
||||||
|
expect(s.lastInputSeq('c-1')).toBe(4);
|
||||||
|
expect(s.shouldApplyInput('c-1', 5)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A client whose seq counter rolled back must heal itself.
|
||||||
|
*
|
||||||
|
* The failure: the browser tags input with (clientId, seq) and persists the
|
||||||
|
* counters to localStorage on a DEBOUNCED write. A tab killed between a send and
|
||||||
|
* that write comes back counting from BELOW the server's watermark, so every
|
||||||
|
* later keystroke is rejected as a duplicate — and, because a rejected frame was
|
||||||
|
* ACKed exactly like an applied one, the client dropped it from its queue and the
|
||||||
|
* UI looked perfectly healthy while the terminal took no input at all. Reloading
|
||||||
|
* could not help: clientId and the stale counter both come back from localStorage.
|
||||||
|
*
|
||||||
|
* Observed live on a server session: a fresh browser (new clientId, no watermark)
|
||||||
|
* typed into the same session fine, which is what isolated it to client state.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const wsSource = readFileSync(resolve(import.meta.dirname, '../src/web/routes/ws-routes.ts'), 'utf8');
|
||||||
|
|
||||||
|
describe('the duplicate ACK carries what the client needs', () => {
|
||||||
|
it('marks a rejected frame as dup and reports the watermark', () => {
|
||||||
|
// A bare ACK is indistinguishable from "applied" — that ambiguity is the bug.
|
||||||
|
expect(wsSource).toMatch(/"dup":true,"last":\$\{watermark\}/);
|
||||||
|
expect(wsSource).toContain('lastInputSeq');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the watermark defensively, so a port without it still ACKs', () => {
|
||||||
|
// The session arrives through a structural port. A throw inside the message
|
||||||
|
// handler aborts it before the ACK is sent, stranding the frame in the
|
||||||
|
// client's durable queue — which is worse than the ambiguity being fixed here.
|
||||||
|
expect(wsSource).toMatch(/typeof \(session as \{ lastInputSeq\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still ACKs a rejected frame, so the client can drop it from its queue', () => {
|
||||||
|
// Silence would strand the record and the redelivery sweep would spin on it.
|
||||||
|
const block = wsSource.slice(wsSource.indexOf('if (seq !== null && socket.readyState === 1)'));
|
||||||
|
expect(block.slice(0, 1200)).toContain('"t":"ia"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the client lifts itself over the watermark', () => {
|
||||||
|
const handler = appSource.slice(
|
||||||
|
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
||||||
|
appSource.indexOf('/** Called from ws.onopen')
|
||||||
|
);
|
||||||
|
|
||||||
|
it('raises the counter to the watermark it was handed', () => {
|
||||||
|
expect(handler).toMatch(/_seqCounters\.set\(sessionId, watermark\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-queues a FIRST-attempt frame, whose input was genuinely lost', () => {
|
||||||
|
expect(handler).toMatch(/rec\.tries <= 1/);
|
||||||
|
expect(handler).toMatch(/this\._reliableSend\(sessionId, lost/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT re-queue a retry, which the dedup correctly suppressed', () => {
|
||||||
|
// A retry called a duplicate means the original DID land; re-sending it would
|
||||||
|
// type the same thing twice — the exact thing exactly-once delivery prevents.
|
||||||
|
expect(handler).toMatch(/const lost = rec && rec\.tries <= 1 \? rec\.data : null;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the raised counter immediately, not on the debounce', () => {
|
||||||
|
expect(handler).toContain('this._persistReliableNow()');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the seq counter is persisted synchronously on every send', () => {
|
||||||
|
it('_reliableSend uses the immediate writer, never the debounced one', () => {
|
||||||
|
// The counter is precisely what must survive a crash, so it cannot ride the
|
||||||
|
// path most likely to be lost. (The queue PAYLOAD may still be debounced.)
|
||||||
|
const send = appSource.slice(appSource.indexOf('_reliableSend(sessionId, data, useMux)'));
|
||||||
|
const body = send.slice(0, send.indexOf('_nextSeq(sessionId) {'));
|
||||||
|
expect(body).toContain('this._persistReliableNow();');
|
||||||
|
expect(body).not.toMatch(/list\.push\(rec\);\s*\n\s*this\._persistReliableState\(\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for remote (SSH) file access (`src/remote-files.ts`).
|
||||||
|
*
|
||||||
|
* Two layers are covered:
|
||||||
|
*
|
||||||
|
* 1. PURE builders/parsers — command construction, escaping and probe parsing, no
|
||||||
|
* connection involved.
|
||||||
|
* 2. The probe SCRIPT itself, executed by a real `/bin/sh` against a real temp
|
||||||
|
* directory. The remote shell is the one place where a quoting mistake becomes an
|
||||||
|
* injection, and it cannot be exercised by an ssh-less unit test any other way: the
|
||||||
|
* script IS the remote command, so `sh -c <script>` reproduces exactly what sshd
|
||||||
|
* runs on the other end.
|
||||||
|
*
|
||||||
|
* Port: N/A (no HTTP server).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync, chmodSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir, tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
RemoteFileAccessError,
|
||||||
|
buildRemoteFileCommand,
|
||||||
|
buildRemoteProbeCommand,
|
||||||
|
buildRemoteReadCommand,
|
||||||
|
parseRemoteProbeRecord,
|
||||||
|
parseRemoteProbeOutput,
|
||||||
|
remoteProbePaths,
|
||||||
|
remoteReadFile,
|
||||||
|
remoteCreateReadStream,
|
||||||
|
} from '../src/remote-files.js';
|
||||||
|
import type { SessionRemote } from '../src/types/session.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a shell line through a real `/bin/sh` and return its `$@` as an argv array,
|
||||||
|
* WITHOUT executing anything. This is how the tests see the exact argument vector a
|
||||||
|
* command line would hand to the process — the local-shell half of the escaping chain.
|
||||||
|
*/
|
||||||
|
function shellArgv(command: string): string[] {
|
||||||
|
const out = execFileSync('sh', ['-c', `set -- ${command}; printf '%s\\0' "$@"`]);
|
||||||
|
// The trailing empty element is the printf format terminator.
|
||||||
|
return out.toString().split('\0').slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A remote session fixture; every field is optional in production, so keep it minimal. */
|
||||||
|
function remoteFixture(overrides: Partial<SessionRemote> = {}): SessionRemote {
|
||||||
|
return {
|
||||||
|
hostId: 'host-1',
|
||||||
|
label: 'testhost',
|
||||||
|
host: '192.0.2.10',
|
||||||
|
username: 'j',
|
||||||
|
remotePath: '/srv/case',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildRemoteFileCommand', () => {
|
||||||
|
it('builds the ssh line from the shared connection args and one shellescaped command', () => {
|
||||||
|
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), 'cat /etc/hostname'));
|
||||||
|
|
||||||
|
// buildSshConnectionArgs returns tokens, and the shell re-splits them into the
|
||||||
|
// flags ssh actually wants (`-o` + `BatchMode=yes`), which is what this pins.
|
||||||
|
expect(argv.slice(0, 3)).toEqual(['ssh', '-o', 'BatchMode=yes']);
|
||||||
|
expect(argv).toContain('ConnectTimeout=10');
|
||||||
|
expect(argv).toContain('j@192.0.2.10');
|
||||||
|
// The remote command is ONE argument, whatever it contains.
|
||||||
|
expect(argv[argv.length - 1]).toBe('cat /etc/hostname');
|
||||||
|
expect(argv[argv.length - 2]).toBe('j@192.0.2.10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes port, identity, jump host and extra options through buildSshConnectionArgs', () => {
|
||||||
|
const argv = shellArgv(
|
||||||
|
buildRemoteFileCommand(
|
||||||
|
remoteFixture({
|
||||||
|
port: 2222,
|
||||||
|
identityFile: '~/.ssh/id_ed25519',
|
||||||
|
jumpHost: 'bastion.example.com',
|
||||||
|
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||||
|
}),
|
||||||
|
'true'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(argv).toContain('-p');
|
||||||
|
expect(argv).toContain('2222');
|
||||||
|
expect(argv).toContain('-J');
|
||||||
|
expect(argv).toContain('bastion.example.com');
|
||||||
|
expect(argv).toContain('StrictHostKeyChecking=accept-new');
|
||||||
|
// `~` is expanded before escaping: ssh does not expand it inside -i.
|
||||||
|
expect(argv).toContain(join(homedir(), '.ssh/id_ed25519'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a shell-metacharacter command as a single opaque argument', () => {
|
||||||
|
const command = "cat '/tmp/it''s here' ; rm -rf ~ #";
|
||||||
|
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), command));
|
||||||
|
|
||||||
|
expect(argv[argv.length - 1]).toBe(command);
|
||||||
|
expect(argv).not.toContain('rm');
|
||||||
|
expect(argv).not.toContain('-rf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRemoteProbeCommand', () => {
|
||||||
|
it('probes every path exactly once, each as its own shell-quoted token', () => {
|
||||||
|
const script = buildRemoteProbeCommand(['/srv/case/a.png', '/srv/case']);
|
||||||
|
const probeCalls = script.split('\n').filter((line) => line.startsWith('probe '));
|
||||||
|
|
||||||
|
// The index is what the parser keys records on, so it is part of the call.
|
||||||
|
expect(probeCalls).toEqual(["probe 0 '/srv/case/a.png'", "probe 1 '/srv/case'"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes a path with spaces, quotes and a command substitution', () => {
|
||||||
|
const nasty = "/srv/case/it's $(touch /tmp/pwned).txt";
|
||||||
|
const script = buildRemoteProbeCommand([nasty]);
|
||||||
|
|
||||||
|
expect(script).toContain(`probe 0 '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
|
||||||
|
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(script);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the probe script through a real `/bin/sh`. With `shadowReadlinkF` the PATH is
|
||||||
|
* fronted by a `readlink` that rejects `-f` the way macOS < 12.3 does (`illegal
|
||||||
|
* option -- f`) and otherwise defers to the real one, which forces the portable
|
||||||
|
* fallback branch on a host that natively has `readlink -f`.
|
||||||
|
*/
|
||||||
|
function runProbe(paths: string[], options: { cwd?: string; shadowReadlinkF?: boolean; shimDir?: string } = {}) {
|
||||||
|
const env =
|
||||||
|
options.shadowReadlinkF && options.shimDir
|
||||||
|
? { ...process.env, PATH: `${options.shimDir}:${process.env.PATH}` }
|
||||||
|
: process.env;
|
||||||
|
const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(paths)], { cwd: options.cwd, env }).toString();
|
||||||
|
return parseRemoteProbeOutput(stdout, paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the probe script on a real shell', () => {
|
||||||
|
let root: string;
|
||||||
|
let shimDir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'codeman-remote-probe-'));
|
||||||
|
shimDir = join(root, 'shim-bin');
|
||||||
|
mkdirSync(shimDir);
|
||||||
|
const realReadlink = execFileSync('sh', ['-c', 'command -v readlink']).toString().trim();
|
||||||
|
writeFileSync(
|
||||||
|
join(shimDir, 'readlink'),
|
||||||
|
`#!/bin/sh\ncase "$1" in -f) echo 'readlink: illegal option -- f' >&2; exit 1;; esac\nexec ${realReadlink} "$@"\n`
|
||||||
|
);
|
||||||
|
chmodSync(join(shimDir, 'readlink'), 0o755);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the fallback branch on a shell whose readlink has no -f', () => {
|
||||||
|
// Sanity check on the shim itself: without it this whole describe would be
|
||||||
|
// exercising the native branch twice.
|
||||||
|
expect(() =>
|
||||||
|
execFileSync('sh', ['-c', 'readlink -f / 2>/dev/null'], {
|
||||||
|
env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}` },
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['readlink -f', false],
|
||||||
|
['portable fallback', true],
|
||||||
|
])('refuses to report a symlink by its own path (%s): the target is what is served', (_label, shadow) => {
|
||||||
|
// The reviewer's exact reproduction: ws/notes.txt -> secret/id_rsa. The old
|
||||||
|
// fallback resolved only the DIRECTORY chain, returned `ws/notes.txt` as the
|
||||||
|
// realpath (with the TARGET's size), containment passed, and `cat` served the key.
|
||||||
|
const ws = join(root, `escape-${shadow ? 'fallback' : 'native'}`);
|
||||||
|
const secret = join(root, `secret-${shadow ? 'fallback' : 'native'}`);
|
||||||
|
mkdirSync(ws);
|
||||||
|
mkdirSync(secret);
|
||||||
|
writeFileSync(join(secret, 'id_rsa'), 'KEYKEYKEYKEY1');
|
||||||
|
execFileSync('ln', ['-s', join(secret, 'id_rsa'), join(ws, 'notes.txt')]);
|
||||||
|
|
||||||
|
const [probe] = runProbe([join(ws, 'notes.txt')], { shadowReadlinkF: shadow, shimDir });
|
||||||
|
|
||||||
|
expect(probe?.realPath).toBe(join(secret, 'id_rsa'));
|
||||||
|
expect(probe?.size).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a relative symlink chain through a symlinked directory on the fallback branch', () => {
|
||||||
|
const ws = join(root, 'chain');
|
||||||
|
mkdirSync(join(ws, 'sub'), { recursive: true });
|
||||||
|
writeFileSync(join(ws, 'sub', 'real.txt'), 'inside');
|
||||||
|
execFileSync('ln', ['-s', 'real.txt', join(ws, 'sub', 'hop1.txt')]);
|
||||||
|
execFileSync('ln', ['-s', 'hop1.txt', join(ws, 'sub', 'hop2.txt')]);
|
||||||
|
execFileSync('ln', ['-s', 'sub', join(ws, 'subl')]);
|
||||||
|
|
||||||
|
const probes = runProbe([join(ws, 'subl', 'hop2.txt'), join(ws, 'subl')], { shadowReadlinkF: true, shimDir });
|
||||||
|
|
||||||
|
expect(probes[0]).toMatchObject({ kind: 'file', size: 6, realPath: join(ws, 'sub', 'real.txt') });
|
||||||
|
expect(probes[1]).toMatchObject({ kind: 'directory', realPath: join(ws, 'sub') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['readlink -f', false],
|
||||||
|
['portable fallback', true],
|
||||||
|
])('fails CLOSED on a symlink loop (%s), never reporting the unresolved path', (_label, shadow) => {
|
||||||
|
const ws = join(root, `loop-${shadow ? 'fallback' : 'native'}`);
|
||||||
|
mkdirSync(ws);
|
||||||
|
execFileSync('ln', ['-s', 'b', join(ws, 'a')]);
|
||||||
|
execFileSync('ln', ['-s', 'a', join(ws, 'b')]);
|
||||||
|
|
||||||
|
const [probe] = runProbe([join(ws, 'a')], { shadowReadlinkF: shadow, shimDir });
|
||||||
|
|
||||||
|
expect(probe).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports kind, size and realpath for a file, a directory and a missing path', () => {
|
||||||
|
const filePath = join(root, 'image.png');
|
||||||
|
writeFileSync(filePath, 'fake png bytes');
|
||||||
|
|
||||||
|
const probes = parseRemoteProbeOutput(
|
||||||
|
execFileSync('sh', ['-c', buildRemoteProbeCommand([filePath, root, join(root, 'nope.png')])]).toString(),
|
||||||
|
[filePath, root, join(root, 'nope.png')]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(probes[0]).toMatchObject({ kind: 'file', size: 14, realPath: filePath });
|
||||||
|
expect(probes[0]?.mtimeMs).toBeGreaterThan(0);
|
||||||
|
expect(probes[1]).toMatchObject({ kind: 'directory', size: 0, realPath: root });
|
||||||
|
expect(probes[2]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a symlink to its target', () => {
|
||||||
|
const target = join(root, 'target.txt');
|
||||||
|
const link = join(root, 'link.txt');
|
||||||
|
writeFileSync(target, 'x');
|
||||||
|
execFileSync('ln', ['-s', target, link]);
|
||||||
|
|
||||||
|
const [probe] = parseRemoteProbeOutput(execFileSync('sh', ['-c', buildRemoteProbeCommand([link])]).toString(), [
|
||||||
|
link,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(probe?.realPath).toBe(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a hostile filename as data, never as a command', () => {
|
||||||
|
// No slashes in the payload: it has to be a legal FILENAME on this host while
|
||||||
|
// still being a command substitution to a shell.
|
||||||
|
const marker = `codeman_pwned_${process.pid}`;
|
||||||
|
const hostile = join(root, `it's; touch ${marker}; $(id).txt`);
|
||||||
|
writeFileSync(hostile, 'hostile');
|
||||||
|
|
||||||
|
const [probe] = parseRemoteProbeOutput(
|
||||||
|
execFileSync('sh', ['-c', buildRemoteProbeCommand([hostile])], { cwd: root }).toString(),
|
||||||
|
[hostile]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(probe?.realPath).toBe(hostile);
|
||||||
|
expect(existsSync(join(root, marker))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a filename containing a newline aligned with its own index', () => {
|
||||||
|
// One record per LINE would have made this two lines, shifting every record
|
||||||
|
// after it by one; records are NUL-terminated and index-keyed instead.
|
||||||
|
const weird = join(root, 'a\nb.txt');
|
||||||
|
writeFileSync(weird, 'nl');
|
||||||
|
const after = join(root, 'after.txt');
|
||||||
|
writeFileSync(after, 'after');
|
||||||
|
|
||||||
|
const probes = runProbe([weird, after, join(root, 'nope')]);
|
||||||
|
|
||||||
|
expect(probes[0]).toMatchObject({ kind: 'file', size: 2, realPath: weird });
|
||||||
|
expect(probes[1]).toMatchObject({ kind: 'file', size: 5, realPath: after });
|
||||||
|
expect(probes[2]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a login banner and rc-file chatter printed before the records', () => {
|
||||||
|
const filePath = join(root, 'banner.txt');
|
||||||
|
writeFileSync(filePath, 'b');
|
||||||
|
|
||||||
|
const stdout = execFileSync('sh', [
|
||||||
|
'-c',
|
||||||
|
`echo 'Welcome to box'; printf '0|f|9|9|/etc/shadow\\n'; ${buildRemoteProbeCommand([filePath])}`,
|
||||||
|
]).toString();
|
||||||
|
|
||||||
|
// The chatter even LOOKS like a record; the leading NUL is what fences it off.
|
||||||
|
expect(parseRemoteProbeOutput(stdout, [filePath])[0]).toMatchObject({ realPath: filePath, size: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a path containing the field separator', () => {
|
||||||
|
const pipePath = join(root, 'a|b.txt');
|
||||||
|
writeFileSync(pipePath, 'xy');
|
||||||
|
|
||||||
|
const [probe] = parseRemoteProbeOutput(execFileSync('sh', ['-c', buildRemoteProbeCommand([pipePath])]).toString(), [
|
||||||
|
pipePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(probe?.realPath).toBe(pipePath);
|
||||||
|
expect(probe?.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks into a nested directory that exists', () => {
|
||||||
|
const nested = join(root, 'sub');
|
||||||
|
mkdirSync(nested, { recursive: true });
|
||||||
|
writeFileSync(join(nested, 'f.txt'), 'abc');
|
||||||
|
|
||||||
|
const [probe] = parseRemoteProbeOutput(
|
||||||
|
execFileSync('sh', ['-c', buildRemoteProbeCommand([join(nested, 'f.txt')])]).toString(),
|
||||||
|
[join(nested, 'f.txt')]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(probe?.size).toBe(3);
|
||||||
|
expect(statSync(join(nested, 'f.txt')).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRemoteProbeRecord', () => {
|
||||||
|
it('parses a file record and converts mtime to milliseconds', () => {
|
||||||
|
expect(parseRemoteProbeRecord('f|1234|1700000000|/srv/case/a.png')).toEqual({
|
||||||
|
realPath: '/srv/case/a.png',
|
||||||
|
kind: 'file',
|
||||||
|
size: 1234,
|
||||||
|
mtimeMs: 1700000000 * 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a path that itself contains the separator', () => {
|
||||||
|
expect(parseRemoteProbeRecord('f|7|0|/srv/ca|se/a b.txt')?.realPath).toBe('/srv/ca|se/a b.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps directories, other kinds, the not-found and the unresolvable markers', () => {
|
||||||
|
expect(parseRemoteProbeRecord('d|0|5|/srv/case')?.kind).toBe('directory');
|
||||||
|
expect(parseRemoteProbeRecord('o|0|0|/srv/case/sock')?.kind).toBe('other');
|
||||||
|
expect(parseRemoteProbeRecord('n')).toBeNull();
|
||||||
|
// Exists but could not be canonicalized: refused like a missing file, never
|
||||||
|
// served under a path whose real target is unknown.
|
||||||
|
expect(parseRemoteProbeRecord('x')).toBeNull();
|
||||||
|
expect(parseRemoteProbeRecord('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed lines instead of inventing a path', () => {
|
||||||
|
expect(parseRemoteProbeRecord('f|1|2')).toBeNull();
|
||||||
|
expect(parseRemoteProbeRecord('x|1|2|/p')).toBeNull();
|
||||||
|
expect(parseRemoteProbeRecord('f|1|2|')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRemoteProbeOutput', () => {
|
||||||
|
it('keys records by index after the leading NUL, so a login banner cannot shift the mapping', () => {
|
||||||
|
const stdout = 'welcome to the remote box\n\x000|f|3|1|/srv/a.txt\x001|n\x00';
|
||||||
|
expect(parseRemoteProbeOutput(stdout, ['/srv/a.txt', '/srv/b.txt'])).toEqual([
|
||||||
|
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts records in any order and ignores duplicates of an index', () => {
|
||||||
|
const stdout = '\x001|d|0|0|/srv\x000|f|3|1|/srv/a.txt\x000|f|9|9|/evil\x00';
|
||||||
|
expect(parseRemoteProbeOutput(stdout, ['/srv/a.txt', '/srv'])).toEqual([
|
||||||
|
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
|
||||||
|
{ realPath: '/srv', kind: 'directory', size: 0, mtimeMs: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a requested path has no record (transport or shell failure, never a 404)', () => {
|
||||||
|
expect(() => parseRemoteProbeOutput('\x000|f|3|1|/srv/a.txt\x00', ['/a', '/b'])).toThrow(RemoteFileAccessError);
|
||||||
|
expect(() => parseRemoteProbeOutput('', ['/a'])).toThrow(RemoteFileAccessError);
|
||||||
|
// No leading NUL at all: the script never ran, whatever the shell printed.
|
||||||
|
expect(() => parseRemoteProbeOutput('0|f|3|1|/srv/a.txt', ['/srv/a.txt'])).toThrow(RemoteFileAccessError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('under vitest', () => {
|
||||||
|
const remote = remoteFixture();
|
||||||
|
|
||||||
|
it('never opens a connection: probes and reads reject with a clear error', async () => {
|
||||||
|
// Mirrors checkRemoteTmuxAvailable's guard. The route tests mock this module, so
|
||||||
|
// this is the backstop for the next test that reaches the real one.
|
||||||
|
await expect(remoteProbePaths(remote, ['/srv/case'])).rejects.toThrow(/disabled under test/);
|
||||||
|
await expect(remoteReadFile(remote, '/srv/case/a.txt', 1024)).rejects.toThrow(/disabled under test/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never opens a connection: a stream fails through its own error path', async () => {
|
||||||
|
const { stream, close } = remoteCreateReadStream(remote, '/srv/case/a.mp4');
|
||||||
|
const failure = await new Promise<Error>((resolveError) => stream.on('error', resolveError));
|
||||||
|
expect(failure).toBeInstanceOf(RemoteFileAccessError);
|
||||||
|
expect(() => close()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRemoteReadCommand', () => {
|
||||||
|
it('streams the whole file with cat', () => {
|
||||||
|
expect(buildRemoteReadCommand("/srv/case/it's.mp4")).toBe("cat '/srv/case/it'\\''s.mp4'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a byte range into a constant-memory tail | head', () => {
|
||||||
|
expect(buildRemoteReadCommand('/srv/case/v.mp4', { start: 2, end: 5 })).toBe(
|
||||||
|
"tail -c +3 '/srv/case/v.mp4' | head -c 4"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers the first byte of the file (tail -c +1, not +0)', () => {
|
||||||
|
expect(buildRemoteReadCommand('/f', { start: 0, end: 0 })).toBe("tail -c +1 '/f' | head -c 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the remote-file ssh concurrency limiter
|
||||||
|
* (`src/remote-ssh-limiter.ts`): the cap holds under interleaved async resumption,
|
||||||
|
* waiters are served FIFO, and a task that throws still releases its slot.
|
||||||
|
*
|
||||||
|
* Port: N/A (no HTTP server).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getActiveRemoteSshCount,
|
||||||
|
getQueuedRemoteSshCount,
|
||||||
|
getRemoteSshLimit,
|
||||||
|
runWithRemoteSshLimit,
|
||||||
|
} from '../src/remote-ssh-limiter.js';
|
||||||
|
|
||||||
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runWithRemoteSshLimit', () => {
|
||||||
|
it('never lets more than the cap run at once, and queues the rest FIFO', async () => {
|
||||||
|
const cap = getRemoteSshLimit();
|
||||||
|
const gates = Array.from({ length: cap + 3 }, () => deferred());
|
||||||
|
const started: number[] = [];
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
|
const runs = gates.map((gate, index) =>
|
||||||
|
runWithRemoteSshLimit(async () => {
|
||||||
|
started.push(index);
|
||||||
|
peak = Math.max(peak, getActiveRemoteSshCount());
|
||||||
|
await gate.promise;
|
||||||
|
return index;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(started).toEqual(Array.from({ length: cap }, (_, i) => i));
|
||||||
|
expect(getActiveRemoteSshCount()).toBe(cap);
|
||||||
|
expect(getQueuedRemoteSshCount()).toBe(3);
|
||||||
|
|
||||||
|
// Releasing one hands the slot to the OLDEST waiter; the count stays at the cap.
|
||||||
|
gates[0].resolve();
|
||||||
|
await runs[0];
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(started).toEqual([...Array.from({ length: cap }, (_, i) => i), cap]);
|
||||||
|
expect(getActiveRemoteSshCount()).toBe(cap);
|
||||||
|
|
||||||
|
for (const gate of gates) gate.resolve();
|
||||||
|
expect(await Promise.all(runs)).toEqual(gates.map((_, i) => i));
|
||||||
|
expect(peak).toBe(cap);
|
||||||
|
expect(getActiveRemoteSshCount()).toBe(0);
|
||||||
|
expect(getQueuedRemoteSshCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the slot when the task throws', async () => {
|
||||||
|
await expect(runWithRemoteSshLimit(async () => Promise.reject(new Error('ssh exit 255')))).rejects.toThrow(
|
||||||
|
'ssh exit 255'
|
||||||
|
);
|
||||||
|
expect(getActiveRemoteSshCount()).toBe(0);
|
||||||
|
expect(await runWithRemoteSshLimit(async () => 'after')).toBe('after');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route tests for Custom Model Endpoint Profiles CRUD + discovery.
|
||||||
|
*
|
||||||
|
* Discovery is mocked at `webviewFetch()` (webview-egress.ts), NOT at the global
|
||||||
|
* `fetch`: the route deliberately goes through the guarded undici dispatcher whose
|
||||||
|
* lookup hook refuses a name that resolves into a link-local / cloud-metadata range,
|
||||||
|
* so a global-fetch stub that still satisfied these tests would mean the guard had
|
||||||
|
* been bypassed.
|
||||||
|
* Port: N/A (app.inject, no real port needed)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
|
||||||
|
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||||
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/web/webview-egress.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||||
|
'../../src/web/webview-egress.js'
|
||||||
|
);
|
||||||
|
return { ...actual, webviewFetch: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMock = vi.mocked(webviewFetch);
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
return createRouteTestHarness(registerCustomModelRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('custom model endpoint CRUD', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts empty', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||||
|
expect(res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates, lists, updates, and deletes an endpoint', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'ep1', label: 'llama.cpp box', baseUrl: 'http://192.168.1.50:8080' },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(200);
|
||||||
|
expect(create.json().data.host.id).toBe('ep1');
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||||
|
expect(list.json()).toHaveLength(1);
|
||||||
|
|
||||||
|
const update = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/model-endpoints/ep1',
|
||||||
|
payload: { label: 'Renamed', baseUrl: 'http://192.168.1.50:8080' },
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(200);
|
||||||
|
expect(update.json().data.host.label).toBe('Renamed');
|
||||||
|
|
||||||
|
const del = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/ep1' });
|
||||||
|
expect(del.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const listAfter = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||||
|
expect(listAfter.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate id on create', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const payload = { id: 'dup', label: 'A', baseUrl: 'http://localhost:8080' };
|
||||||
|
await app.inject({ method: 'POST', url: '/api/model-endpoints', payload });
|
||||||
|
const second = await app.inject({ method: 'POST', url: '/api/model-endpoints', payload });
|
||||||
|
expect(second.json().success).toBe(false);
|
||||||
|
expect(second.json().errorCode).toBe('ALREADY_EXISTS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s updating/deleting an id that does not exist', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const update = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/model-endpoints/ghost',
|
||||||
|
payload: { label: 'A', baseUrl: 'http://localhost:8080' },
|
||||||
|
});
|
||||||
|
expect(update.json().errorCode).toBe('NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a link-local/cloud-metadata base URL', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'meta', label: 'A', baseUrl: 'http://169.254.169.254/' },
|
||||||
|
});
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers models via GET /v1/models and stores the result', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'ep1', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'k' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockImplementation(async (url: URL, init?: RequestInit) => {
|
||||||
|
expect(url.href).toBe('http://localhost:8080/v1/models');
|
||||||
|
const headers = init?.headers as Record<string, string>;
|
||||||
|
// Exactly ONE auth header — never both (a real server hung when sent both).
|
||||||
|
expect(headers.Authorization).toBe('Bearer k');
|
||||||
|
expect(headers['api-key']).toBeUndefined();
|
||||||
|
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep1/discover-models' });
|
||||||
|
expect(res.json().data.models).toEqual(['qwen3', 'llama3']);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Data dir is shared across this WHOLE test file (one temp HOME per file, not per
|
||||||
|
// test — test/setup.ts), so find by id rather than assuming index 0.
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||||
|
const stored = (list.json() as Array<{ id: string }>).find((h) => h.id === 'ep1');
|
||||||
|
expect(stored?.models).toEqual(['qwen3', 'llama3']);
|
||||||
|
expect(stored?.lastDiscoveredAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers models with authStyle "api-key" using only that header, never Authorization', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'ep-azure', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'k', authStyle: 'api-key' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => {
|
||||||
|
const headers = init?.headers as Record<string, string>;
|
||||||
|
expect(headers['api-key']).toBe('k');
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-azure/discover-models' });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error when the endpoint is unreachable', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'ep-err', label: 'A', baseUrl: 'http://localhost:8080' },
|
||||||
|
});
|
||||||
|
// undici's shape: a bare `fetch failed` with the real reason one level down.
|
||||||
|
fetchMock.mockRejectedValue(
|
||||||
|
new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:8080') })
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-err/discover-models' });
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||||
|
expect(res.json().error).toContain('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the egress refusal when the endpoint resolves into a blocked range', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'ep-meta', label: 'A', baseUrl: 'http://models.example:8080' },
|
||||||
|
});
|
||||||
|
const { WebviewEgressBlockedError } = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||||
|
'../../src/web/webview-egress.js'
|
||||||
|
);
|
||||||
|
fetchMock.mockRejectedValue(
|
||||||
|
new TypeError('fetch failed', { cause: new WebviewEgressBlockedError('resolves to 169.254.169.254') })
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-meta/discover-models' });
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().error).toMatch(/refused.*169\.254\.169\.254/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a baseUrl with embedded credentials or a non-http scheme at save time', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
for (const baseUrl of ['http://user:pw@host:8080', 'ftp://host/models', 'http://169.254.169.254']) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/model-endpoints',
|
||||||
|
payload: { id: 'bad', label: 'A', baseUrl },
|
||||||
|
});
|
||||||
|
expect(res.json().success, baseUrl).toBe(false);
|
||||||
|
expect(res.json().errorCode, baseUrl).toBe('INVALID_INPUT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route tests for file READ routes in a remote (SSH) case (#415).
|
||||||
|
*
|
||||||
|
* The mirror image of `test/routes/file-routes.test.ts`: every request here resolves
|
||||||
|
* against a path that exists only on another host, so the local `fs` layer must never
|
||||||
|
* be the thing that answers. The ssh layer (`src/remote-files.ts`) is mocked — a test
|
||||||
|
* never opens a connection — but the REAL module is kept alongside the mocks so
|
||||||
|
* `RemoteFileAccessError` and the command builders stay authentic.
|
||||||
|
*
|
||||||
|
* Port: N/A (app.inject doesn't open ports)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
import { attachmentRegistry } from '../../src/attachment-registry.js';
|
||||||
|
import { RemoteFileAccessError } from '../../src/remote-files.js';
|
||||||
|
import type { RemoteProbe } from '../../src/remote-files.js';
|
||||||
|
import type { SessionRemote } from '../../src/types/session.js';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { MAX_FILE_DOWNLOAD_BYTES } from '../../src/config/buffer-limits.js';
|
||||||
|
|
||||||
|
// Keep the pure builders + the error class real; replace only the IO.
|
||||||
|
vi.mock('../../src/remote-files.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
remoteProbePaths: vi.fn(),
|
||||||
|
remoteReadFile: vi.fn(),
|
||||||
|
remoteCreateReadStream: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { remoteProbePaths, remoteReadFile, remoteCreateReadStream } from '../../src/remote-files.js';
|
||||||
|
|
||||||
|
const mockedProbePaths = vi.mocked(remoteProbePaths);
|
||||||
|
const mockedReadFile = vi.mocked(remoteReadFile);
|
||||||
|
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
|
||||||
|
|
||||||
|
const REMOTE_DIR = '/srv/remote/case';
|
||||||
|
const remote: SessionRemote = {
|
||||||
|
hostId: 'host-1',
|
||||||
|
label: 'testhost',
|
||||||
|
host: '192.0.2.10',
|
||||||
|
username: 'j',
|
||||||
|
remotePath: REMOTE_DIR,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fileProbe(realPath: string, size: number): RemoteProbe {
|
||||||
|
return { realPath, kind: 'file', size, mtimeMs: 1_700_000_000_000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirProbe: RemoteProbe = { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 };
|
||||||
|
|
||||||
|
describe('file routes in a remote (SSH) case', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
let sessionId: string;
|
||||||
|
let closeSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes);
|
||||||
|
sessionId = harness.ctx._sessionId;
|
||||||
|
harness.ctx._session.attachmentHistory = [];
|
||||||
|
// The whole point of the fixture: the workspace is a path on ANOTHER host.
|
||||||
|
harness.ctx._session.workingDir = REMOTE_DIR;
|
||||||
|
harness.ctx._session.remote = { ...remote };
|
||||||
|
|
||||||
|
closeSpy = vi.fn();
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/img.png`, 9), dirProbe]);
|
||||||
|
mockedReadFile.mockResolvedValue(Buffer.from('remote text'));
|
||||||
|
mockedCreateReadStream.mockReturnValue({
|
||||||
|
stream: Readable.from([Buffer.from('remote bytes')]),
|
||||||
|
close: closeSpy,
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// The registry is process-global: a record left behind would leak into the next
|
||||||
|
// test's by-id requests.
|
||||||
|
attachmentRegistry.clearSession(sessionId);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/file-raw', () => {
|
||||||
|
it('streams the remote file and probes the path AND the workspace in one call', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toBe('image/png');
|
||||||
|
expect(res.body).toBe('remote bytes');
|
||||||
|
// Both paths in one ssh round trip: the workspace root is needed to check
|
||||||
|
// containment against a REMOTELY canonicalized root.
|
||||||
|
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||||
|
`${REMOTE_DIR}/img.png`,
|
||||||
|
REMOTE_DIR,
|
||||||
|
]);
|
||||||
|
expect(mockedCreateReadStream).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ host: '192.0.2.10' }),
|
||||||
|
`${REMOTE_DIR}/img.png`,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a byte range as a 206 from the remote host', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/clip.mp4`, 100), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=clip.mp4`,
|
||||||
|
headers: { range: 'bytes=10-19' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 10-19/100');
|
||||||
|
expect(res.headers['content-length']).toBe('10');
|
||||||
|
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||||
|
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/clip.mp4`, {
|
||||||
|
start: 10,
|
||||||
|
end: 19,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaps the ssh stream when the response is done', async () => {
|
||||||
|
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||||
|
// The cleanup is registered on the raw response's lifecycle; without it an
|
||||||
|
// aborted download would leave the ssh child running.
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a path that escapes the workspace lexically, without connecting', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=../../etc/shadow`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a symlink that resolves outside the workspace on the remote host', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=innocent.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a workspace reached through a remote symlink (both sides canonicalized)', async () => {
|
||||||
|
// remotePath is a symlinked mount: the file's realpath is genuinely inside the
|
||||||
|
// workspace's realpath, so refusing it would break the whole case.
|
||||||
|
harness.ctx._session.workingDir = '/mnt/link/case';
|
||||||
|
mockedProbePaths.mockResolvedValue([
|
||||||
|
fileProbe('/srv/real/case/img.png', 3),
|
||||||
|
{ realPath: '/srv/real/case', kind: 'directory', size: 0, mtimeMs: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s a file that does not exist on the remote host', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=gone.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreachable host as a gateway failure, not a 404 or a 500', async () => {
|
||||||
|
mockedProbePaths.mockRejectedValue(
|
||||||
|
new RemoteFileAccessError('remote host testhost unreachable: Connection refused')
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
expect(JSON.parse(res.body).error).toContain('Connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the size cap to the REMOTE size, before reading', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/huge.mp4`, MAX_FILE_DOWNLOAD_BYTES + 1), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=huge.mp4`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(413);
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a directory', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([dirProbe, dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch the ssh layer for a local session', async () => {
|
||||||
|
delete harness.ctx._session.remote;
|
||||||
|
|
||||||
|
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||||
|
|
||||||
|
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a path with the same absolute name ALSO exists on this host', () => {
|
||||||
|
// The case that really happens in practice: the remote tree is mounted on the
|
||||||
|
// Codeman host at the identical absolute path (an sshfs mount, which is the
|
||||||
|
// documented stop-gap workaround for this very bug). The remote host stays the
|
||||||
|
// source of truth: there is deliberately no local fallback, because a fallback
|
||||||
|
// would silently serve the OTHER filesystem's bytes under the same path.
|
||||||
|
let shadowRoot: string;
|
||||||
|
let shadowFile: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
shadowRoot = mkdtempSync(join(tmpdir(), 'codeman-remote-shadow-'));
|
||||||
|
shadowFile = join(shadowRoot, 'img.png');
|
||||||
|
writeFileSync(shadowFile, 'LOCAL BYTES');
|
||||||
|
harness.ctx._session.workingDir = shadowRoot;
|
||||||
|
harness.ctx._session.remote = { ...remote, remotePath: shadowRoot };
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(shadowFile, 12), { ...dirProbe, realPath: shadowRoot }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(shadowRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the REMOTE bytes, never the local copy at the same path', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toBe('remote bytes');
|
||||||
|
expect(res.body).not.toBe('LOCAL BYTES');
|
||||||
|
// The local file is untouched, proving the local side was never the source.
|
||||||
|
expect(readFileSync(shadowFile, 'utf8')).toBe('LOCAL BYTES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still 404s when the remote host does not have the file, even though a local one exists', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([null, { ...dirProbe, realPath: shadowRoot }]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads text from the remote host, not from the local twin', async () => {
|
||||||
|
const localText = join(shadowRoot, 'notes.txt');
|
||||||
|
writeFileSync(localText, 'local text');
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(localText, 11), { ...dirProbe, realPath: shadowRoot }]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(res.body).data.content).toBe('remote text');
|
||||||
|
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), localText, expect.any(Number));
|
||||||
|
expect(readFileSync(localText, 'utf8')).toBe('local text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/file-content', () => {
|
||||||
|
it('returns remote text content and never advertises the editor', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.content).toBe('remote text');
|
||||||
|
expect(body.data.editable).toBe(false);
|
||||||
|
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/notes.txt`, expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies remote media by extension without reading it', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/logo.png`, 1024), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=logo.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.type).toBe('image');
|
||||||
|
expect(body.data.url).toContain('file-raw');
|
||||||
|
expect(mockedReadFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an edit request into an explicit 400 instead of a misleading 404', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=notes.txt&edit=1`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toContain('not supported for files in a remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreachable host as a real 502 with the remote reason', async () => {
|
||||||
|
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toContain('timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path that escapes the workspace', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=../../../etc/passwd`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(res.body).success).toBe(false);
|
||||||
|
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/file-preview and file-thumbnail', () => {
|
||||||
|
it('redirects a non-office remote file to file-raw', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-preview?path=scan.pdf`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toContain('/file-raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says office previews are unavailable rather than 404-ing', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.docx`, 10), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says thumbnails are unavailable for a remote file', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.pdf`, 10), dirProbe]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-thumbnail?path=doc.pdf`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreachable host for previews too', async () => {
|
||||||
|
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: no route'));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('attachments — a file click OUTSIDE the case directory (#415)', () => {
|
||||||
|
const outsidePath = '/tmp/agent-output/shot.png';
|
||||||
|
|
||||||
|
async function publish(path: string): Promise<{ statusCode: number; body: unknown }> {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments`,
|
||||||
|
payload: { path, notify: false },
|
||||||
|
});
|
||||||
|
return { statusCode: res.statusCode, body: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers an out-of-workspace remote path by probing the remote host', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||||
|
|
||||||
|
const res = await publish(outsidePath);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const data = (res.body as { data: { attachmentId: string; size: number; fileName: string } }).data;
|
||||||
|
expect(data.attachmentId).toMatch(/^att_/);
|
||||||
|
expect(data.fileName).toBe('shot.png');
|
||||||
|
expect(data.size).toBe(42);
|
||||||
|
// The path is outside the workspace, so a workspace-relative resolution could
|
||||||
|
// never have found it — the probe is what makes this work at all.
|
||||||
|
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||||
|
outsidePath,
|
||||||
|
REMOTE_DIR,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the registered remote attachment's bytes by id, with range support", async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||||
|
const published = await publish(outsidePath);
|
||||||
|
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||||
|
|
||||||
|
// The by-id route re-probes (guard defense-in-depth) before streaming.
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toBe('image/png');
|
||||||
|
expect(res.body).toBe('remote bytes');
|
||||||
|
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), outsidePath, undefined);
|
||||||
|
|
||||||
|
const ranged = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||||
|
headers: { range: 'bytes=1-3' },
|
||||||
|
});
|
||||||
|
expect(ranged.statusCode).toBe(206);
|
||||||
|
expect(ranged.headers['content-range']).toBe('bytes 1-3/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the remote size in the attachment metadata poll', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||||
|
const published = await publish(outsidePath);
|
||||||
|
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||||
|
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 84), dirProbe]);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments/${attachmentId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.size).toBe(84);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s a remote path that does not exist instead of reporting it as unreadable', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||||
|
|
||||||
|
const res = await publish('/tmp/agent-output/gone.png');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(JSON.stringify(res.body)).toContain('Attachment file not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreachable host as 502 for the click path too', async () => {
|
||||||
|
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||||
|
|
||||||
|
const res = await publish(outsidePath);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a blocked remote path (the blocklist is host-agnostic)', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||||
|
|
||||||
|
const res = await publish('/etc/shadow');
|
||||||
|
|
||||||
|
// 403 from the guard (the same answer the local path gives for a blocked tree).
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/blocked/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer office previews or thumbnails for a remote attachment', async () => {
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||||
|
const published = await publish('/tmp/agent-output/report.docx');
|
||||||
|
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||||
|
|
||||||
|
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||||
|
const preview = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/preview`,
|
||||||
|
});
|
||||||
|
const thumbnail = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/thumbnail`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.statusCode).toBe(400);
|
||||||
|
expect(thumbnail.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists an out-of-workspace remote history entry without marking it missing', async () => {
|
||||||
|
harness.ctx._session.attachmentHistory = [
|
||||||
|
{
|
||||||
|
id: 'hist-1',
|
||||||
|
sessionId,
|
||||||
|
fileName: 'shot.png',
|
||||||
|
extension: 'png',
|
||||||
|
attachmentType: 'image',
|
||||||
|
size: 1,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'external',
|
||||||
|
externalPath: outsidePath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedProbePaths.mockImplementation(async (_remote, paths) =>
|
||||||
|
paths.map((path) => (path === outsidePath ? fileProbe(outsidePath, 42) : path === REMOTE_DIR ? dirProbe : null))
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const [item] = JSON.parse(res.body).data.items;
|
||||||
|
expect(item.missing).toBe(false);
|
||||||
|
expect(item.size).toBe(42);
|
||||||
|
expect(item.attachmentId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a workspace-relative history entry over ssh', async () => {
|
||||||
|
harness.ctx._session.attachmentHistory = [
|
||||||
|
{
|
||||||
|
id: 'hist-2',
|
||||||
|
sessionId,
|
||||||
|
fileName: 'out.png',
|
||||||
|
extension: 'png',
|
||||||
|
attachmentType: 'image',
|
||||||
|
size: 1,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'detected',
|
||||||
|
relativePath: 'out.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedProbePaths.mockImplementation(async (_remote, paths) =>
|
||||||
|
paths.map((path) =>
|
||||||
|
path === `${REMOTE_DIR}/out.png`
|
||||||
|
? fileProbe(`${REMOTE_DIR}/out.png`, 7)
|
||||||
|
: path === REMOTE_DIR
|
||||||
|
? dirProbe
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||||
|
|
||||||
|
const [item] = JSON.parse(res.body).data.items;
|
||||||
|
expect(item.missing).toBe(false);
|
||||||
|
expect(item.size).toBe(7);
|
||||||
|
expect(item.rawUrl).toContain('file-raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the history list probes the whole history in ONE ssh round trip', () => {
|
||||||
|
// One connection per entry (up to ATTACHMENT_HISTORY_LIMIT, re-run on every
|
||||||
|
// attachment:detected while the drawer is open) tripped OpenSSH's default
|
||||||
|
// MaxStartups 10:30:100, which drops most of a burst that size.
|
||||||
|
const history = () => [
|
||||||
|
{
|
||||||
|
id: 'hist-a',
|
||||||
|
sessionId,
|
||||||
|
fileName: 'out.png',
|
||||||
|
extension: 'png',
|
||||||
|
attachmentType: 'image' as const,
|
||||||
|
size: 1,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'detected' as const,
|
||||||
|
relativePath: 'out.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hist-b',
|
||||||
|
sessionId,
|
||||||
|
fileName: 'shot.png',
|
||||||
|
extension: 'png',
|
||||||
|
attachmentType: 'image' as const,
|
||||||
|
size: 1,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'external' as const,
|
||||||
|
externalPath: outsidePath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hist-c',
|
||||||
|
sessionId,
|
||||||
|
fileName: 'gone.png',
|
||||||
|
extension: 'png',
|
||||||
|
attachmentType: 'image' as const,
|
||||||
|
size: 1,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
source: 'detected' as const,
|
||||||
|
relativePath: 'gone.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('issues a single batched probe covering every entry plus the workspace root', async () => {
|
||||||
|
harness.ctx._session.attachmentHistory = history();
|
||||||
|
mockedProbePaths.mockImplementation(async (_remote, paths) =>
|
||||||
|
paths.map((path) =>
|
||||||
|
path === `${REMOTE_DIR}/out.png`
|
||||||
|
? fileProbe(path, 7)
|
||||||
|
: path === outsidePath
|
||||||
|
? fileProbe(outsidePath, 42)
|
||||||
|
: path === REMOTE_DIR
|
||||||
|
? dirProbe
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(mockedProbePaths).toHaveBeenCalledTimes(1);
|
||||||
|
const [, probed] = mockedProbePaths.mock.calls[0];
|
||||||
|
expect([...probed].sort()).toEqual(
|
||||||
|
[REMOTE_DIR, `${REMOTE_DIR}/gone.png`, `${REMOTE_DIR}/out.png`, outsidePath].sort()
|
||||||
|
);
|
||||||
|
// (ids are re-minted for external entries by the sanitizer, so key on the name)
|
||||||
|
const items = JSON.parse(res.body).data.items as Array<{ fileName: string; missing: boolean; size: number }>;
|
||||||
|
expect(items.map((item) => [item.fileName, item.missing, item.size])).toEqual([
|
||||||
|
['out.png', false, 7],
|
||||||
|
['shot.png', false, 42],
|
||||||
|
['gone.png', true, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every entry as unknown (missing: false), detected AND external alike, when the host is unreachable', async () => {
|
||||||
|
harness.ctx._session.attachmentHistory = history();
|
||||||
|
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const items = JSON.parse(res.body).data.items as Array<{ id: string; missing: boolean }>;
|
||||||
|
// The two branches used to disagree here: detected kept missing:false while
|
||||||
|
// external's 502 was folded into missing:true.
|
||||||
|
expect(items.map((item) => item.missing)).toEqual([false, false, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/sessions/:id/file-content', () => {
|
||||||
|
// The remote guard has to come BEFORE the local path validation: with a
|
||||||
|
// directory of the same absolute name on this host (an sshfs mount of the remote
|
||||||
|
// tree, the documented stop-gap for #415) the write would land on the local twin
|
||||||
|
// while the viewer believes it edited the remote file.
|
||||||
|
let shadowRoot: string;
|
||||||
|
let shadowFile: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
shadowRoot = mkdtempSync(join(tmpdir(), 'codeman-remote-put-'));
|
||||||
|
shadowFile = join(shadowRoot, 'notes.txt');
|
||||||
|
writeFileSync(shadowFile, 'LOCAL TEXT');
|
||||||
|
harness.ctx._session.workingDir = shadowRoot;
|
||||||
|
harness.ctx._session.remote = { ...remote, remotePath: shadowRoot };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(shadowRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 400 for a remote case and never touches the local file of the same name', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content`,
|
||||||
|
payload: { path: 'notes.txt', content: 'OVERWRITTEN', baseHash: 'whatever', force: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).error).toMatch(/not supported for files in a remote/);
|
||||||
|
expect(readFileSync(shadowFile, 'utf8')).toBe('LOCAL TEXT');
|
||||||
|
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a client that gives up during the guard probe', () => {
|
||||||
|
it('still has its ssh body child reaped', async () => {
|
||||||
|
// The probe is an ssh round trip; a client that aborted during it has already
|
||||||
|
// closed the response, so a `close` listener attached afterwards never fires.
|
||||||
|
const controller = new AbortController();
|
||||||
|
mockedProbePaths.mockImplementation(async () => {
|
||||||
|
controller.abort();
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 20));
|
||||||
|
return [fileProbe(`${REMOTE_DIR}/img.png`, 9), dirProbe];
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.app
|
||||||
|
.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png`, signal: controller.signal })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
|
||||||
|
|
||||||
|
// The body WAS opened (the route ran to completion against an already-closed
|
||||||
|
// response), which is exactly the window the guard covers.
|
||||||
|
expect(mockedCreateReadStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for POST /api/sessions/:id/custom-model (docs/custom-model-endpoints-plan.md
|
||||||
|
* chunk 5 — applying/clearing a session's custom model endpoint + CLI restart).
|
||||||
|
* Port: N/A (app.inject, no real port needed)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { getDataDir } from '../../src/config/instance.js';
|
||||||
|
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const CLAUDE_ENDPOINT: CustomModelHost = {
|
||||||
|
id: 'ep1',
|
||||||
|
label: 'llama.cpp box',
|
||||||
|
baseUrl: 'http://192.168.1.50:8080',
|
||||||
|
apiKey: 'k',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT]);
|
||||||
|
return createRouteTestHarness(registerSessionRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/sessions/:id/custom-model', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await writeCustomModelHosts(getDataDir(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an endpoint/model to a claude-mode session and restarts the CLI', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'claude';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.customModel).toEqual({ endpointId: 'ep1', modelId: 'qwen3', label: 'llama.cpp box' });
|
||||||
|
expect(body.restarted).toBe(true);
|
||||||
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verify the actual injected env vars via setCustomModel's captured call args.
|
||||||
|
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
||||||
|
expect(next.envKeys).toEqual([
|
||||||
|
'ANTHROPIC_BASE_URL',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
]);
|
||||||
|
expect(envOverrides.ANTHROPIC_BASE_URL).toBe('http://192.168.1.50:8080');
|
||||||
|
expect(envOverrides.ANTHROPIC_API_KEY).toBe('k');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears back to the native default', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'claude';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { clear: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().customModel).toBeUndefined();
|
||||||
|
expect(session.setCustomModel).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown endpoint id', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ghost', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a mode with no known custom-model mechanism (antigravity)', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
ctx.sessions.get('test-session-1')!.mode = 'antigravity';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a remote (SSH) session before touching it: restartCli would only reattach the remote tmux', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'claude';
|
||||||
|
session.remote = { hostId: 'h1', remotePath: '/srv/case' };
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
expect(res.json().error).toMatch(/remote/i);
|
||||||
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||||
|
expect(session.restartCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a Docker session the same way, for clear as well as apply', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'claude';
|
||||||
|
session.docker = { containerName: 'codeman-case' };
|
||||||
|
|
||||||
|
for (const payload of [{ endpointId: 'ep1', modelId: 'qwen3' }, { clear: true }]) {
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload });
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
}
|
||||||
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||||
|
expect(session.restartCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi: writes the config dir AND forces --model custom/<id>, since the file alone does not select the model', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'pi';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3.5-0.8b' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
||||||
|
expect(next.launchModel).toBe('custom/qwen3.5-0.8b');
|
||||||
|
expect(next.configDir).toBe(join(getDataDir(), 'custom-model-configs', 'test-session-1'));
|
||||||
|
expect(envOverrides.HOME).toBe(next.configDir);
|
||||||
|
const written = join(next.configDir, '.pi', 'agent', 'models.json');
|
||||||
|
expect(existsSync(written)).toBe(true);
|
||||||
|
// pi embeds the key literally, so the file is private to the server account.
|
||||||
|
expect(statSync(written).mode & 0o777).toBe(0o600);
|
||||||
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a model id the CLI cannot carry on its command line instead of launching without it', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'pi';
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen 3 with spaces' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||||
|
expect(session.restartCli).not.toHaveBeenCalled();
|
||||||
|
// The config dir written before the check is cleaned up again.
|
||||||
|
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear removes the previous config dir the session reports', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'pi';
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { clear: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(existsSync(dir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to touch a busy session', async () => {
|
||||||
|
const { app, ctx } = await setup();
|
||||||
|
const session = ctx.sessions.get('test-session-1')!;
|
||||||
|
session.mode = 'claude';
|
||||||
|
session.isBusy = () => true;
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions/test-session-1/custom-model',
|
||||||
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
expect(res.json().errorCode).toBe('SESSION_BUSY');
|
||||||
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
|
||||||
|
*
|
||||||
|
* A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
|
||||||
|
* auto-naming can never overwrite a name a person chose, on this server or
|
||||||
|
* on the one that restores the session after a restart.
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { Session } from '../../src/session.js';
|
||||||
|
import { SseEvent } from '../../src/web/sse-events.js';
|
||||||
|
|
||||||
|
describe('PUT /api/sessions/:id/name', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
let session: Session;
|
||||||
|
const updateSessionName = vi.fn(() => true);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||||
|
// A REAL session, since the ownership flag lives on the class, not the mock.
|
||||||
|
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
harness.ctx.sessions.set(session.id, session as never);
|
||||||
|
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
|
||||||
|
expect(session.nameSource).toBe('placeholder');
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sessions/${session.id}/name`,
|
||||||
|
payload: { name: 'my window' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
// The harness registers the bare route; the {success,data} envelope is a server-level hook.
|
||||||
|
expect(res.json()).toMatchObject({ name: 'my window' });
|
||||||
|
expect(session.name).toBe('my window');
|
||||||
|
expect(session.nameSource).toBe('manual');
|
||||||
|
expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
|
||||||
|
expect(session.name).toBe('my window');
|
||||||
|
|
||||||
|
expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
|
||||||
|
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
|
||||||
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||||
|
SseEvent.SessionUpdated,
|
||||||
|
expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
|
||||||
|
);
|
||||||
|
// What the restore path will read back: the persisted state carries the flag.
|
||||||
|
expect(session.toState().nameSource).toBe('manual');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -250,7 +250,10 @@ describe('ws-routes', () => {
|
|||||||
|
|
||||||
ws.send(JSON.stringify({ t: 'i', d: 'again\r', cid: 'c1', seq: 7 }));
|
ws.send(JSON.stringify({ t: 'i', d: 'again\r', cid: 'c1', seq: 7 }));
|
||||||
|
|
||||||
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 7 });
|
// The ACK now SAYS it was a duplicate and hands back the watermark: a bare
|
||||||
|
// ACK is indistinguishable from "applied", and that ambiguity left a client
|
||||||
|
// whose seq counter had rolled back silently unable to type at all.
|
||||||
|
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 7, dup: true, last: 7 });
|
||||||
expect(session.writeBuffer).not.toContain('again\r');
|
expect(session.writeBuffer).not.toContain('again\r');
|
||||||
} finally {
|
} finally {
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Auto-naming a session after its first prompt (#376).
|
||||||
|
*
|
||||||
|
* The tracker sits on the raw keystroke stream, so most of these pin the
|
||||||
|
* per-key rules that a review of the first cut found missing: a bare Esc ate
|
||||||
|
* the next prompt's first character, a wheel report mid-word dropped half the
|
||||||
|
* prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
|
||||||
|
*
|
||||||
|
* Port: N/A (no server needed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { Session } from '../src/session.js';
|
||||||
|
import {
|
||||||
|
SubmittedPromptTracker,
|
||||||
|
deriveAutoSessionName,
|
||||||
|
composeAutoSessionName,
|
||||||
|
isGeneratedSessionName,
|
||||||
|
} from '../src/session-auto-name.js';
|
||||||
|
|
||||||
|
describe('SubmittedPromptTracker', () => {
|
||||||
|
it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('fix the')).toEqual([]);
|
||||||
|
expect(tracker.feed(' login bugs\x7f')).toEqual([]);
|
||||||
|
expect(tracker.feed('\r')).toEqual(['fix the login bug']);
|
||||||
|
expect(tracker.feed('\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
tracker.feed('\x1b');
|
||||||
|
expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
|
||||||
|
tracker.feed('\x1b');
|
||||||
|
expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
|
||||||
|
// Esc then digits and punctuation used to grow the escape buffer without bound.
|
||||||
|
tracker.feed('\x1b');
|
||||||
|
expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
|
||||||
|
// A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
|
||||||
|
// is Alt+s by the terminal's own encoding and stays swallowed).
|
||||||
|
tracker.feed('\x1b');
|
||||||
|
tracker.feed('\x1b');
|
||||||
|
expect(tracker.feed('still here\r')).toEqual(['still here']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
|
||||||
|
expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
|
||||||
|
expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
|
||||||
|
expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('\x1bOB\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('\x10x\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('\x12search\r')).toEqual([]);
|
||||||
|
expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
|
||||||
|
// Ctrl+C empties the composer, which also clears the taint.
|
||||||
|
expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps bracketed-paste newlines inside the draft', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
|
||||||
|
'line one line two line three plus typed',
|
||||||
|
]);
|
||||||
|
// A paste split across chunks stays a paste.
|
||||||
|
tracker.feed('\x1b[200~first\r');
|
||||||
|
expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
tracker.feed('Fix the login bug');
|
||||||
|
tracker.feed('\n');
|
||||||
|
expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
|
||||||
|
expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
|
||||||
|
expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the HEAD of an over-long draft', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
|
||||||
|
expect(prompt).toHaveLength(8192);
|
||||||
|
// Backspaces past the cap consume the overflow before the kept text.
|
||||||
|
const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
|
||||||
|
expect(again).toHaveLength(8190);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
|
||||||
|
expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
|
||||||
|
// Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
|
||||||
|
expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
|
||||||
|
expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a CSI split across chunks', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
tracker.feed('abc\x1b[');
|
||||||
|
expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveAutoSessionName', () => {
|
||||||
|
it('takes the first sentence, drops the full stop, and bounds the length', () => {
|
||||||
|
expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
|
||||||
|
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
|
||||||
|
expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
|
||||||
|
expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
|
||||||
|
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
|
||||||
|
const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
|
||||||
|
expect(cut.endsWith('…')).toBe(true);
|
||||||
|
expect(cut).toMatch(/^(word )+word…$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cut on an abbreviation early in the prompt', () => {
|
||||||
|
expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
|
||||||
|
expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for commands and empties, but not for paths', () => {
|
||||||
|
expect(deriveAutoSessionName('/clear')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName('/model opus')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName('! npm test')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName(' ')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips control bytes and ANSI before the title is persisted', () => {
|
||||||
|
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
|
||||||
|
expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composeAutoSessionName', () => {
|
||||||
|
it('keeps the placeholder as a prefix so the case and the counter survive', () => {
|
||||||
|
expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
|
||||||
|
expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the rename cap in UTF-16 units', () => {
|
||||||
|
const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
|
||||||
|
expect(name.length).toBeLessThanOrEqual(40);
|
||||||
|
expect(name.startsWith('w3-myapp: ')).toBe(true);
|
||||||
|
expect(name.endsWith('…')).toBe(true);
|
||||||
|
expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises only the generated w/s + number + case form', () => {
|
||||||
|
expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
|
||||||
|
expect(isGeneratedSessionName('s1-shell')).toBe(true);
|
||||||
|
expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
|
||||||
|
expect(isGeneratedSessionName('alpha')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session name ownership', () => {
|
||||||
|
it('infers placeholder vs manual from the name and persists the source', () => {
|
||||||
|
const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
expect(placeholder.nameSource).toBe('placeholder');
|
||||||
|
expect(placeholder.toState().nameSource).toBe('placeholder');
|
||||||
|
expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
|
||||||
|
expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
|
||||||
|
expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
|
||||||
|
// The boot restore passes the persisted source, which outranks the inference.
|
||||||
|
const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
|
||||||
|
expect(recovered.nameSource).toBe('auto');
|
||||||
|
expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names once: the first prompt takes it, later prompts and renames do not', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
|
||||||
|
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||||
|
expect(session.nameSource).toBe('auto');
|
||||||
|
expect(session.applyAutoName('w1-demo: 1')).toBe(false);
|
||||||
|
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||||
|
|
||||||
|
session.name = 'mine';
|
||||||
|
expect(session.nameSource).toBe('manual');
|
||||||
|
expect(session.applyAutoName('other')).toBe(false);
|
||||||
|
expect(session.name).toBe('mine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes the first prompt even when the composed name is unchanged', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
expect(session.applyAutoName('w1-demo')).toBe(false);
|
||||||
|
expect(session.nameSource).toBe('auto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session promptSubmitted', () => {
|
||||||
|
function withFakePty(session: Session): ReturnType<typeof vi.fn> {
|
||||||
|
const write = vi.fn();
|
||||||
|
(session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
|
||||||
|
return write;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('emits for user input only, after the bytes reached the PTY', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
const prompts: string[] = [];
|
||||||
|
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||||
|
|
||||||
|
// No PTY yet: the write fails and nothing is reported.
|
||||||
|
expect(session.write('lost\r', { fromUser: true })).toBe(false);
|
||||||
|
expect(prompts).toEqual([]);
|
||||||
|
|
||||||
|
const write = withFakePty(session);
|
||||||
|
expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
|
||||||
|
expect(prompts).toEqual([]);
|
||||||
|
expect(session.write('fix the ', { fromUser: true })).toBe(true);
|
||||||
|
expect(session.write('login bug\r', { fromUser: true })).toBe(true);
|
||||||
|
expect(prompts).toEqual(['fix the login bug']);
|
||||||
|
expect(write).toHaveBeenCalledTimes(3);
|
||||||
|
// The pane's last-Enter stamp is kept for EVERY write, user or not.
|
||||||
|
expect(session.lastSubmitAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never feeds the tracker for a shell session', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
|
||||||
|
const prompts: string[] = [];
|
||||||
|
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||||
|
withFakePty(session);
|
||||||
|
expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
|
||||||
|
session.trackUserInput('cd src\r');
|
||||||
|
expect(prompts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
const prompts: string[] = [];
|
||||||
|
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||||
|
withFakePty(session);
|
||||||
|
session.write('Fix the login bug', { fromUser: true });
|
||||||
|
session.trackUserInput('\n');
|
||||||
|
session.write('Also add tests.\r', { fromUser: true });
|
||||||
|
expect(prompts).toEqual(['Fix the login bug Also add tests.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports through writeViaMux only when the mux accepted the input', async () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
const prompts: string[] = [];
|
||||||
|
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||||
|
const sendInput = vi.fn(async () => false);
|
||||||
|
(session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
|
||||||
|
(session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
|
||||||
|
|
||||||
|
expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
|
||||||
|
expect(prompts).toEqual([]);
|
||||||
|
sendInput.mockResolvedValue(true);
|
||||||
|
expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
|
||||||
|
expect(prompts).toEqual(['delivered']);
|
||||||
|
expect(await session.writeViaMux('/clear\r')).toBe(true);
|
||||||
|
expect(prompts).toEqual(['delivered']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Custom Model Endpoint Profiles, the Session half of applying and
|
||||||
|
* clearing a selection against a live pane (`Session.setCustomModel()` +
|
||||||
|
* `Session.restartCli()`), pinned against the three ways the first cut broke a
|
||||||
|
* working session:
|
||||||
|
*
|
||||||
|
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
|
||||||
|
* which persists at the tmux-session level and is inherited by `respawn-pane`,
|
||||||
|
* so removing them from `_envOverrides` relaunched the CLI still pointed at the
|
||||||
|
* endpoint (and, for the configDir kinds, at a `HOME`/`CODEX_HOME` that had just
|
||||||
|
* been deleted). The retired keys must ride `RespawnPaneOptions.unsetEnvKeys`.
|
||||||
|
* 2. Applying to a local claude session killed the pane: the relaunch was
|
||||||
|
* `claude --session-id <id>` and Claude refuses an id that already has a
|
||||||
|
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
|
||||||
|
* docker and remote pane commands use, i.e. a pinned resume id.
|
||||||
|
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||||
|
* that selects it, so the file was ignored.
|
||||||
|
*
|
||||||
|
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
||||||
|
* spying on `respawnPane` to read the options the relaunch would get.
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
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 { Session } from '../src/session.js';
|
||||||
|
import { TmuxManager } from '../src/tmux-manager.js';
|
||||||
|
import type { MuxSession, SessionMode } from '../src/types.js';
|
||||||
|
|
||||||
|
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
|
||||||
|
const sessions: Session[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const s of sessions.splice(0)) s.stop();
|
||||||
|
rmSync(workingDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
||||||
|
mkdirSync(workingDir, { recursive: true });
|
||||||
|
const mux = new TmuxManager();
|
||||||
|
const muxSession: MuxSession = {
|
||||||
|
sessionId: 'placeholder',
|
||||||
|
muxName: 'codeman-cafe0001',
|
||||||
|
pid: 1,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
workingDir,
|
||||||
|
mode,
|
||||||
|
attached: false,
|
||||||
|
};
|
||||||
|
const session = new Session({ workingDir, mode, mux, useMux: true, muxSession, ...extra });
|
||||||
|
sessions.push(session);
|
||||||
|
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
|
||||||
|
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
|
||||||
|
return { session, respawn };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_SONNET_MODEL'];
|
||||||
|
const claudeEnv = {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://box:8080',
|
||||||
|
ANTHROPIC_API_KEY: 'k',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'q',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('clearing a selection unsets what it injected', () => {
|
||||||
|
it('queues the retired keys for setenv -u on the next respawn and drops them from the overrides', async () => {
|
||||||
|
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CODE_KEEP: '1' } });
|
||||||
|
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||||
|
|
||||||
|
const result = session.setCustomModel(undefined);
|
||||||
|
expect(result.removedEnvKeys).toEqual(CLAUDE_KEYS);
|
||||||
|
expect(session.customModel).toBeUndefined();
|
||||||
|
|
||||||
|
expect(await session.restartCli()).toBe(true);
|
||||||
|
const options = respawn.mock.calls[0][0];
|
||||||
|
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
||||||
|
expect(options.envOverrides).toEqual({ CLAUDE_CODE_KEEP: '1' });
|
||||||
|
|
||||||
|
// Drained once the respawn succeeded: the next relaunch has nothing to unset.
|
||||||
|
respawn.mockClear();
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].unsetEnvKeys).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching endpoints re-sets the shared keys instead of unsetting them', async () => {
|
||||||
|
const { session, respawn } = liveSession('claude');
|
||||||
|
session.setCustomModel({ endpointId: 'a', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||||
|
const next = { ...claudeEnv, ANTHROPIC_BASE_URL: 'http://other:8080' };
|
||||||
|
session.setCustomModel({ endpointId: 'b', modelId: 'q', envKeys: CLAUDE_KEYS }, next);
|
||||||
|
|
||||||
|
await session.restartCli();
|
||||||
|
const options = respawn.mock.calls[0][0];
|
||||||
|
expect(options.unsetEnvKeys).toBeUndefined();
|
||||||
|
expect(options.envOverrides?.ANTHROPIC_BASE_URL).toBe('http://other:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the previous config dir so the caller can delete it, and never leaks bookkeeping on the wire', () => {
|
||||||
|
const { session } = liveSession('pi');
|
||||||
|
session.setCustomModel(
|
||||||
|
{ endpointId: 'ep', modelId: 'q', envKeys: ['HOME'], configDir: '/tmp/cfg-1', launchModel: 'custom/q' },
|
||||||
|
{ HOME: '/tmp/cfg-1' }
|
||||||
|
);
|
||||||
|
expect(session.customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
||||||
|
expect(session.toState().customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
||||||
|
expect(session.getCustomModelForPersist()).toEqual({
|
||||||
|
endpointId: 'ep',
|
||||||
|
modelId: 'q',
|
||||||
|
label: undefined,
|
||||||
|
envKeys: ['HOME'],
|
||||||
|
configDir: '/tmp/cfg-1',
|
||||||
|
launchModel: 'custom/q',
|
||||||
|
});
|
||||||
|
expect(session.setCustomModel(undefined).previousConfigDir).toBe('/tmp/cfg-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restartCli() must not kill a working pane', () => {
|
||||||
|
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
|
||||||
|
const { session, respawn } = liveSession('claude');
|
||||||
|
await session.restartCli();
|
||||||
|
const options = respawn.mock.calls[0][0];
|
||||||
|
expect(options.resumeSessionId).toBe(session.claudeSessionId);
|
||||||
|
expect(options.resumeSessionId).toBe(session.id);
|
||||||
|
// The pin is per-respawn: nothing about the session's own resume id changed.
|
||||||
|
expect(session.toState().resumeSessionId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
|
||||||
|
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||||
|
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi: no top-level resume pin, its resume id is minted by the CLI and lives in piConfig', async () => {
|
||||||
|
const { session, respawn } = liveSession('pi');
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('launchModel reaches the CLI through its own launch param', () => {
|
||||||
|
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
|
||||||
|
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
|
||||||
|
session.setCustomModel(
|
||||||
|
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
|
||||||
|
{ HOME: '/tmp/cfg' }
|
||||||
|
);
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'custom/qwen3', thinking: 'low' });
|
||||||
|
// The user's own choice survives underneath, which is what a clear falls back to.
|
||||||
|
expect(session.toState().piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
||||||
|
|
||||||
|
session.setCustomModel(undefined);
|
||||||
|
respawn.mockClear();
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grok: the block name lands in grokConfig.model', async () => {
|
||||||
|
const { session, respawn } = liveSession('grok');
|
||||||
|
session.setCustomModel(
|
||||||
|
{
|
||||||
|
endpointId: 'ep',
|
||||||
|
modelId: 'qwen3',
|
||||||
|
envKeys: ['GROK_HOME'],
|
||||||
|
configDir: '/tmp/cfg',
|
||||||
|
launchModel: 'codeman-custom',
|
||||||
|
},
|
||||||
|
{ GROK_HOME: '/tmp/cfg' }
|
||||||
|
);
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].grokConfig).toEqual({ model: 'codeman-custom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: a selection without a launchModel leaves the model param untouched', async () => {
|
||||||
|
const { session, respawn } = liveSession('claude');
|
||||||
|
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||||
|
await session.restartCli();
|
||||||
|
expect(respawn.mock.calls[0][0].model).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||||
|
import { SseEvent } from '../src/web/sse-events.js';
|
||||||
|
|
||||||
describe('session listener wiring', () => {
|
describe('session listener wiring', () => {
|
||||||
it('forwards the attachment request source through registerAttachment', async () => {
|
it('forwards the attachment request source through registerAttachment', async () => {
|
||||||
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
|
|||||||
);
|
);
|
||||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
function autoNameDeps(session: Session, enabled: boolean) {
|
||||||
|
const deps = {
|
||||||
|
updateSessionName: vi.fn(() => true),
|
||||||
|
persistSessionState: vi.fn(),
|
||||||
|
broadcast: vi.fn(),
|
||||||
|
getSessionStateWithRespawn: vi.fn(() => session.toState()),
|
||||||
|
isAutoNameEnabled: vi.fn(async () => enabled),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
deps,
|
||||||
|
refs: createSessionListeners(session, deps as unknown as Parameters<typeof createSessionListeners>[1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
|
||||||
|
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
const { deps, refs } = autoNameDeps(session, true);
|
||||||
|
|
||||||
|
// A slash command yields no title and leaves the session eligible; the
|
||||||
|
// setting is not even read for it.
|
||||||
|
refs.promptSubmitted('/clear');
|
||||||
|
await flush();
|
||||||
|
expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
|
||||||
|
expect(session.name).toBe('w1-demo');
|
||||||
|
|
||||||
|
refs.promptSubmitted('整理登录模块并补充测试');
|
||||||
|
await flush();
|
||||||
|
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||||
|
expect(session.nameSource).toBe('auto');
|
||||||
|
expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
|
||||||
|
expect(deps.persistSessionState).toHaveBeenCalledWith(session);
|
||||||
|
expect(deps.broadcast).toHaveBeenCalledWith(
|
||||||
|
SseEvent.SessionUpdated,
|
||||||
|
expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
|
||||||
|
);
|
||||||
|
|
||||||
|
// The second prompt never reaches the setting: the tab is named.
|
||||||
|
refs.promptSubmitted('1');
|
||||||
|
await flush();
|
||||||
|
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
|
||||||
|
const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
const { deps, refs } = autoNameDeps(session, false);
|
||||||
|
|
||||||
|
refs.promptSubmitted('fix the login bug');
|
||||||
|
await flush();
|
||||||
|
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.name).toBe('w1-demo');
|
||||||
|
// Still a placeholder: flipping the setting on names the NEXT prompt.
|
||||||
|
expect(session.nameSource).toBe('placeholder');
|
||||||
|
expect(deps.updateSessionName).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
session.name = '人工命名';
|
||||||
|
refs.promptSubmitted('新的任务不能覆盖人工命名');
|
||||||
|
await flush();
|
||||||
|
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.name).toBe('人工命名');
|
||||||
|
expect(deps.persistSessionState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user