mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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 <noreply@anthropic.com> (cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275)
This commit is contained in:
@@ -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`.
|
**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 <id>`, `--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 <default|auto_edit|yolo|plan>` 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).
|
**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 <id>`, `--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 <default|auto_edit|yolo|plan>` 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 <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — 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`.
|
**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 <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — 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`.
|
||||||
|
|||||||
@@ -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<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini'>` — 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<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-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 <connection-args> user@host \
|
||||||
|
'tmux -L codeman new-session -A -s codeman-<id> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
|
||||||
|
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-<id>`** = 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 <cli>`** 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 `<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.
|
||||||
|
|
||||||
|
## 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 <remoteSessionName>'`. 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).
|
||||||
Reference in New Issue
Block a user