diff --git a/README.md b/README.md
index 0f65303..d5387a9 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 df69ba7..d5be47d 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 0000000..a2a55b2
--- /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 5389f7c..e878553 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 84de46a..30248ee 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 dc57099..12779cf 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 0000000..503db14
--- /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 fd1e6f4..b5f586b 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 0000000..ef89372
--- /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 0000000..b6ef540
--- /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);
+ });
+});