From 115ada1e9ee951dd971306f663a29d0d4da3ba97 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 15:17:51 -0400 Subject: [PATCH] docs: cover COD-105 remote discover/attach + detach-not-kill in remote-sessions.md 55f5ada (COD-105) added Phase 2 of the remote-tmux arc: discover codeman-* sessions on a host and attach to non-owned ones, with detach-not-kill on close. - Data model: SessionRemote.owned/remoteSessionName + RemoteSessionInfo; toSessionRemote (owned:true) vs toAttachedSessionRemote (owned:false). - New Ownership section: discovery (listRemoteCodemanSessions, the literal-\t parse quirk, never-throws/VITEST), attach-vs-launch selection (buildRemoteSessionCommand), and the killSession detach-not-kill guarantee. - API: GET /api/remote-hosts/:hostId/sessions + the attachRemoteSession create path. - CLAUDE.md Remote Key Pattern notes discover/attach + detach-not-kill. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275) --- CLAUDE.md | 2 + docs/remote-sessions.md | 237 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 docs/remote-sessions.md diff --git a/CLAUDE.md b/CLAUDE.md index e39051e5..0fe15984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. +**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON). Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`. + **External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md new file mode 100644 index 00000000..5c1f9cb2 --- /dev/null +++ b/docs/remote-sessions.md @@ -0,0 +1,237 @@ +# Remote Sessions (SSH) + +Codeman can run a session's agent on a **remote host over SSH** instead of the +local machine. The agent (Claude, OpenCode, Codex, Gemini, 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. + +## Why it exists + +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`). + +## Data model + +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](#ownership-launched-vs-discovered-and-attached-cod-105)). 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` — 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. + +## SSH command construction (COD-107 — the injection surface) + +**All** SSH command lines flow through one function so user-controlled fields are +escaped once and the launch + prereq probe can never drift apart: + +```ts +// src/remote-hosts.ts +buildSshConnectionArgs(remote: RemoteSshOptions & Pick): string[] +``` + +It returns the **ordered leading tokens** of an ssh command line (no `-t`, no +target, no remote command): + +``` +ssh -o BatchMode=yes + [-p ] + [-i ] # ~ / $HOME expanded, then shellescaped + [-J ] # shellescaped, single token + [-o ProxyCommand=nc -X 5 -x %h %p] # ONE shellescaped -o token + [-o ] … # 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-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors + the one in `tmux-manager.ts`. +- **`~`/`$HOME` in `identityFile` is 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=VALUE` token**, so its spaces and + the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive + verbatim — **ssh** expands them to the real host/port, not the shell. +- **Empty options ⇒ `['ssh', '-o BatchMode=yes']`** (+ `-p` only when set) — + byte-identical to the historical behavior. + +Token construction is unit-tested independently of any live connection (see +`test/` for `buildSshConnectionArgs` / `buildRemoteTmuxCheckCommand` cases). + +## Durable launch (COD-104) + +`buildRemoteLaunchCommand({ mode, remote, sessionId })` in `tmux-manager.ts` +builds the command that launches (or **reattaches** to) the remote session: + +``` +ssh -o BatchMode=yes -t user@host \ + 'tmux -L codeman new-session -A -s codeman- -c "cd && exec " \; \ + set -g status off \; set -g mouse off \; set -sg escape-time 0 \; set -g prefix C-q' +``` + +Key points: + +- **`new-session -A -s codeman-`** = attach-if-exists-else-create, so a + reconnect (same deterministic `remoteTmuxSessionName(sessionId)`) lands back in + the **same** remote session rather than spawning a duplicate. This is what makes + the remote agent survive an SSH drop. +- **`-L codeman`** = canonical remote socket (the remote's own tmux server, not + the local one). +- **`exec `** replaces the pane shell with the agent, so the pane PID *is* + the agent. The per-mode command comes from `remote.commands?.[mode]` or + `defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` / + `exec codex` / `exec gemini` / `exec bash -l`). +- The **whole tmux invocation is a single shell-quoted ssh argument**, and the + pane command is independently quoted, so a `remotePath` with spaces is safe. +- Connection options come from the **same `buildSshConnectionArgs(remote)`** as + the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`, + preserving historical token order. + +### tmux prerequisite probe + +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 `` needs tmux installed for + durable remote sessions"* +- stderr present → *"could not verify tmux on remote host ``: ``"* + (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. + +## Ownership: launched vs. discovered-and-attached (COD-105) + +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 via `buildRemoteLaunchCommand` and may explicitly kill it. +- **`owned: false`** — discovered + attached; another Codeman owns the remote + session. `remoteSessionName` holds its existing tmux name. Closing the tab + **detaches**, never kills. + +### Discovery + +`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions: + +- `buildRemoteListSessionsCommand()` runs `tmux -L codeman list-sessions -F "…"` + over SSH (connection args from the shared `buildSshConnectionArgs`, so discovery + connects identically to launch/probe). `2>/dev/null` swallows 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 only + `codeman-*` 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 `[]` under + `VITEST`** so 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. + +### Attach vs. launch selection + +`buildRemoteSessionCommand(mode, remote, sessionId)` in `tmux-manager.ts` picks the +remote command line by ownership: + +- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits + `ssh … -t … 'tmux -L codeman attach -t '`. It uses **`attach`, + NOT `new-session -A`**, so it only *joins* an existing session and never creates + one. +- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above). + +### Detach-not-kill + +`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. + +## API + +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). + +## Security notes + +- **`identityFile` is a path only — never key bytes.** Codeman stores the path and + passes it to `ssh -i`; the key never enters Codeman's state or the wire. +- The injection surface is the SSH option fields. The single-source + `buildSshConnectionArgs` + `shellescape` discipline (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=yes` means **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. + +## Related + +- `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).