Codeman can run a session's agent on a remote host over SSH instead of the
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, or a plain shell)
runs inside a tmux server on the remote host, so it survives the SSH
connection dropping; Codeman attaches to it the same way it attaches to a local
managed session.
This document covers the data model, the shell-safe SSH command construction
(COD-107), the durable-launch design (COD-104), and the operational caveats.
For the local session/mux machinery this builds on, see the Mux and
Session entries in CLAUDE.md → Architecture.
A developer box (AA-DESKTOP) often needs to drive an agent on another machine —
a NAS, a build server, a host reachable only through a jump box or a
cloudflared SOCKS5 proxy. Rather than wrap ssh by hand per host, Codeman
stores reusable remote hosts + remote cases and reproduces the exact
connection the operator already uses (ssh-aa-desktop-style configs:
custom port, identity file, -J jump host, -o ProxyCommand).
Types live in src/types/session.ts; persistence in src/remote-hosts.ts.
| Type | Role |
|---|---|
RemoteSshOptions |
The HOW-to-reach fields, shared by host + session: identityFile, socksProxy (host:port), jumpHost ([user@]host[:port]), extraSshOptions (KEY=VALUE[]). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
RemoteHost (extends RemoteSshOptions) |
A saved host: id, label, host, username, port?, commands? (per-mode launch command override). |
RemoteCase |
A working directory on a host: name, type: 'remote', hostId, remotePath. |
SessionRemote (extends RemoteSshOptions) |
The resolved bundle stamped onto a live session: host coordinates + remotePath + commands, plus owned? and remoteSessionName? (COD-105 — see Ownership). Built by toSessionRemote(host, case) (sets owned: true) for the launch path, or toAttachedSessionRemote(host, name, path) (sets owned: false) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
RemoteCommandMode |
Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'> — the modes that can run remotely. |
RemoteSessionInfo (COD-105) |
One discovered remote tmux session: name (always codeman-*), attached (a client is connected), created (epoch s), windows. Returned by listRemoteCodemanSessions(). |
Persistence is two flat JSON arrays in the instance data dir:
~/.codeman/remote-hosts.json—readRemoteHosts()/writeRemoteHosts()~/.codeman/remote-cases.json—readRemoteCases()/writeRemoteCases()
(Paths via remoteHostsPath() / remoteCasesPath(); both honor CODEMAN_INSTANCE
because the config dir is the instance data dir.)
On the live Session, the remote rides as _remote?: SessionRemote. When
attaching, resolveMuxAttachCwd() forces the cwd to /tmp for remote sessions —
the local working directory is meaningless on the remote box.
All SSH command lines flow through one function so user-controlled fields are escaped once and the launch + prereq probe can never drift apart:
// src/remote-hosts.ts
buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[]It returns the ordered leading tokens of an ssh command line (no -t, no
target, no remote command):
ssh -o BatchMode=yes
[-p <port>]
[-i <abs-identity>] # ~ / $HOME expanded, then shellescaped
[-J <jumpHost>] # shellescaped, single token
[-o ProxyCommand=nc -X 5 -x <socks> %h %p] # ONE shellescaped -o token
[-o <KEY=VALUE>] … # each extra option, shellescaped
Rules that keep this safe — do not bypass them by hand-building an ssh line elsewhere:
- Every user-controlled value (
-i,-J,-o, ProxyCommand) is POSIX single-quoteshellescaped ('…'with embedded'\''). The helper mirrors the one intmux-manager.ts. ~/$HOMEinidentityFileis expanded at build time (expandIdentityPath), before escaping — ssh does not expand~inside-i, and the escaped value never reaches a shell that would.- The ProxyCommand is one shellescaped
-o KEY=VALUEtoken, so its spaces and the%h/%pplaceholders reach ssh as a single argument.%h %psurvive verbatim — ssh expands them to the real host/port, not the shell. - Empty options ⇒
['ssh', '-o BatchMode=yes'](+-ponly when set) — byte-identical to the historical behavior.
Token construction is unit-tested independently of any live connection (see
test/ for buildSshConnectionArgs / buildRemoteTmuxCheckCommand cases).
buildRemoteLaunchCommand({ mode, remote, sessionId }) in tmux-manager.ts
builds the command that launches (or reattaches to) the remote session:
ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest'
Key points:
new-session -A -s codeman-ssh-<id8>= attach-if-exists-else-create, so a reconnect (same deterministicremoteTmuxSessionName(sessionId)—codeman-ssh-+ the first 8 chars of the session id) lands back in the same remote session rather than spawning a duplicate. This is what makes the remote agent survive an SSH drop. The name deliberately failsSAFE_MUX_NAME_PATTERNso a Codeman running ON the remote host never adopts it.-L codeman-remote= a DEDICATED socket for sessions launched by remote Codemans, NOT the canonical-L codemansocket the remote host's own Codeman uses. Options are set per-session (set -t), never-g, so a shared remote tmux server's other sessions are untouched (#145 hardening). Note the asymmetry: discovery/attach (COD-105) target the canonical-L codemansocket — they join sessions the remote's own Codeman manages, while owned durable launches live on-L codeman-remote.exec <cli>replaces the pane shell with the agent, so the pane PID is the agent. The per-mode command comes fromremote.commands?.[mode]ordefaultRemoteCommandForMode(mode)(exec claude/exec opencode/exec codex/exec gemini/exec agy/exec bash -l).- The whole tmux invocation is a single shell-quoted ssh argument, and the
pane command is independently quoted, so a
remotePathwith spaces is safe. - Connection options come from the same
buildSshConnectionArgs(remote)as the prereq probe;-tis inserted right afterssh -o BatchMode=yes, preserving historical token order.
Because durable remote sessions require tmux on the remote host,
checkRemoteTmuxAvailable(host) runs command -v tmux over SSH before
creating a remote case/session and returns a structured, never-throwing result:
- empty stdout / non-zero exit → "remote host
<host>needs tmux installed for durable remote sessions" - stderr present → "could not verify tmux on remote host
<host>:<stderr>" (a real connection failure, surfaced to the operator) - success →
{ ok: true, tmuxPath }
It connects with the identical options as the launch
(buildRemoteTmuxCheckCommand reuses buildSshConnectionArgs and inserts
-o ConnectTimeout=10), so a proxied/custom-port/identity host that the launch
can reach also passes the probe (and vice-versa).
Test-mode short-circuit: under VITEST the probe returns
{ ok: true, tmuxPath: '(test-mode)' } without opening a socket — mirroring
TmuxManager's no-op-shell-under-VITEST (IS_TEST_MODE). Without it, remote-case
create-path tests would hit a real ~10s ssh timeout. Only the live probe is
skipped; command construction is still asserted by unit tests.
COD-104 (above) was Phase 1 — Codeman launches a remote session and owns it.
COD-105 is Phase 2 — Codeman can also discover codeman-* tmux sessions
already running on a remote host (created by the remote's own Codeman or another
instance) and attach to one it didn't launch. Ownership decides what happens
when the tab closes.
SessionRemote.owned carries this:
owned: true(or absent — legacy/COD-104 sessions persisted before this field) — we launched it viabuildRemoteLaunchCommandand may explicitly kill it.owned: false— discovered + attached; another Codeman owns the remote session.remoteSessionNameholds its existing tmux name. Closing the tab detaches, never kills.
listRemoteCodemanSessions(host) lists the remote's codeman-* sessions:
buildRemoteListSessionsCommand()runstmux -L codeman list-sessions -F "…"over SSH (connection args from the sharedbuildSshConnectionArgs, so discovery connects identically to launch/probe).2>/dev/nullswallows tmux's "no server running" stderr.parseRemoteSessionList()is a pure, unit-tested parser.⚠️ Quirk: the remote tmux's-F "…\t…"format emits the literal two-character\t, not a real tab (verified on tmux next-3.7), so the parser splits on/\\t|\t/(literal backslash-t or a real tab, for builds that do expand it). It keeps onlycodeman-*names, coerces types, and skips malformed lines.listRemoteCodemanSessions()never throws — unreachable host / no tmux / no sessions all map to[]. Like the prereq probe, it no-ops to[]underVITESTso a request path never opens a real ssh connection.
Discovery is explicit — the UI has a "Discover existing sessions" button per host; Codeman never auto-discovers on host select.
buildRemoteSessionCommand(mode, remote, sessionId) in tmux-manager.ts picks the
remote command line by ownership:
owned === false→buildRemoteAttachCommand(remote, name)— emitsssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'. It usesattach, NOTnew-session -A, so it only joins an existing session and never creates one.- owned (default) →
buildRemoteLaunchCommand(the COD-104 path above).
TmuxManager.killSession() has an early return for non-owned remote sessions:
it tears down only the LOCAL pane holding the ssh client (tmux -L codeman kill-session on this host's socket). Killing the local ssh sends SIGHUP to the
remote tmux attach, which detaches — the durable remote session survives.
The early return is a structural guarantee that no code path can ever issue a
remote kill-session for a session we don't own — the only kill-session run is
on the local socket, which never reaches the remote socket.
Routes are registered in src/web/routes/case-routes.ts:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/remote-hosts |
List saved hosts |
POST |
/api/remote-hosts |
Create a host |
PUT |
/api/remote-hosts/:id |
Update a host |
DELETE |
/api/remote-hosts/:id |
Delete a host |
GET |
/api/remote-hosts/:hostId/sessions |
Discover codeman-* sessions on the host (COD-105; listRemoteCodemanSessions, never errors) |
POST |
/api/cases/remote-link |
Link a case to a remote host (creates the RemoteCase) |
Attaching to a discovered session is a session-create path, not a host route:
POST /api/sessions accepts attachRemoteSession: { hostId, remoteSessionName }
(schema in schemas.ts; remoteSessionName must match ^codeman-[a-zA-Z0-9._-]+$),
which session-routes.ts turns into a non-owned (owned: false) session.
Frontend touchpoints: the remote-host management UI is in session-ui.js /
panels-ui.js; a remote session is created by picking a remote host/case in the
session-create flow, or via the per-host "Discover existing sessions" button →
Attach action (creates an owned: false session).
identityFileis a path only — never key bytes. Codeman stores the path and passes it tossh -i; the key never enters Codeman's state or the wire.- The injection surface is the SSH option fields. The single-source
buildSshConnectionArgs+shellescapediscipline (COD-107) is the control — audit any new code path that constructs an ssh command to route through it rather than concatenating options inline. BatchMode=yesmeans no interactive password/passphrase prompts — remote hosts must be reachable with key-based or agent auth (or an unencrypted key the agent has loaded). A host needing a passphrase will fail the probe with an ssh diagnostic rather than hang.
CLAUDE.md→ Architecture → Remote row, and the Remote sessions (SSH) Key Pattern.docs/security-architecture.md— overall network/auth model.- COD-104 (tmux prereq + durable launch), COD-105 (discover + attach, detach-not-kill ownership), COD-107 (shell-safe connection args).