Conversation
A tmux session someone opened by hand (`tmux new -s work`, then `claude`, or just a shell) is invisible to Codeman: it lives on the default socket, not the instance-scoped one Codeman owns. The home screen now lists those, and one click wraps one in a Codeman tab. Local, in-container and over-ssh all work. The shape is a WRAPPER session, not a direct attach: adoption creates an ordinary `codeman-<8hex>` session on our own socket whose pane runs the attach. That makes "detach, never kill the foreign session" structural rather than a matter of discipline — `killSession` can only ever reach our own wrapper, so no shape of caller bug reaches the foreign server. View reclamation differs by location: local relies on client death and ssh on SIGHUP, but a `docker exec` does NOT die with its client, so the in-container view has to be reclaimed explicitly on kill or every adoption leaks one. Measured facts, recorded in the code comments: - A grouped session (`new-session -t`) buys `status off` and an independent current window. It does NOT buy an independent size. A window is one object with one size and a group shares it: on tmux 3.3a a bare attach and a grouped attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds it, and that is a shared window option which survives our detach, so setting it would permanently rewrite the owner's configuration. Left unset. - Session names come from other people, while the local launch chain ends in `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the outer shell performs command substitution before the inner single quotes are considered (verified: both substitutions inside the inner quotes ran). So session names and socket paths go through a character allowlist and are dropped at DISCOVERY, which means a non-conforming candidate never gets an id and the adopt endpoint fails closed when it re-resolves. - `pane_current_command` is `node` for both claude and codex, so the mode can only come from a bounded process-tree walk of `pane_pid`'s argv; anything unrecognized is treated as a shell. An adopted session has to say no to every assumption that Codeman owns the pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook check runs BEFORE the mode check, because adoption asks who launched the process and no mode can vouch for a workspace Codeman never touched. `paneExit` is forced to unknown, since the pane runs `tmux attach` rather than the agent and its exiting with 0 only means the connection ended. Reboot restore refuses too: all Codeman ever had was an attach command, and replaying it would build a connection to a foreign session a reboot has almost certainly taken with it, while presenting whatever it lands on as a session we restored. Adoption is admin-only under multi-user mode: adopting someone's shell is equivalent to arbitrary host execution. Discovery is read-only, never creates a session, and degrades to an empty list whenever a probe cannot reach its target.
|
Thank you for this, @dignfei. The PR lets Codeman list tmux sessions someone started by hand (on this machine, inside a docker case, or on a saved ssh host) and open one as a tab through a wrapper session, so closing the tab can never reach the foreign server. The wrapper shape, the allowlist applied at discovery, and the opaque-id adopt request are all well reasoned, and the measurements in the comments made this much easier to review. Typecheck, lint, format and the full A few places still let Codeman act on a session, server or repo it does not own. These need to change before merge:
Smaller items, fine in the same round:
I will handle the docs ( |
Review items 1 and 2 on Ark0N#510. `tmux new-session -t <name>` does not fail on a missing target. Measured on 3.3a: with only `work-prod` present it joined that session's group, with nothing matching it opened a fresh shell, and on an empty socket it started a tmux server that had none. The command re-runs whenever the wrapper pane is respawned, and boot recovery does that for every dead pane, so after the owner exits their session the next Codeman restart would put a new shell on their default socket, in their container, or on their ssh host. The invocation now begins with an exact-match `has-session -t '=<target>'` and exits with a message when it misses. Measured: `has-session` does not start a server on an empty socket, and the `=` prefix is what stops it matching `work-prod` by prefix. `=` cannot go on `new-session -t` itself — tmux reads it as part of the name and creates a group literally called `=work` — so it has to be its own command. The read-only `attach-session -r` fallback is gone rather than fixed. It hung off `||`, so it fired on ANY `new-session` failure and not just an old tmux: a leftover view from a previous adoption of the same Codeman session makes `new-session` report `duplicate session` (measured), and the user then got a read-only client showing the owner's status bar, with typed input dropped, while the API reported success. Our own stale view is now removed first with a `kill-session -t '=<our view>'` — the view name is derived from the Codeman session id so it is provably ours, and measured, killing by name touches only that session and leaves the owner's alone. Nothing to remove is the normal case, so that step must not abort the chain. `SessionAdopt.readOnly` goes with it. It was never set and never read, while its comment claimed the UI surfaced it. Adoption now either attaches a writable view or fails with a message; there is no third state.
… start Review items 3, 4 and the smaller ones on Ark0N#510. An adopted session wraps a process someone else launched, and its mode is whatever the probe saw — very often `claude`. Every guard below is therefore its own `isAdopted` check rather than a side effect of the external-CLI rule: the two ask different questions, and merging them means the day the first loosens, Codeman starts acting on a live conversation that is not ours. - The boot workspace-hook sweep skipped only non-claude and `session.remote`. An adopted session keeps its connection on `adopt.remote` / `adopt.docker`, so a tab wrapping someone's in-container or over-ssh claude looked local and Codeman wrote `.claude/settings.local.json` into their repo on every restart. - The workspace-trust auto-accept read the pane and pressed keys for 90s after `startInteractive()`. In an adopted pane that is someone else's dialog, and boot recovery re-opens the window on every restart. - Auto-clear, auto-compact, auto-resume and the Ralph auto-enable in `POST /interactive` all drove the pane. Each now refuses with its own gate. - The wrapper's `workingDir` was the foreign pane's cwd. That cwd can carry characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which makes `createSession` throw so the tab never starts, and for a container or ssh candidate it is not a path on this host at all. It is now a neutral local `/tmp`, the same cwd `resolveMuxAttachCwd` already gives the pane; the foreign cwd stays on `adopt.paneCurrentPath` for display. - The CLI signature table hardcoded ids, which CLAUDE.md forbids outside `stock.ts`, and had already gone stale: `omp` was missing, so a hand-started omp pane read as `shell`. It is now derived from each entry's `discovery.binaries` at call time, regex-escaped, longest name first. Stock entries only, since `SessionMode` is the closed union of stock ids. - The descendant walk now passes `onTruncated`. A capped walk returns "nothing matched", which is byte-identical to the answer for a real shell. - The home screen's wide scan is one-shot. Riding `docker exec` and a full ssh handshake per target, each with a ~12s timeout, on the 8s poll stacked connections on a slow host. Wide rows are kept apart and merged, because a local-only payload cannot contain them. - Adoption failures report the server's own message. `_apiJson` folds a non-2xx to null, which collapsed "could not reach it just now" into "that session is gone" and reported a live remote session as deleted on every link flicker. Tests: route tests for the refusals, the admin gate, the 404 re-resolve, the one-wrapper-per-target reuse and the adopt round-trip; unit tests for the two non-HTTP guards, the registry-derived signatures, truncation reporting and the frontend's scan and error paths. Each guard was removed in turn to confirm its own tests go red.
|
Thanks again, @dignfei. This PR lets Codeman list tmux sessions someone started by hand (on this machine, in a docker case container, or on a saved ssh host) and open one as a tab through a wrapper session that only ever detaches. The second round answers every item from my first review. I ran the new attach command against real tmux 3.4 servers, and the Two bugs need fixing before merge:
Smaller items, fine in the same round:
As before, I will write the docs at merge time ( |
What
A tmux session someone opened by hand (
tmux new -s work, thenclaude, or just a shell) is invisible to Codeman: it lives on the default socket, not the instance-scoped one Codeman owns. This adds an "outside sessions" block to the home screen listing those, and one click wraps one in a Codeman tab. Local, in-container and over-ssh all work.Shape: a wrapper session, not a direct attach
Adoption creates an ordinary
codeman-<8hex>session on our own socket whose pane runs the attach. That makes "detach, never kill the foreign session" structural rather than a matter of discipline:killSessioncan only ever reach our own wrapper, so no shape of caller bug reaches the foreign server. Same reasoning as the detach-not-kill early return for non-owned remote sessions.View reclamation differs by location: local relies on client death and ssh on SIGHUP, but a
docker execdoes not die with its client, so the in-container view has to be reclaimed explicitly on kill or every adoption leaks one.Measured facts, recorded in the code comments
new-session -t) buysstatus offand an independent current window. It does not buy an independent size. A window is one object with one size and a group shares it: on tmux 3.3a a bare attach and a grouped attach both shrink a 200x49 target to 80x23. Onlywindow-size largestholds it, and that is a shared window option which survives our detach, so setting it would permanently rewrite the owner's configuration. Left unset.bash -c+JSON.stringify, which escapes neither$nor backticks: the outer shell performs command substitution before the inner single quotes are considered (verified, both substitutions inside the inner quotes ran). So session names and socket paths go through a character allowlist and are dropped at discovery, which means a non-conforming candidate never gets an id and the adopt endpoint fails closed when it re-resolves.pane_current_commandisnodefor both claude and codex, so the mode can only come from a bounded process-tree walk ofpane_pid's argv; anything unrecognized is treated as a shell.Refusals
An adopted session says no to every assumption that Codeman owns the pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook check runs before the mode check, because adoption asks who launched the process and no mode can vouch for a workspace Codeman never touched.
paneExitis forced to unknown, since the pane runstmux attachrather than the agent and its exiting with 0 only means the connection ended. Reboot restore refuses too: all Codeman ever had was an attach command, and replaying it would build a connection to a foreign session a reboot has almost certainly taken with it, while presenting whatever it lands on as a session we restored.Adoption is admin-only under multi-user mode: adopting someone's shell is equivalent to arbitrary host execution. Discovery is read-only, never creates a session, and degrades to an empty list whenever a probe cannot reach its target.
Testing
test/foreign-tmux.test.tscovers the pure core (probe script, output parsing, mode classification, the name/socket allowlists, the three attach-command builders).test/reboot-restore.test.tsandtest/docker-adopted-container.test.tscover the refusals.npm testgreen on this branch (the 2 pre-existingdocker-entrypointfailures on this machine reproduce on unmodified master), plus typecheck, lint, format:check and check:frontend-syntax.Note
This is a large change. Happy to split it, turn it into a Discussion first, or trim scope if you would rather see it land differently.