fix: keep foreign discovery from throwing, and correct the adoption docs (release review)

candidatesFrom had moved outside the try when the transports moved to
runForeignProbe, so a classifier failure would have reached the route as a 500.
The module promises it never throws, so the guard is back for local and docker.

Docs:
- The api reference said `blocked` waits are always refused, and listed Ralph as
  refused while two of its file routes were not (they are now).
- The invariants doc and CLAUDE.md gain the rules this review turned up: target
  syntax in names, the ping before list-panes, nothing typed into an adopted pane,
  quoteAdoptLaunch.
- CLAUDE.md no longer says every claude session gets hooks or a stop wait.
- ForeignTmuxSession's mode and workingDir describe the first listed pane, not
  the active one.
- The wiki had no page on adoption: The Dashboard gains an Opened outside Codeman
  section, and the remote and docker pages point at it. docs/docker-cases.md now
  separates an adopted container from an adopted tmux session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:45:29 +01:00
1 parent 25214fcc5f
commit 2d7cd49449
11 files changed
+104 -30

No files matched your search

+8 -4
View File
@@ -1125,8 +1125,10 @@ browser sends them only on an explicit scan. `?force=1` skips the local cache.
`adoptedBy` names the session already wrapping that target. `notes` says why a location
gave nothing, each opening with where it came from (`local`, `docker <name>`,
`remote <label>`). A session whose name or socket path holds characters Codeman will not
pass to a shell is skipped and counted in a note. Sessions on a Codeman socket never
appear.
pass to a shell, or whose name tmux reads as a target rather than a name (one starting
with `=`, `@` or `%`, or a bare `~`), is skipped and counted in a note. A tmux server that
does not answer within 2 seconds costs only its own rows and gets a note, and a scan cut
short keeps the rows it read and says so in one. Sessions on a Codeman socket never appear.
`POST /api/v1/sessions/adopt` wraps one, with `{ "id": "...", "docker": true, "remote":
true }` (the two flags must match the listing the id came from; `name` and
@@ -1144,8 +1146,10 @@ comes from the pane's process tree.
Closing the session detaches. The foreign session keeps running, and only Codeman's
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
session: respawn, Ralph, auto-clear, auto-compact, auto-resume, custom model endpoints,
prompt uploads and the image watcher. `stop` and `blocked` waits are a 400, the git
session: respawn, Ralph (its config and the `fix-plan/write`, `fix-plan/read` and
`ralph-prompt/write` file routes), auto-clear, auto-compact, auto-resume, custom model
endpoints, prompt uploads and the image watcher. `stop` waits are a 400, and so are
`blocked` waits unless Codeman reads that CLI's dialogs off its screen (codex), the git
routes answer `unsupported` with `reason: "adopted"`, and `last-response` answers empty,
since Codeman knows no transcript for it. Its `paneExit` stays absent, because the pane
holds a `tmux attach` and not the agent. Reboot restore does not rebuild one.
+5 -3
View File
@@ -99,13 +99,15 @@ Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, ne
**Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together.
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it, and the `=` cannot go on `new-session -t` itself. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes.
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it. The `=` cannot stand in for that guard on `new-session -t`, where a missing target makes a group literally called `=<name>`. ⚠️ The guard only holds for a name both commands read as a name, so a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`: a leading `=`, `@` or `%`, or a bare `~`) is refused at discovery: measured on tmux 3.4, `@1` joined the session holding window `@1` and input typed in the tab reached it, and `=eq` made a new group with a fresh shell. A `command -v tmux` runs first, because the guard fires on any failure and "tmux is not on this PATH" must not read as "your session is gone". The client is `tmux -u`, or a POSIX-locale container garbles the tab, and the probe's `list-panes` is too, or a name reads as underscores the guard then cannot find. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start: `startShell()`'s new-session `clear\n` submitted whatever the owner had half-typed (measured) until it was gated on `!this._adopt`, and the trust-dialog auto-accept stays off for the same reason. `shell` is also the classifier's answer for anything it cannot name, such as vim or psql.
**Names are the injection surface.** A session name is chosen by someone else and the local launch chain ends in `bash -c "<JSON>"`, where `$` and backticks stay live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
**Names are the injection surface.** A session name is chosen by someone else and the local launch chain ends in `bash -c "<JSON>"`, where `$` and backticks stay live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. ⚠️ The attach line crosses `execSync` inside double quotes, so both launch sites use `quoteAdoptLaunch()`, never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on THIS host and a tmux in the remote `~/.local/bin` is found by discovery and missed by the attach. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
**A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time.
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response` and guarded input all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command.
+7
View File
@@ -121,6 +121,13 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
## Attach to a container you already run
⚠️ Two different things are called "adopted". This section is about a CONTAINER
(`DockerCase.owned === false`). A tmux SESSION a human started inside a container is
listed by `GET /api/mux/foreign?docker=1` and wrapped by `POST /api/sessions/adopt`, for
which the container needs a Docker case but no adoption of its own. That one is
`Session.isAdopted`, with the connection on `Session.adopt.docker` and `session.docker`
undefined. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
The tab's **Attach to an existing container** toggle points a case at a container **you**
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
+6
View File
@@ -176,6 +176,12 @@ when the tab closes.
session. `remoteSessionName` holds its existing tmux name. Closing the tab
**detaches**, never kills.
⚠️ This covers `codeman-*` sessions on the `codeman-remote` socket, the ones another
Codeman runs. A tmux session a HUMAN started on the host is a separate path:
`GET /api/mux/foreign?remote=1` finds it and `POST /api/sessions/adopt` wraps it, with
the connection on `Session.adopt.remote` and `session.remote` undefined. Closing that tab
detaches too. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
### Discovery
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
+4
View File
@@ -98,6 +98,10 @@ Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own con
with one bind mount that has already been checked; an adopted container's mounts belong to
whoever started it, and one that mounts `/` hands the adopter the host.
That is about a container. A tmux session someone started by hand inside a container is opened
from the home screen instead, and it needs a Docker case pointing at that container: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Credentials
Your existing host logins work inside the container without logging in again. Credentials
+4
View File
@@ -73,6 +73,10 @@ Attaching to someone else's session and closing your tab must not end their run,
not. Several clients can attach the same remote session at different window sizes without
clamping each other, and discovery shows a shared badge with the client count.
This list holds Codeman's own sessions. A tmux session someone started by hand on the host is
found by the home screen instead: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Files
Previews, downloads and text reads in a remote case go over the same ssh connection the
+32
View File
@@ -287,6 +287,38 @@ Two extras depending on the device:
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
current sessions, then past ones. On by default.
### Opened outside Codeman
A tmux session you started by hand (`tmux new -s work`, then `claude`, or just a shell) shows
up on the home screen under **Opened outside Codeman**, on the desktop and in the phone
overview. **Open** wraps it in a tab, and **Go to tab** on a row means it already has one.
| Where it runs | How it is found |
| -------------------- | ---------------------------------------------------------------------------- |
| **This machine** | Automatically, every few seconds while the home screen is up. |
| **A container** | The `⟳` button in the block's header scans the containers behind your Docker cases. |
| **Over SSH** | The same button scans your saved remote hosts. |
The container and SSH scan costs one connection per target, so it runs when you press the
button, and **Rescan** runs it again.
- **Closing the tab only detaches.** The session keeps running, and the close dialog offers no
kill. Opening one types nothing into it.
- **Codeman did not start it,** so it will not drive it: respawn, Ralph, auto-clear,
auto-compact, auto-resume, custom model endpoints, prompt uploads and the image watcher are
refused, and the git panel shows nothing.
- **The tab opens on the session's first window,** and the row describes that same window.
- **It joins like a second tmux client.** While the tab is open it can resize a session
someone else is viewing, the way any second client does, and that reverses when it closes.
- **A short line in the block says why a session is missing.** One whose name holds unusual
characters is skipped, and so is one named so that tmux reads the name as something else (it
starts with `=`, `@` or `%`, or is a bare `~`). Rename it with `tmux rename-session`. A tmux
server that stopped answering costs only its own sessions.
Only sockets in tmux's own folder are scanned, so `tmux -L <name>` is found and
`tmux -S /custom/path` is not. In multi-user mode the block is for admins, since it shows
everyone's processes.
## Panels
| Panel | Opened from | Covered in |