diff --git a/.changeset/remote-launch-expansion.md b/.changeset/remote-launch-expansion.md index f7f1d5ea..392a0a48 100644 --- a/.changeset/remote-launch-expansion.md +++ b/.changeset/remote-launch-expansion.md @@ -2,4 +2,4 @@ "aicodeman": patch --- -Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. Behaviour change: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host, and a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out. Thanks to @Randalix for the report and the fix. +Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. `commands.omp` and `commands.copilot` are now accepted, like every other mode. Behaviour changes: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out, and the same goes for a remote host's `extraSshOptions` value at launch, which now matches what the tmux probes always sent. A remote host whose login shell is tcsh no longer starts a stock launch: tcsh refuses `-i -l`, which 1.48.0 avoided by running the Codeman host's shell there, so such a host now needs a `commands.*` override per mode (`exec claude`), and a `commands.claude` override gives up the conversation pinning, so a respawn starts a new conversation. Thanks to @Randalix for the report and the fix. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..caa80d29 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -87,9 +87,9 @@ Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmu **Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. -**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 an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); 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`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside the workspace is `NOT_FOUND`, anything else `OPERATION_FAILED`, never a local fallback), and `fix-plan/write` and `ralph-prompt/write` refuse a remote session (`INVALID_INPUT`) before touching the local disk. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`, `test/routes/ralph-routes-remote.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 ` 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 and `$` rejected; `extraSshOptions` refuses backticks and `$(` only, so a bare `$` passes there and reaches `ssh` as those literal characters); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); 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`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string: the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths: a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string: the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside the workspace is `NOT_FOUND`, anything else `OPERATION_FAILED`, never a local fallback), and `fix-plan/write` and `ralph-prompt/write` refuse a remote session (`INVALID_INPUT`) before touching the local disk. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`, `test/routes/ralph-routes-remote.test.ts`. -**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup. +**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. ⚠️ The bootstrap only works if it reaches the far end verbatim, so a remote launch goes to the local `bash -c` single-quoted (`bashCArg()` in tmux-manager.ts): in double quotes the local `sh` expanded `$HOME` and `$PATH` on this host first, and a remote user with another home found no tmux (#760, exit 127). ⚠️ Codeman's stock pane text is POSIX (`"${SHELL:-/bin/sh}"`), so it goes to `/bin/sh -c` as separate tmux arguments: tmux runs a ONE-argument command through the remote default-shell and exec's two or more directly (measured on 3.4), and fish refuses `${…}` with status 127. A `commands.*` override stays one argument, since it is the user's text for their own shell. `test/remote-pane-command-shell.test.ts` pins that split without needing fish. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup. Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids). diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 4b8bd7bc..fa6be19c 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -202,7 +202,7 @@ The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, an ## Notes & limits - Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time). -- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. +- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. A `$` in that override expands inside the container, never on this host, and a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal characters, so write the value out. - macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling). Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md). diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 95308d22..5c55c84b 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -91,7 +91,7 @@ builds the command that launches (or **reattaches** to) the remote session: ``` ssh -o BatchMode=yes -t user@host \ - 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c "cd && exec " \; \ + 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c /bin/sh -c \; \ set -t codeman-ssh- status off \; set -t codeman-ssh- mouse off \; \ set -t codeman-ssh- prefix C-q \; set -s escape-time 0 \; \ set -t codeman-ssh- window-size latest \; \ @@ -100,6 +100,9 @@ ssh -o BatchMode=yes -t user@host \ set -t codeman-ssh- remain-on-exit failed' ``` +`` is `cd && exec `, shell-quoted as one token. With a +`commands.*` override that token stands alone, with no `/bin/sh -c` in front of it. + Key points: - **`new-session -A -s codeman-ssh-`** = attach-if-exists-else-create, so a @@ -137,9 +140,11 @@ Key points: local `bash -c` in single quotes, so `$HOME`, `$PATH` and `$SHELL` are the remote user's. In double quotes the local `sh` expanded them first, which sent this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override - now expands on the far end too. A Docker launch is verbatim as well, so a bare - `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal - characters: write the value out. + now expands on the far end too, and a bare `$VAR` in an `extraSshOptions` value + reaches `ssh` as those literal characters at launch, as it always did in the link + and quick-start tmux probes. A Docker launch is verbatim as well, so a bare `$VAR` + in `extraCreateArgs`/`extraExecArgs` reaches `docker` the same way. Write those + values out. - **tmux picks the shell by argument count** (measured on 3.4): one argument runs through the remote `default-shell`, two or more are exec'd directly. Codeman's own pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish @@ -147,10 +152,17 @@ Key points: fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*` override stays one argument on purpose, since it is written for the user's own shell (`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`). -- **Login shells**: POSIX shells and fish work. tcsh refuses the `-i -l` pair and needs - a `commands.*` override per mode (`exec claude`). nushell and elvish cannot run the - PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe does - not start under fish. +- **Login shells**: POSIX shells and fish work. nushell and elvish cannot run the + PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe + does not start under fish. +- ⚠️ **A tcsh login shell stopped working in 1.49.0.** tcsh refuses `-l` beside any + other flag (``Unknown option: `-l'``), so every stock launch leaves a dead pane. On + 1.48.0 the local `sh` rewrote `${SHELL:-/bin/sh}` to this host's shell, so such a + host ran that shell instead (when its path also exists on the remote) and came up. + It now needs a `commands.*` override per mode (`exec claude`). The Create Case + form's Remote tab offers codex only, the other modes go through + `PUT /api/remote-hosts/:id`, and a `commands.claude` override gives up the + `--session-id`/`--resume` pair, so a respawn starts a new conversation. - 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. diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md index 4c6e85da..e1146f08 100644 --- a/docs/wiki/Docker-Cases.md +++ b/docs/wiki/Docker-Cases.md @@ -225,7 +225,8 @@ this does not widen your network exposure. Add it to the service unit and restar - Per-session environment overrides, effort, and per-CLI configuration are **rejected** for docker cases, because they do not cross into the container. Configure the container through - the docker host's per-mode command override instead. + the docker host's per-mode command override instead. A `$` in that override expands inside + the container, not on the Codeman machine. - tmux must exist in the base image. It is a hard prerequisite and is probed when linking a host. - On macOS, Docker Desktop takes a dedicated uid path, and memory caps are subject to the diff --git a/docs/wiki/Remote-SSH-Sessions.md b/docs/wiki/Remote-SSH-Sessions.md index 29354bf2..a5580c81 100644 --- a/docs/wiki/Remote-SSH-Sessions.md +++ b/docs/wiki/Remote-SSH-Sessions.md @@ -29,11 +29,18 @@ through a jump host. Your laptop can sleep, change networks, or close, and the r Hosts are saved and reusable, so a second case on the same machine is just a path. Host profiles can also carry per-run-mode launch command overrides, for when the binary lives -somewhere unusual on that host. +somewhere unusual on that host. An override runs on the remote in your login shell, so a `$` +in it expands there, not on the Codeman machine. The remote host needs **tmux**. Codeman probes for it when you link the host rather than failing later at launch. +The remote login shell can be bash, zsh, dash or fish. Since 1.49.0 **tcsh needs an override** +for each run mode you use (`exec claude`), because tcsh refuses the flags of a stock launch and +the pane dies. The Remote tab offers the codex override, the others go through +`PUT /api/remote-hosts/:id`, and a claude override gives up resuming the same conversation on a +respawn. nushell and elvish cannot run a remote session at all. + ## What actually runs The agent lives inside a dedicated tmux server on the **remote** host, and Codeman fronts it diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d7546c59..50184b2d 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1284,12 +1284,15 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { * * Emits: * ssh -o BatchMode=yes -t [] user@host \ - * 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c "cd && exec " \ + * 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c /bin/sh -c \ * \; set -t codeman-ssh- status off \; set -t codeman-ssh- mouse off \ * \; set -t codeman-ssh- prefix C-q \; set -s escape-time 0 \; \ * bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \ * bind-key -T prefix C-q send-prefix \; set -t codeman-ssh- remain-on-exit failed' * + * `` is `cd && exec `, quoted as one token. A per-host `commands.*` + * override is that one token alone, with no `/bin/sh -c` in front of it. + * * COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`, * arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the * prereq tmux probe and this launch connect with identical options. diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 5e091b8e..f07740cf 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -129,22 +129,19 @@ describe('remote-hosts domain', () => { describe('per-mode command overrides', () => { const modes = ['shell', ...listClis().map((cli) => cli.id)]; + const host = { id: 'h', label: 'H', host: '10.0.0.1', username: 'u' }; // `.strict()` refuses an unknown key, so a mode missing here cannot take an override, and // `commands.*` is the documented way out for a login shell the stock launch cannot run. it.each(modes)('accepts a %s override on a remote host and a docker host', (mode) => { const commands = { [mode]: 'exec my-agent' }; - const remote = { id: 'h', label: 'H', host: '10.0.0.1', username: 'u', commands }; const docker = { id: 'h', label: 'H', image: 'codeman/agent:base', commands }; - expect(RemoteHostSchema.safeParse(remote).success).toBe(true); + expect(RemoteHostSchema.safeParse({ ...host, commands }).success).toBe(true); expect(DockerHostSchema.safeParse(docker).success).toBe(true); }); it('still refuses a key that is no mode', () => { - const commands = { nope: 'exec my-agent' }; - expect(RemoteHostSchema.safeParse({ id: 'h', label: 'H', host: '10.0.0.1', username: 'u', commands }).success).toBe( - false - ); + expect(RemoteHostSchema.safeParse({ ...host, commands: { nope: 'exec my-agent' } }).success).toBe(false); }); });