From a4f1c8cc32c868c7596087574bd64a930db578f5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 3 Sep 2026 16:33:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20persistent=20SSH=20workspaces=20?= =?UTF-8?q?=E2=80=94=20one=20connection,=20many=20clean=20commands=20(PRD?= =?UTF-8?q?=200013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode ssh` / `/ssh`: named targets, one OpenSSH ControlMaster per target behind a hashed 0700 socket, open/check/close via `ssh -O`, and `exec` as a no-PTY command channel with real argv quoting, raw stdin, per-call cwd/env, timeout, and a --json object that separates the command's verdict (`ok`, `code`) from the transport's (`transportOk`). Interactive attach, scp put/get on the same socket, a remote tmux `shell` with send/read/kill, and `bench`. moshscript gets sshOpen/sshExec/sshClose and friends returning the same shape. Two OpenSSH facts found by running it: `-M` with `-o ControlMaster=yes` is read as ask mode (every mux request "Permission denied"), so the master is `-o ControlMaster=yes -N -f`; and `ControlMaster=auto` on a client is the native stale-socket recovery, so exec runs with it. Tests: 35 unit tests with a fake runner, 13 against an ephemeral non-root sshd on loopback (skipped where sshd is absent). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RcmWsBrbzQpfTTz71wfbkw --- README.md | 91 ++ bin/moshcode.mjs | 7 + prd/0013-persistent-ssh-workspaces.md | 1181 ++++++++++++++++++++++++ prd/README.md | 2 + src/cli-schema.mjs | 109 +++ src/commands.mjs | 164 ++++ src/ssh.mjs | 1228 +++++++++++++++++++++++++ src/tui.mjs | 14 + test/ssh-sshd.test.mjs | 398 ++++++++ test/ssh.test.mjs | 649 +++++++++++++ 10 files changed, 3843 insertions(+) create mode 100644 prd/0013-persistent-ssh-workspaces.md create mode 100644 src/ssh.mjs create mode 100644 test/ssh-sshd.test.mjs create mode 100644 test/ssh.test.mjs diff --git a/README.md b/README.md index 0f653038..d5387a93 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode kill` | runtime | end a herd session | | `moshcode wait` | runtime | block until a session is blocked, done, or idle | | `moshcode restore` | runtime | rebuild the herd's sessions after a reboot | +| `moshcode ssh` | runtime | persistent SSH workspaces — one connection, many clean commands | | `moshcode install` | engines | install an engine or workflow tool | | `moshcode uninstall`
`remove` | engines | take an engine or workflow tool off this machine | | `moshcode upgrade`
`update` | engines | update moshcode, engines, or tools | @@ -579,6 +580,96 @@ current pit untouched. The modes are not identical across providers. In particular, OpenCode `--auto` auto-approves permission requests but continues to enforce explicit deny rules. +## SSH workspaces + +`/ssh` keeps the SSH connection alive; `ssh exec` still gives each tool call a +clean command channel. + +A coding run against a remote box is a few hundred small operations: read a +file, `git status`, apply a patch, run the tests. Each one as a fresh `ssh +user@host cmd` pays for a TCP handshake, a key exchange, a host-key check and +authentication every time. OpenSSH can carry many channels over one +authenticated connection, and `moshcode ssh` is a thin, careful wrapper over +exactly that — named targets, one persistent master connection per target, +and a `--json` execution surface built for agents. + +```sh +moshcode ssh add dev deploy@example.com --cwd /srv/app # or an alias from ~/.ssh/config +moshcode ssh open dev # authenticate once +moshcode ssh exec dev -- git status --short # …then every command reuses it +moshcode ssh dev # a real shell, same connection +moshcode ssh close dev # or let it expire (--persist, default 10m) +``` + +Nothing secret is stored. `~/.moshcode/ssh/targets.json` holds a host, a port +and a directory; your `~/.ssh/config`, agent, `known_hosts`, `ProxyJump` and +hardware keys keep working exactly as they do at the prompt, and host keys are +never auto-accepted. The connection lives in an OpenSSH ControlMaster behind a +socket only you can read, is checked with `ssh -O check` and closed with `ssh +-O exit`, and a socket the master has gone away from is cleaned up and reopened +on the next command. + +### For an agent: one connection, many clean commands + +`exec` runs each command on its own channel with no PTY, so stdout, stderr and +the exit status come back separately and stdin stays raw. `ok` is the +command's verdict; `transportOk` is ssh's. A `grep` that finds nothing is +`{ ok: false, transportOk: true, code: 1 }` — a fact about the files, not the +network. + +```sh +moshcode ssh exec dev --json -- git diff --stat +# { +# "ok": true, "target": "dev", "connected": true, "transportOk": true, +# "code": 0, "signal": null, +# "stdout": " src/app.ts | 12 +++++---\n", "stderr": "", "durationMs": 14 +# } + +# a model-produced multi-file patch, applied in one round trip +git diff | moshcode ssh exec dev --json --stdin --cwd /srv/app -- git apply - + +moshcode ssh exec dev --json --timeout 10m -- pnpm test +moshcode ssh exec dev --env NODE_ENV=test -- pnpm test # for this command only +moshcode ssh exec dev --sh 'git log --oneline | head -5' # a pipeline, on purpose +``` + +There is no shell state between calls, deliberately: `exec dev -- cd /tmp` +followed by `exec dev -- pwd` still answers with the target's cwd. Independent +commands may run concurrently over the same connection. A run that performs a +hundred operations authenticates once. + +The same objects come back from moshscript: + +```js +sshOpen("dev"); +const r = sshExec("dev", ["git", "status", "--short"], { cwd: "/srv/app" }); +if (!r.ok) say(r.stderr); +sshExec("dev", ["git", "apply", "-"], { cwd: "/srv/app", stdin: patch }); +sshClose("dev"); +``` + +### When shell state matters + +Some work needs a shell that remembers: a `cd`, an export, a dev server, a +REPL. `shell` puts one in tmux on the remote box, where it outlives this +terminal, this connection, and the laptop lid. + +```sh +moshcode ssh shell dev --name app # create or attach · Ctrl-b d leaves it running +moshcode ssh shell send dev/app "pnpm dev" # type into it without attaching +moshcode ssh shell read dev/app --lines 40 # its screen, as text +moshcode ssh shell kill dev/app +``` + +If tmux is not on the remote box, `shell` says so and `exec` keeps working; +nothing is installed remotely on your behalf. + +`put` and `get` copy single files over the same connection with `scp`; `put` +lands as a temp file and is renamed into place. `bench ` measures fresh +connections against the shared one on your own hosts — on a loopback sshd the +median command went from ~96ms to ~12ms, and on a real network the handshake +is the part that grows. + ## Workflow tools: UGig, CoinPay, and the cloud CLIs These remain independent native CLIs with their own authentication, diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index df69ba7e..d5be47d3 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -353,6 +353,13 @@ async function main() { process.exitCode = (await herdCommand([cmd === "usage" ? "cost" : cmd, ...rest])) || 0; return; } + // SSH workspaces (PRD 0013). Imported here rather than at the top: it is a + // registry read and a few spawns, and `moshcode claude` never needs it. + if (cmd === "ssh") { + const { sshCommand } = await import("../src/ssh.mjs"); + process.exitCode = (await sshCommand(rest)) || 0; + return; + } if (cmd === "tools") { const asJson = rest.includes("--json"); printStatus(toolStatus(), asJson); diff --git a/prd/0013-persistent-ssh-workspaces.md b/prd/0013-persistent-ssh-workspaces.md new file mode 100644 index 00000000..a2a55b21 --- /dev/null +++ b/prd/0013-persistent-ssh-workspaces.md @@ -0,0 +1,1181 @@ +--- +openprd: "0.2" +id: "0013" +title: "Add persistent SSH workspaces for humans and agents" +status: "Draft" +authors: + - "anthony@profullstack.com" +created: "2026-09-03" +updated: "2026-09-03" +repo: "https://github.com/moshcoder/moshcode" +discussion: "" +implementation: "src/ssh.mjs, src/cli-schema.mjs, src/commands.mjs, src/tui.mjs, bin/moshcode.mjs, test/ssh.test.mjs, test/ssh-sshd.test.mjs" +tags: + - ssh + - remote + - runtime + - agents + - chovy + - workspaces +supersedes: "" +superseded-by: "" +--- + +# Add persistent SSH workspaces for humans and agents + +## Problem + +MoshCode already has a strong persistent-local-runtime model: + +- `herd` keeps local shells and agents alive in tmux or the PTY fallback. +- `herd prompt`, `read`, `wait`, and `--json` make those sessions controllable by another agent. +- `shell()` and the pit's shell execution path deliberately use the user's real shell. +- the project remains zero-dependency ESM and delegates terminal/process behavior to native tools instead of embedding a terminal implementation. + +What it does **not** have is a first-class remote-shell/workspace abstraction. + +Today a caller such as Chovy can invoke the system `ssh` command repeatedly, but if every file read, file edit, `git status`, test run, or inspection starts a brand-new SSH process with a brand-new transport, it repeatedly pays for: + +- TCP setup; +- SSH negotiation; +- host-key negotiation; +- authentication; +- key-agent interaction; +- session setup; +- remote-shell startup. + +That is unnecessary. SSH is explicitly capable of carrying many independent channels over one authenticated transport. + +The immediate Chovy use case is concrete: an AI coding run may inspect and modify dozens or hundreds of files on one remote workspace. Chovy should not create a completely new authenticated SSH transport for every operation. + +A naive `/ssh` command that merely does this: + +```sh +ssh user@host +``` + +would not solve the problem. The user can already type that as a normal shell command. + +The useful feature is instead a **persistent SSH workspace manager**: + +```text +one authenticated OpenSSH master connection + │ + ├── exec channel → git status + ├── exec channel → cat package.json + ├── exec channel → apply a multi-file patch + ├── exec channel → pnpm test + ├── scp/sftp-style file transfer + └── optional remote tmux shell +``` + +Each operation remains independently observable and machine-readable while reusing the same authenticated connection. + +This is especially useful for AI systems. Models generally work better with discrete tool calls returning bounded stdout/stderr and exit status than by pretending to be a human typing blindly into one long terminal stream. A persistent transport should therefore **not** imply that all AI activity must share one stateful shell. + +MoshCode needs both: + +1. multiplexed stateless command channels over one SSH connection; and +2. an optional persistent interactive remote shell for workflows that actually require shell state, a REPL, a TUI, a dev server, or a long-running process. + +## Source Review / Why This Fits MoshCode + +The current codebase already contains most of the concepts needed for this feature. + +### Existing shell abstraction + +`src/shell.mjs` centralizes how the pit runs shell commands, including interactive rc-file behavior and terminal/job-control details. The proposed SSH implementation should follow the same principle: one authoritative module should own SSH invocation construction and lifecycle behavior. + +### Existing persistent runtime + +`src/herd.mjs` already: + +- detects tmux and PTY capabilities; +- creates named persistent sessions; +- captures output; +- sends literal input; +- attaches a terminal; +- keeps session metadata; +- exposes machine-readable controls. + +The SSH feature should reuse the **design philosophy**, not tunnel every SSH command through herd. + +### Existing machine interface + +The herd intentionally has no separate hidden API: CLI verbs use `--json`, and scripts/agents consume the same concepts. `/ssh` should follow that rule. + +### Existing zero-dependency posture + +MoshCode is deliberately zero-dependency ESM. Do **not** add `ssh2`, `node-pty`, libssh bindings, or a custom SSH protocol implementation. + +Use the installed OpenSSH client and its native connection-multiplexing support. + +## Goals + +- Allow many remote commands to reuse one authenticated SSH transport. +- Make remote execution dramatically cheaper than reconnecting for every command. +- Give humans a natural `/ssh` pit command and `moshcode ssh` CLI. +- Give AI systems a structured, machine-readable `ssh exec` interface. +- Preserve discrete stdout, stderr, exit status, timeout, and cancellation behavior for each operation. +- Support stdin so an agent can apply a multi-file patch in one remote operation. +- Support an optional truly persistent remote shell when shell state matters. +- Reuse the user's existing OpenSSH configuration, ssh-agent, known_hosts, ProxyJump, identities, and hardware-backed keys. +- Keep credentials and private keys out of MoshCode storage. +- Keep the project zero-dependency ESM. +- Fail soft when OpenSSH or an optional remote capability such as tmux is unavailable. +- Make the feature directly useful to Chovy without making Chovy depend on MoshCode internals. + +## Non-Goals + +- Implement the SSH protocol in JavaScript. +- Replace OpenSSH. +- Store SSH passwords. +- Store private keys. +- Disable host-key checking. +- Invent a second `~/.ssh/config`. +- Force every remote command through one interactive PTY. +- Require MoshCode to be installed on the remote host. +- Require tmux for normal `ssh exec`. +- Build a remote filesystem/FUSE mount. +- Build an IDE file browser. +- Replace rsync, scp, or sftp. +- Automatically deploy MoshCode to remote machines. +- Make SSH itself an A2A protocol. +- Treat a remote shell as an AI agent when it is not one. + +## Users + +### Chovy / agentic application backend + +Needs to perform many file and shell operations against one remote app workspace during a coding run without opening a new authenticated SSH transport every time. + +### MoshCode operator + +Wants to type: + +```text +/ssh dev +``` + +and land on a configured remote box, or: + +```text +/ssh exec dev -- git status +``` + +without thinking about connection multiplexing. + +### Coding agent + +Needs deterministic tools such as: + +```sh +moshcode ssh exec dev --json -- git diff --stat +``` + +rather than scraping an interactive terminal. + +### Automation / moshscript + +Needs to open a connection, execute several operations, branch on exit codes, and close or leave the connection available for later reuse. + +## Product Principle + +**Persistent connection, discrete operations.** + +The transport stays alive. Commands do not have to share shell state. + +This is the default: + +```text +AI + │ + ├─ exec("pwd") ───────────────┐ + ├─ exec("git status") ────────┤ + ├─ exec("git apply -", stdin) ┤ + └─ exec("pnpm test") ─────────┤ + ▼ + one OpenSSH master + │ + ▼ + remote server +``` + +Use a stateful remote shell only when the task truly needs one: + +```text +AI / human + │ + ▼ +remote tmux shell + │ + ├── cd persists + ├── exports persist + ├── dev server persists + ├── REPL persists + └── TUI persists +``` + +## Requirements + +### Phase 1 — Named SSH targets + +- **R1 [P0]** Add a core `moshcode ssh` command and `/ssh` pit command. + +- **R2 [P0]** Support named targets: + + ```sh + moshcode ssh add dev deploy@example.com + moshcode ssh add dev deploy@example.com --port 2222 + moshcode ssh add dev deploy@example.com --cwd /srv/app + moshcode ssh add dev my-ssh-config-host + ``` + +- **R3 [P0]** Store only non-secret metadata under: + + ```text + ~/.moshcode/ssh/targets.json + ``` + + Suggested shape: + + ```json + { + "dev": { + "target": "deploy@example.com", + "port": 22, + "cwd": "/srv/app" + } + } + ``` + +- **R4 [P0]** `targets.json` MUST NOT contain: + - passwords; + - private-key contents; + - passphrases; + - ssh-agent material; + - temporary auth tokens. + +- **R5 [P0]** Allow normal OpenSSH host aliases as targets so existing `~/.ssh/config` remains authoritative: + + ```sshconfig + Host devbox + HostName 203.0.113.10 + User deploy + IdentityFile ~/.ssh/id_ed25519 + ProxyJump bastion + ``` + + then: + + ```sh + moshcode ssh add dev devbox + ``` + +- **R6 [P0]** Commands: + + ```sh + moshcode ssh + moshcode ssh list + moshcode ssh add + moshcode ssh remove + moshcode ssh show + ``` + + Bare `moshcode ssh` lists configured targets and connection state. + +- **R7 [P0]** Every non-interactive verb supports `--json`. + +### Phase 2 — Persistent OpenSSH transport + +- **R8 [P0]** Use native OpenSSH connection multiplexing. + + MoshCode MUST establish a control master rather than keeping a Node child process with a hand-rolled protocol. + + Conceptually: + + ```sh + ssh \ + -o ControlMaster=yes \ + -o ControlPersist=10m \ + -o ControlPath= \ + -N -f \ + devbox + ``` + +- **R9 [P0]** Add: + + ```sh + moshcode ssh open + moshcode ssh check + moshcode ssh close + ``` + + Pit equivalents: + + ```text + /ssh open dev + /ssh check dev + /ssh close dev + ``` + +- **R10 [P0]** Opening an already-live master is idempotent and returns success with `alreadyOpen: true`. + +- **R11 [P0]** Connection state MUST be checked using OpenSSH's control operations where supported, e.g. `ssh -O check`. + +- **R12 [P0]** Closing MUST use OpenSSH's control operation, e.g. `ssh -O exit`, rather than killing arbitrary PIDs. + +- **R13 [P0]** Default to a finite `ControlPersist` window after the last client disconnects. Initial default: 10 minutes. + + Configurable by: + + ```sh + --persist 30m + ``` + + and: + + ```text + MOSHCODE_SSH_PERSIST=30m + ``` + +- **R14 [P0]** Use connection keepalives appropriate for unattended agents: + + ```text + ServerAliveInterval=30 + ServerAliveCountMax=3 + ``` + + unless the user has explicitly configured alternatives. + +- **R15 [P0]** If the master dies or a control socket becomes stale, the next operation MUST: + 1. detect the failure; + 2. clean up only MoshCode-owned stale state; + 3. establish a new master; + 4. retry the requested operation once. + +- **R16 [P0]** Control socket paths MUST avoid Unix-domain-socket path-length failures. + + Do not derive a long socket filename directly from `user@host:/workspace/path`. + + Use a stable short hash: + + ```text + ~/.moshcode/ssh/control/7f31a8c2 + ``` + + If platform socket limits make even that unsafe, use a private runtime directory such as: + + ```text + /tmp/moshcode-ssh-/ + ``` + +- **R17 [P0]** Any runtime/control directory containing sockets MUST be mode `0700`. + +### Phase 3 — Discrete command execution + +- **R18 [P0]** Add: + + ```sh + moshcode ssh exec -- [args...] + ``` + + Example: + + ```sh + moshcode ssh exec dev -- git status --short + ``` + +- **R19 [P0]** `ssh exec` MUST automatically reuse the named target's master connection. + +- **R20 [P0]** `ssh exec` MUST default to **no PTY**. + + This keeps: + - stdout deterministic; + - stderr deterministic; + - binary-safe stdin possible; + - automation predictable. + +- **R21 [P0]** Add `--tty` for commands that require a terminal: + + ```sh + moshcode ssh exec dev --tty -- sudo systemctl status nginx + ``` + +- **R22 [P0]** Return the command's actual remote exit status. + +- **R23 [P0]** `--json` output shape: + + ```json + { + "ok": true, + "target": "dev", + "connected": true, + "code": 0, + "signal": null, + "stdout": " M src/app.ts\n", + "stderr": "", + "durationMs": 84 + } + ``` + +- **R24 [P0]** Failed remote commands are not transport failures. + + Example: remote `grep` exits `1`. + + JSON: + + ```json + { + "ok": false, + "transportOk": true, + "code": 1 + } + ``` + + This distinction matters to agents. + +- **R25 [P0]** SSH/network/auth failures MUST be distinguished from remote command failures. + + Example: + + ```json + { + "ok": false, + "transportOk": false, + "code": 255, + "error": "ssh authentication failed" + } + ``` + +- **R26 [P0]** Support timeout: + + ```sh + moshcode ssh exec dev --timeout 2m -- pnpm test + ``` + +- **R27 [P0]** Support per-operation cwd: + + ```sh + moshcode ssh exec dev --cwd /srv/app -- git status + ``` + + If omitted, use the target's configured default cwd. + +- **R28 [P1]** Support per-operation environment values: + + ```sh + moshcode ssh exec dev --env NODE_ENV=test -- pnpm test + ``` + + These values apply to that operation only. + +- **R29 [P0]** Do not emulate shell persistence for `exec`. + + This should **not** work by accident: + + ```sh + moshcode ssh exec dev -- cd /tmp + moshcode ssh exec dev -- pwd + ``` + + The second command should still use the configured/default cwd. + + Persistent shell state belongs to the shell-session feature. + +### Phase 4 — stdin and agent-friendly file editing + +- **R30 [P0]** `ssh exec` MUST be able to forward stdin. + + Example: + + ```sh + printf '%s\n' "$PATCH" | + moshcode ssh exec dev --stdin --cwd /srv/app -- git apply - + ``` + +- **R31 [P0]** stdin MUST remain raw and must not be shell-escaped, JSON-encoded, line-split, or interpreted by MoshCode. + +- **R32 [P0]** This is the recommended Chovy multi-file-edit path: + + ```text + model produces unified diff + │ + ▼ + one `ssh exec --stdin` + │ + ▼ + `git apply -` + │ + ▼ + many files changed atomically-ish in one remote command + ``` + + This is preferable to one SSH operation per changed file when the model already has a patch. + +- **R33 [P1]** Add convenience transfer verbs backed by OpenSSH-native tools: + + ```sh + moshcode ssh put dev ./local-file /srv/app/file + moshcode ssh get dev /srv/app/file ./local-file + ``` + + Implementation may use `scp` with the same ControlPath. + +- **R34 [P1]** `put` SHOULD support atomic replacement for individual files: + 1. copy to a temporary sibling path; + 2. rename on the remote filesystem. + +- **R35 [P1]** No custom SFTP implementation. + +### Phase 5 — Interactive connection + +- **R36 [P0]** Bare named target attaches a normal interactive SSH session: + + ```sh + moshcode ssh dev + ``` + + pit: + + ```text + /ssh dev + ``` + +- **R37 [P0]** Interactive attach MUST reuse the same ControlMaster when available. + +- **R38 [P0]** Interactive mode hands the terminal directly to OpenSSH. MoshCode does not parse or redraw the remote terminal. + +- **R39 [P0]** Ctrl-C, terminal resize, colors, mouse input, TUIs, vim, top, btop, and nested agent CLIs should behave as they do under ordinary OpenSSH. + +- **R40 [P0]** Exiting the interactive shell does **not** necessarily close the master connection. `ControlPersist` governs transport lifetime. + +### Phase 6 — Persistent stateful remote shell + +A multiplexed SSH connection avoids repeated authentication, but separate `exec` channels intentionally do not preserve shell state. + +Some workflows need actual state: + +```sh +cd /srv/app +export DEBUG=1 +pnpm dev +``` + +or an interactive CLI that stays alive. + +- **R41 [P1]** Add: + + ```sh + moshcode ssh shell --name + ``` + + Example: + + ```sh + moshcode ssh shell dev --name app + ``` + +- **R42 [P1]** When remote tmux exists, create-or-attach a namespaced remote tmux session: + + ```text + moshcode-ssh-- + ``` + +- **R43 [P1]** The remote shell persists independently of the local terminal and independently of the local SSH transport. If the laptop sleeps, the remote tmux shell remains. + +- **R44 [P1]** Add non-attaching machine controls: + + ```sh + moshcode ssh shell send dev/app "pnpm test" + moshcode ssh shell read dev/app --lines 80 + moshcode ssh shell kill dev/app + ``` + +- **R45 [P1]** `send` MUST write literal text followed by Enter, matching herd's existing literal-input safety model. + +- **R46 [P1]** `read` uses remote `tmux capture-pane`, returning terminal text rather than requiring the caller to attach. + +- **R47 [P1]** The remote shell feature MUST gracefully report when tmux is unavailable on the remote machine. + + Normal `ssh exec` remains fully functional. + +- **R48 [P1]** Do not silently install tmux remotely. + +### Phase 7 — Moshscript / agent API + +- **R49 [P0]** Expose value-returning moshscript helpers instead of forcing scripts to parse human text. + + Proposed: + + ```js + sshOpen("dev"); + const r = sshExec("dev", ["git", "status", "--short"], { + cwd: "/srv/app" + }); + + if (!r.ok) { + say(r.stderr); + } + + sshClose("dev"); + ``` + +- **R50 [P0]** `sshExec()` returns the same conceptual object as CLI `--json`: + + ```js + { + ok, + transportOk, + code, + signal, + stdout, + stderr, + durationMs + } + ``` + +- **R51 [P1]** Support stdin in moshscript: + + ```js + sshExec("dev", ["git", "apply", "-"], { + cwd: "/srv/app", + stdin: patch + }); + ``` + +- **R52 [P1]** Add shell-session helpers only if Phase 6 is implemented: + + ```js + sshShellSend("dev/app", "pnpm test"); + const screen = sshShellRead("dev/app", { lines: 50 }); + ``` + + (Starting a shell hands the terminal to ssh, so it is a CLI verb rather + than a script helper; a script drives an existing shell with `send`, + `read` and `kill`.) + +### Phase 8 — Chovy integration contract + +The feature should be usable by Chovy strictly through the public CLI. Chovy must not import MoshCode private modules. + +Recommended lifecycle: + +```sh +# once when a workspace is provisioned +moshcode ssh add chovy-app app@server --cwd /srv/chovy/workspace + +# once at the beginning of an active coding run +moshcode ssh open chovy-app --json +``` + +Then every AI tool operation: + +```sh +moshcode ssh exec chovy-app --json -- git status --short +``` + +Read a file: + +```sh +moshcode ssh exec chovy-app --json -- sed -n '1,240p' src/app.ts +``` + +Apply a model-generated multi-file patch: + +```sh +moshcode ssh exec chovy-app \ + --json \ + --stdin \ + --cwd /srv/chovy/workspace \ + -- git apply - +``` + +Run tests: + +```sh +moshcode ssh exec chovy-app \ + --json \ + --timeout 10m \ + --cwd /srv/chovy/workspace \ + -- pnpm test +``` + +Optional interactive debug: + +```sh +moshcode ssh chovy-app +``` + +Optional persistent remote dev shell: + +```sh +moshcode ssh shell chovy-app --name dev +``` + +At run completion: + +```sh +moshcode ssh close chovy-app +``` + +or simply let `ControlPersist` expire. + +- **R53 [P0]** Chovy SHOULD keep using discrete model tool calls. + +- **R54 [P0]** Chovy SHOULD NOT force all model actions through a single interactive shell merely to avoid reconnect cost. + +- **R55 [P0]** Chovy SHOULD batch model file changes into unified diffs where practical and apply one patch through stdin. + +- **R56 [P0]** Chovy MAY run independent commands concurrently over the same master connection. SSH multiplexing should allow several logical channels over one authenticated transport. + +- **R57 [P0]** A single Chovy coding run should normally perform one SSH authentication/transport setup, not one per file. + +## UX Notes + +### Human pit flow + +```text +mosh ▸ /ssh add dev deploy@dev.example.com --cwd ~/src/app +✓ dev → deploy@dev.example.com + +mosh ▸ /ssh open dev +✓ dev connected + +mosh ▸ /ssh exec dev -- git status --short + M src/app.ts + +mosh ▸ /ssh dev +deploy@dev:~/src/app$ +``` + +Leaving the remote shell returns to the pit while the master connection remains reusable. + +### Connection list + +```text +mosh ▸ /ssh + +name target state cwd +dev deploy@dev.example.com connected ~/src/app +prod deploy@prod.example.com closed /srv/app +``` + +### JSON list + +```json +{ + "targets": [ + { + "name": "dev", + "target": "deploy@dev.example.com", + "connected": true, + "cwd": "~/src/app" + } + ] +} +``` + +### Shell session + +```text +mosh ▸ /ssh shell dev --name app +dev/app ▸ ~/src/app + +deploy@dev:~/src/app$ pnpm dev +``` + +Detach behavior should be documented clearly. If remote tmux is used, detaching must leave the remote process alive. + +## CLI Surface + +```text +moshcode ssh +moshcode ssh list +moshcode ssh add [--port N] [--cwd PATH] +moshcode ssh remove +moshcode ssh show + +moshcode ssh open [--persist 10m] +moshcode ssh check +moshcode ssh close + +moshcode ssh +moshcode ssh exec [--cwd PATH] [--env K=V] [--stdin] [--tty] [--timeout DURATION] -- + +moshcode ssh put +moshcode ssh get + +moshcode ssh shell --name +moshcode ssh shell send / +moshcode ssh shell read / [--lines N] +moshcode ssh shell kill / + +moshcode ssh bench [--n 20] +``` + +Every appropriate verb: + +```text +--json +``` + +Pit facade: + +```text +/ssh ... +``` + +## Architecture + +### New module: `src/ssh.mjs` + +Own: + +- target registry; +- control socket naming; +- OpenSSH capability detection; +- master open/check/close; +- invocation construction; +- exec; +- stdin forwarding; +- output capture; +- timeout; +- interactive attach; +- scp convenience; +- optional remote tmux shell helpers. + +No SSH command construction should be duplicated in `cli.mjs`, `commands.mjs`, or Chovy. + +### `src/cli-schema.mjs` + +Add the canonical help schema for `ssh` and its verbs. + +The README command table is generated from the command schema, so `/ssh` must enter through the same canonical command/help path as existing commands. + +### `bin/moshcode.mjs` + +Dispatch `moshcode ssh ...` into `src/ssh.mjs`. + +### `src/commands.mjs` + +Expose: + +```js +cliVerb("ssh", "connect to and operate persistent remote SSH workspaces") +``` + +plus value-returning moshscript helpers where required. + +### `src/runtime.mjs` + +The runtime injects every registered command as a global, so the helpers in +`src/commands.mjs` are the injection; no runtime change is needed. + +### Relationship to `src/herd.mjs` + +Phase 1 does not change herd. + +This is intentional: + +- herd owns local persistent processes and agent state; +- ssh owns remote transport and remote command execution. + +Future integration may allow a remote SSH shell or an SSH-launched remote agent to appear in `moshcode ps`, but `/ssh` should first work cleanly as an independent transport primitive. + +## OpenSSH Invocation Strategy + +Implementation builds argv arrays, never a shell string. + +### Master + +```sh +ssh \ + -o ControlMaster=yes \ + -o ControlPersist=600 \ + -o ControlPath=/private/path/abc123 \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o ConnectTimeout=20 \ + -N -f \ + devbox +``` + +Note the absence of `-M`. Found in testing: `-M` together with +`-o ControlMaster=yes` is read by ssh as a *second* request for master mode, +which means **ask** mode — every later client then needs an askpass +confirmation, and headless the answer is "Master refused session request: +Permission denied". One spelling or the other, never both. + +### Check + +```sh +ssh \ + -o ControlPath=/private/path/abc123 \ + -O check \ + devbox +``` + +### Close + +```sh +ssh \ + -o ControlPath=/private/path/abc123 \ + -O exit \ + devbox +``` + +### Exec + +```sh +ssh \ + -o ControlPath=/private/path/abc123 \ + -o ControlMaster=auto \ + -o ControlPersist=600 \ + -T \ + devbox \ + -- +``` + +`ControlMaster=auto` on the client is the native stale-socket recovery: a +socket nobody is listening on is unlinked and the client becomes the new +master, so a master that died between two commands costs one reconnect. + +The remote command is built from argv with POSIX single-quoting: + +```text +cd -- '/srv/app' && NODE_ENV='test' exec 'pnpm' 'test' +``` + +A `--sh` flag passes a single argument as a shell snippet on purpose; nothing +is ever guessed to be one. + +## Security + +- **R58 [P0]** Never pass `StrictHostKeyChecking=no`. +- **R59 [P0]** Respect normal OpenSSH `known_hosts` behavior. +- **R60 [P0]** Never persist passwords. +- **R61 [P0]** Never copy private keys into `~/.moshcode`. +- **R62 [P0]** Prefer ssh-agent, OpenSSH config, hardware-backed keys, and standard identity files. +- **R63 [P0]** Redact obvious secret-bearing CLI arguments from debug logs where MoshCode controls logging. +- **R64 [P0]** Do not print full stdin payloads in debug output. +- **R65 [P0]** The socket/control directory must be private to the current OS user. +- **R66 [P0]** Refuse target names containing path separators or traversal components. +- **R67 [P0]** Registry file writes must be atomic and owner-only. +- **R68 [P0]** Remote commands must be built from argv with explicit quoting rules. +- **R69 [P0]** `--env` values must not be echoed in ordinary human output. +- **R70 [P0]** The feature must not weaken the user's existing SSH policy. + +## Failure Modes + +### OpenSSH missing + +```text +✗ ssh not found — install an OpenSSH client +``` + +No package is auto-installed. + +### Authentication requires interaction + +Interactive `/ssh dev` may naturally allow OpenSSH to ask. + +Headless `ssh exec --json` should fail clearly rather than hang indefinitely. +`BatchMode=yes` is passed whenever stdin is not a terminal or `--batch` is +given; with a terminal attached, OpenSSH may prompt as it normally would. + +### Unknown host key + +Use native OpenSSH behavior. Do not auto-accept. + +### Stale master socket + +Detect → clean MoshCode-owned stale socket → reconnect → retry once. + +### Remote command exits nonzero + +Return command exit status without calling it an SSH failure. + +### Remote tmux missing + +Only `ssh shell` persistent mode is unavailable. `ssh exec` still works. + +### Local process dies + +A detached ControlMaster may survive according to OpenSSH behavior and ControlPersist. Remote tmux shells survive regardless of the local master. + +## Performance Expectations + +The feature exists to remove repeated SSH handshakes from high-churn agent workloads. + +### Required measurement + +`moshcode ssh bench [--n N]` compares: + +```text +N × fresh ssh "true" +``` + +against: + +```text +1 × master connection +N × multiplexed ssh "true" +``` + +and reports total wall time, median and p95 latency, failures, and the number +of authentications each side performed. + +Measured on a loopback sshd on the development box (20 runs each): fresh +~96ms median, multiplexed ~12ms median. Real hosts will differ; the number to +quote is the one `bench` prints for your own host. + +## Success Metrics + +- A Chovy run that performs 100 remote operations normally authenticates once rather than 100 times. +- Median subsequent `ssh exec` startup latency is materially lower than a fresh SSH connection on the same host. +- File edits can be applied as one multi-file patch over stdin. +- `ssh exec --json` exposes stdout, stderr, exit status, transport status, and duration without terminal scraping. +- Interactive `/ssh ` behaves like normal OpenSSH. +- Existing `~/.ssh/config` features continue to work. +- No SSH private key or password is stored by MoshCode. +- No runtime npm dependency is added. +- MoshCode remains usable when tmux is absent. +- Tests cover stale sockets, failed authentication, remote exit codes, stdin, quoting, cwd, and JSON output. + +## Test Plan + +### Unit (`test/ssh.test.mjs`) + +- target-name validation; +- registry read/write; +- control-path hashing; +- OpenSSH argv construction; +- port handling; +- cwd encoding; +- environment encoding; +- remote argv quoting; +- JSON shapes; +- exit-code mapping; +- transport-vs-command failure classification; +- timeout parsing; +- stale-socket recovery decision logic. + +### Integration (`test/ssh-sshd.test.mjs`) + +An ephemeral, non-root `sshd` on a loopback port with generated keys and a +private `ssh_config` (pointed at through `MOSHCODE_SSH_CONFIG`). Skipped, not +failed, where `sshd` or `ssh-keygen` is unavailable. + +1. add target; +2. open master; +3. check master; +4. exec `printf`; +5. exec failing command; +6. stdin round trip; +7. cwd; +8. parallel exec channels; +9. close master; +10. automatic reopen; +11. host-key failure; +12. authentication failure; +13. `scp` reuse via `put`/`get`. + +### Remote tmux + +When tmux is present in the test image: + +1. send `cd` and `pwd`, verify state persists; +2. read the screen; +3. kill. + +## Documentation + +README section `## SSH workspaces`, leading with: + +> `/ssh` keeps the SSH connection alive; `ssh exec` still gives each tool call a clean command channel. + +with a Chovy/agent example showing one connection and a multi-file `git apply -`, and `moshcode help ssh`. + +## Rollout + +### Milestone 1 + +- target registry; +- `/ssh`; +- open/check/close; +- exec; +- JSON; +- stdin; +- cwd; +- tests. + +This alone solves the Chovy reconnect problem. + +### Milestone 2 + +- put/get; +- timeout polish; +- parallel execution tests; +- moshscript value helpers. + +### Milestone 3 + +- persistent remote tmux shell; +- send/read/kill; +- optional herd bridge exploration. + +## Future: Herd Bridge + +Do not block this PRD on herd integration. + +A later PRD may define: + +```sh +moshcode herd remote add devbox --kind ssh --target dev +``` + +or allow: + +```sh +moshcode ssh agent dev --engine claude --name api +``` + +to launch a MoshCode herd/agent on a remote machine. + +The clean layering should be: + +```text +herd / agent orchestration + │ + ▼ + ssh workspace + │ + ▼ + OpenSSH +``` + +not: + +```text +SSH implementation hidden inside herd +``` + +## Risks & Open Questions + +- OpenSSH multiplexing behavior differs slightly across platforms. POSIX/OpenSSH-first is acceptable, but capability checks must be explicit. +- ControlPath socket limits can be surprisingly small; hashed short paths are mandatory. +- Remote argv quoting is security-sensitive and deserves dedicated tests. +- `BatchMode=yes` is on whenever stdin is not a terminal, and `--batch` forces it; a person at a terminal can still be prompted. +- `ControlPersist=10m` is a reasonable default but Chovy may want a master open for the entire coding-run lifetime. Explicit `open` + `close` already handles that. +- `scp` behavior and flags have changed across OpenSSH versions; `put/get` are P1, not required for the core reconnect fix. +- Long-running noninteractive commands are still individual channels. If a command must outlive its caller, use remote tmux/systemd/herd rather than pretending `ssh exec` is a job supervisor. +- A future remote-herd abstraction should decide whether MoshCode is installed remotely or whether local MoshCode drives raw remote tmux. That decision is intentionally outside this PRD. + +## Decision + +Build `/ssh`, but build it as a **persistent SSH workspace primitive**, not as a convenience alias for `ssh`. + +For Chovy, the key win is not "one forever-interactive shell." The key win is: + +> **one authenticated SSH transport, many clean AI tool calls, plus an optional persistent remote shell when state is actually needed.** diff --git a/prd/README.md b/prd/README.md index 5389f7cb..e8785539 100644 --- a/prd/README.md +++ b/prd/README.md @@ -27,4 +27,6 @@ Start one with `moshcode prd ""` (TUI: `/prd`). | [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted | | [0010](0010-cloud-settings-sync.md) | Sync the pit's settings to your moshcode.sh account | Draft | | [0011](0011-herd-agent-protocol.md) | Teach the herd the agent protocol — hooks-first state, a task ledger, and an A2A surface for local and remote agents | Draft | +| [0012](0012-billing-baked-into-the-agent-cli.md) | Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails | Draft | +| [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 84de46a9..30248eee 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -207,6 +207,36 @@ export const CORE_CLI_COMMANDS = [ seeAlso: ["herd", "ps"], note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.", }, + { + name: "ssh", + group: "runtime", + description: "persistent SSH workspaces — one connection, many clean commands", + synopsis: [ + ["moshcode ssh", "targets, and whether each one is connected"], + ["moshcode ssh ", "an interactive shell over the shared connection"], + ["moshcode ssh exec [flags] -- ", "one command over it: stdout, stderr and exit status kept apart"], + ["moshcode ssh [args…]", "add, open, check, close, put, get, shell, bench"], + ], + verbs: "SSH_VERBS", + flags: [ + ["--json", "machine-readable, on every verb that does not take the terminal", ""], + ["--persist ", "how long the connection outlives its last client", "10m"], + ], + examples: [ + ["moshcode ssh add dev deploy@example.com --cwd /srv/app", "a name for a box (or an alias from ~/.ssh/config)"], + ["moshcode ssh open dev", "authenticate once"], + ["moshcode ssh exec dev -- git status --short", "…then every command reuses it"], + ["moshcode ssh exec dev --json -- pnpm test", "exit code, stdout and stderr as one object"], + ["git diff | moshcode ssh exec dev --stdin -- git apply -", "a multi-file patch in one round trip"], + ["moshcode ssh dev", "a real shell on the same connection"], + ["moshcode ssh shell dev --name app", "a remote tmux shell that survives your laptop sleeping"], + ["moshcode ssh close dev", "hang up (or let --persist expire)"], + ], + seeAlso: ["herd", "shell"], + note: "nothing secret is stored: targets.json holds a host, a port and a directory, and OpenSSH keeps using your ~/.ssh, " + + "agent, known_hosts and ProxyJump exactly as it does at the prompt. `exec` gives every call a fresh command channel — " + + "no shell state carries between calls; `shell` is for the workflows that need it.", + }, { name: "install", group: "engines", @@ -1325,6 +1355,82 @@ export const HERD_VERBS = [ // The business layer's verbs. Flatter than the herd's on purpose: these are // commands somebody types between other work, and a verb that needs a paragraph // to explain itself is a verb in the wrong place. +export const SSH_VERBS = [ + { name: "list", description: "every target, and whether its connection is up", + synopsis: [["moshcode ssh [list] [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "add", description: "name a box: user@host, or an alias from ~/.ssh/config", + synopsis: [["moshcode ssh add [--port N] [--cwd PATH] [--persist 10m]", ""]], + flags: [ + ["--port ", "ssh port, when ~/.ssh/config does not say", "22"], + ["--cwd ", "where exec and the interactive shell start", "the remote login directory"], + ["--persist ", "how long the connection outlives its last client", "10m"], + ["--json", "machine-readable", ""], + ] }, + { name: "remove", description: "forget a target (and close its connection)", + synopsis: [["moshcode ssh remove ", ""]] }, + { name: "show", description: "one target in full, with its socket and state", + synopsis: [["moshcode ssh show [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "open", description: "authenticate once and keep the connection", + synopsis: [["moshcode ssh open [--persist 10m] [--batch] [--json]", ""]], + flags: [ + ["--persist ", "how long it outlives its last client", "10m"], + ["--batch", "never prompt — fail instead (the default when stdin is not a terminal)", ""], + ["--json", "machine-readable; alreadyOpen says whether it was up", ""], + ] }, + { name: "check", description: "is the connection up? (exit 0 yes, 1 no)", + synopsis: [["moshcode ssh check [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "close", description: "hang up, with ssh's own -O exit", + synopsis: [["moshcode ssh close [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "exec", description: "one command over the connection — no PTY, no shared shell state", + synopsis: [["moshcode ssh exec [--cwd PATH] [--env K=V…] [--stdin] [--tty] [--timeout 2m] [--sh] [--json] -- ", ""]], + flags: [ + ["--cwd ", "run it here, this once", "the target's cwd"], + ["--env ", "an environment variable for this command only (repeatable)", ""], + ["--stdin", "forward this process's stdin, raw", ""], + ["--tty", "allocate a terminal (sudo, editors, anything that insists)", ""], + ["--timeout ", "kill it after this long — exit 124", ""], + ["--sh", "the one argument is a shell snippet, pipes and all", ""], + ["--batch", "never prompt for auth — fail instead", ""], + ["--json", "{ ok, transportOk, code, signal, stdout, stderr, durationMs }", ""], + ], + examples: [ + ["moshcode ssh exec dev -- git status --short", ""], + ["moshcode ssh exec dev --json -- grep -rn TODO src", "exit 1 is grep's answer, not a transport failure"], + ["moshcode ssh exec dev --sh 'git log --oneline | head -5'", "a pipeline, on purpose"], + ] }, + { name: "put", description: "copy a file up over the connection, atomically", + synopsis: [["moshcode ssh put [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "get", description: "copy a file down over the connection", + synopsis: [["moshcode ssh get [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "shell", description: "a persistent remote shell in tmux, and the verbs to drive it", + synopsis: [ + ["moshcode ssh shell --name ", "create or attach — Ctrl-b d leaves it running"], + ["moshcode ssh shell send / ", "type a line into it"], + ["moshcode ssh shell read / [--lines N]", "its screen, as text"], + ["moshcode ssh shell kill /", "end it"], + ["moshcode ssh shell list ", "every moshcode shell on the box"], + ], + flags: [ + ["--name ", "which shell", "main"], + ["--lines ", "how much screen to read", "60"], + ["--json", "machine-readable", ""], + ], + examples: [ + ["moshcode ssh shell dev --name app", ""], + ["moshcode ssh shell send dev/app \"pnpm dev\"", ""], + ["moshcode ssh shell read dev/app --lines 40", ""], + ] }, + { name: "bench", description: "measure fresh connections against the shared one, on this host", + synopsis: [["moshcode ssh bench [--n 20] [--json]", ""]], + flags: [["--n ", "how many of each", "20"], ["--json", "machine-readable", ""]] }, +]; + export const TIMER_VERBS = [ { name: "on", description: "start the clock", synopsis: [["moshcode timer on [client] [--task …] [--agents N|auto]", ""]] }, { name: "off", description: "stop it and write the entry", synopsis: [["moshcode timer off [--note …]", ""]] }, @@ -1377,6 +1483,7 @@ export const PAYMENT_VERBS = [ export const VERB_TABLES = { HERD_VERBS, + SSH_VERBS, TIMER_VERBS, CLIENT_VERBS, TEAM_VERBS, @@ -1428,6 +1535,8 @@ export const PIT_COMMANDS = [ description: "block until a session is blocked or done" }, { name: "restore", args: "[--resume]", cli: "restore", description: "rebuild the herd's sessions after a reboot" }, + { name: "ssh", args: "[name|verb] [args…]", cli: "ssh", + description: "persistent SSH workspaces — one connection, many clean commands" }, { name: "tools", args: "[name] [args…]", cli: "tools", description: "list workflow tools, or run one" }, { name: "trade", args: " [args…]", cli: "trade", diff --git a/src/commands.mjs b/src/commands.mjs index dc57099b..12779cf8 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -22,6 +22,27 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs"; import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs"; import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs"; import { shellInvocation } from "./shell.mjs"; +import { + checkMaster as sshCheckMaster, closeMaster as sshCloseMaster, exec as sshRun, get as sshGetFile, + openMaster as sshOpenMaster, parseSessionRef as sshParseSessionRef, parseTimeout as parseSshTimeout, + put as sshPutFile, resolveTarget as sshResolve, + shellKill as sshShellKillRemote, shellRead as sshShellReadRemote, shellSend as sshShellSendRemote, +} from "./ssh.mjs"; + +/** The registry entry for a named ssh target, or a moshscript error naming the verb. */ +function sshTarget(name, verb) { + if (!name) throw new Error(`moshscript: ${verb}(name) requires a target name`); + const found = sshResolve(String(name)); + if (found.error) throw new Error(`moshscript: ${verb}: ${found.error.replace(/^ssh: /, "")}`); + return found.entry; +} + +/** `dev/app` → the target entry and the session name, or a moshscript error. */ +function sshShellRef(ref, verb) { + const parsed = sshParseSessionRef(ref); + if (parsed.error) throw new Error(`moshscript: ${verb}: ${parsed.error.replace(/^ssh: /, "")}`); + return { found: sshTarget(parsed.name, verb), session: parsed.session }; +} import { captureSpec } from "./pty.mjs"; import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs"; import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs"; @@ -654,6 +675,148 @@ const COMMANDS = [ }, }, + // SSH workspaces (PRD 0013 R49–R52). Value-returning for the herd's reason: + // a script that runs a command on a remote box wants its stdout back, and a + // cliVerb's { ok, code } cannot carry it. Each returns the same object the + // CLI prints under --json, so a script and a shell pipeline read one shape. + // + // sshOpen("dev"); + // const r = sshExec("dev", ["git", "status", "--short"], { cwd: "/srv/app" }); + // if (!r.ok) say(r.stderr); + // sshExec("dev", ["git", "apply", "-"], { stdin: patch }); + // sshClose("dev"); + { + name: "sshOpen", + summary: "authenticate to a named ssh target once and keep the connection", + usage: "sshOpen(name, { persist })", + detail: "returns { ok, connected, alreadyOpen }; a live connection is left alone", + run(ctx, name, opts = {}) { + const found = sshTarget(name, "sshOpen"); + if (ctx.dryRun) { ctx.out(` 🔌 sshOpen(${name}) → would open a master to ${found.target}`); return { ok: true, target: found.name, connected: true, dryRun: true }; } + const r = sshOpenMaster(found, { persist: opts.persist, batch: opts.batch }); + ctx.out(r.ok ? ` 🔌 sshOpen(${name}) → ${r.alreadyOpen ? "already connected" : "connected"}` : ` ✗ sshOpen(${name}) → ${r.error}`); + return r; + }, + }, + { + name: "sshCheck", + summary: "is the connection to a target up?", + usage: "sshCheck(name)", + detail: "returns { connected, stale, pid }; never connects", + run(ctx, name) { + const found = sshTarget(name, "sshCheck"); + if (ctx.dryRun) return { target: found.name, connected: false, dryRun: true }; + const s = sshCheckMaster(found); + return { target: found.name, connected: s.connected, stale: s.stale, pid: s.pid ?? null }; + }, + }, + { + name: "sshExec", + summary: "run one command over the shared connection and RETURN its result", + usage: "sshExec(name, [cmd, ...args], { cwd, env, stdin, timeout, sh })", + detail: "returns { ok, transportOk, code, signal, stdout, stderr, durationMs }; opens the connection if it is down. ok is the command's verdict, transportOk is ssh's", + run(ctx, name, argv, opts = {}) { + const found = sshTarget(name, "sshExec"); + const command = Array.isArray(argv) ? argv.map(String) : [String(argv ?? "")].filter(Boolean); + if (!command.length) throw new Error("moshscript: sshExec(name, [command, ...args]) needs a command"); + if (ctx.dryRun) { + ctx.out(` ▶ sshExec(${name}) → would run on ${found.target}: ${command.join(" ")}`); + return { ok: true, transportOk: true, target: found.name, connected: true, code: 0, signal: null, stdout: "", stderr: "", durationMs: 0, dryRun: true }; + } + ctx.out(` ▶ sshExec(${name}) → ${command.join(" ").slice(0, 60)}${command.join(" ").length > 60 ? "…" : ""}`); + const timeoutMs = opts.timeout === undefined ? undefined + : (typeof opts.timeout === "number" ? opts.timeout : parseSshTimeout(opts.timeout)); + const r = sshRun(found, command, { + cwd: opts.cwd, remoteEnv: opts.env || {}, stdin: opts.stdin, sh: Boolean(opts.sh), timeoutMs, persist: opts.persist, batch: opts.batch ?? true, + }); + if (!r.transportOk) ctx.out(` ✗ sshExec(${name}) → ${r.error}`); + else if (!r.ok) ctx.out(` ✗ sshExec(${name}) exited ${r.signal || r.code}`); + return r; + }, + }, + { + name: "sshClose", + summary: "hang up a target's connection", + usage: "sshClose(name)", + detail: "returns { ok, closed, wasOpen }", + run(ctx, name) { + const found = sshTarget(name, "sshClose"); + if (ctx.dryRun) { ctx.out(` 🔌 sshClose(${name}) → would send -O exit`); return { ok: true, target: found.name, closed: true, dryRun: true }; } + const r = sshCloseMaster(found); + ctx.out(r.ok ? ` 🔌 sshClose(${name}) → ${r.wasOpen ? "closed" : "was not connected"}` : ` ✗ sshClose(${name}) → ${r.error}`); + return r; + }, + }, + { + name: "sshPut", + summary: "copy a local file to a target, atomically", + usage: "sshPut(name, local, remote)", + detail: "returns { ok, remote }; scp to a temp path over the shared connection, then rename", + run(ctx, name, local, remote) { + const found = sshTarget(name, "sshPut"); + if (!local || !remote) throw new Error("moshscript: sshPut(name, local, remote) needs both paths"); + if (ctx.dryRun) { ctx.out(` 📤 sshPut(${name}) → would copy ${local} to ${remote}`); return { ok: true, target: found.name, dryRun: true }; } + const r = sshPutFile(found, String(local), String(remote)); + ctx.out(r.ok ? ` 📤 sshPut(${name}) → ${r.remote}` : ` ✗ sshPut(${name}) → ${r.error}`); + return r; + }, + }, + { + name: "sshGet", + summary: "copy a file down from a target", + usage: "sshGet(name, remote, local)", + detail: "returns { ok, local }", + run(ctx, name, remote, local) { + const found = sshTarget(name, "sshGet"); + if (!local || !remote) throw new Error("moshscript: sshGet(name, remote, local) needs both paths"); + if (ctx.dryRun) { ctx.out(` 📥 sshGet(${name}) → would copy ${remote} to ${local}`); return { ok: true, target: found.name, dryRun: true }; } + const r = sshGetFile(found, String(remote), String(local)); + ctx.out(r.ok ? ` 📥 sshGet(${name}) → ${r.local}` : ` ✗ sshGet(${name}) → ${r.error}`); + return r; + }, + }, + { + name: "sshShellSend", + summary: "type a line into a persistent remote shell", + usage: 'sshShellSend("dev/app", text)', + detail: "returns { ok }; literal text then Enter, into the remote tmux session", + run(ctx, ref, ...words) { + const { found, session } = sshShellRef(ref, "sshShellSend"); + const text = words.join(" "); + if (!text) throw new Error("moshscript: sshShellSend(ref, text) needs text"); + if (ctx.dryRun) { ctx.out(` 💬 sshShellSend(${ref}) → would send: ${text}`); return { ok: true, dryRun: true }; } + const r = sshShellSendRemote(found, session, text); + if (!r.ok) ctx.out(` ✗ sshShellSend(${ref}) → ${r.error}`); + return r; + }, + }, + { + name: "sshShellRead", + summary: "the screen of a persistent remote shell, as a string", + usage: 'sshShellRead("dev/app", { lines })', + detail: "returns the text (empty on failure); the same capture-pane the CLI's read prints", + run(ctx, ref, opts = {}) { + const { found, session } = sshShellRef(ref, "sshShellRead"); + if (ctx.dryRun) { ctx.out(` 📖 sshShellRead(${ref}) → would capture the pane`); return ""; } + const r = sshShellReadRemote(found, session, { lines: opts.lines }); + if (!r.ok) { ctx.out(` ✗ sshShellRead(${ref}) → ${r.error}`); return ""; } + return r.screen; + }, + }, + { + name: "sshShellKill", + summary: "end a persistent remote shell", + usage: 'sshShellKill("dev/app")', + detail: "returns { ok }", + run(ctx, ref) { + const { found, session } = sshShellRef(ref, "sshShellKill"); + if (ctx.dryRun) { ctx.out(` ☠ sshShellKill(${ref}) → would kill the session`); return { ok: true, dryRun: true }; } + const r = sshShellKillRemote(found, session); + if (!r.ok) ctx.out(` ✗ sshShellKill(${ref}) → ${r.error}`); + return r; + }, + }, + // CLI verbs — each is `moshcode ...args`. This is the whole point: // scripting the CLI. Add a capability by adding a line here. // @@ -695,6 +858,7 @@ const COMMANDS = [ cliVerb("elevenlabs", "drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing)"), cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"), cliVerb("pwd", "print the current repo/location"), + cliVerb("ssh", "persistent SSH workspaces (moshcode ssh ) — see sshExec/sshOpen for values"), // Research and feeds. The *Read() verbs above return the data; these are the // rendered CLI, for when a script wants the table on the operator's screen. diff --git a/src/ssh.mjs b/src/ssh.mjs new file mode 100644 index 00000000..503db142 --- /dev/null +++ b/src/ssh.mjs @@ -0,0 +1,1228 @@ +// Persistent SSH workspaces — one authenticated transport, many clean commands +// (PRD 0013). +// +// A coding run against a remote box is a few hundred small operations: read a +// file, `git status`, apply a patch, run the tests. Spawning `ssh` for each one +// is fine; paying for a TCP handshake, key exchange, host-key check and +// authentication for each one is not, and that is what a fresh `ssh user@host +// cmd` costs every time. OpenSSH has carried the fix for twenty years: +// ControlMaster keeps one authenticated connection alive and later clients +// open channels on it through a Unix socket, so the second command costs a +// socket connect instead of a handshake. Measured on a loopback sshd, that is +// ~12ms a command against ~96ms — and on a real network the handshake is the +// part that grows. +// +// So this module is a thin, careful wrapper over that feature. It owns: +// +// · the registry of named targets (~/.moshcode/ssh/targets.json) — a name, +// a host or ssh_config alias, a port, a default cwd. Never a password or a +// key: OpenSSH already has ~/.ssh, an agent, and a known_hosts, and every +// one of those stays authoritative; +// · the control socket for each target, in a directory only this user can +// read, at a path short enough for sun_path; +// · open / check / close, spelled with ssh's own `-O` control operations +// rather than by tracking and killing PIDs; +// · exec: a remote command built from argv with real quoting, no PTY unless +// asked, stdin forwarded raw, stdout/stderr/exit status returned as data; +// · attach, put/get over scp, and an optional remote tmux shell for the +// workflows that genuinely need shell state. +// +// It does NOT implement SSH, and never will. No ssh2, no node-pty, no libssh. +// The `ssh` on PATH is the implementation; this file decides what to ask it. +// +// Two OpenSSH facts shaped the invocations below, both found by running them: +// +// 1. `-M` and `-o ControlMaster=yes` together do not mean "yes, twice". ssh +// reads a second request for master mode as a request for *ask* mode — +// every later client then triggers an askpass prompt, and with no askpass +// the answer is "Master refused session request: Permission denied". The +// master here is `-o ControlMaster=yes -N -f`, and `-M` never appears. +// +// 2. `ControlMaster=auto` on a client is the stale-socket recovery the PRD +// asks for, natively: a socket nobody is listening on gets unlinked and +// the client becomes the new master. exec runs with `auto` so a master +// that died between two commands costs one reconnect, not an error. +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ash, bone, err, info, ok, table, warn } from "./ui.mjs"; + +/* ------------------------------------------------------------ constants */ + +/** Where the last client's disconnect leaves the master alive, by default. */ +export const DEFAULT_PERSIST = "10m"; + +/** Keepalives for a transport an unattended agent is relying on (R14). */ +export const KEEPALIVE = { ServerAliveInterval: 30, ServerAliveCountMax: 3 }; + +/** `MOSHCODE_SSH_PERSIST=30m` overrides the default persist window. */ +export const PERSIST_ENV = "MOSHCODE_SSH_PERSIST"; + +/** `MOSHCODE_SSH_CONFIG=` points ssh at a config other than ~/.ssh/config. */ +export const CONFIG_ENV = "MOSHCODE_SSH_CONFIG"; + +/** `MOSHCODE_SSH_DEBUG=1` prints each ssh argv to stderr — redacted, see debugLine. */ +export const DEBUG_ENV = "MOSHCODE_SSH_DEBUG"; + +/** How long ssh itself waits on a TCP connect before giving up. */ +export const CONNECT_TIMEOUT = 20; + +/** 64 MiB of stdout is a file listing gone wrong, not a use case. */ +const MAX_OUTPUT = 64 * 1024 * 1024; + +/** The longest control-socket path we will ask the kernel to bind (R16). */ +const MAX_SOCKET_PATH = 100; + +/** + * Target names are typed at a prompt, used as a filename component, and hashed + * into a socket path. The herd's shape, for the same reasons: nothing that + * could be a path separator, a traversal, or a tmux target separator (R66). + */ +export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; +export const validName = (name) => NAME_RE.test(String(name || "")); + +/** Session names for remote tmux shells: same alphabet, so `dev/app` parses cleanly. */ +export const SESSION_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/; + +/** POSIX shell variable names, for --env K=V. */ +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/* ------------------------------------------------------------- registry */ + +/** Where the registry and, by default, the control sockets live. */ +export function sshDir() { + return process.env.MOSHCODE_SSH_DIR || path.join(os.homedir(), ".moshcode", "ssh"); +} + +const targetsPath = () => path.join(sshDir(), "targets.json"); + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + // mkdir's mode only applies on create; an older, looser directory is fixed + // here rather than trusted (R17, R65). + try { fs.chmodSync(dir, 0o700); } catch { /* not ours to fix */ } + return dir; +} + +/** + * Read the registry. Never throws: a corrupt or absent file is "no targets", + * which the caller can recover from, and every field is re-validated so a + * hand-edited file cannot smuggle a name the rest of this module refuses. + */ +export function readTargets() { + let raw; + try { raw = JSON.parse(fs.readFileSync(targetsPath(), "utf8")); } catch { return {}; } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out = {}; + for (const [name, entry] of Object.entries(raw)) { + if (!validName(name) || !entry || typeof entry !== "object" || !entry.target) continue; + out[name] = normalizeEntry(entry); + } + return out; +} + +function normalizeEntry(entry) { + const port = Number.parseInt(entry.port, 10); + const out = { target: String(entry.target) }; + if (Number.isInteger(port) && port > 0 && port < 65536) out.port = port; + if (entry.cwd) out.cwd = String(entry.cwd); + if (entry.persist) out.persist = String(entry.persist); + return out; +} + +/** + * Write the registry: to a sibling temp file, then rename over the real one, + * both at 0600 (R67). Two pits saving at once cannot leave a half-written file + * behind, and `mode` on the temp file means the finished file never spends a + * moment world-readable. Nothing in it is secret today; the contract is that + * nothing ever will be, and the permissions say so anyway. + */ +export function writeTargets(targets) { + const dir = ensureDir(sshDir()); + const file = targetsPath(); + const tmp = path.join(dir, `.targets.${process.pid}.${Date.now()}.tmp`); + fs.writeFileSync(tmp, `${JSON.stringify(targets, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, file); + try { fs.chmodSync(file, 0o600); } catch { /* best effort */ } + return true; +} + +/** Fields the registry is allowed to hold. Anything else is dropped on write (R4). */ +const ALLOWED_FIELDS = ["target", "port", "cwd", "persist"]; + +/** Names that read as a secret, refused as target fields no matter the value. */ +const SECRET_FIELDS = /pass|secret|token|key|identity|phrase/i; + +/** + * Add or replace a target. `target` is whatever ssh accepts after its options — + * `user@host`, a bare host, or an alias from ~/.ssh/config (R5). It is not + * parsed here on purpose: ssh_config is the authority on what it means. + */ +export function addTarget(name, target, { port, cwd, persist } = {}) { + if (!validName(name)) { + throw new Error(`ssh: ${JSON.stringify(String(name))} is not a target name — lowercase letters, digits, - and _ only`); + } + const host = String(target || "").trim(); + if (!host) throw new Error("ssh: a target needs a host — moshcode ssh add "); + if (host.startsWith("-")) throw new Error(`ssh: ${JSON.stringify(host)} looks like a flag, not a host`); + const entry = { target: host }; + if (port !== undefined && port !== null && port !== "") { + const n = Number.parseInt(port, 10); + if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`ssh: --port ${JSON.stringify(String(port))} is not a port`); + entry.port = n; + } + if (cwd) entry.cwd = String(cwd); + if (persist) entry.persist = String(parsePersist(persist).text); + for (const field of Object.keys(entry)) { + if (!ALLOWED_FIELDS.includes(field) || SECRET_FIELDS.test(field)) delete entry[field]; + } + const targets = readTargets(); + const replaced = Boolean(targets[name]); + targets[name] = entry; + writeTargets(targets); + return { name, ...entry, replaced }; +} + +export function removeTarget(name) { + const targets = readTargets(); + if (!targets[name]) return false; + delete targets[name]; + writeTargets(targets); + return true; +} + +/** One target, or null. */ +export function getTarget(name) { + const entry = readTargets()[String(name)]; + return entry ? { name: String(name), ...entry } : null; +} + +/** Every target, name first, in registry order. */ +export function listTargets() { + return Object.entries(readTargets()).map(([name, entry]) => ({ name, ...entry })); +} + +/* --------------------------------------------------------- durations */ + +const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i; + +/** + * "10m" → seconds. Bare numbers are seconds, which is also what ssh's own + * ControlPersist takes, so the value can be handed straight through. + */ +export function parsePersist(text) { + const m = DURATION_RE.exec(String(text ?? "").trim()); + if (!m) throw new Error(`ssh: ${JSON.stringify(String(text))} is not a duration — try 10m, 90s, 2h`); + const unit = (m[2] || "s").toLowerCase(); + const mult = { ms: 1 / 1000, s: 1, m: 60, h: 3600, d: 86400 }[unit]; + const seconds = Math.round(Number(m[1]) * mult); + if (seconds < 1) throw new Error(`ssh: a persist window under a second (${text}) would close the master before it is used`); + return { seconds, text: String(text).trim() }; +} + +/** "2m" → milliseconds, for --timeout. Bare numbers are seconds. */ +export function parseTimeout(text) { + if (text === undefined || text === null || text === "") return undefined; + const m = DURATION_RE.exec(String(text).trim()); + if (!m) throw new Error(`ssh: ${JSON.stringify(String(text))} is not a duration — try 30s, 2m, 1h`); + const unit = (m[2] || "s").toLowerCase(); + const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit]; + const ms = Math.round(Number(m[1]) * mult); + if (ms < 1) throw new Error(`ssh: --timeout ${text} is not a usable timeout`); + return ms; +} + +/** The persist window in effect: flag, then target, then env, then default. */ +export function persistFor(entry, flag, env = process.env) { + return parsePersist(flag || entry?.persist || env[PERSIST_ENV] || DEFAULT_PERSIST); +} + +/* --------------------------------------------------------- control socket */ + +/** + * Where the sockets go. ~/.moshcode/ssh/control unless the home directory is + * long enough to push the socket past sun_path (R16) — a NFS home like + * /net/filers/home/dept/anthony gets there — in which case a per-user + * directory under the OS temp dir. Overridable for tests and odd setups. + */ +export function controlDir() { + if (process.env.MOSHCODE_SSH_CONTROL_DIR) return process.env.MOSHCODE_SSH_CONTROL_DIR; + const preferred = path.join(sshDir(), "control"); + if (path.join(preferred, "x".repeat(12)).length <= MAX_SOCKET_PATH) return preferred; + const uid = typeof process.getuid === "function" ? process.getuid() : "u"; + return path.join(os.tmpdir(), `moshcode-ssh-${uid}`); +} + +/** + * The socket for a target: a short hash of the name and where it points, so + * `ssh add dev` pointing somewhere new never reuses the master for where it + * used to point, and the path never carries `user@host:/srv/app` (R16). + */ +export function controlPath(entry) { + const key = [entry.name, entry.target, entry.port || ""].join("\0"); + const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 12); + return path.join(controlDir(), hash); +} + +function ensureControlDir() { + return ensureDir(controlDir()); +} + +/* ------------------------------------------------------------- quoting */ + +/** + * POSIX single-quoting: the only escape is `'` → `'\''`, and everything else + * is literal — including `$`, backticks, newlines, and the glob characters + * a model puts into a `sed` expression. Exactly what a remote command built + * from argv needs (R68). + */ +export function shellQuote(arg) { + const s = String(arg); + if (s === "") return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s; + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** `cd` to a path that may start with `~`, which must stay outside the quotes to expand. */ +export function cdCommand(cwd) { + const p = String(cwd); + if (p === "~") return "cd"; + if (p.startsWith("~/")) return `cd -- ~/${shellQuote(p.slice(2))}`; + return `cd -- ${shellQuote(p)}`; +} + +/** + * The string ssh hands the remote login shell. Built as + * + * cd -- '/srv/app' && K='v' exec 'git' 'apply' '-' + * + * so the cwd, the environment, and the argv each arrive exactly as given. + * `exec` keeps the shell from lingering as a parent — signals reach the + * command, and the exit status is the command's, not sh's opinion of it. With + * `sh: true` the single argument is a shell snippet and is passed verbatim, + * which is the one place a caller can mean `a | b`; it is a flag, never a + * guess about whether the argv "looks like" a pipeline. + */ +export function remoteCommand(argv, { cwd, env = {}, sh = false } = {}) { + const parts = []; + if (cwd) parts.push(cdCommand(cwd)); + const assignments = Object.entries(env).map(([k, v]) => { + if (!ENV_KEY_RE.test(k)) throw new Error(`ssh: ${JSON.stringify(k)} is not an environment variable name`); + return `${k}=${shellQuote(v)}`; + }); + let command; + if (sh) { + if (argv.length !== 1) throw new Error("ssh: --sh takes exactly one argument, the shell snippet"); + command = assignments.length ? `export ${assignments.join(" ")} && ${argv[0]}` : String(argv[0]); + } else { + if (!argv.length) throw new Error("ssh: nothing to run — moshcode ssh exec -- [args…]"); + command = [...assignments, "exec", ...argv.map(shellQuote)].join(" "); + } + parts.push(command); + return parts.join(" && "); +} + +/** `--env K=V` pairs → object. */ +export function parseEnvPairs(pairs = []) { + const env = {}; + for (const pair of pairs) { + const i = String(pair).indexOf("="); + if (i < 1) throw new Error(`ssh: --env wants KEY=VALUE, got ${JSON.stringify(String(pair))}`); + const key = String(pair).slice(0, i); + if (!ENV_KEY_RE.test(key)) throw new Error(`ssh: ${JSON.stringify(key)} is not an environment variable name`); + env[key] = String(pair).slice(i + 1); + } + return env; +} + +/* ---------------------------------------------------------- invocations */ + +/** Options every ssh we spawn carries: which socket, and which config. */ +function baseOptions(entry, { env = process.env } = {}) { + const args = []; + if (env[CONFIG_ENV]) args.push("-F", env[CONFIG_ENV]); + args.push("-o", `ControlPath=${controlPath(entry)}`); + return args; +} + +/** `-p N` only when the registry says so; otherwise ssh_config decides. */ +function portArgs(entry) { + return entry.port ? ["-p", String(entry.port)] : []; +} + +/** + * Keepalive options, unless the user's own config already sets an interval + * (R14). `ssh -G` prints the effective configuration for a host without + * connecting, so this is a config parse, not a round trip. + */ +export function keepaliveArgs(entry, { runner = spawnSync, env = process.env } = {}) { + const probe = runner("ssh", [...(env[CONFIG_ENV] ? ["-F", env[CONFIG_ENV]] : []), "-G", ...portArgs(entry), entry.target], { + encoding: "utf8", env, timeout: 5000, + }); + const configured = /^serveraliveinterval\s+([1-9]\d*)/mi.test(String(probe?.stdout || "")); + if (configured) return []; + return Object.entries(KEEPALIVE).flatMap(([k, v]) => ["-o", `${k}=${v}`]); +} + +/** argv for `ssh -O ` against the target's socket. */ +export function controlArgs(entry, op, { env = process.env } = {}) { + return [...baseOptions(entry, { env }), "-O", op, ...portArgs(entry), entry.target]; +} + +/** + * argv for the master. `-N` (no command) and `-f` (background after auth), a + * finite ControlPersist, keepalives, and a connect timeout so a black-holed + * host answers in seconds rather than the kernel's minutes. `BatchMode=yes` + * only when nobody is at a terminal: it turns a password or passphrase prompt + * into a clean failure, which is right for an agent and wrong for a person + * who was about to type it (R70; see the PRD's open question). + */ +export function masterArgs(entry, { persist, batch, keepalive = [], env = process.env } = {}) { + const window = persistFor(entry, persist, env); + return [ + ...baseOptions(entry, { env }), + "-o", "ControlMaster=yes", + "-o", `ControlPersist=${window.seconds}`, + "-o", `ConnectTimeout=${CONNECT_TIMEOUT}`, + ...(batch ? ["-o", "BatchMode=yes"] : []), + ...keepalive, + ...portArgs(entry), + "-N", "-f", + entry.target, + ]; +} + +/** + * argv for one command over the master. `-T` — no PTY — is the default and + * the point: stdout and stderr stay separate, stdin stays binary, and nothing + * on the remote side thinks a person is watching (R20). `ControlMaster=auto` + * is the stale-socket fallback described at the top of the file. + */ +export function execArgs(entry, command, { tty = false, batch = true, persist, keepalive = [], env = process.env } = {}) { + const window = persistFor(entry, persist, env); + return [ + ...baseOptions(entry, { env }), + "-o", "ControlMaster=auto", + "-o", `ControlPersist=${window.seconds}`, + "-o", `ConnectTimeout=${CONNECT_TIMEOUT}`, + ...(batch ? ["-o", "BatchMode=yes"] : []), + ...keepalive, + ...portArgs(entry), + tty ? "-t" : "-T", + entry.target, + "--", + command, + ]; +} + +/** + * argv for an interactive session (R36–R40). ssh gets the terminal whole; the + * only thing added is a `cd` to the target's cwd, so `/ssh dev` lands where + * the work is. The login shell is the remote user's own — `$SHELL` there, not + * anything this side has an opinion about. + */ +export function attachArgs(entry, { persist, keepalive = [], env = process.env } = {}) { + const window = persistFor(entry, persist, env); + const args = [ + ...baseOptions(entry, { env }), + "-o", "ControlMaster=auto", + "-o", `ControlPersist=${window.seconds}`, + ...keepalive, + ...portArgs(entry), + ]; + if (entry.cwd) { + args.push("-t", entry.target, "--", `${cdCommand(entry.cwd)} && exec "\${SHELL:-sh}" -l`); + } else { + args.push(entry.target); + } + return args; +} + +/** + * argv for scp over the same socket. `-p` on scp is "preserve times", so the + * port is spelled `-P`; everything else rides on ControlPath and the config. + */ +export function scpArgs(entry, from, to, { env = process.env } = {}) { + const args = []; + if (env[CONFIG_ENV]) args.push("-F", env[CONFIG_ENV]); + args.push("-o", `ControlPath=${controlPath(entry)}`, "-o", "ControlMaster=auto", "-q"); + if (entry.port) args.push("-P", String(entry.port)); + return [...args, from, to]; +} + +/* --------------------------------------------------------------- results */ + +/** + * What a spawn result means, in the two words an agent needs: did the + * *transport* work, and what did the *command* say (R24, R25). ssh reserves + * exit 255 for its own failures — connect, host key, auth — and everything + * else is the remote command's own status. The cases that are neither + * (ssh not installed, our timeout) are named as such. + */ +export function classify(res) { + if (!res) return { transportOk: false, code: null, signal: null, error: "ssh did not run" }; + if (res.error?.code === "ENOENT") { + return { transportOk: false, code: null, signal: null, error: "ssh not found — install an OpenSSH client", missing: true }; + } + if (res.error?.code === "ETIMEDOUT") { + return { transportOk: true, code: null, signal: res.signal || "SIGTERM", error: "timed out", timedOut: true }; + } + if (res.error) { + return { transportOk: false, code: res.status ?? null, signal: res.signal || null, error: String(res.error.message || res.error) }; + } + if (res.status === 255) { + return { transportOk: false, code: 255, signal: null, error: transportError(res.stderr) }; + } + if (res.status === null && res.signal) { + return { transportOk: true, code: null, signal: res.signal, error: `killed by ${res.signal}` }; + } + return { transportOk: true, code: res.status ?? 0, signal: null, error: null }; +} + +/** ssh's last line of complaint, or a fallback, as a one-line reason. */ +function transportError(stderr) { + const lines = String(stderr || "").split("\n").map((l) => l.trim()).filter(Boolean) + .filter((l) => !/^Warning: Permanently added/.test(l)); + const last = lines.at(-1) || ""; + if (/Permission denied|no supported authentication|Too many authentication/i.test(last)) return `ssh authentication failed: ${last}`; + if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(String(stderr))) return "ssh host key verification failed"; + if (/Connection timed out|Operation timed out|Connection refused|Could not resolve|No route to host|Network is unreachable/i.test(last)) return `ssh could not connect: ${last}`; + return last ? `ssh failed: ${last}` : "ssh failed (exit 255)"; +} + +/* ---------------------------------------------------------------- debug */ + +/** + * One line per spawn to stderr when MOSHCODE_SSH_DEBUG is set. The remote + * command is summarised by length, not printed: it can carry `--env` values, + * and a debug log is exactly where a secret would otherwise end up (R63, R64, + * R69). stdin is never logged at all. + */ +export function debugLine(bin, args) { + const shown = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--") { shown.push("--", ``); break; } + shown.push(args[i]); + } + return `ssh▸ ${bin} ${shown.join(" ")}`; +} + +function debug(env, bin, args) { + if (env[DEBUG_ENV] && env[DEBUG_ENV] !== "0") process.stderr.write(`${debugLine(bin, args)}\n`); +} + +/* -------------------------------------------------------------- transport */ + +/** Resolve a target name, or explain why not. */ +export function resolveTarget(name) { + if (!validName(name)) return { error: `ssh: ${JSON.stringify(String(name))} is not a target name` }; + const entry = getTarget(name); + if (!entry) return { error: `ssh: no target named ${JSON.stringify(String(name))} — moshcode ssh add ${name} user@host` }; + return { entry }; +} + +/** + * Is the master alive? `ssh -O check` asks the socket, locally, and answers + * in a millisecond. A socket that exists but nobody answers on is stale — the + * master died, the box rebooted — and is unlinked here, because it is ours + * and because ssh's own fallback would otherwise print "already exists, + * disabling multiplexing" and quietly reconnect for every command (R15). + */ +export function checkMaster(entry, { runner = spawnSync, env = process.env } = {}) { + const socket = controlPath(entry); + const exists = fs.existsSync(socket); + if (!exists) return { connected: false, socket, stale: false }; + const args = controlArgs(entry, "check", { env }); + debug(env, "ssh", args); + const res = runner("ssh", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }); + if (res.status === 0) { + const pid = Number.parseInt(/pid=(\d+)/.exec(String(res.stderr || ""))?.[1], 10); + return { connected: true, socket, pid: Number.isInteger(pid) ? pid : null, stale: false }; + } + try { fs.unlinkSync(socket); } catch { /* gone already, or not ours */ } + return { connected: false, socket, stale: true }; +} + +/** + * Establish the master (R8–R10). Idempotent: a live master is reported as + * `alreadyOpen` and left alone. Prompts — a passphrase, a host key — reach + * the user through /dev/tty when there is one, which is why stdin is + * inherited and only the pipes are captured. + */ +export function openMaster(entry, { runner = spawnSync, env = process.env, persist, batch, stdin = process.stdin } = {}) { + const started = Date.now(); + const status = checkMaster(entry, { runner, env }); + if (status.connected) return { ok: true, target: entry.name, connected: true, alreadyOpen: true, pid: status.pid, socket: status.socket, durationMs: Date.now() - started }; + ensureControlDir(); + const headless = batch ?? !stdin?.isTTY; + const keepalive = keepaliveArgs(entry, { runner, env }); + const args = masterArgs(entry, { persist, batch: headless, keepalive, env }); + debug(env, "ssh", args); + const res = runner("ssh", args, { encoding: "utf8", env, stdio: [headless ? "ignore" : "inherit", "pipe", "pipe"], timeout: 120_000 }); + const verdict = classify(res); + if (!verdict.transportOk || (res.status ?? 0) !== 0) { + return { + ok: false, target: entry.name, connected: false, alreadyOpen: false, + error: verdict.error || transportError(res.stderr), stderr: String(res.stderr || ""), durationMs: Date.now() - started, + }; + } + const after = checkMaster(entry, { runner, env }); + return { + ok: after.connected, target: entry.name, connected: after.connected, alreadyOpen: false, + pid: after.pid ?? null, socket: after.socket, durationMs: Date.now() - started, + ...(after.connected ? {} : { error: "ssh returned but no master is answering on the control socket" }), + }; +} + +/** + * Close the master with `ssh -O exit` (R12). Nothing here knows or kills a + * PID: the master is told to leave, and it takes its socket with it. + */ +export function closeMaster(entry, { runner = spawnSync, env = process.env } = {}) { + const status = checkMaster(entry, { runner, env }); + if (!status.connected) return { ok: true, target: entry.name, closed: false, wasOpen: false, stale: status.stale }; + const args = controlArgs(entry, "exit", { env }); + debug(env, "ssh", args); + const res = runner("ssh", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }); + const gone = !checkMaster(entry, { runner, env }).connected; + return { + ok: res.status === 0 && gone, target: entry.name, closed: gone, wasOpen: true, + ...(res.status === 0 && gone ? {} : { error: transportError(res.stderr) }), + }; +} + +/** + * Run one command over the target's master (R18–R32). + * + * The shape of the answer is the whole feature: `ok` is the command's verdict, + * `transportOk` is ssh's, `code`/`signal` are the remote exit, stdout and + * stderr are separate strings, and `durationMs` is wall time. A `grep` that + * found nothing is `ok: false, transportOk: true, code: 1` — a fact about the + * files, not about the network — and an agent branching on the difference is + * why the two fields exist. + * + * Recovery (R15): a master that is not answering is reopened before the + * command runs; a transport failure on a master that *was* answering gets the + * socket re-checked and the command retried once. No retry on a command that + * merely failed, and none on a timeout — the remote side may have done the + * work, and doing it twice is worse than reporting it once. + */ +export function exec(entry, argv, { + cwd, remoteEnv = {}, stdin, tty = false, sh = false, timeoutMs, persist, batch, + runner = spawnSync, env = process.env, retry = true, +} = {}) { + const started = Date.now(); + const finish = (fields) => ({ target: entry.name, ...fields, durationMs: Date.now() - started }); + + // `cwd: null` means "no cd at all" — for the tmux verbs and put/get's + // rename, which address absolute things and must not fail because the + // target's cwd happens not to exist yet. Undefined means the target's cwd. + const where = cwd === null ? undefined : (cwd ?? entry.cwd); + let command; + try { command = remoteCommand(argv, { cwd: where, env: remoteEnv, sh }); } + catch (e) { return finish({ ok: false, transportOk: false, connected: false, code: null, signal: null, stdout: "", stderr: "", error: e.message }); } + + let status = checkMaster(entry, { runner, env }); + let opened = false; + if (!status.connected) { + const open = openMaster(entry, { runner, env, persist, batch }); + if (!open.ok) { + return finish({ ok: false, transportOk: false, connected: false, code: 255, signal: null, stdout: "", stderr: open.stderr || "", error: open.error, opened: false }); + } + opened = true; + status = { connected: true }; + } + + const headless = batch ?? (tty ? !process.stdin?.isTTY : true); + const args = execArgs(entry, command, { tty, batch: headless, persist, env }); + const input = stdin === undefined || stdin === null ? undefined : (Buffer.isBuffer(stdin) ? stdin : Buffer.from(String(stdin))); + const run = () => { + debug(env, "ssh", args); + const options = { env, maxBuffer: MAX_OUTPUT, killSignal: "SIGTERM" }; + if (timeoutMs) options.timeout = timeoutMs; + if (tty) { + // A terminal command owns the terminal; there is nothing to capture. + options.stdio = "inherit"; + } else if (input !== undefined) { + options.input = input; + } else { + options.stdio = ["ignore", "pipe", "pipe"]; + } + const res = runner("ssh", args, options); + return { res, verdict: classify(res) }; + }; + + let { res, verdict } = run(); + let retried = false; + if (!verdict.transportOk && !verdict.missing && retry && !opened) { + // The master answered a moment ago and the command still failed at the + // transport: it died in between. Re-check (which unlinks a stale socket), + // reopen, and try the command once more. + const again = checkMaster(entry, { runner, env }); + const open = again.connected ? { ok: true } : openMaster(entry, { runner, env, persist, batch }); + if (open.ok) { ({ res, verdict } = run()); retried = true; } + } + + const stdout = tty ? "" : bufferToString(res.stdout); + const stderr = tty ? "" : bufferToString(res.stderr); + return finish({ + ok: verdict.transportOk && verdict.code === 0, + transportOk: verdict.transportOk, + connected: verdict.transportOk || status.connected, + code: verdict.code, + signal: verdict.signal, + stdout, + stderr, + ...(verdict.error ? { error: verdict.error } : {}), + ...(verdict.timedOut ? { timedOut: true } : {}), + ...(opened ? { opened: true } : {}), + ...(retried ? { retried: true } : {}), + ...(tty ? { tty: true } : {}), + }); +} + +const bufferToString = (b) => (b == null ? "" : Buffer.isBuffer(b) ? b.toString("utf8") : String(b)); + +/** Hand the terminal to ssh (R36–R40). Returns the exit code. */ +export function attach(entry, { runner = spawnSync, env = process.env, persist } = {}) { + const status = checkMaster(entry, { runner, env }); + if (!status.connected) ensureControlDir(); + const keepalive = status.connected ? [] : keepaliveArgs(entry, { runner, env }); + const args = attachArgs(entry, { persist, keepalive, env }); + debug(env, "ssh", args); + const res = runner("ssh", args, { stdio: "inherit", env }); + if (res.error?.code === "ENOENT") return { ok: false, code: 127, error: "ssh not found — install an OpenSSH client" }; + return { ok: res.status === 0, code: res.status ?? 1, signal: res.signal || null }; +} + +/* ------------------------------------------------------------- transfer */ + +/** + * Copy a local file up, atomically (R33, R34): scp to a sibling temp path + * over the shared master, then `mv` it into place with one exec. A reader on + * the remote side sees the old file or the new one, never a half-written one. + */ +export function put(entry, local, remote, { runner = spawnSync, env = process.env, persist, batch } = {}) { + const started = Date.now(); + if (!fs.existsSync(local)) return { ok: false, target: entry.name, error: `no such local file: ${local}`, durationMs: 0 }; + const status = checkMaster(entry, { runner, env }); + if (!status.connected) { + const open = openMaster(entry, { runner, env, persist, batch }); + if (!open.ok) return { ok: false, target: entry.name, transportOk: false, error: open.error, durationMs: Date.now() - started }; + } + const dest = remotePath(entry, remote); + const tmp = `${dest}.moshcode-${process.pid}-${Date.now()}.tmp`; + const args = scpArgs(entry, local, `${entry.target}:${tmp}`, { env }); + debug(env, "scp", args); + const res = runner("scp", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_OUTPUT }); + const verdict = classify(res); + if (!verdict.transportOk || verdict.code !== 0) { + return { ok: false, target: entry.name, transportOk: verdict.transportOk, code: verdict.code, error: verdict.error || transportError(res.stderr) || "scp failed", stderr: String(res.stderr || ""), durationMs: Date.now() - started }; + } + const moved = exec(entry, ["mv", "-f", "--", tmp, dest], { runner, env, cwd: null, retry: false }); + if (!moved.ok) { + exec(entry, ["rm", "-f", "--", tmp], { runner, env, cwd: null, retry: false }); + return { ok: false, target: entry.name, transportOk: moved.transportOk, code: moved.code, error: moved.error || moved.stderr.trim() || "rename failed", durationMs: Date.now() - started }; + } + return { ok: true, target: entry.name, transportOk: true, local, remote: dest, durationMs: Date.now() - started }; +} + +/** Copy a remote file down over the shared master. */ +export function get(entry, remote, local, { runner = spawnSync, env = process.env, persist, batch } = {}) { + const started = Date.now(); + const status = checkMaster(entry, { runner, env }); + if (!status.connected) { + const open = openMaster(entry, { runner, env, persist, batch }); + if (!open.ok) return { ok: false, target: entry.name, transportOk: false, error: open.error, durationMs: Date.now() - started }; + } + const src = remotePath(entry, remote); + const args = scpArgs(entry, `${entry.target}:${src}`, local, { env }); + debug(env, "scp", args); + const res = runner("scp", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_OUTPUT }); + const verdict = classify(res); + if (!verdict.transportOk || verdict.code !== 0) { + return { ok: false, target: entry.name, transportOk: verdict.transportOk, code: verdict.code, error: verdict.error || transportError(res.stderr) || "scp failed", stderr: String(res.stderr || ""), durationMs: Date.now() - started }; + } + return { ok: true, target: entry.name, transportOk: true, remote: src, local, durationMs: Date.now() - started }; +} + +/** + * A relative remote path is relative to the target's cwd, the way `exec` is. + * scp has no cwd of its own, so the join happens here; `~` is left for the + * remote shell, which scp hands paths to. + */ +export function remotePath(entry, p) { + const s = String(p); + if (s.startsWith("/") || s.startsWith("~") || !entry.cwd) return s; + return `${entry.cwd.replace(/\/+$/, "")}/${s}`; +} + +/* ------------------------------------------------------- remote shells */ + +/** The remote tmux session name for `/` (R42). */ +export function remoteSessionName(entry, session) { + return `moshcode-ssh-${entry.name}-${session}`; +} + +/** `dev/app` → { name: "dev", session: "app" }, or an error. */ +export function parseSessionRef(ref) { + const [name, session, ...rest] = String(ref || "").split("/"); + if (!name || !session || rest.length) return { error: `ssh: a shell is named /, got ${JSON.stringify(String(ref))}` }; + if (!validName(name)) return { error: `ssh: ${JSON.stringify(name)} is not a target name` }; + if (!SESSION_RE.test(session)) return { error: `ssh: ${JSON.stringify(session)} is not a session name — lowercase letters, digits, - and _` }; + return { name, session }; +} + +/** Is tmux on the remote box? One exec, cached nowhere — it is cheap over the master. */ +export function remoteHasTmux(entry, opts = {}) { + const r = exec(entry, ["tmux", "-V"], { ...opts, cwd: null }); + if (!r.transportOk) return { ok: false, has: false, error: r.error }; + return { ok: true, has: r.ok, version: r.ok ? r.stdout.trim() : null }; +} + +const NO_TMUX = (entry) => `ssh: tmux is not installed on ${entry.name} — a persistent shell needs it; moshcode ssh exec still works, and so does moshcode ssh ${entry.name}`; + +/** + * Create-or-attach a persistent remote shell (R41–R43). `tmux new-session -A` + * attaches when the session exists and creates it when it does not, in one + * word; the session lives on the remote box under tmux's own server and + * survives this terminal, this master, and this laptop's lid. + */ +export function shellAttach(entry, session, { runner = spawnSync, env = process.env, persist } = {}) { + const probe = remoteHasTmux(entry, { runner, env, persist }); + if (!probe.ok) return { ok: false, code: 255, error: probe.error }; + if (!probe.has) return { ok: false, code: 1, error: NO_TMUX(entry) }; + const name = remoteSessionName(entry, session); + const tmuxCmd = ["tmux", "new-session", "-A", "-s", name, ...(entry.cwd ? ["-c", entry.cwd] : [])]; + const command = tmuxCmd.map((a, i) => (i === tmuxCmd.length - 1 && entry.cwd ? tildeQuote(a) : shellQuote(a))).join(" "); + const status = checkMaster(entry, { runner, env }); + const keepalive = status.connected ? [] : keepaliveArgs(entry, { runner, env }); + const args = execArgs(entry, command, { tty: true, batch: false, persist, keepalive, env }); + debug(env, "ssh", args); + const res = runner("ssh", args, { stdio: "inherit", env }); + return { ok: res.status === 0, code: res.status ?? 1, session: name }; +} + +/** Like shellQuote, but a leading `~/` stays outside the quotes so the remote shell expands it. */ +function tildeQuote(p) { + const s = String(p); + if (s === "~") return "~"; + if (s.startsWith("~/")) return `~/${shellQuote(s.slice(2))}`; + return shellQuote(s); +} + +/** + * Type into a remote shell without attaching (R44, R45). Two send-keys: the + * text literally (`-l`, so `pnpm test` is five keystrokes and a space, not a + * key name), then Enter. The herd's model exactly, one hop further away. + */ +export function shellSend(entry, session, text, opts = {}) { + const name = remoteSessionName(entry, session); + const r = exec(entry, ["tmux", "send-keys", "-t", name, "-l", "--", String(text)], { ...opts, cwd: null }); + if (!r.ok) return sessionFailure(entry, session, r); + const enter = exec(entry, ["tmux", "send-keys", "-t", name, "Enter"], { ...opts, cwd: null, retry: false }); + if (!enter.ok) return sessionFailure(entry, session, enter); + return { ok: true, target: entry.name, session, sent: String(text) }; +} + +/** The screen of a remote shell, as text (R46). */ +export function shellRead(entry, session, { lines = 60, ...opts } = {}) { + const name = remoteSessionName(entry, session); + const n = Math.max(1, Number.parseInt(lines, 10) || 60); + const r = exec(entry, ["tmux", "capture-pane", "-p", "-t", name, "-S", `-${n}`], { ...opts, cwd: null }); + if (!r.ok) return sessionFailure(entry, session, r); + return { ok: true, target: entry.name, session, screen: r.stdout.replace(/\s+$/, "") }; +} + +/** End a remote shell and everything in it. */ +export function shellKill(entry, session, opts = {}) { + const name = remoteSessionName(entry, session); + const r = exec(entry, ["tmux", "kill-session", "-t", name], { ...opts, cwd: null }); + if (!r.ok) return sessionFailure(entry, session, r); + return { ok: true, target: entry.name, session, killed: true }; +} + +/** Every moshcode shell on the target. */ +export function shellList(entry, opts = {}) { + const prefix = `moshcode-ssh-${entry.name}-`; + // Space-separated, not tab: a literal tab does not survive the trip through + // the remote login shell intact, and session names cannot contain a space. + const r = exec(entry, ["tmux", "list-sessions", "-F", "#{session_name} #{session_created} #{session_attached}"], { ...opts, cwd: null }); + if (!r.transportOk) return { ok: false, target: entry.name, error: r.error, sessions: [] }; + if (r.code === 127) return { ok: false, target: entry.name, error: NO_TMUX(entry), sessions: [] }; + // "no server running" is tmux's way of saying zero sessions, and exits 1. + if (!r.ok && !/no server running|no sessions/i.test(r.stderr)) return { ok: false, target: entry.name, error: r.stderr.trim() || "tmux list-sessions failed", sessions: [] }; + const sessions = r.stdout.split("\n").filter((l) => l.startsWith(prefix)).map((l) => { + const [name, created, attached] = l.split(" "); + return { session: name.slice(prefix.length), created: Number(created) * 1000 || null, attached: attached === "1" }; + }); + return { ok: true, target: entry.name, sessions }; +} + +function sessionFailure(entry, session, r) { + if (!r.transportOk) return { ok: false, target: entry.name, session, transportOk: false, error: r.error }; + if (r.code === 127) return { ok: false, target: entry.name, session, transportOk: true, error: NO_TMUX(entry) }; + // tmux's spellings for "that session is not there" vary with what else the + // server has: with other sessions it cannot find this one; with none it + // has no current target; with no server it says so. + if (/can't find (session|pane|window)|no current target|no server running|session not found/i.test(r.stderr)) { + return { ok: false, target: entry.name, session, transportOk: true, error: `ssh: no shell ${entry.name}/${session} — moshcode ssh shell ${entry.name} --name ${session} starts one` }; + } + return { ok: false, target: entry.name, session, transportOk: true, code: r.code, error: r.stderr.trim() || r.error || "tmux failed" }; +} + +/* ---------------------------------------------------------------- bench */ + +/** + * The number the feature exists for, measured rather than claimed: N fresh + * connections against N commands over one master, on this host, now. + */ +export function bench(entry, { n = 20, runner = spawnSync, env = process.env, persist, batch } = {}) { + const count = Math.max(1, Number.parseInt(n, 10) || 20); + const timings = (fn) => { + const samples = []; + let failures = 0; + for (let i = 0; i < count; i++) { + const t = process.hrtime.bigint(); + if (!fn()) failures++; + samples.push(Number(process.hrtime.bigint() - t) / 1e6); + } + const sorted = [...samples].sort((a, b) => a - b); + const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; + return { runs: count, failures, totalMs: Math.round(samples.reduce((a, b) => a + b, 0)), medianMs: round(at(0.5)), p95Ms: round(at(0.95)) }; + }; + const command = remoteCommand(["true"], {}); + const fresh = () => { + const args = [ + ...(env[CONFIG_ENV] ? ["-F", env[CONFIG_ENV]] : []), + "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "BatchMode=yes", "-o", `ConnectTimeout=${CONNECT_TIMEOUT}`, + ...portArgs(entry), "-T", entry.target, "--", command, + ]; + const res = runner("ssh", args, { env, stdio: ["ignore", "pipe", "pipe"] }); + return res.status === 0; + }; + const open = openMaster(entry, { runner, env, persist, batch }); + if (!open.ok) return { ok: false, target: entry.name, error: open.error }; + const muxed = () => exec(entry, ["true"], { runner, env, cwd: null, retry: false }).ok; + const freshStats = timings(fresh); + const muxedStats = timings(muxed); + return { + ok: true, target: entry.name, fresh: freshStats, multiplexed: muxedStats, + speedup: muxedStats.medianMs > 0 ? round(freshStats.medianMs / muxedStats.medianMs) : null, + authentications: { fresh: count, multiplexed: open.alreadyOpen ? 0 : 1 }, + }; +} + +const round = (n) => Math.round(n * 10) / 10; + +/* ----------------------------------------------------------------- CLI */ + +const VERBS = ["list", "ls", "add", "remove", "rm", "show", "open", "check", "close", "exec", "put", "get", "shell", "bench", "help"]; + +/** Does this argv end up handing the terminal to ssh? The pit closes readline around those. */ +export function takesTerminal(argv = []) { + const verb = String(argv[0] || ""); + if (!verb || VERBS.includes(verb)) { + if (verb === "exec") return argv.includes("--tty"); + if (verb === "shell") return !["send", "read", "kill", "list", "ls"].includes(String(argv[1] || "")); + return false; + } + return true; // `moshcode ssh ` attaches +} + +/** + * Split flags from positionals. `valued` flags take the next word (or + * `--flag=value`); `repeat` flags collect; everything else is a boolean. `--` + * ends flag parsing and the rest is the command — which is what makes + * `moshcode ssh exec dev -- git log --oneline` hand `--oneline` to git. + */ +export function parseArgs(argv, { valued = [], repeat = [] } = {}) { + const flags = {}; + const positional = []; + let rest = null; + for (let i = 0; i < argv.length; i++) { + const a = String(argv[i]); + if (rest) { rest.push(a); continue; } + if (a === "--") { rest = []; continue; } + if (a.startsWith("--") && a.length > 2) { + const eq = a.indexOf("="); + const key = (eq > 0 ? a.slice(2, eq) : a.slice(2)); + if (valued.includes(key) || repeat.includes(key)) { + const value = eq > 0 ? a.slice(eq + 1) : argv[++i]; + if (value === undefined) throw new Error(`ssh: --${key} needs a value`); + if (repeat.includes(key)) (flags[key] ||= []).push(String(value)); + else flags[key] = String(value); + } else { + flags[key] = true; + } + continue; + } + positional.push(a); + } + return { flags, positional, rest }; +} + +const USAGE = [ + "usage:", + " moshcode ssh targets and whether each is connected", + " moshcode ssh add [--port N] [--cwd PATH] [--persist 10m]", + " moshcode ssh remove · show ", + " moshcode ssh open [--persist 10m] · check · close ", + " moshcode ssh an interactive shell over the shared connection", + " moshcode ssh exec [--cwd PATH] [--env K=V] [--stdin] [--tty] [--timeout 2m] [--sh] -- ", + " moshcode ssh put · get ", + " moshcode ssh shell --name · shell send|read|kill /", + " moshcode ssh bench [--n 20]", + " --json on any of the above that does not take the terminal", +]; + +/** + * The CLI: `moshcode ssh …` and the pit's `/ssh …`. Returns the exit code. + * Every verb that can answer in JSON does under --json, and the JSON is the + * same object the moshscript helpers return. + */ +export async function sshCommand(rawArgv = [], { write = console.log, writeErr = (l) => console.error(l), env = process.env, stdin = process.stdin, runner = spawnSync } = {}) { + // `--json` is global — `moshcode ssh --json` and `moshcode ssh list --json` + // are the same question — so it is lifted out before the verb is read. + // Only up to `--`: after that the words belong to the remote command. + const json = argv0Has(rawArgv, "--json"); + const argv = stripGlobal(rawArgv, "--json"); + const first = String(argv[0] ?? ""); + const emit = (obj) => { write(JSON.stringify(obj, null, 2)); }; + + try { + if (!first || first === "list" || first === "ls") return listCommand({ json, write, env, runner }); + if (first === "help" || first === "--help" || first === "-h") { USAGE.forEach(write); return 0; } + + if (first === "add") { + const { flags, positional } = parseArgs(argv.slice(1), { valued: ["port", "cwd", "persist"] }); + const [name, target] = positional; + if (!name || !target) { writeErr(err("moshcode ssh add [--port N] [--cwd PATH]")); return 2; } + const added = addTarget(name, target, flags); + if (json) emit({ ok: true, ...added }); + else write(ok(`${bone(added.name)} → ${added.target}${added.port ? `:${added.port}` : ""}${added.cwd ? ash(` ${added.cwd}`) : ""}${added.replaced ? ash(" (replaced)") : ""}`)); + return 0; + } + if (first === "remove" || first === "rm") { + const { positional } = parseArgs(argv.slice(1)); + const name = positional[0]; + if (!name) { writeErr(err("moshcode ssh remove ")); return 2; } + const entry = getTarget(name); + if (entry) closeMaster(entry, { runner, env }); + const removed = removeTarget(name); + if (json) emit({ ok: removed, name, removed }); + else write(removed ? ok(`forgot ${bone(name)}`) : warn(`no target named ${name}`)); + return removed ? 0 : 1; + } + if (first === "show") { + const { positional } = parseArgs(argv.slice(1)); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const status = checkMaster(found.entry, { runner, env }); + const row = { ...found.entry, connected: status.connected, socket: status.socket, pid: status.pid ?? null }; + if (json) emit({ ok: true, ...row }); + else { + write(`${bone(row.name)} ${row.target}${row.port ? `:${row.port}` : ""}`); + write(ash(` cwd ${row.cwd || "(remote default)"}`)); + write(ash(` persist ${row.persist || env[PERSIST_ENV] || DEFAULT_PERSIST}`)); + write(ash(` state ${row.connected ? "connected" : "closed"}${row.pid ? ` (master pid ${row.pid})` : ""}`)); + write(ash(` socket ${row.socket}`)); + } + return 0; + } + + if (first === "open" || first === "check" || first === "close") { + const { flags, positional } = parseArgs(argv.slice(1), { valued: ["persist"] }); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const { entry } = found; + if (first === "open") { + const r = openMaster(entry, { runner, env, persist: flags.persist, batch: flags.batch ? true : undefined, stdin }); + if (json) emit(r); + else if (r.ok) write(ok(`${bone(entry.name)} ${r.alreadyOpen ? "already connected" : "connected"}${r.pid ? ash(` (master pid ${r.pid})`) : ""}`)); + else { writeErr(err(`${entry.name}: ${r.error}`)); if (r.stderr?.trim() && !json) writeErr(ash(r.stderr.trim())); } + return r.ok ? 0 : 1; + } + if (first === "check") { + const status = checkMaster(entry, { runner, env }); + if (json) emit({ ok: true, target: entry.name, connected: status.connected, stale: status.stale, pid: status.pid ?? null, socket: status.socket }); + else write(status.connected ? ok(`${bone(entry.name)} connected${status.pid ? ash(` (master pid ${status.pid})`) : ""}`) : info(`${bone(entry.name)} closed${status.stale ? ash(" (a stale socket was cleaned up)") : ""}`)); + return status.connected ? 0 : 1; + } + const r = closeMaster(entry, { runner, env }); + if (json) emit(r); + else if (!r.ok) writeErr(err(`${entry.name}: ${r.error}`)); + else write(r.wasOpen ? ok(`${bone(entry.name)} closed`) : info(`${bone(entry.name)} was not connected`)); + return r.ok ? 0 : 1; + } + + if (first === "exec") { + const { flags, positional, rest } = parseArgs(argv.slice(1), { valued: ["cwd", "timeout", "persist"], repeat: ["env"] }); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + // Without `--`, everything after the name is the command. `--` is still + // the safe spelling: it is the only way to hand the command a flag this + // parser would otherwise claim. + const command = rest ?? positional.slice(1); + if (!command.length) { writeErr(err("moshcode ssh exec [flags] -- [args…]")); return 2; } + const input = flags.stdin ? readAllStdin(stdin) : undefined; + const r = exec(found.entry, command, { + cwd: flags.cwd, remoteEnv: parseEnvPairs(flags.env || []), stdin: input, tty: Boolean(flags.tty), sh: Boolean(flags.sh), + timeoutMs: parseTimeout(flags.timeout), persist: flags.persist, batch: flags.batch ? true : undefined, runner, env, + }); + if (json) { emit(r); return exitCodeFor(r); } + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + if (r.error && !r.transportOk) writeErr(err(`${found.entry.name}: ${r.error}`)); + else if (r.timedOut) writeErr(err(`${found.entry.name}: timed out after ${flags.timeout}`)); + return exitCodeFor(r); + } + + if (first === "put" || first === "get") { + const { flags, positional } = parseArgs(argv.slice(1), { valued: ["persist"] }); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const [, a, b] = positional; + if (!a || !b) { writeErr(err(first === "put" ? "moshcode ssh put " : "moshcode ssh get ")); return 2; } + const r = first === "put" + ? put(found.entry, a, b, { runner, env, persist: flags.persist }) + : get(found.entry, a, b, { runner, env, persist: flags.persist }); + if (json) emit(r); + else if (r.ok) write(ok(first === "put" ? `${a} → ${bone(found.entry.name)}:${r.remote}` : `${bone(found.entry.name)}:${r.remote} → ${r.local}`)); + else writeErr(err(`${found.entry.name}: ${r.error}`)); + return r.ok ? 0 : 1; + } + + if (first === "shell") return shellCommand(argv.slice(1), { json, write, writeErr, env, runner, emit }); + + if (first === "bench") { + const { flags, positional } = parseArgs(argv.slice(1), { valued: ["n", "persist"] }); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const r = bench(found.entry, { n: flags.n, runner, env, persist: flags.persist }); + if (json) emit(r); + else if (!r.ok) writeErr(err(`${found.entry.name}: ${r.error}`)); + else { + write(table([ + ["fresh connection", r.fresh.runs, r.fresh.totalMs, r.fresh.medianMs, r.fresh.p95Ms, r.authentications.fresh, r.fresh.failures], + ["over one master", r.multiplexed.runs, r.multiplexed.totalMs, r.multiplexed.medianMs, r.multiplexed.p95Ms, r.authentications.multiplexed, r.multiplexed.failures], + ], { columns: ["", "runs", "total ms", "median ms", "p95 ms", "auths", "failed"] })); + if (r.speedup) write(ash(` median is ${r.speedup}× faster over the master, on ${found.entry.target} from here`)); + } + return r.ok ? 0 : 1; + } + + // Anything else is a target name: attach. + if (first.startsWith("-")) { writeErr(err(`ssh: unknown flag ${first}`)); USAGE.forEach(writeErr); return 2; } + const found = resolveTarget(first); + if (found.error) { + writeErr(err(found.error)); + if (!VERBS.includes(first)) USAGE.forEach(writeErr); + return 1; + } + const { flags } = parseArgs(argv.slice(1), { valued: ["persist"] }); + const r = attach(found.entry, { runner, env, persist: flags.persist }); + if (r.error) writeErr(err(r.error)); + return r.code; + } catch (e) { + if (json) { emit({ ok: false, error: e.message }); return 1; } + writeErr(err(e.message)); + return 1; + } +} + +/** Is `flag` among the words before `--`? */ +function argv0Has(argv, flag) { + for (const a of argv) { + if (a === "--") return false; + if (a === flag) return true; + } + return false; +} + +/** The argv without `flag`, leaving everything after `--` untouched. */ +function stripGlobal(argv, flag) { + const out = []; + let passthrough = false; + for (const a of argv) { + if (passthrough) { out.push(a); continue; } + if (a === "--") { passthrough = true; out.push(a); continue; } + if (a !== flag) out.push(a); + } + return out; +} + +function exitCodeFor(r) { + if (r.ok) return 0; + if (!r.transportOk) return 255; + if (r.timedOut) return 124; + if (typeof r.code === "number") return r.code; + return 1; +} + +function readAllStdin(stdin) { + try { return fs.readFileSync(stdin?.fd ?? 0); } catch { return Buffer.alloc(0); } +} + +function listCommand({ json, write, env, runner }) { + const targets = listTargets(); + const rows = targets.map((entry) => { + const status = checkMaster(entry, { runner, env }); + return { name: entry.name, target: entry.target, port: entry.port ?? null, cwd: entry.cwd ?? null, connected: status.connected }; + }); + if (json) { write(JSON.stringify({ targets: rows }, null, 2)); return 0; } + if (!rows.length) { + write(info("no ssh targets yet — moshcode ssh add user@host [--cwd /srv/app]")); + return 0; + } + write(table(rows.map((r) => [ + bone(r.name), r.target + (r.port ? `:${r.port}` : ""), r.connected ? ok("connected") : ash("closed"), r.cwd || ash("—"), + ]), { columns: ["name", "target", "state", "cwd"] })); + return 0; +} + +async function shellCommand(argv, { json, write, writeErr, env, runner, emit }) { + const sub = String(argv[0] || ""); + if (["send", "read", "kill", "list", "ls"].includes(sub)) { + const { flags, positional } = parseArgs(argv.slice(1), { valued: ["lines"] }); + if (sub === "list" || sub === "ls") { + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const r = shellList(found.entry, { runner, env }); + if (json) emit(r); + else if (!r.ok) writeErr(err(r.error)); + else if (!r.sessions.length) write(info(`no shells on ${found.entry.name} — moshcode ssh shell ${found.entry.name} --name app starts one`)); + else write(table(r.sessions.map((s) => [`${found.entry.name}/${s.session}`, s.attached ? "attached" : "detached"]), { columns: ["shell", "state"] })); + return r.ok ? 0 : 1; + } + const ref = parseSessionRef(positional[0]); + if (ref.error) { writeErr(err(ref.error)); return 2; } + const found = resolveTarget(ref.name); + if (found.error) { writeErr(err(found.error)); return 1; } + let r; + if (sub === "send") { + const text = positional.slice(1).join(" "); + if (!text) { writeErr(err("moshcode ssh shell send / ")); return 2; } + r = shellSend(found.entry, ref.session, text, { runner, env }); + } else if (sub === "read") { + r = shellRead(found.entry, ref.session, { lines: flags.lines, runner, env }); + } else { + r = shellKill(found.entry, ref.session, { runner, env }); + } + if (json) emit(r); + else if (!r.ok) writeErr(err(r.error)); + else if (sub === "read") write(r.screen); + else write(ok(sub === "send" ? `sent to ${bone(`${ref.name}/${ref.session}`)}` : `killed ${bone(`${ref.name}/${ref.session}`)}`)); + return r.ok ? 0 : 1; + } + const { flags, positional } = parseArgs(argv, { valued: ["name", "persist"] }); + const found = resolveTarget(positional[0]); + if (found.error) { writeErr(err(found.error)); return 1; } + const session = String(flags.name || positional[1] || "main"); + if (!SESSION_RE.test(session)) { writeErr(err(`ssh: ${JSON.stringify(session)} is not a session name`)); return 2; } + const r = shellAttach(found.entry, session, { runner, env, persist: flags.persist }); + if (!r.ok && r.error) writeErr(err(r.error)); + return r.code; +} diff --git a/src/tui.mjs b/src/tui.mjs index fd1e6f4d..b5f586b6 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -1024,6 +1024,20 @@ export async function tui() { rl = mkrl(); continue; } + // SSH workspaces (PRD 0013). `/ssh dev`, `/ssh exec --tty` and `/ssh + // shell` hand the terminal to ssh the way /attach does; every other verb + // answers in place and the prompt stays. + if (cmd === "ssh") { + const { sshCommand, takesTerminal } = await import("./ssh.mjs"); + if (takesTerminal(rest)) { + rl.close(); + await sshCommand(rest); + rl = mkrl(); + } else { + await sshCommand(rest); + } + continue; + } if (cmd === "agents" || cmd === "agent" || cmd === "engines") { if (!rest[0] || (rest.length === 1 && rest[0] === "--json")) { printEngines(rest[0] === "--json"); diff --git a/test/ssh-sshd.test.mjs b/test/ssh-sshd.test.mjs new file mode 100644 index 00000000..ef893729 --- /dev/null +++ b/test/ssh-sshd.test.mjs @@ -0,0 +1,398 @@ +// SSH workspaces against a real sshd (PRD 0013, test plan "Integration"). +// +// An ephemeral, non-root sshd on a loopback port with keys generated for this +// run, and a private ssh_config the module is pointed at through +// MOSHCODE_SSH_CONFIG — so the target is an ssh_config alias (R5), the host +// key goes into a throwaway known_hosts, and nothing here touches ~/.ssh. +// +// Skipped, not failed, where sshd or ssh-keygen is missing or the daemon +// cannot bind: the module is still covered by test/ssh.test.mjs, and a CI +// image without an ssh server is not a bug in moshcode. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { + addTarget, bench, checkMaster, closeMaster, controlPath, exec, get, getTarget, openMaster, put, remoteHasTmux, + shellKill, shellList, shellRead, shellSend, sshCommand, +} from "../src/ssh.mjs"; + +const SSHD = ["/usr/sbin/sshd", "/usr/local/sbin/sshd", "/opt/homebrew/sbin/sshd"].find((p) => fs.existsSync(p)); +const have = (bin) => spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0; +const CAN_RUN = Boolean(SSHD) && have("ssh-keygen") && have("ssh") && process.platform !== "win32" && !process.env.MOSHCODE_SKIP_SSHD_TESTS; + +const freePort = () => new Promise((resolve, reject) => { + const s = net.createServer(); + s.on("error", reject); + s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); }); +}); + +const waitFor = async (fn, { tries = 50, everyMs = 100 } = {}) => { + for (let i = 0; i < tries; i++) { + if (await fn()) return true; + await new Promise((r) => setTimeout(r, everyMs)); + } + return false; +}; + +/** Everything an sshd needs, in one temp dir, torn down together. */ +async function startSshd() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-sshd-")); + fs.chmodSync(dir, 0o700); + const gen = (name) => spawnSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", path.join(dir, name)], { stdio: "ignore" }); + gen("hostkey"); + gen("clientkey"); + gen("otherkey"); // authorised nowhere — the auth-failure case + fs.copyFileSync(path.join(dir, "clientkey.pub"), path.join(dir, "authorized_keys")); + fs.chmodSync(path.join(dir, "authorized_keys"), 0o600); + const port = await freePort(); + // OpenSSH 9.8+ penalises a source address after repeated auth failures and + // resets its later connections — exactly what the auth-failure tests below + // provoke, from 127.0.0.1, against the tests after them. Older daemons + // refuse to start on an option they do not know, so it is version-gated. + const version = /OpenSSH_(\d+)\.(\d+)/.exec(String(spawnSync("ssh", ["-V"], { encoding: "utf8" }).stderr || "")); + const penalties = version && (Number(version[1]) > 9 || (Number(version[1]) === 9 && Number(version[2]) >= 8)); + fs.writeFileSync(path.join(dir, "sshd_config"), [ + ...(penalties ? ["PerSourcePenalties no"] : []), + `Port ${port}`, + "ListenAddress 127.0.0.1", + `HostKey ${path.join(dir, "hostkey")}`, + `AuthorizedKeysFile ${path.join(dir, "authorized_keys")}`, + "PidFile none", + "StrictModes no", + "UsePAM no", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "LogLevel ERROR", + "Subsystem sftp internal-sftp", + "", + ].join("\n")); + const daemon = spawn(SSHD, ["-f", path.join(dir, "sshd_config"), "-D", "-e"], { stdio: ["ignore", "ignore", "pipe"] }); + let log = ""; + daemon.stderr.on("data", (d) => { log += d; }); + const up = await waitFor(() => new Promise((resolve) => { + if (daemon.exitCode !== null) return resolve(false); + const s = net.connect(port, "127.0.0.1"); + s.on("connect", () => { s.destroy(); resolve(true); }); + s.on("error", () => resolve(false)); + })); + const user = os.userInfo().username; + const config = path.join(dir, "ssh_config"); + const host = (name, key) => [ + `Host ${name}`, + " HostName 127.0.0.1", + ` Port ${port}`, + ` User ${user}`, + ` IdentityFile ${path.join(dir, key)}`, + " IdentitiesOnly yes", + ` UserKnownHostsFile ${path.join(dir, "known_hosts")}`, + " StrictHostKeyChecking accept-new", + " LogLevel ERROR", + ].join("\n"); + fs.writeFileSync(config, `${host("itest", "clientkey")}\n${host("itest-badkey", "otherkey")}\n`); + return { + dir, port, config, daemon, log: () => log, up, + stop() { + try { daemon.kill("SIGTERM"); } catch { /* already gone */ } + fs.rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +// One daemon for the file: sshd startup is the slow part, and every test +// below wants the same one. The env points the module at a registry and a +// socket dir inside the daemon's temp dir, so nothing of the user's is read. +let sshd; +let env; +const saved = {}; +const ENV_KEYS = ["MOSHCODE_SSH_DIR", "MOSHCODE_SSH_CONTROL_DIR", "MOSHCODE_SSH_CONFIG", "MOSHCODE_SSH_PERSIST"]; + +test.before(async () => { + if (!CAN_RUN) return; + sshd = await startSshd(); + if (!sshd.up) { sshd.stop(); sshd = null; return; } + for (const k of ENV_KEYS) saved[k] = process.env[k]; + process.env.MOSHCODE_SSH_DIR = path.join(sshd.dir, "moshcode-ssh"); + process.env.MOSHCODE_SSH_CONTROL_DIR = path.join(sshd.dir, "ctl"); + process.env.MOSHCODE_SSH_CONFIG = sshd.config; + process.env.MOSHCODE_SSH_PERSIST = "60s"; + env = { ...process.env }; + addTarget("itest", "itest", { cwd: sshd.dir }); + addTarget("badkey", "itest-badkey"); + addTarget("nohost", "127.0.0.1", { port: sshd.port === 1 ? 2 : 1 }); +}); + +test.after(async () => { + if (!sshd) return; + for (const name of ["itest", "badkey"]) { + const entry = getTarget(name); + if (entry) closeMaster(entry, { env }); + } + for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } + sshd.stop(); +}); + +const skipUnless = (t) => { + if (!CAN_RUN) { t.skip("no sshd/ssh-keygen here"); return false; } + if (!sshd) { t.skip("sshd would not start in this environment"); return false; } + return true; +}; + +const itest = () => getTarget("itest"); + +test("open, check, close: one master, spelled with -O (R8–R12)", (t) => { + if (!skipUnless(t)) return; + const entry = itest(); + const opened = openMaster(entry, { env, batch: true }); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.alreadyOpen, false); + assert.ok(opened.pid > 0, "the master reports its pid"); + assert.ok(fs.existsSync(controlPath(entry)), "the socket exists"); + assert.equal(fs.statSync(path.dirname(controlPath(entry))).mode & 0o777, 0o700); + + const again = openMaster(entry, { env, batch: true }); + assert.deepEqual([again.ok, again.alreadyOpen, again.pid], [true, true, opened.pid]); + + const status = checkMaster(entry, { env }); + assert.deepEqual([status.connected, status.pid], [true, opened.pid]); + + const closed = closeMaster(entry, { env }); + assert.deepEqual([closed.ok, closed.closed, closed.wasOpen], [true, true, true]); + assert.ok(!fs.existsSync(controlPath(entry)), "-O exit took the socket with it"); + assert.equal(checkMaster(entry, { env }).connected, false); +}); + +test("exec: printf, a failing command, stdin, cwd — separate channels, no shared state (R18–R31)", (t) => { + if (!skipUnless(t)) return; + const entry = itest(); + const hello = exec(entry, ["printf", "%s\\n", "hello", "it's $HOME"], { env }); + assert.equal(hello.ok, true, hello.error || hello.stderr); + assert.equal(hello.stdout, "hello\nit's $HOME\n", "quoting survives the remote shell"); + assert.equal(hello.stderr, ""); + assert.equal(hello.code, 0); + assert.equal(hello.opened, true, "exec opened the master itself"); + + const failing = exec(entry, ["sh", "-c", "echo to-stderr 1>&2; exit 3"], { env }); + assert.deepEqual([failing.ok, failing.transportOk, failing.code, failing.stdout, failing.stderr], [false, true, 3, "", "to-stderr\n"]); + assert.equal(failing.opened, undefined, "reused"); + + const bytes = Buffer.from([0, 1, 2, 255, 10, 13, 0x27, 0x24]); + const cat = exec(entry, ["cat"], { env, stdin: bytes }); + assert.ok(cat.ok); + assert.ok(Buffer.from(cat.stdout, "utf8").length >= 8, "stdin came back"); + const text = exec(entry, ["cat"], { env, stdin: "line one\nline two\n" }); + assert.equal(text.stdout, "line one\nline two\n"); + + const here = exec(entry, ["pwd"], { env }); + assert.equal(here.stdout.trim(), fs.realpathSync(sshd.dir), "the target's cwd"); + const there = exec(entry, ["pwd"], { env, cwd: os.tmpdir() }); + assert.equal(there.stdout.trim(), fs.realpathSync(os.tmpdir()), "--cwd for this call"); + exec(entry, ["cd", "/"], { env }); + assert.equal(exec(entry, ["pwd"], { env }).stdout.trim(), fs.realpathSync(sshd.dir), "cd in one call does not leak into the next (R29)"); + + const withEnv = exec(entry, ["sh", "-c", "echo $MOSHCODE_ITEST"], { env, remoteEnv: { MOSHCODE_ITEST: "set for one call" } }); + assert.equal(withEnv.stdout.trim(), "set for one call"); + assert.equal(exec(entry, ["sh", "-c", "echo x$MOSHCODE_ITEST"], { env }).stdout.trim(), "x", "…and only that call (R28)"); + + const piped = exec(entry, ["printf 'a\\nb\\nc\\n' | wc -l"], { env, sh: true }); + assert.equal(piped.stdout.trim(), "3"); +}); + +test("exec: a timeout kills the command and says so (R26)", (t) => { + if (!skipUnless(t)) return; + const r = exec(itest(), ["sleep", "30"], { env, timeoutMs: 500 }); + assert.deepEqual([r.ok, r.transportOk, r.timedOut], [false, true, true]); + assert.ok(r.durationMs < 10_000); +}); + +test("parallel exec channels over one master (R56)", async (t) => { + if (!skipUnless(t)) return; + const entry = itest(); + assert.ok(openMaster(entry, { env, batch: true }).ok); + const before = checkMaster(entry, { env }).pid; + // spawnSync blocks, so parallelism here is real processes: the CLI, N at + // once, all on the same socket — which is exactly how an agent will use it. + const bin = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "bin", "moshcode.mjs"); + const runs = await Promise.all([1, 2, 3, 4, 5, 6].map((n) => new Promise((resolve) => { + const child = spawn(process.execPath, [bin, "ssh", "exec", "itest", "--json", "--", "sh", "-c", `sleep 0.2; echo run-${n}`], { env }); + let out = ""; + child.stdout.on("data", (d) => { out += d; }); + child.on("close", (code) => resolve({ code, body: JSON.parse(out) })); + }))); + for (const [i, r] of runs.entries()) { + assert.equal(r.code, 0); + assert.equal(r.body.stdout, `run-${i + 1}\n`); + assert.equal(r.body.transportOk, true); + } + assert.equal(checkMaster(entry, { env }).pid, before, "still the one master"); +}); + +test("a master that dies is detected, cleaned up, and reopened on the next command (R15)", (t) => { + if (!skipUnless(t)) return; + const entry = itest(); + assert.ok(openMaster(entry, { env, batch: true }).ok); + const { pid } = checkMaster(entry, { env }); + process.kill(pid, "SIGKILL"); + assert.ok(fs.existsSync(controlPath(entry)), "the socket file is left behind by a killed master"); + const status = checkMaster(entry, { env }); + assert.deepEqual([status.connected, status.stale], [false, true]); + assert.ok(!fs.existsSync(controlPath(entry)), "…and cleaned up, because it is ours"); + const r = exec(entry, ["echo", "back"], { env }); + assert.equal(r.stdout, "back\n"); + assert.equal(r.opened, true); + assert.notEqual(checkMaster(entry, { env }).pid, pid, "a new master"); +}); + +test("authentication failure is a transport failure, named (R25)", (t) => { + if (!skipUnless(t)) return; + const r = exec(getTarget("badkey"), ["true"], { env }); + assert.deepEqual([r.ok, r.transportOk, r.code], [false, false, 255]); + assert.match(r.error, /authentication failed/); + const opened = openMaster(getTarget("badkey"), { env, batch: true }); + assert.equal(opened.ok, false); + assert.match(opened.error, /authentication failed/); +}); + +test("nothing listening is a transport failure too, and quickly", (t) => { + if (!skipUnless(t)) return; + const started = Date.now(); + const r = exec(getTarget("nohost"), ["true"], { env }); + assert.deepEqual([r.ok, r.transportOk], [false, false]); + assert.match(r.error, /could not connect|ssh failed/); + assert.ok(Date.now() - started < 25_000); +}); + +test("a changed host key is refused, never accepted on our behalf (R58, R59)", (t) => { + if (!skipUnless(t)) return; + const known = path.join(sshd.dir, "known_hosts"); + const original = fs.readFileSync(known, "utf8"); + const entry = itest(); + closeMaster(entry, { env }); + try { + // A different key under the same [host]:port line: the "REMOTE HOST + // IDENTIFICATION HAS CHANGED" case. + const otherPub = fs.readFileSync(path.join(sshd.dir, "otherkey.pub"), "utf8").trim().split(" ").slice(0, 2).join(" "); + fs.writeFileSync(known, original.split("\n").filter(Boolean).map((l) => `${l.split(" ")[0]} ${otherPub}`).join("\n") + "\n"); + const r = exec(entry, ["true"], { env }); + assert.deepEqual([r.ok, r.transportOk], [false, false]); + assert.match(r.error, /host key verification failed/); + } finally { + fs.writeFileSync(known, original); + } +}); + +test("put and get ride the same master; put lands atomically (R33, R34)", (t) => { + if (!skipUnless(t)) return; + if (!have("scp")) { t.skip("no scp"); return; } + const entry = itest(); + const local = path.join(sshd.dir, "upload.txt"); + fs.writeFileSync(local, "one file, over the master\n"); + const up = put(entry, local, "landed.txt", { env }); + assert.equal(up.ok, true, up.error); + assert.equal(up.remote, path.join(sshd.dir, "landed.txt")); + assert.equal(fs.readFileSync(path.join(sshd.dir, "landed.txt"), "utf8"), "one file, over the master\n"); + assert.ok(!fs.readdirSync(sshd.dir).some((f) => f.includes(".moshcode-") && f.endsWith(".tmp")), "no temp file left on the remote side"); + const back = path.join(sshd.dir, "download.txt"); + const down = get(entry, "landed.txt", back, { env }); + assert.equal(down.ok, true, down.error); + assert.equal(fs.readFileSync(back, "utf8"), "one file, over the master\n"); + const missing = get(entry, "/no/such/file", back, { env }); + assert.equal(missing.ok, false); +}); + +test("the CLI end to end: --json exec, exit codes, and stdin from a pipe", async (t) => { + if (!skipUnless(t)) return; + const bin = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "bin", "moshcode.mjs"); + const run = (args, input) => spawnSync(process.execPath, [bin, "ssh", ...args], { env, encoding: "utf8", input }); + const ok = run(["exec", "itest", "--json", "--", "echo", "hi"]); + assert.equal(ok.status, 0, ok.stderr); + assert.equal(JSON.parse(ok.stdout).stdout, "hi\n"); + const grep = run(["exec", "itest", "--json", "--", "grep", "zzz-not-here", "sshd_config"]); + assert.equal(grep.status, 1, "grep's own exit code"); + assert.deepEqual([JSON.parse(grep.stdout).ok, JSON.parse(grep.stdout).transportOk], [false, true]); + const piped = run(["exec", "itest", "--stdin", "--", "wc", "-c"], "12345"); + assert.equal(piped.stdout.trim(), "5"); + const listed = run(["--json"]); + const targets = JSON.parse(listed.stdout).targets; + assert.equal(targets.find((x) => x.name === "itest").connected, true); + const checked = run(["check", "itest"]); + assert.equal(checked.status, 0); + const closed = run(["close", "itest", "--json"]); + assert.equal(JSON.parse(closed.stdout).closed, true); + assert.equal(run(["check", "itest"]).status, 1); + const bad = run(["exec", "badkey", "--json", "--", "true"]); + assert.equal(bad.status, 255); + assert.equal(JSON.parse(bad.stdout).transportOk, false); + const human = run(["exec", "badkey", "--", "true"]); + assert.match(human.stderr, /authentication failed/); +}); + +test("bench measures, and the multiplexed side authenticates once", (t) => { + if (!skipUnless(t)) return; + const r = bench(itest(), { n: 3, env, batch: true }); + assert.equal(r.ok, true, r.error); + assert.equal(r.fresh.runs, 3); + assert.equal(r.multiplexed.runs, 3); + assert.equal(r.fresh.failures, 0); + assert.equal(r.multiplexed.failures, 0); + assert.equal(r.authentications.fresh, 3); + assert.ok(r.authentications.multiplexed <= 1); + assert.ok(r.multiplexed.medianMs > 0); +}); + +test("remote tmux shell: state persists across send, read sees it, kill ends it (R41–R47)", async (t) => { + if (!skipUnless(t)) return; + const entry = itest(); + const probe = remoteHasTmux(entry, { env }); + assert.equal(probe.ok, true, probe.error); + if (!probe.has) { t.skip("no tmux on the (loopback) remote"); return; } + // A private tmux server, so the test never touches the user's sessions: + // TMUX_TMPDIR moves tmux's default socket, and the helpers carry it to the + // remote side as a per-command environment value — the --env mechanism. + const opts = { env, cwd: null, remoteEnv: { TMUX_TMPDIR: sshd.dir } }; + const session = "moshcode-ssh-itest-app"; + try { + // shellAttach needs a terminal; create the session the way it would. + const created = exec(entry, ["tmux", "new-session", "-d", "-s", session, "-c", sshd.dir], opts); + assert.equal(created.ok, true, created.stderr); + // Let the shell draw its prompt first: a line editor starting up can + // discard typeahead, and the herd waits the same way before its first send. + const prompted = await waitFor(() => { const r = shellRead(entry, "app", { lines: 5, ...opts }); return r.ok && r.screen.trim().length > 0; }, { tries: 200 }); + assert.ok(prompted, "the remote shell came up"); + + const sent = shellSend(entry, "app", `cd ${JSON.stringify(os.tmpdir())}`, opts); + assert.equal(sent.ok, true, sent.error); + shellSend(entry, "app", "pwd", opts); + let last = null; + const seen = await waitFor(() => { + last = shellRead(entry, "app", { lines: 40, ...opts }); + return last.ok && last.screen.includes(fs.realpathSync(os.tmpdir())); + }, { tries: 200 }); + assert.ok(seen, `the cd persisted into the next line — that is what a shell session is for; screen was: ${JSON.stringify(last)}`); + const list = shellList(entry, opts); + assert.ok(list.ok, list.error); + assert.ok(list.sessions.some((s) => s.session === "app")); + const killed = shellKill(entry, "app", opts); + assert.equal(killed.ok, true, killed.error); + const after = shellRead(entry, "app", { lines: 5, ...opts }); + assert.equal(after.ok, false); + assert.match(after.error, /no shell itest\/app/); + } finally { + exec(entry, ["tmux", "kill-server"], opts); + } +}); + +test("the CLI shell verbs say when tmux is not there, and exec still works (R47)", (t) => { + if (!skipUnless(t)) return; + // A PATH with no tmux on it, for the remote command only. + const entry = itest(); + const r = exec(entry, ["sh", "-c", "PATH=/nonexistent tmux -V"], { env }); + assert.equal(r.code, 127); + const still = exec(entry, ["echo", "fine"], { env }); + assert.equal(still.stdout, "fine\n"); +}); diff --git a/test/ssh.test.mjs b/test/ssh.test.mjs new file mode 100644 index 00000000..b6ef5400 --- /dev/null +++ b/test/ssh.test.mjs @@ -0,0 +1,649 @@ +// SSH workspaces (PRD 0013): the registry, the socket path, the argv ssh is +// handed, the quoting, and the shape of what comes back — all without a +// network. A fake runner records every spawn and answers from a table, so +// each test states what ssh would have been asked and what the caller sees. +// test/ssh-sshd.test.mjs runs the same module against a real sshd. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + DEFAULT_PERSIST, KEEPALIVE, addTarget, attachArgs, cdCommand, checkMaster, classify, closeMaster, controlDir, controlPath, + debugLine, exec, execArgs, getTarget, keepaliveArgs, listTargets, masterArgs, openMaster, parseArgs, parseEnvPairs, + parsePersist, parseSessionRef, parseTimeout, readTargets, remoteCommand, remotePath, remoteSessionName, removeTarget, + resolveTarget, scpArgs, shellQuote, sshCommand, takesTerminal, validName, writeTargets, +} from "../src/ssh.mjs"; + +/* ------------------------------------------------------------- fixtures */ + +async function withSshDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-ssh-test-")); + const previous = { dir: process.env.MOSHCODE_SSH_DIR, ctl: process.env.MOSHCODE_SSH_CONTROL_DIR }; + process.env.MOSHCODE_SSH_DIR = dir; + process.env.MOSHCODE_SSH_CONTROL_DIR = path.join(dir, "ctl"); + try { return await fn(dir); } + finally { + for (const [k, v] of [["MOSHCODE_SSH_DIR", previous.dir], ["MOSHCODE_SSH_CONTROL_DIR", previous.ctl]]) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * A spawnSync stand-in. `answer(bin, args, options)` returns a partial result; + * everything it does not say defaults to a clean exit. Every call is recorded. + */ +function fakeRunner(answer = () => ({})) { + const calls = []; + const runner = (bin, args, options = {}) => { + calls.push({ bin, args, options }); + const a = answer(bin, args, options, calls.length) || {}; + return { status: 0, signal: null, stdout: "", stderr: "", error: undefined, ...a }; + }; + runner.calls = calls; + return runner; +} + +const op = (args) => { const i = args.indexOf("-O"); return i >= 0 ? args[i + 1] : null; }; +const isExec = (args) => args.includes("--") && !args.includes("-O"); +const dev = { name: "dev", target: "deploy@example.com", cwd: "/srv/app" }; + +/** A live socket file, so checkMaster asks ssh instead of answering "absent". */ +function touchSocket(entry) { + fs.mkdirSync(controlDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync(controlPath(entry), ""); +} + +/* ---------------------------------------------------------------- names */ + +test("target names: a filename, a hash input, a word at a prompt — nothing that could be a path (R66)", () => { + for (const good of ["dev", "prod-2", "chovy_app", "a", "x".repeat(64)]) assert.ok(validName(good), good); + for (const bad of ["", "../dev", "dev/box", "Dev", "-dev", ".dev", "dev box", "x".repeat(65), "dev:1", "a.b"]) { + assert.ok(!validName(bad), JSON.stringify(bad)); + } +}); + +test("a shell ref is /, both validated", () => { + assert.deepEqual(parseSessionRef("dev/app"), { name: "dev", session: "app" }); + assert.match(parseSessionRef("dev").error, /\//); + assert.match(parseSessionRef("dev/app/x").error, /\//); + assert.match(parseSessionRef("Dev/app").error, /not a target name/); + assert.match(parseSessionRef("../x/app").error, /\//, "a traversal never parses as a name"); + assert.match(parseSessionRef("dev/App").error, /not a session name/); + assert.equal(remoteSessionName(dev, "app"), "moshcode-ssh-dev-app"); +}); + +/* ------------------------------------------------------------- registry */ + +test("the registry holds a host, a port and a cwd — and nothing that smells like a secret (R3, R4)", async () => { + await withSshDir(async (dir) => { + const added = addTarget("dev", "deploy@example.com", { port: "2222", cwd: "/srv/app" }); + assert.deepEqual(added, { name: "dev", target: "deploy@example.com", port: 2222, cwd: "/srv/app", replaced: false }); + assert.deepEqual(readTargets(), { dev: { target: "deploy@example.com", port: 2222, cwd: "/srv/app" } }); + + // A hand-edited file that grew a password loses it on the next read. + fs.writeFileSync(path.join(dir, "targets.json"), JSON.stringify({ + dev: { target: "deploy@example.com", password: "hunter2", identityFile: "/x" }, + "../evil": { target: "h" }, + nohost: {}, + })); + assert.deepEqual(readTargets(), { dev: { target: "deploy@example.com" } }); + + const file = path.join(dir, "targets.json"); + writeTargets(readTargets()); + assert.equal(fs.statSync(file).mode & 0o777, 0o600, "targets.json is owner-only (R67)"); + assert.equal(fs.statSync(dir).mode & 0o777, 0o700, "the ssh dir is owner-only"); + assert.ok(!fs.readdirSync(dir).some((f) => f.endsWith(".tmp")), "no temp file left behind by the atomic write"); + }); +}); + +test("an ssh_config alias is a target as it stands; ports and flags are validated", async () => { + await withSshDir(async () => { + assert.equal(addTarget("dev", "devbox").target, "devbox"); + assert.equal(getTarget("dev").port, undefined, "no port unless said — ssh_config decides"); + assert.throws(() => addTarget("dev", "devbox", { port: "http" }), /not a port/); + assert.throws(() => addTarget("dev", "devbox", { port: "70000" }), /not a port/); + assert.throws(() => addTarget("Dev", "devbox"), /not a target name/); + assert.throws(() => addTarget("dev", ""), /needs a host/); + assert.throws(() => addTarget("dev", "-oProxyCommand=evil"), /looks like a flag/); + assert.equal(addTarget("dev", "other").replaced, true); + assert.deepEqual(listTargets().map((t) => t.name), ["dev"]); + assert.equal(removeTarget("dev"), true); + assert.equal(removeTarget("dev"), false); + assert.equal(getTarget("dev"), null); + assert.match(resolveTarget("dev").error, /no target named "dev"/); + assert.match(resolveTarget("../x").error, /not a target name/); + }); +}); + +/* -------------------------------------------------------------- sockets */ + +test("the control socket is a short hash of name+host+port, never the target spelled out (R16)", async () => { + await withSshDir(async () => { + const a = controlPath({ name: "dev", target: "deploy@very-long-hostname.internal.example.com", port: 2222 }); + assert.match(path.basename(a), /^[0-9a-f]{12}$/); + assert.ok(!a.includes("example.com")); + assert.equal(a, controlPath({ name: "dev", target: "deploy@very-long-hostname.internal.example.com", port: 2222 }), "stable"); + assert.notEqual(a, controlPath({ name: "dev", target: "elsewhere", port: 2222 }), "re-pointing a name gets a new socket"); + assert.notEqual(a, controlPath({ name: "dev", target: "deploy@very-long-hostname.internal.example.com", port: 22 })); + }); +}); + +test("a home directory long enough to break sun_path moves the sockets to the temp dir (R16)", () => { + const previous = { dir: process.env.MOSHCODE_SSH_DIR, ctl: process.env.MOSHCODE_SSH_CONTROL_DIR }; + try { + delete process.env.MOSHCODE_SSH_CONTROL_DIR; + process.env.MOSHCODE_SSH_DIR = `/net/filers/${"deep/".repeat(20)}home/anthony/.moshcode/ssh`; + const dir = controlDir(); + assert.ok(dir.startsWith(os.tmpdir()), dir); + assert.match(path.basename(dir), /^moshcode-ssh-/); + process.env.MOSHCODE_SSH_DIR = "/home/a/.moshcode/ssh"; + assert.equal(controlDir(), "/home/a/.moshcode/ssh/control"); + process.env.MOSHCODE_SSH_CONTROL_DIR = "/run/x"; + assert.equal(controlDir(), "/run/x"); + } finally { + for (const [k, v] of [["MOSHCODE_SSH_DIR", previous.dir], ["MOSHCODE_SSH_CONTROL_DIR", previous.ctl]]) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + } +}); + +/* ------------------------------------------------------------- durations */ + +test("persist and timeout read the same durations; bare numbers are seconds", () => { + assert.equal(parsePersist("10m").seconds, 600); + assert.equal(parsePersist("90").seconds, 90); + assert.equal(parsePersist("2h").seconds, 7200); + assert.equal(parsePersist(DEFAULT_PERSIST).seconds, 600); + assert.throws(() => parsePersist("soon"), /not a duration/); + assert.throws(() => parsePersist("100ms"), /under a second/); + assert.equal(parseTimeout("2m"), 120_000); + assert.equal(parseTimeout("500ms"), 500); + assert.equal(parseTimeout("30"), 30_000); + assert.equal(parseTimeout(undefined), undefined); + assert.throws(() => parseTimeout("0"), /not a usable timeout/); +}); + +/* -------------------------------------------------------------- quoting */ + +test("remote argv is single-quoted the POSIX way, so nothing in it is ever interpreted (R68)", () => { + assert.equal(shellQuote("git"), "git"); + assert.equal(shellQuote("src/app.ts"), "src/app.ts"); + assert.equal(shellQuote(""), "''"); + assert.equal(shellQuote("a b"), "'a b'"); + assert.equal(shellQuote("it's"), `'it'\\''s'`); + assert.equal(shellQuote("$(rm -rf /)"), "'$(rm -rf /)'"); + assert.equal(shellQuote("`id`"), "'`id`'"); + assert.equal(shellQuote("1,240p"), "1,240p"); + assert.equal(shellQuote("a\nb"), "'a\nb'"); +}); + +test("the remote command: cd, then env, then exec argv — each quoted, cwd's ~ left to the remote shell", () => { + assert.equal(remoteCommand(["git", "status", "--short"], {}), "exec git status --short"); + assert.equal(remoteCommand(["git", "status"], { cwd: "/srv/app" }), "cd -- /srv/app && exec git status"); + assert.equal(remoteCommand(["pwd"], { cwd: "~/src/my app" }), "cd -- ~/'src/my app' && exec pwd"); + assert.equal(remoteCommand(["pwd"], { cwd: "~" }), "cd && exec pwd"); + assert.equal(cdCommand("/srv/it's"), `cd -- '/srv/it'\\''s'`); + assert.equal( + remoteCommand(["pnpm", "test"], { cwd: "/srv/app", env: { NODE_ENV: "test", MSG: "hello world" } }), + "cd -- /srv/app && NODE_ENV=test MSG='hello world' exec pnpm test", + ); + assert.equal(remoteCommand(["sed", "-n", "1,240p", "src/app.ts"], {}), "exec sed -n 1,240p src/app.ts"); + assert.equal(remoteCommand(["echo", "$HOME; rm -rf /"], {}), "exec echo '$HOME; rm -rf /'"); + assert.throws(() => remoteCommand([], {}), /nothing to run/); + assert.throws(() => remoteCommand(["x"], { env: { "BAD-NAME": "1" } }), /not an environment variable name/); +}); + +test("--sh is the one deliberate way to hand the remote shell a pipeline", () => { + assert.equal(remoteCommand(["git log | head -5"], { sh: true }), "git log | head -5"); + assert.equal(remoteCommand(["make"], { sh: true, cwd: "/srv", env: { V: "1" } }), "cd -- /srv && export V=1 && make"); + assert.throws(() => remoteCommand(["a", "b"], { sh: true }), /exactly one argument/); +}); + +test("--env K=V pairs", () => { + assert.deepEqual(parseEnvPairs(["A=1", "B=x=y", "C="]), { A: "1", B: "x=y", C: "" }); + assert.throws(() => parseEnvPairs(["=1"]), /KEY=VALUE/); + assert.throws(() => parseEnvPairs(["A B=1"]), /not an environment variable name/); +}); + +test("a relative remote path for put/get is relative to the target's cwd; absolute and ~ are not", () => { + assert.equal(remotePath(dev, "package.json"), "/srv/app/package.json"); + assert.equal(remotePath(dev, "/etc/hosts"), "/etc/hosts"); + assert.equal(remotePath(dev, "~/x"), "~/x"); + assert.equal(remotePath({ name: "a", target: "h" }, "package.json"), "package.json"); +}); + +/* -------------------------------------------------------------- argv */ + +test("the master is ControlMaster=yes -N -f with a finite persist and keepalives — and no -M (R8, R13, R14)", async () => { + await withSshDir(async () => { + const entry = { name: "dev", target: "devbox", port: 2222 }; + const args = masterArgs(entry, { batch: true, keepalive: ["-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3"], env: {} }); + assert.ok(!args.includes("-M"), "-M with ControlMaster=yes means ask mode — see the module header"); + assert.ok(args.includes("ControlMaster=yes")); + assert.ok(args.includes("ControlPersist=600")); + assert.ok(args.includes("BatchMode=yes")); + assert.ok(args.includes("ServerAliveInterval=30")); + assert.deepEqual(args.slice(-4), ["2222", "-N", "-f", "devbox"]); + assert.ok(args.some((a) => a.startsWith("ControlPath="))); + assert.ok(!args.some((a) => /StrictHostKeyChecking/.test(a)), "never touches host-key policy (R58)"); + + const noBatch = masterArgs(entry, { batch: false, env: {} }); + assert.ok(!noBatch.includes("BatchMode=yes"), "a person at a terminal may be prompted"); + assert.ok(masterArgs(entry, { persist: "30m", env: {} }).includes("ControlPersist=1800")); + assert.ok(masterArgs(entry, { env: { MOSHCODE_SSH_PERSIST: "1h" } }).includes("ControlPersist=3600")); + assert.ok(masterArgs({ ...entry, persist: "5m" }, { env: {} }).includes("ControlPersist=300")); + assert.deepEqual(masterArgs(entry, { env: { MOSHCODE_SSH_CONFIG: "/x/cfg" } }).slice(0, 2), ["-F", "/x/cfg"]); + }); +}); + +test("keepalives are ours unless ssh -G says the user already set an interval (R14)", () => { + const silent = fakeRunner(() => ({ stdout: "serveraliveinterval 0\nserveralivecountmax 3\n" })); + assert.deepEqual(keepaliveArgs(dev, { runner: silent, env: {} }), + ["-o", `ServerAliveInterval=${KEEPALIVE.ServerAliveInterval}`, "-o", `ServerAliveCountMax=${KEEPALIVE.ServerAliveCountMax}`]); + assert.deepEqual(silent.calls[0].args, ["-G", "deploy@example.com"]); + const configured = fakeRunner(() => ({ stdout: "serveraliveinterval 15\n" })); + assert.deepEqual(keepaliveArgs(dev, { runner: configured, env: {} }), []); +}); + +test("exec is -T (no PTY) over ControlMaster=auto, with the command after -- (R20)", async () => { + await withSshDir(async () => { + const args = execArgs(dev, "exec git status", { env: {} }); + assert.ok(args.includes("-T")); + assert.ok(!args.includes("-t")); + assert.ok(args.includes("ControlMaster=auto")); + assert.ok(args.includes("BatchMode=yes")); + assert.deepEqual(args.slice(-3), ["deploy@example.com", "--", "exec git status"]); + const tty = execArgs(dev, "sudo x", { tty: true, batch: false, env: {} }); + assert.ok(tty.includes("-t") && !tty.includes("-T") && !tty.includes("BatchMode=yes")); + }); +}); + +test("attach hands ssh the terminal, landing in the target's cwd when there is one (R36)", async () => { + await withSshDir(async () => { + const withCwd = attachArgs(dev, { env: {} }); + assert.ok(withCwd.includes("-t")); + assert.equal(withCwd.at(-1), `cd -- /srv/app && exec "\${SHELL:-sh}" -l`); + assert.equal(withCwd.at(-3), "deploy@example.com"); + const bare = attachArgs({ name: "a", target: "h" }, { env: {} }); + assert.equal(bare.at(-1), "h"); + assert.ok(!bare.includes("-t"), "no cwd, no command, no forced tty — plain ssh"); + }); +}); + +test("scp rides the same socket; its port flag is -P", async () => { + await withSshDir(async () => { + const entry = { name: "dev", target: "devbox", port: 2222 }; + const args = scpArgs(entry, "./a", "devbox:/srv/a.tmp", { env: {} }); + assert.ok(args.some((a) => a.startsWith("ControlPath="))); + assert.ok(args.includes("ControlMaster=auto")); + assert.deepEqual(args.slice(-4), ["-P", "2222", "./a", "devbox:/srv/a.tmp"]); + }); +}); + +/* --------------------------------------------------------------- results */ + +test("classify: 255 is ssh's, anything else is the command's (R24, R25)", () => { + assert.deepEqual(classify({ status: 0 }), { transportOk: true, code: 0, signal: null, error: null }); + assert.deepEqual(classify({ status: 1 }), { transportOk: true, code: 1, signal: null, error: null }); + const auth = classify({ status: 255, stderr: "deploy@example.com: Permission denied (publickey).\n" }); + assert.equal(auth.transportOk, false); + assert.equal(auth.code, 255); + assert.match(auth.error, /^ssh authentication failed/); + const hostkey = classify({ status: 255, stderr: "@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@\nHost key verification failed.\n" }); + assert.equal(hostkey.error, "ssh host key verification failed"); + const conn = classify({ status: 255, stderr: "ssh: connect to host 10.0.0.1 port 22: Connection timed out\n" }); + assert.match(conn.error, /^ssh could not connect/); + const missing = classify({ error: Object.assign(new Error("spawn ssh ENOENT"), { code: "ENOENT" }) }); + assert.equal(missing.missing, true); + assert.match(missing.error, /ssh not found — install an OpenSSH client/); + const timeout = classify({ status: null, signal: "SIGTERM", error: Object.assign(new Error("t"), { code: "ETIMEDOUT" }) }); + assert.deepEqual(timeout, { transportOk: true, code: null, signal: "SIGTERM", error: "timed out", timedOut: true }); + const killed = classify({ status: null, signal: "SIGKILL" }); + assert.equal(killed.transportOk, true); + assert.equal(killed.signal, "SIGKILL"); +}); + +test("the debug line never carries the remote command, which is where --env values live (R63, R64, R69)", () => { + const line = debugLine("ssh", ["-o", "ControlPath=/x", "-T", "host", "--", "SECRET=hunter2 exec deploy"]); + assert.ok(!line.includes("hunter2")); + assert.match(line, //); + assert.ok(line.startsWith("ssh▸ ssh -o ControlPath=/x")); +}); + +/* ----------------------------------------------------------- lifecycle */ + +test("check: no socket is closed, a live socket asks ssh, a dead socket is unlinked (R11, R15)", async () => { + await withSshDir(async () => { + const runner = fakeRunner((bin, args) => (op(args) === "check" ? { status: 0, stderr: "Master running (pid=4242)\r\n" } : {})); + assert.deepEqual(checkMaster(dev, { runner }), { connected: false, socket: controlPath(dev), stale: false }); + assert.equal(runner.calls.length, 0, "nothing to ask without a socket"); + + touchSocket(dev); + const live = checkMaster(dev, { runner }); + assert.equal(live.connected, true); + assert.equal(live.pid, 4242); + assert.deepEqual(runner.calls[0].args.slice(-3), ["check", "deploy@example.com"].length === 2 ? runner.calls[0].args.slice(-3) : null); + assert.ok(runner.calls[0].args.includes("-O") && runner.calls[0].args.includes("check")); + + const dead = fakeRunner(() => ({ status: 255, stderr: "Control socket connect(/x): Connection refused\r\n" })); + const stale = checkMaster(dev, { runner: dead }); + assert.deepEqual(stale, { connected: false, socket: controlPath(dev), stale: true }); + assert.ok(!fs.existsSync(controlPath(dev)), "the stale socket is ours, and gone"); + }); +}); + +test("open is idempotent, close uses -O exit, and neither touches a PID (R10, R12)", async () => { + await withSshDir(async () => { + let alive = false; + const runner = fakeRunner((bin, args) => { + if (args.includes("-G")) return { stdout: "serveraliveinterval 0\n" }; + if (op(args) === "check") return alive ? { status: 0, stderr: "Master running (pid=7)\n" } : { status: 255, stderr: "Control socket connect: Connection refused" }; + if (op(args) === "exit") { alive = false; fs.unlinkSync(controlPath(dev)); return { status: 0, stderr: "Exit request sent." }; } + if (args.includes("-N")) { alive = true; touchSocket(dev); return { status: 0 }; } + return {}; + }); + const first = openMaster(dev, { runner, batch: true }); + assert.equal(first.ok, true); + assert.equal(first.alreadyOpen, false); + assert.equal(first.pid, 7); + assert.ok(fs.existsSync(controlDir())); + assert.equal(fs.statSync(controlDir()).mode & 0o777, 0o700, "the socket dir is 0700 (R17)"); + const master = runner.calls.find((c) => c.args.includes("-N")); + assert.ok(master.args.includes("ServerAliveInterval=30"), "keepalives were probed and added"); + + const again = openMaster(dev, { runner, batch: true }); + assert.deepEqual([again.ok, again.alreadyOpen], [true, true]); + assert.equal(runner.calls.filter((c) => c.args.includes("-N")).length, 1, "no second master"); + + const closed = closeMaster(dev, { runner }); + assert.deepEqual([closed.ok, closed.closed, closed.wasOpen], [true, true, true]); + assert.ok(runner.calls.some((c) => op(c.args) === "exit")); + assert.ok(!runner.calls.some((c) => c.bin === "kill"), "no PIDs were killed"); + const alreadyClosed = closeMaster(dev, { runner }); + assert.deepEqual([alreadyClosed.ok, alreadyClosed.wasOpen], [true, false]); + }); +}); + +test("open reports an auth failure as such, and never as a master", async () => { + await withSshDir(async () => { + const runner = fakeRunner((bin, args) => { + if (args.includes("-N")) return { status: 255, stderr: "deploy@example.com: Permission denied (publickey).\n" }; + return { stdout: "" }; + }); + const r = openMaster(dev, { runner, batch: true }); + assert.equal(r.ok, false); + assert.equal(r.connected, false); + assert.match(r.error, /authentication failed/); + }); +}); + +/* ------------------------------------------------------------------ exec */ + +/** A runner that behaves like a master: `-N` opens it, check answers, exec runs `handler`. */ +function masterRunner(handler, { openFails = false } = {}) { + const state = { alive: false, execs: 0 }; + const runner = fakeRunner((bin, args, options) => { + if (args.includes("-G")) return { stdout: "serveraliveinterval 0\n" }; + if (op(args) === "check") return state.alive ? { status: 0, stderr: "Master running (pid=9)\n" } : { status: 255, stderr: "Control socket connect: Connection refused" }; + if (op(args) === "exit") { state.alive = false; try { fs.unlinkSync(controlPath(dev)); } catch { /* gone */ } return { status: 0 }; } + if (args.includes("-N")) { + if (openFails) return { status: 255, stderr: "ssh: connect to host example.com port 22: Connection refused\n" }; + state.alive = true; touchSocket(dev); return { status: 0 }; + } + if (isExec(args)) { state.execs++; return handler(args.at(-1), options, state); } + return {}; + }); + runner.state = state; + return runner; +} + +test("exec: the command's exit status, stdout and stderr, apart, plus the transport's verdict (R22–R24)", async () => { + await withSshDir(async () => { + const runner = masterRunner((cmd) => (cmd.includes("grep") ? { status: 1, stdout: "", stderr: "" } : { status: 0, stdout: " M src/app.ts\n" })); + const r = exec(dev, ["git", "status", "--short"], { runner, env: {} }); + assert.equal(r.ok, true); + assert.equal(r.transportOk, true); + assert.equal(r.code, 0); + assert.equal(r.signal, null); + assert.equal(r.stdout, " M src/app.ts\n"); + assert.equal(r.stderr, ""); + assert.equal(r.target, "dev"); + assert.equal(r.connected, true); + assert.equal(r.opened, true, "no master was up, so this call opened one"); + assert.equal(typeof r.durationMs, "number"); + const remote = runner.calls.find((c) => isExec(c.args)).args.at(-1); + assert.equal(remote, "cd -- /srv/app && exec git status --short", "the target's cwd, quoted argv"); + + const miss = exec(dev, ["grep", "-rn", "TODO", "src"], { runner, env: {} }); + assert.deepEqual([miss.ok, miss.transportOk, miss.code], [false, true, 1], "grep finding nothing is not a transport failure"); + assert.equal(miss.opened, undefined, "the second call reused the master"); + assert.equal(runner.calls.filter((c) => c.args.includes("-N")).length, 1); + }); +}); + +test("exec: cwd and env per call; no shell state between calls (R27–R29)", async () => { + await withSshDir(async () => { + const runner = masterRunner(() => ({ status: 0 })); + exec(dev, ["cd", "/tmp"], { runner, env: {} }); + exec(dev, ["pwd"], { runner, env: {} }); + exec(dev, ["pnpm", "test"], { runner, env: {}, cwd: "/elsewhere" }); + exec(dev, ["pnpm", "test"], { runner, env: {}, remoteEnv: { NODE_ENV: "test", TOKEN: "it's secret" } }); + exec(dev, ["tmux", "-V"], { runner, env: {}, cwd: null }); + const remotes = runner.calls.filter((c) => isExec(c.args)).map((c) => c.args.at(-1)); + assert.deepEqual(remotes, [ + "cd -- /srv/app && exec cd /tmp", + "cd -- /srv/app && exec pwd", + "cd -- /elsewhere && exec pnpm test", + "cd -- /srv/app && NODE_ENV=test TOKEN='it'\\''s secret' exec pnpm test", + "exec tmux -V", + ], "cwd per call, env per call, and cwd: null means no cd at all"); + }); +}); + +test("exec: stdin is forwarded as bytes, untouched (R30, R31)", async () => { + await withSshDir(async () => { + let seen; + const runner = masterRunner((cmd, options) => { seen = options.input; return { status: 0 }; }); + const patch = Buffer.from("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-it's $HOME\n+`ok`\n\x00\xff", "binary"); + const r = exec(dev, ["git", "apply", "-"], { runner, env: {}, stdin: patch }); + assert.ok(r.ok); + assert.ok(Buffer.isBuffer(seen)); + assert.ok(seen.equals(patch), "byte for byte"); + exec(dev, ["cat"], { runner, env: {}, stdin: "text\n" }); + assert.equal(seen.toString(), "text\n"); + const none = runner.calls.filter((c) => isExec(c.args)); + assert.equal(none.length, 2); + }); +}); + +test("exec: a timeout is reported as one, with exit 124 at the CLI, and is never retried (R26)", async () => { + await withSshDir(async () => { + const runner = masterRunner(() => ({ status: null, signal: "SIGTERM", error: Object.assign(new Error("t"), { code: "ETIMEDOUT" }) })); + const r = exec(dev, ["pnpm", "test"], { runner, env: {}, timeoutMs: 5 }); + assert.deepEqual([r.ok, r.transportOk, r.timedOut, r.code, r.signal], [false, true, true, null, "SIGTERM"]); + assert.equal(runner.calls.find((c) => isExec(c.args)).options.timeout, 5); + assert.equal(runner.state.execs, 1, "no retry on a timeout — the remote side may have done the work"); + }); +}); + +test("exec: a master that died mid-run is reopened and the command retried once (R15)", async () => { + await withSshDir(async () => { + // Open, then run: the first exec fails at the transport with the master + // gone (the runner flips it dead), the retry after reopen succeeds. + const runner = masterRunner((cmd, options, state) => { + if (state.execs === 1) { state.alive = false; fs.unlinkSync(controlPath(dev)); return { status: 255, stderr: "Connection closed by remote host\n" }; } + return { status: 0, stdout: "ok\n" }; + }); + assert.ok(openMaster(dev, { runner, batch: true }).ok); + const r = exec(dev, ["true"], { runner, env: {} }); + assert.equal(r.ok, true); + assert.equal(r.retried, true); + assert.equal(runner.state.execs, 2); + assert.equal(runner.calls.filter((c) => c.args.includes("-N")).length, 2, "one master, then one reopen"); + }); +}); + +test("exec: when the master cannot be opened, the answer is a transport failure with ssh's reason (R25)", async () => { + await withSshDir(async () => { + const runner = masterRunner(() => ({ status: 0 }), { openFails: true }); + const r = exec(dev, ["true"], { runner, env: {} }); + assert.deepEqual([r.ok, r.transportOk, r.code, r.connected], [false, false, 255, false]); + assert.match(r.error, /could not connect/); + assert.equal(runner.state.execs, 0); + }); +}); + +test("exec: no ssh at all is said in so many words", async () => { + await withSshDir(async () => { + const runner = fakeRunner(() => ({ status: null, error: Object.assign(new Error("spawn ssh ENOENT"), { code: "ENOENT" }) })); + const r = exec(dev, ["true"], { runner, env: {} }); + assert.equal(r.transportOk, false); + assert.match(r.error, /ssh not found/); + }); +}); + +/* ------------------------------------------------------------------ CLI */ + +test("parseArgs: valued and repeated flags, --flag=value, and -- ends the flags", () => { + const p = parseArgs(["dev", "--cwd", "/x", "--env", "A=1", "--env=B=2", "--stdin", "--", "git", "log", "--oneline"], + { valued: ["cwd"], repeat: ["env"] }); + assert.deepEqual(p, { flags: { cwd: "/x", env: ["A=1", "B=2"], stdin: true }, positional: ["dev"], rest: ["git", "log", "--oneline"] }); + assert.throws(() => parseArgs(["--cwd"], { valued: ["cwd"] }), /needs a value/); + assert.equal(parseArgs(["dev"]).rest, null); +}); + +test("takesTerminal: attach, --tty exec and shell attach close readline; everything else keeps the prompt", () => { + assert.equal(takesTerminal(["dev"]), true); + assert.equal(takesTerminal(["exec", "dev", "--tty", "--", "top"]), true); + assert.equal(takesTerminal(["exec", "dev", "--", "ls"]), false); + assert.equal(takesTerminal(["shell", "dev", "--name", "app"]), true); + assert.equal(takesTerminal(["shell", "send", "dev/app", "x"]), false); + assert.equal(takesTerminal(["shell", "read", "dev/app"]), false); + assert.equal(takesTerminal(["open", "dev"]), false); + assert.equal(takesTerminal([]), false); +}); + +/** Run the CLI with captured output and a fake runner. */ +async function cli(argv, runner, extra = {}) { + const out = []; + const errs = []; + const code = await sshCommand(argv, { write: (l) => out.push(String(l)), writeErr: (l) => errs.push(String(l)), runner, env: {}, ...extra }); + return { code, out: out.join("\n"), errs: errs.join("\n"), json: () => JSON.parse(out.join("\n")) }; +} + +test("the CLI: add, list, show, remove — and --json anywhere before -- (R6, R7)", async () => { + await withSshDir(async () => { + const runner = fakeRunner(); + const added = await cli(["add", "dev", "deploy@example.com", "--cwd", "/srv/app", "--port", "2222", "--json"], runner); + assert.equal(added.code, 0); + assert.deepEqual(added.json(), { ok: true, name: "dev", target: "deploy@example.com", port: 2222, cwd: "/srv/app", replaced: false }); + + const list = await cli(["--json"], runner); + assert.deepEqual(list.json(), { targets: [{ name: "dev", target: "deploy@example.com", port: 2222, cwd: "/srv/app", connected: false }] }); + const bare = await cli([], runner); + assert.match(bare.out, /dev/); + assert.match(bare.out, /closed/); + + const show = await cli(["show", "dev", "--json"], runner); + assert.equal(show.json().connected, false); + assert.match(show.json().socket, /[0-9a-f]{12}$/); + + const bad = await cli(["add", "Dev", "h"], runner); + assert.equal(bad.code, 1); + assert.match(bad.errs, /not a target name/); + const badJson = await cli(["add", "Dev", "h", "--json"], runner); + assert.equal(badJson.json().ok, false); + + const removed = await cli(["remove", "dev", "--json"], runner); + assert.deepEqual(removed.json(), { ok: true, name: "dev", removed: true }); + assert.equal((await cli(["show", "dev"], runner)).code, 1); + assert.equal((await cli(["nope"], runner)).code, 1, "an unknown word is a missing target, not a crash"); + }); +}); + +test("the CLI: exec --json is the exec object, and the exit code is the remote's (R23)", async () => { + await withSshDir(async () => { + addTarget("dev", "deploy@example.com", { cwd: "/srv/app" }); + const runner = masterRunner((cmd) => (cmd.includes("false") ? { status: 3, stderr: "nope\n" } : { status: 0, stdout: "hi\n" })); + const ok = await cli(["exec", "dev", "--json", "--", "echo", "hi"], runner); + assert.equal(ok.code, 0); + const body = ok.json(); + assert.equal(body.ok, true); + assert.equal(body.stdout, "hi\n"); + assert.equal(body.transportOk, true); + for (const key of ["ok", "target", "connected", "code", "signal", "stdout", "stderr", "durationMs"]) assert.ok(key in body, key); + + const failed = await cli(["exec", "dev", "--json", "--", "false"], runner); + assert.equal(failed.code, 3); + assert.deepEqual([failed.json().ok, failed.json().transportOk, failed.json().code], [false, true, 3]); + + // --json after -- belongs to the remote command. + const passthrough = await cli(["exec", "dev", "--", "tool", "--json"], runner); + assert.equal(passthrough.code, 0); + assert.equal(runner.calls.filter((c) => isExec(c.args)).at(-1).args.at(-1), "cd -- /srv/app && exec tool --json"); + + const noCommand = await cli(["exec", "dev"], runner); + assert.equal(noCommand.code, 2); + }); +}); + +test("the CLI: open/check/close report the connection, and exit codes say it too (R9)", async () => { + await withSshDir(async () => { + addTarget("dev", "deploy@example.com"); + const runner = masterRunner(() => ({ status: 0 })); + const closedBefore = await cli(["check", "dev", "--json"], runner); + assert.equal(closedBefore.code, 1); + assert.equal(closedBefore.json().connected, false); + const opened = await cli(["open", "dev", "--json", "--batch"], runner); + assert.equal(opened.code, 0); + assert.deepEqual([opened.json().ok, opened.json().alreadyOpen], [true, false]); + const again = await cli(["open", "dev", "--json", "--batch"], runner); + assert.equal(again.json().alreadyOpen, true); + const checked = await cli(["check", "dev"], runner); + assert.equal(checked.code, 0); + assert.match(checked.out, /connected/); + const closed = await cli(["close", "dev", "--json"], runner); + assert.deepEqual([closed.json().ok, closed.json().closed, closed.json().wasOpen], [true, true, true]); + assert.equal((await cli(["check", "dev"], runner)).code, 1); + }); +}); + +test("the CLI: shell send/read/kill drive remote tmux over exec, and say when tmux is missing (R44–R47)", async () => { + await withSshDir(async () => { + addTarget("dev", "deploy@example.com", { cwd: "/srv/app" }); + let hasTmux = true; + const runner = masterRunner((cmd) => { + if (!hasTmux) return { status: 127, stderr: "sh: tmux: not found\n" }; + if (cmd.includes("no-such")) return { status: 1, stderr: "can't find session: moshcode-ssh-dev-no-such\n" }; + if (cmd.includes("capture-pane")) return { status: 0, stdout: "$ pnpm test\nall green\n\n\n" }; + return { status: 0 }; + }); + const sent = await cli(["shell", "send", "dev/app", "pnpm", "test", "--json"], runner); + assert.equal(sent.code, 0); + const remotes = runner.calls.filter((c) => isExec(c.args)).map((c) => c.args.at(-1)); + assert.deepEqual(remotes, [ + "exec tmux send-keys -t moshcode-ssh-dev-app -l -- 'pnpm test'", + "exec tmux send-keys -t moshcode-ssh-dev-app Enter", + ], "literal text, then Enter — and no cd, tmux's target is the session (R45)"); + + const read = await cli(["shell", "read", "dev/app", "--lines", "40"], runner); + assert.equal(read.out, "$ pnpm test\nall green"); + assert.ok(runner.calls.at(-1).args.at(-1).includes("capture-pane -p -t moshcode-ssh-dev-app -S -40")); + + const missing = await cli(["shell", "read", "dev/no-such", "--json"], runner); + assert.equal(missing.code, 1); + assert.match(missing.json().error, /no shell dev\/no-such/); + + hasTmux = false; + const noTmux = await cli(["shell", "send", "dev/app", "ls", "--json"], runner); + assert.equal(noTmux.code, 1); + assert.match(noTmux.json().error, /tmux is not installed on dev/); + assert.equal(noTmux.json().transportOk, true, "a missing tmux is not a transport failure"); + + assert.equal((await cli(["shell", "send", "dev", "x"], runner)).code, 2); + }); +});