diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1..673087e8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -89,6 +89,8 @@ File reads in a remote case are the second ssh surface (see Remote SSH cases bel **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`. Tests: `test/remote-files.test.ts`, `test/routes/file-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. 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). Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`. diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3dea3bde..c443945e 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,6 +89,19 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** + * Remote tmux runs in sshd's non-interactive shell, which may omit Homebrew and + * per-user binaries. Keep every remote tmux operation on the same PATH. Quoting + * the entire assignment preserves spaces in HOME/inherited PATH under dash too. + * CLI commands still use their existing login-shell/configuration handling. + */ +export function withRemoteTmuxPath(command: string): string { + return ( + 'export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; ' + + command + ); +} + /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays..command` when the entry names one, otherwise the bare @@ -228,7 +241,11 @@ export function buildRemoteTmuxCheckCommand( host: Pick & RemoteSshOptions ): string { // ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch). - return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' '); + return [ + ...buildSshConnectionArgs(host), + remoteSshTarget(host), + shellescape(withRemoteTmuxPath('command -v tmux')), + ].join(' '); } export interface RemoteTmuxCheckResult { @@ -368,7 +385,7 @@ export function buildRemoteSessionAliveCommand( ): string { const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`; - return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' '); + return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd))].join(' '); } /** @@ -453,7 +470,7 @@ export function buildRemoteListSessionsCommand( // separators (tmux expands them); `2>/dev/null` is inside the quoted command. const remoteCmd = 'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null'; - parts.push(remoteSshTarget(host), shellescape(remoteCmd)); + parts.push(remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd))); return parts.join(' '); } diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index f4a12e48..8b83dec4 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,7 +48,13 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; +import { + buildSshConnectionArgs, + classifyRemoteAliveExit, + remoteSshTarget, + shellescape, + withRemoteTmuxPath, +} from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -153,7 +159,7 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); + const script = withRemoteTmuxPath(buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker)); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 961d5683..8a5acdfd 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -84,6 +84,7 @@ import { remoteLoginShellCommand, remoteSshTarget, remoteTmuxSessionAlive, + withRemoteTmuxPath, } from './remote-hosts.js'; import { buildDockerBaseArgs, @@ -1381,7 +1382,14 @@ export function buildRemoteLaunchCommand(options: { // `ssh -o BatchMode=yes` (preserving the historical token order), then the rest // of the connection args, then the target and the quoted tmux invocation. const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote); - const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)]; + const sshParts = [ + ssh, + batchMode, + '-t', + ...connectionArgs, + remoteSshTarget(remote), + shellescape(withRemoteTmuxPath(tmuxInvocation)), + ]; return sshParts.join(' '); } @@ -1398,7 +1406,7 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session const remoteName = remoteTmuxSessionName(sessionId); const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`; const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote); - return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' '); + return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(withRemoteTmuxPath(killCmd))].join(' '); } // ========== Docker cases (COD-Docker) ========== @@ -1777,7 +1785,14 @@ export function resolveDockerLaunchOptions( export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string { const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`; const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote); - const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)]; + const sshParts = [ + ssh, + batchMode, + '-t', + ...connectionArgs, + remoteSshTarget(remote), + shellescape(withRemoteTmuxPath(tmuxInvocation)), + ]; return sshParts.join(' '); } diff --git a/test/remote-auto-reconnect.test.ts b/test/remote-auto-reconnect.test.ts index 00b2c0fc..afdccef0 100644 --- a/test/remote-auto-reconnect.test.ts +++ b/test/remote-auto-reconnect.test.ts @@ -26,7 +26,12 @@ import { decideReconnect, } from '../src/remote-reconnect.js'; import type { ReconnectSessionView } from '../src/remote-reconnect.js'; -import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js'; +import { + buildRemoteSessionAliveCommand, + classifyRemoteAliveExit, + shellescape, + withRemoteTmuxPath, +} from '../src/remote-hosts.js'; import { TmuxManager } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -227,7 +232,7 @@ describe('remote has-session probe (pure)', () => { // Literal pin: the session name is shellescaped inside the remote command, // which is itself one shellescaped ssh argument. expect(cmd).toBe( - "ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'" + `ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box ${shellescape(withRemoteTmuxPath("tmux -L codeman-remote has-session -t 'codeman-ssh-abc' 2>/dev/null"))}` ); }); diff --git a/test/remote-discover-attach.test.ts b/test/remote-discover-attach.test.ts index 193bdf1b..1a4cb4d5 100644 --- a/test/remote-discover-attach.test.ts +++ b/test/remote-discover-attach.test.ts @@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process'; import { describe, it, expect } from 'vitest'; -import { parseRemoteSessionList } from '../src/remote-hosts.js'; +import { parseRemoteSessionList, withRemoteTmuxPath } from '../src/remote-hosts.js'; import { buildRemoteAttachCommand } from '../src/tmux-manager.js'; import { TmuxManager } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -103,7 +103,7 @@ describe('COD-105 buildRemoteAttachCommand', () => { .map((l) => l.slice(2)); expect(sshArgs).toContain('ubuntu@10.0.0.42'); const tmuxArg = sshArgs.find((a) => a.includes('attach')); - expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'"); + expect(tmuxArg).toBe(withRemoteTmuxPath("tmux -L codeman attach -t 'codeman-disco1'")); }); }); diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index cb565424..4265fbfe 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -13,7 +13,7 @@ * `-o ProxyCommand=…` token; the local shell must not expand/mangle them). * - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at * build time (ssh does NOT expand `~` in `-i`), then shellescaped. - * - empty options ⇒ byte-identical ssh to today (full back-compat). + * - empty options retain the SSH option order; tmux gets the shared PATH wrapper. * * Pure command-string construction; no real tmux, no ssh. Port: N/A. */ @@ -25,6 +25,8 @@ import { buildRemoteTmuxCheckCommand, buildRemoteCliVersionProbeCommand, remoteSshTarget, + shellescape, + withRemoteTmuxPath, } from '../src/remote-hosts.js'; import { buildRemoteLaunchCommand } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -146,10 +148,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { expect(idxProxy).toBeLessThan(idxTarget); }); - it('a remote with no advanced options is byte-identical to the expected form', () => { + it('preserves the no-options launch command inside the shared remote PATH wrapper', () => { const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID }); // Reconstruct the command using the SAME nested POSIX single-quote escaping the - // production code uses, to prove byte-identity. Session runs on the DEDICATED + // production code uses, preserving the payload inside the PATH wrapper. The DEDICATED // `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's // discovery ignores; set-options are scoped per-session (never `-g`). const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; @@ -170,7 +172,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { `set -t ${remoteName} remain-on-exit failed`, ].join(' \\; '); // Connection args (with the default -o ConnectTimeout=10) sit after -t. - const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`; + const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(withRemoteTmuxPath(tmuxInvocation))}`; expect(command).toBe(expected); }); @@ -193,12 +195,12 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`); expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'"); expect(cmd).toContain('aakht@192.168.55.170'); - expect(cmd).toContain("'command -v tmux'"); + expect(cmd).toContain(shellescape(withRemoteTmuxPath('command -v tmux'))); }); - it('back-compat: no advanced options ⇒ unchanged probe string', () => { + it('no advanced options preserve the ssh prefix and wrap the tmux probe', () => { expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe( - "ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'" + `ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ${shellescape(withRemoteTmuxPath('command -v tmux'))}` ); expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222'); }); diff --git a/test/remote-tmux-path.test.ts b/test/remote-tmux-path.test.ts new file mode 100644 index 00000000..a16375db --- /dev/null +++ b/test/remote-tmux-path.test.ts @@ -0,0 +1,157 @@ +/** Real shell/argv regression for every remote tmux builder. No network or real tmux runs. */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildRemoteTmuxCheckCommand, + buildRemoteSessionAliveCommand, + buildRemoteListSessionsCommand, +} from '../src/remote-hosts.js'; +import { + buildRemoteLaunchCommand, + buildRemoteAttachCommand, + buildRemoteKillCommand, + remoteTmuxSessionName, +} from '../src/tmux-manager.js'; +import { buildRemoteScrollbackCommand, parseRemoteScrollbackOutput } from '../src/remote-pane-history.js'; +import type { SessionRemote } from '../src/types/session.js'; + +const id = '663e3eae-8da6-4c7f-9231-611eb2840641'; +const marker = 'path-history-marker'; +const remote: SessionRemote = { + hostId: 'offline', + label: 'Offline fixture', + host: 'never-connect.invalid', + username: 'tester', + port: 2222, + identityFile: '/tmp/key with spaces', + jumpHost: 'jump.invalid', + socksProxy: '127.0.0.1:1080', + extraSshOptions: ['StrictHostKeyChecking=accept-new'], + remotePath: '/tmp/project with spaces', +}; +const discovered = "team's session $literal"; +const cases = [ + { name: 'link preflight', build: () => buildRemoteTmuxCheckCommand(remote), command: null, socket: null }, + { + name: 'launch', + build: () => buildRemoteLaunchCommand({ remote, sessionId: id, mode: 'shell' }), + command: 'new-session', + socket: 'codeman-remote', + }, + { name: 'attach', build: () => buildRemoteAttachCommand(remote, discovered), command: 'attach', socket: 'codeman' }, + { + name: 'alive probe', + build: () => buildRemoteSessionAliveCommand(remote, remoteTmuxSessionName(id)), + command: 'has-session', + socket: 'codeman-remote', + }, + { + name: 'discovery', + build: () => buildRemoteListSessionsCommand(remote), + command: 'list-sessions', + socket: 'codeman', + }, + { + name: 'history and screen capture', + build: () => buildRemoteScrollbackCommand(remote, id, 100, 0, marker), + command: 'capture-pane', + socket: 'codeman-remote', + }, + { + name: 'cleanup', + build: () => buildRemoteKillCommand({ remote, sessionId: id }), + command: 'kill-session', + socket: 'codeman-remote', + }, +]; + +describe('remote tmux PATH under a sparse non-interactive shell', () => { + let root: string; + let home: string; + let sshBin: string; + let tmuxPath: string; + let tmuxLog: string; + let sshLog: string; + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'remote-tmux-path-')); + home = join(root, 'remote home with spaces'); + sshBin = join(root, 'ssh tools'); + tmuxPath = join(home, '.local', 'bin', 'tmux'); + tmuxLog = join(root, 'tmux-args'); + sshLog = join(root, 'ssh-args'); + mkdirSync(join(home, '.local', 'bin'), { recursive: true }); + mkdirSync(sshBin); + writeFileSync( + join(sshBin, 'ssh'), + `#!/bin/sh +for arg do printf '%s\\n' "$arg" >> "$TEST_SSH_LOG"; command="$arg"; done +exec "$TEST_REMOTE_SHELL" -c "$command" +`, + { mode: 0o755 } + ); + writeFileSync( + tmuxPath, + `#!/bin/sh +for arg do printf '%s\\n' "$arg" >> "$TEST_TMUX_LOG"; done +case "$*" in + *capture-pane*) printf 'older row\\n${marker}\\n1\\ncurrent screen\\n' ;; + *list-sessions*) printf 'discovered\\t0\\t123\\t1\\n' ;; +esac +`, + { mode: 0o755 } + ); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + for (const shell of ['/bin/sh', ...(existsSync('/bin/dash') ? ['/bin/dash'] : [])]) { + for (const item of cases) { + it(`${item.name} finds the remote-user tmux with ${shell} and preserves targeting/options`, () => { + writeFileSync(tmuxLog, ''); + writeFileSync(sshLog, ''); + const stdout = execFileSync('/bin/sh', ['-c', item.build()], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + // Only the SSH stub is initially on PATH; an unwrapped command cannot + // accidentally run this machine's real tmux during a negative test. + PATH: sshBin, + TEST_REMOTE_SHELL: shell, + TEST_TMUX_LOG: tmuxLog, + TEST_SSH_LOG: sshLog, + }, + }); + const sshArgs = readFileSync(sshLog, 'utf8').trimEnd().split('\n'); + expect(sshArgs).toContain('tester@never-connect.invalid'); + expect(sshArgs).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'); + expect(sshArgs).toContain('StrictHostKeyChecking=accept-new'); + expect(sshArgs.slice(sshArgs.indexOf('-p'), sshArgs.indexOf('-p') + 2)).toEqual(['-p', '2222']); + expect(sshArgs.slice(sshArgs.indexOf('-i'), sshArgs.indexOf('-i') + 2)).toEqual(['-i', '/tmp/key with spaces']); + expect(sshArgs.slice(sshArgs.indexOf('-J'), sshArgs.indexOf('-J') + 2)).toEqual(['-J', 'jump.invalid']); + if (item.command === null) { + expect(stdout.trim()).toBe(tmuxPath); + return; + } + const args = readFileSync(tmuxLog, 'utf8').trimEnd().split('\n'); + expect(args.slice(0, 2)).toEqual(['-L', item.socket]); + expect(args).toContain(item.command); + if (item.name === 'attach') expect(args).toContain(discovered); + if (item.name === 'launch') { + expect(args).toContain(remote.remotePath); + expect(sshArgs).toContain('-t'); + } + if (['alive probe', 'cleanup'].includes(item.name)) expect(args).toContain(remoteTmuxSessionName(id)); + if (item.name === 'history and screen capture') { + expect(args).toContain(`=${remoteTmuxSessionName(id)}:`); + expect(parseRemoteScrollbackOutput(stdout, marker, 0)).toEqual({ + scrollback: 'older row\n', + screen: 'current screen\n', + }); + } + }); + } + } +});