From ec1df6321559717133c35d03047776eabe175da5 Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sun, 6 Sep 2026 21:00:37 +0300 Subject: [PATCH] Make the skill actually fire: rewrite the description, split the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness was skipping this skill. Two reasons, both fixed here. The description led with the mechanism ("keeps a live session, no handshake per command"), which reads as a performance nicety rather than a condition to act on, and it never displaced the default: the model already has ssh via Bash, that path appears to work, so an extra layer looks optional. The new description opens with the action, names `ssh host "cmd"` as the thing not to do and says what it costs, and keeps the triggers dense. The body now opens with the same displacement instead of a neutral comparison. The body was 646 lines / 32 KB, so one invocation cost ~8k tokens of context. SKILL.md is now 180 lines — locate the driver, requirements, quick start, the decision table, the command list and the rules that save a session — with the rest moved to references/ (interactive, parallel, pitfalls, troubleshooting, platform, internals) and an index saying when to open each. Verified that every paragraph of the old file survives somewhere except the frontmatter and the intro paragraph, both rewritten on purpose. The driver is untouched. plugin.json and marketplace.json go to 1.2.0 with descriptions matching the new one; both were still advertising 1.1.0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014pWzPoxzPfMzEhCBRekUiG --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 4 +- README.md | 6 +- README.ru.md | 6 +- skills/ssh-session/SKILL.md | 566 ++---------------- skills/ssh-session/references/interactive.md | 210 +++++++ skills/ssh-session/references/internals.md | 18 + skills/ssh-session/references/parallel.md | 68 +++ skills/ssh-session/references/pitfalls.md | 130 ++++ skills/ssh-session/references/platform.md | 57 ++ .../ssh-session/references/troubleshooting.md | 32 + 11 files changed, 577 insertions(+), 524 deletions(-) create mode 100644 skills/ssh-session/references/interactive.md create mode 100644 skills/ssh-session/references/internals.md create mode 100644 skills/ssh-session/references/parallel.md create mode 100644 skills/ssh-session/references/pitfalls.md create mode 100644 skills/ssh-session/references/platform.md create mode 100644 skills/ssh-session/references/troubleshooting.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bf4f4ae..000271a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,8 +9,8 @@ { "name": "ssh-session", "source": "./", - "description": "Persistent SSH sessions for agents: one live connection per named session, so cwd, env vars, venvs and background jobs survive between tool calls. Stdlib-only Python driver, no tmux and no pip packages.", - "version": "1.1.0", + "description": "Run commands on a remote host over a persistent SSH session instead of a bare `ssh host \"cmd\"` — one live connection per named session, so cwd, env vars, venvs and background jobs survive between tool calls, and interactive prompts can be answered. Stdlib-only Python driver, no tmux and no pip packages.", + "version": "1.2.0", "author": { "name": "Flexlug" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d77c81b..a342157 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ssh-session", - "description": "Persistent SSH sessions for agents: one live connection per named session, so cwd, env vars, venvs and background jobs survive between tool calls.", - "version": "1.1.0", + "description": "Run commands on a remote host over a persistent SSH session instead of a bare `ssh host \"cmd\"` — one live connection per named session, so cwd, env vars, venvs and background jobs survive between tool calls, and interactive prompts can be answered.", + "version": "1.2.0", "author": { "name": "Flexlug", "url": "https://github.com/Flexlug" diff --git a/README.md b/README.md index 1aea58d..8489b0a 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,10 @@ operations go through the unix socket: `send` / `info` / `kill`. executing until it has read the closing brace, so all the echo ends up **before** the first marker's output — that is, outside the region being cut. -The full description — pitfalls, the diagnostics table and the exit codes — is in -[`SKILL.md`](skills/ssh-session/SKILL.md). That is the very file the agent reads. +The full description is in [`SKILL.md`](skills/ssh-session/SKILL.md) — the very +file the agent reads — plus the `references/` files next to it, which it opens on +demand: pitfalls, the diagnostics table and exit codes, interactive prompts and +TUIs, parallel sessions, platform notes and internals. ## Authors diff --git a/README.ru.md b/README.ru.md index c0dfe4c..73e8c2e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -238,8 +238,10 @@ bash tests/local-sshd.sh stop # убивает и удаляет рабоч дочитает закрывающую скобку, поэтому всё эхо оказывается **до** вывода первого маркера — то есть вне вырезаемой области. -Полное описание — включая подводные камни, таблицу диагностики и коды -возврата — в [`SKILL.md`](skills/ssh-session/SKILL.md). Это же файл, который читает агент. +Полное описание — в [`SKILL.md`](skills/ssh-session/SKILL.md), это же файл читает +агент, — и в соседних файлах `references/`, которые он открывает по мере +надобности: подводные камни, таблица диагностики и коды возврата, интерактивные +приглашения и TUI, параллельные сессии, особенности платформ и внутреннее устройство. ## Авторы diff --git a/skills/ssh-session/SKILL.md b/skills/ssh-session/SKILL.md index 0f188e5..f14a2b0 100644 --- a/skills/ssh-session/SKILL.md +++ b/skills/ssh-session/SKILL.md @@ -1,17 +1,24 @@ --- name: ssh-session -description: Keeps a live SSH session to a server and runs commands over it without a fresh handshake each time — cwd, exported vars, venvs and background jobs persist between calls. Use this skill whenever you need to run more than one command on a remote host, work through a server step by step, connect over SSH, set up or deploy something on a VPS, answer interactive prompts (sudo, host key, REPL, TUI) or read the output of a long-running command. Triggers - ssh, connect to the server, log in to the server, run this on the server, remote shell, VPS, deploy to a server, persistent ssh session, run commands on host. +description: Run commands on a remote host over a persistent SSH session — one live connection per named session, so cwd, exported vars, activated venvs and background jobs survive between tool calls. Use this instead of `ssh host "cmd"` in Bash - a bare ssh call pays a full handshake, starts a fresh shell that loses all state, and cannot answer an interactive prompt. Applies whenever a task touches a remote host - ssh, connect or log in to the server, run this on the server, VPS, deploy, remote shell - and especially for more than one command, sudo or host-key prompts, REPLs (python3, psql, mysql), TUIs, or reading the output of a long-running command. --- # Persistent SSH sessions -A plain `ssh host "cmd"` pays a full handshake per command and starts a **new** -shell every time — so `cd`, `export`, an activated venv and background jobs are -all lost between calls. This skill keeps one connection alive in a background -daemon that owns a pty, and sends commands into the **same** shell. +**Do not run `ssh host "cmd"` from Bash for this work.** Every such call pays a +full handshake and starts a **new** shell, so `cd`, `export`, an activated venv +and background jobs are all lost between calls, and there is nobody to answer a +sudo password or a host-key question — the call just hangs until it times out. -Measured on a real host: 10 commands over a fresh ssh each time — **25.2 s**, -over a live session — **3.5 s**. +This skill keeps one connection alive in a background daemon that owns a pty and +sends commands into the **same** shell. Measured on a real host: 10 commands +over a fresh ssh each time — **25.2 s**, over a live session — **3.5 s**. + +Use it whenever the task touches a remote host at all. A single trivial +read-only command is the only case where plain `ssh` is still fine; the moment +there is a second command, state to keep, or a prompt to answer, use a session. + +## Locate the driver The driver is `scripts/sshsess.py` next to this file — executable, runnable directly (Python 3, stdlib only: no tmux, no pip packages). Its full path @@ -31,60 +38,13 @@ assignment differs: `set S ...`. ## Requirements -`python3` and `ssh` — nothing else. No pip packages, no tmux, no Node. - -Verified on Python 3.8 — the full `new`/`run`/`kill` cycle, including parsing of -`-- ssh arguments`, so any interpreter from 3.8 up will do. - -**The reference platforms are Linux and macOS. Windows is not supported**, and -that is a decision rather than an open debt: there is no pty and no `fork()` -there, and ConPTY would require a third-party dependency such as `pywinpty`, -breaking the skill's main property — stdlib only, nothing to install. The script -exits with code 1 and a clear message. WSL covers Windows: inside it this is -ordinary Linux and everything works as is. - -The same follows for what **not** to do: do not replace the unix socket with a -file-based channel, and do not add a pty-less mode. Neither idea gets any closer -to Windows — the obstacle is not the channel, it is the pty and `fork()`. - -**On the client side** this is tested on Linux; macOS is untested (no machine to -test on), but the parts specific to it are covered: - -- **State directory.** The order is: `SSHSESS_DIR` → `$XDG_RUNTIME_DIR` (Linux) - → `$TMPDIR` on macOS → `~/.cache/sshsess`. On macOS `$TMPDIR` is a private - per-user `0700` directory that the system cleans, i.e. the closest analogue of - `XDG_RUNTIME_DIR`. The persistent `~/.cache` is left only as the last fallback: - a password can land in `out.log` if a remote prompt forgot to turn echo off, - and such a file has no business staying on disk forever. -- **Permissions.** Directories `0700`, files `0600` — verified in every location, - including upgrading a directory created by an older version with `0755`. -- **The AF_UNIX path length** there is 104 bytes against 108 on Linux. With a - real macOS prefix (`/var/folders/xx//T/sshsess`, ~50 bytes) that leaves - ~45 characters for the session name. Verified on an emulation of the same - length: 101 bytes works, 104 refuses with a clear message rather than "daemon - failed to start". - -The test stand is portable too: `sshd` is looked up in several paths including -Homebrew, and port occupancy is checked through Python rather than the -Linux-only `ss`. - -**On the server side** the dependency is not the distribution but the shell: - -| Needed for | Requirement | Where it breaks | -|---|---|---| -| `run` (markers, `$?`) | any POSIX shell | `fish`, `csh` → fixed by `--shell 'bash -i'` | -| layer 3 (busy check) | bracketed paste, i.e. readline/zle | `dash`, `ash`/busybox → the check answers "unknown" and does not protect | - -The wrapper construct is verified in busybox `ash` — markers, `$?` and the -trailing `&` all work, so the core is portable there as well. The muting -variables (`DEBIAN_FRONTEND`, `NEEDRESTART_MODE`) are Debian-specific, but on -other systems they are simply unknown environment variables — no harm done. The -confirmation patterns cover `apt`, `dnf`, `pacman` and `zypper`. - -For checks there is a local stand that needs no remote host at all: -`tests/local-sshd.sh start` brings up a temporary sshd on 127.0.0.1 (no sudo, no -system-wide changes, `~/.ssh` is never touched), and `tests/TESTCASES.md` is the -smoke set. +`python3` and `ssh` on the client — nothing else, no pip packages, no tmux, no +Node. On the server the dependency is not the distribution but the shell: `run` +needs any POSIX shell (`fish`/`csh` are fixed with `--shell 'bash -i'`). + +**Linux and macOS only — Windows is not supported** (no pty, no `fork()`); WSL +covers it and works as ordinary Linux. Details, the state-directory order and +the server-side matrix: `references/platform.md`. ## Quick start @@ -188,459 +148,33 @@ Keys for `send --key`: `C-c`, `C-d`, `C-z` (any `C-`), `Enter`, `Tab`, `Escape`, `Up`/`Down`/`Left`/`Right`, `Home`, `End`, `PgUp`, `PgDn`, `Space`, `BSpace`. The flag is repeatable. -### Interactive work: REPLs - -```bash -$S send box 'python3 -q' -$S wait box '>>>' -$S send box '2+2' --wait 1.5 # send, then after 1.5 s show what came back -# 2+2 -# 4 -$S send box --key C-d # leave the REPL -``` - -Send multi-line input **with `--paste`**. Modern REPLs (PyREPL in Python 3.13+) -add indentation to every continuation line themselves, so a plain `send` stacks -your indentation on top of theirs and the block dies with `IndentationError`. -`--paste` wraps the text in bracketed paste, and it is accepted as is. An -unclosed block (`...` instead of `>>>`) is closed by an **empty line** — that is -a `send` with no text: - -```bash -$S send box --paste 'def fib(n): - a, b = 0, 1 - for _ in range(n): - a, b = b, a + b - return a -' -$S send box '' --wait 1 # an empty line closes the block -$S send box 'fib(30)' --wait 2 # -> 832040 -``` - -In `read` such a session looks ragged (`>>> f>>> fi>>> fib…`): PyREPL redraws -the line by moving the cursor rather than with `\r`, and gluing that back -together is impossible without a full terminal emulator. It does not affect the -result — only readability. - -## Several sessions at once - -You can have as many sessions as you need, including several to the same host. -Each is a separate daemon with its own connection and its own shell, and they do -not intersect: verified that `cd /etc` in one does not affect another. - -This is the intended way to do parallel work — start a process in one session -and watch it from another. An important detail about pacing: **one Bash call -costs the agent about 15 seconds**, so "look three times with pauses" as -separate calls is impossible for a task shorter than a minute — by the first -measurement everything is already over. Make the pauses with a remote `sleep` -**inside** `run`, and take a series of measurements in a single call: - -```bash -$S new worker server.example.com -$S new watcher server.example.com - -$S run worker 'rm -f /tmp/job.log; (for i in $(seq 1 10); do echo "tick-$i" >> /tmp/job.log; sleep 1; done) &' -$S run watcher 'wc -l < /tmp/job.log; sleep 3; wc -l < /tmp/job.log; sleep 3; wc -l < /tmp/job.log' -$S run worker 'jobs' # meanwhile worker is not blocked -``` - -**A session name is a machine-wide resource.** All sessions live in one -directory, so anyone using the same name lands in the same shell. What exists -and what does not: - -- `ls` and `info` show the owner (`same owner` / `other:5367636a`); -- `new` refuses a name held by a **live** session (code 2) and will not silently - hijack someone else's shell. A dead name is reused by an ordinary `new`; - `--force` is not needed for that; -- `kill --all` will close other people's sessions too — in a shared directory, - kill by name. - -**There is no automatic protection between parallel agents of one chat, and -there cannot be.** Verified: a subagent inherits the parent's -`CLAUDE_CODE_SESSION_ID` unchanged, its environment matches the parent's byte -for byte. So all subagents of one chat see each other as `same owner`, and `ls` -will not tell your session apart from a neighbouring agent's. - -Two consequences follow, both mandatory: - -1. **Name the session after the task, not `box`** — `deploy-web`, `logs-nginx`, - `db-migrate`. In a subagent flow, hand every agent its own name explicitly. - This is the only real protection. -2. If you need genuine isolation, set the owner yourself through - `SSHSESS_OWNER`; it overrides everything else: - -```bash -SSHSESS_OWNER=agent-a $S new a-deploy server.example.com -SSHSESS_OWNER=agent-b $S ls # will see a-deploy as other:agent-a -``` - -Dead sessions stay in `ls` on purpose — their log is needed for the post-mortem. -To drop the records along with the logs: `$S prune`. - -**Parallel `run`s into one session are serialised.** The remote shell executes -one thing at a time, so two simultaneous `run`s used to interleave their output — -A would receive B's wrapper inside itself. Now they queue on a flock and each -gets its own clean output. If there is no time to wait, the second exits with -code **125** saying the session is busy. `--timeout` is the shared budget for -"wait for the queue plus execute", so a call never hangs longer than requested. - -`send` deliberately does **not** take the lock: `send --key C-c` has to get -through precisely when something is running in the session. - -For a subagent flow: give every agent its own session name. Then they physically -do not interfere with each other rather than relying on the queue. - -## Keeping the work from stalling - -SSH tooling is built for a human at a keyboard. Nobody is there to rescue an -agent, so there are two layers here: prevent the prompt from appearing, and if -it did appear, answer it. - -### Layer 1: muting interactivity (on by default) - -Right after connecting, `new` applies a non-interactive environment and prints -`(non-interactive env applied)`. Disabled with `--no-harden`. - -``` -PAGER=cat GIT_PAGER=cat SYSTEMD_PAGER=cat LESS=FRX MANPAGER=cat -DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a APT_LISTCHANGES_FRONTEND=none -EDITOR=false VISUAL=false GIT_TERMINAL_PROMPT=0 -LC_ALL=C.UTF-8 (only if such a locale exists — otherwise unicode breaks) -``` - -This closes the nastiest trap — **the pager**. Verified: with `--no-harden`, -`run box 'git log'` runs into timeout 124 because `less` is waiting for `q`; with -muting on, the same command and `systemctl status` go through normally. `man`, -`journalctl` and `git diff` belong to the same family — all of them silently wait -for a human. `EDITOR=false` means `git commit` without `-m` honestly fails -instead of hanging the session in `vim`. - -### Layer 2: auto-answers - -`run` watches the output and answers known prompts without waiting for a human. -What was answered is written to stderr, so it is visible in the work log too. - -```bash -$S run box 'sudo apt-get install -y nginx' # the password is substituted from a file -$S run box --yes 'apt-get upgrade' # + [Y/n] confirmations -$S run box --no-auto 'something delicate' # answer nothing -``` - -The split is deliberate: **passwords, pagers and "Press ENTER" are answered -immediately**, while confirmations that change something (`[Y/n]`, `Proceed?`, a -`dpkg` conffile conflict) only with `--yes`. An accidental `y` does not roll -back. - -The key property that makes this safe: a rule fires **only if the match sits at -the very end of the output**, i.e. the program really is waiting. Without that -condition a pattern once caught the help text inside `less` and sent Enter three -times into someone else's TUI — and Enter on a highlighted menu entry can -confirm anything at all. Verified: `echo "Do you want to continue? [Y/n]"` as -ordinary output is not answered, a real prompt is. - -### Passwords - -They live in `~/.config/sshsess/secrets.json`, mode strictly `0600` — otherwise -`sshsess` refuses to run. The password is never passed into the chat and never -ends up on a command line. - -```bash -mkdir -p ~/.config/sshsess -cat > ~/.config/sshsess/secrets.json <<'EOF' -{"secrets": {"sudo": "…", "db": "…"}} -EOF -chmod 600 ~/.config/sshsess/secrets.json -``` - -Verified: the password is not in the session log — neither as text nor in the -raw bytes (password prompts turn terminal echo off). The report prints -`` in place of the value. If the required secret is missing, -`sshsess` says so explicitly instead of hanging. - -### An already stuck session, and TUIs - -```bash -$S expect box --dry-run --once # what is it waiting for? without sending anything -$S expect box --once --yes # answer and unblock -$S expect box --list-rules # which rules are in effect -``` - -`expect` also looks backwards through the log, so it catches a prompt printed -**before** it was started — and that is the main scenario. Verified on a stuck -`[Y/n]` and on a password request. - -For TUIs, send keys directly. A single printable character is a key too, and is -sent without Enter (`q` in `less`/`top`, `y` in a dialog): - -```bash -$S send box --key Down --key Enter # pick a whiptail menu entry -$S send box --key q # leave the pager -``` - -Verified on a real `whiptail --menu`: `Down` + `Enter` selected the second entry. -There are `F1`-`F12`, arrows, `Home`/`End`/`PgUp`/`PgDn`, and any `C-`. - -A single key is sent without a newline, so it stays sitting on the input line — -but the next `run` will not break because of it: the wrapper starts with Ctrl-U, -which discards unfinished input. Verified both with a leftover `q` and with a -whole line without Enter — previously that produced `bash: q{: command not found` -plus `syntax error`. - -### Custom rules - -```json -{ - "extend_defaults": true, - "rules": [ - {"label": "mysql-pw", "pattern": "Enter password:", "secret": "db"}, - {"label": "my-installer", "pattern": "Continue\\? \\(yes/no\\)", "send": "yes", - "confirm": true}, - {"label": "dialog-ok", "pattern": "\\s*$", "key": "Enter"} - ] -} -``` - -Goes into `~/.config/sshsess/rules.json` or is passed with `--rules FILE`. -Fields: `pattern` (a regexp) and exactly one of `send` / `key` / `secret`; -`confirm: true` makes the rule depend on `--yes`; `extend_defaults` adds the -rules to the built-in ones instead of replacing them. - -### Layer 3: do not type into something that is not a prompt - -`run` used to print its wrapper into a running `top` or `less` as **keystrokes** — -`less` was observed answering `Pattern not found` and `There is no - option`, and -in `vim` you can write a file that way. Now `run` first checks whether the shell -is reading commands, and on refusal says which key to press. - -The evidence is free — the bracketed-paste state in the log already received, no -request to the server and no byte sent. `readline` turns it on (`?2004h`) while -the shell is reading a line, and turns it off (`?2004l`) before launching any -command. Measured: bash at a prompt → `h`; `less`, `top`, `whiptail` → `l`; after -leaving `less` → `h` again. On top of that, the switch to the alternate screen -used by `whiptail` is detected separately — then the message names the -full-screen program outright. - -One check covers two cases at once: a TUI started through `send` (there is no -timeout there, so the busy mark was not set) and a command that simply has not -finished. The exit code is **126**. - -Limits worth knowing: - -- **A REPL with line-by-line input** (`python3`, `psql`) turns bracketed paste on - itself, so it reads as "a prompt", and `run` will type into it after all. This - is less dangerous — the text will be evaluated as an expression and produce an - error — but it is still better to return to the shell first. -- **A shell without bracketed paste** (`dash`, `sh`, `bash --noediting`) leaves no - evidence: the check honestly answers "unknown" and does not block the work. But - it does not protect either: the probe only fires if the busy mark is set, and a - TUI started through `send` does not set it. On such shells layer 3 simply does - not work — verified on `bash --noediting`, the wrapper went into `top`. -- **The evidence can be forged**: a command printing `ESC[?2004h` into its own - output looks like a returned prompt. That is why the busy mark is cleared by - the probe, not by the log — the log alone cannot clear it. -- **A stuck alternate screen** (a TUI killed with SIGKILL before `rmcup`) no - longer hangs the session forever: the latest evidence by position wins, so a - prompt printed after that overrides the stale flag. -- **`truncate` refuses while a TUI is running** (code 126): the log is the only - evidence, and erasing it means removing the protection. -- The check sits **inside** the lock: outside it, a second parallel `run` would - see the first one's command executing and fail with 126 instead of waiting its - turn. - -### What automation does not solve - -- **A plain `less` with no flags** shows the file name in inverse video rather - than text: once escape sequences are stripped this is indistinguishable from - output, and no rule will catch it — you get 124, and the way out is - `send --key q`. What is caught: `(END)`, `--More--` and `lines N-M/T` - (including `less -M`, where the line ends with a percentage). Muting makes this - almost irrelevant — the pager does not start at all. -- **`expect --list-rules`** shows all rules, including those gated behind - `--yes` — they are marked `[needs --yes]`. -- **A prompt with no secret for it** is printed as a separate - `PROMPT NOT ANSWERED` line rather than as "auto-answered": nothing was sent, - and the answer count is not spoiled by it. -- **`run` into a running TUI is no longer destructive — it refuses.** See below. -- **Accepting a host key is deliberately not automated** — that is a security - decision and it belongs to a human. - -## Pitfalls - -Everything below was actually reproduced, not guessed at. - -**`exit` inside `run` destroys the session — it runs in that same shell.** That -is the price of keeping state: `run` works in ssh's login shell, so `exit`, -`logout` or a fragment like `[ -f x ] || exit 1` logs it out and takes the -session with it, along with cwd, venv and background jobs. `run` now **rejects** -such commands (code 2) and offers alternatives; verified that `(exit 42)` and -`sh -c 'exit 7'` still honestly return 42 and 7 with the session alive: - -```bash -$S run box '(exit 42)' # subshell — code 42, session intact -$S run box "sh -c 'exit 7'" # separate process — code 7 -$S run --allow-exit box 'exit' # if closing the session is the actual goal -``` - -**`run` requires a POSIX shell at the prompt.** The marker mechanics rely on -`bash`/`zsh`/`sh` (`{ ... }` and `$?` are needed). It will not work in `fish` — -`new` exits with code 4 and suggests `--shell 'bash -i'`. And if a REPL or a TUI -is running in the session, `run` will simply print its wrapper inside that -program — return to the shell first. - -**After a timeout the session is marked busy, and `run` checks that.** A command -that did not finish by the timeout keeps reading the pty and eats the first line -of the **next** command's wrapper — garbled output and -`bash: syntax error near unexpected token '}'`. Previously this happened -silently. - -The mark is not "sticky": `run` sends a probe and sees whether the shell answers. -If the command has already been dealt with (fed through `send`, answered through -`expect`, or finished on its own), the mark is cleared automatically and work -continues. If the probe is swallowed — refusal with code **126** and the text -"verified: it swallowed a probe". `ls` shows such a session as `busy`, `info` -shows the reason. - -```bash -$S read box --tail 20 # look at what is hanging -$S interrupt box # Ctrl-C plus a check that the shell came back -$S send box --key q # if it is a full-screen program — its own key -``` - -`interrupt` does **not** report success blindly: `less`, `top` and others ignore -SIGINT, so after Ctrl-C it probes the shell and on failure exits with 126, -suggesting you send the program's native exit key. Previously it cleared the -mark, and the next `run` printed the wrapper into `less` as keystrokes. - -`run --force` bypasses the check, but carefully: if the command really is -hanging, `--force` will **not** return its code — it will sit out the whole -`--timeout` and give back 124 with a raw transcript. The default is 120 seconds -of silence, so set a short `--timeout` explicitly. Auto-answers are disabled with -`--force` in a busy session: otherwise the rules match text drawn by the TUI and -start pressing keys inside it (three Enters into a live `less` — observed). - -**`send --key C-c` interrupts the remote command but does not unblock a waiting -`run`.** Ctrl-C tears down the whole `{ ... }` group, so the closing marker is -never executed: `run` sits out its timeout and holds the lock all that time, -giving others a false "busy" although the shell is already free. There is a -separate command for this that does both: - -```bash -$S interrupt box # run returns immediately with code 130, the lock is released -``` - -**A command that reads stdin eats the marker — and the next command's marker -too.** `run box 'read x'` runs into a timeout (code 124), and the next `run` gets -garbled output like `got=[{ printf '__SSB...']` and -`syntax error near unexpected token '}'`. The session recovers on its own -afterwards, but the cure is this: - -```bash -$S run box 'read x < /dev/null; echo "rc=$?"' # close off stdin -$S run box 'echo hello | { read x; echo "[$x]"; }' # or feed it data -``` - -**`wait` sees the echo of what you have just sent.** The shell echoes input back, -so `send box 'sleep 3; echo TRAP_WORD'` + `wait box 'TRAP_WORD'` fires instantly -rather than after 3 s. That is why `wait` looks at **new** output only by default -(`--from-start` restores the old behaviour) — and even so: if you need to wait for -a command to finish, that is `run --timeout`'s job, not `wait`'s. `wait` is good -for a line in the log of an already running process. - -**Full-screen TUIs are read approximately.** `read` is a line-by-line history, -not a screen snapshot: cursor-positioning escape sequences are stripped, so -`top`'s frames overlay each other and the header can end up glued to a line from -the previous frame. For data, use batch modes (`top -b -n1`, -`journalctl --no-pager`), and keep `read` for what streams line by line anyway: -logs, builds, REPLs. - -**One command at a time, but `run` will not always wait.** Distinguish two cases: - -- the command is held by a **parallel `run`** — the second one queues on the - flock and executes after the first (verified: two 5 s `run`s took 10.4 s); -- the command was started through **`send`** — then `run` does not wait but - refuses immediately with code 126 in ~0.05 s, because it sees the shell is not - at a prompt. - -For real parallelism, use separate sessions — see the section above. - -**An unknown host key hangs the connection.** `new` exits with code 4, shows the -tail of the output with the fingerprint question, and suggests the command. -Accepting the key writes to `~/.ssh/known_hosts` — that is a configuration -change, so ask the user before answering `yes`: - -```bash -$S send box yes -``` - -**Sessions do not survive a reboot.** State lives in the directory from the -"Requirements" section — on Linux that is `/run/user//sshsess/`, on macOS -`$TMPDIR`. The system cleans both. Override with `SSHSESS_DIR`. - -Sessions created before the move to `$TMPDIR` lived in `~/.cache/sshsess` and no -longer appear in the listing. There is nothing to migrate deliberately — a -session is a live ssh connection with a daemon process, and after a reboot it is -dead in any case. But if the old directory is not empty, `ls` mentions it and -suggests the command to look inside. -`out.log` is not rotated — a TUI running for a few minutes inflates it to -megabytes. It is cleared without dropping the connection: `truncate NAME` (which -refuses if a command is executing right now, because that command counts output -by offsets in the file). - -**Network drops.** `ServerAliveInterval=15` / `ServerAliveCountMax=4` are set, so -a dead connection is detected in about a minute and the session goes `dead`. -There is no auto-reconnect, but you do not need to reopen it by hand with the -same arguments: `reconnect NAME` takes the target, the ssh arguments, `--shell` -and the pty size from `meta.json`. Verified on a killed ssh process. The remote -state is genuinely lost in the process — a new shell, a new cwd, no variables — -and `reconnect` says so outright. - -## Diagnostics - -| Symptom | What it means and what to do | +## Rules that save a session + +- **Never `exit` inside `run`** — it runs in that same shell and takes the + session with it. `run` rejects such commands (code 2); use `(exit 42)` or + `sh -c 'exit 7'`, or `--allow-exit` if closing really is the goal. +- **Name the session after the task**, not `box` — `deploy-web`, `db-migrate`. + Names are a machine-wide resource shared with neighbouring agents. +- **Accepting a host key is never automated** — it changes `~/.ssh/known_hosts`, + so ask the user before sending `yes`. +- **Interactivity is muted by default** (`PAGER=cat`, `DEBIAN_FRONTEND`, + `EDITOR=false`, …) so `git log` and `systemctl status` do not hang on a pager. +- **Sudo passwords come from `~/.config/sshsess/secrets.json`** (mode `0600`) and + never touch the chat or a command line. Confirmations that change something + need `--yes`. +- **A command reading stdin eats the marker** — close it off: + `run box 'read x < /dev/null'`. + +## Going deeper + +Read the reference file for the situation you are actually in — do not read them +all up front: + +| File | When to open it | |---|---| -| `session 'x' is not running (no live socket ...)`, code 3 | There is no such session, or it died. `ls` shows the status, `new` opens it again. | -| `could not be opened` + a tail of ssh output, code 3 | ssh failed immediately. The message contains the full launch line and ssh's output — read it: `unknown option`, `No address associated with hostname`, `Connection refused`. | -| `started but the remote shell did not respond`, code 4 | The connection is waiting on input — a host key or a password. The output tail is in the message; answer through `send`. | -| `command still running after Ns`, code 124 | The command did not finish. The output above is partial. Investigate with `read`, interrupt with `interrupt`. | -| `session 'x' is busy`, code 125 | A parallel `run` holds the lock. Give a bigger `--timeout` or work through a separate session. | -| `still has a command running`, code 126 | Verified by probe: the previous command is eating input. `interrupt`, or the program's native exit key. | -| `sent Ctrl-C … still not reading`, code 126 | The program ignores SIGINT (`less`, `top`). Send its exit key: `send NAME --key q`. | -| `refusing to run … not at a prompt`, code 126 | Something is executing in the session, or a TUI is open. `read --tail 20`, then `interrupt` or the native exit key. | -| `refusing to run … full-screen program`, code 126 | A full-screen program (`whiptail`, `dialog`). Leave it with its key — usually `Enter` or `q`. | -| `died while running the command`, code 3 | The connection dropped mid-command; the message carries the log tail. | -| Output contains `__SSB...` / `syntax error near '}'` | A program reading stdin ate the marker. See the pitfalls. | -| `refusing to run this: 'exit' would log out...`, code 2 | The command would close the session. Wrap it in `( ... )` or `sh -c`. | -| `session 'x' is already running`, code 2 | The name is held by a live session. Take another one — it may belong to a neighbouring agent. | - -Exit codes: `0`/the remote command's code, `2` — argument error (and the `exit` -guard's refusal), `3` — the session is dead or absent, `4` — connected but the -shell does not answer, `124` — timeout, `125` — the lock is held by a parallel -`run`, `126` — an unfinished command is hanging in the session, `130` — -interrupted through `interrupt`. - -If something is truly strange, look at the raw stream without filtering and at -the daemon's own log: - -```bash -$S read box --raw | tail -c 2000 -cat /run/user/1000/sshsess/box/daemon.log -``` - -## How it works - -The daemon is a double-forked process that owns the pty master and appends -everything coming from the server to `out.log`. Clients read that file directly, -so `read` works even while a command is still streaming output. Only -side-effecting operations go through the unix socket: `send` / `info` / `kill`. - -`run` rests on two tricks, and both are mandatory: - -1. The marker literals are split by quotes (`'__SS''B...'`), so the **echo** of - the line we send does not contain the text we are searching for. Otherwise the - search would always hit the echo and cut the output in the wrong place. -2. Everything is wrapped in a single `{ ... }` group. The shell will not start - executing until it has read the closing brace, so all the echo (including the - `>` continuation lines) ends up **before** the first marker's output — that is, - outside the region being cut. The command sits on its own physical line for - this: `foo & ; printf` is a syntax error, while `foo &` plus a newline is not, - which is why `run box 'sleep 30 &'` works. +| `references/interactive.md` | REPLs, sudo/`[Y/n]` prompts, passwords, TUIs, `expect`, custom rules, an already stuck session | +| `references/parallel.md` | Several sessions at once, subagent flows, session ownership, lock behaviour | +| `references/pitfalls.md` | Something behaved unexpectedly — timeouts, code 124/125/126, garbled markers, reconnects | +| `references/troubleshooting.md` | You have an error message or an exit code and want the meaning | +| `references/platform.md` | macOS/Windows/WSL specifics, state directory, server-side shell matrix, the local test stand | +| `references/internals.md` | You are changing the driver and need the marker/pty mechanics | diff --git a/skills/ssh-session/references/interactive.md b/skills/ssh-session/references/interactive.md new file mode 100644 index 0000000..8233cf3 --- /dev/null +++ b/skills/ssh-session/references/interactive.md @@ -0,0 +1,210 @@ +# Interactive work: REPLs, prompts, TUIs + +### Interactive work: REPLs + +```bash +$S send box 'python3 -q' +$S wait box '>>>' +$S send box '2+2' --wait 1.5 # send, then after 1.5 s show what came back +# 2+2 +# 4 +$S send box --key C-d # leave the REPL +``` + +Send multi-line input **with `--paste`**. Modern REPLs (PyREPL in Python 3.13+) +add indentation to every continuation line themselves, so a plain `send` stacks +your indentation on top of theirs and the block dies with `IndentationError`. +`--paste` wraps the text in bracketed paste, and it is accepted as is. An +unclosed block (`...` instead of `>>>`) is closed by an **empty line** — that is +a `send` with no text: + +```bash +$S send box --paste 'def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a +' +$S send box '' --wait 1 # an empty line closes the block +$S send box 'fib(30)' --wait 2 # -> 832040 +``` + +In `read` such a session looks ragged (`>>> f>>> fi>>> fib…`): PyREPL redraws +the line by moving the cursor rather than with `\r`, and gluing that back +together is impossible without a full terminal emulator. It does not affect the +result — only readability. + + + +SSH tooling is built for a human at a keyboard. Nobody is there to rescue an +agent, so there are two layers here: prevent the prompt from appearing, and if +it did appear, answer it. + +### Layer 1: muting interactivity (on by default) + +Right after connecting, `new` applies a non-interactive environment and prints +`(non-interactive env applied)`. Disabled with `--no-harden`. + +``` +PAGER=cat GIT_PAGER=cat SYSTEMD_PAGER=cat LESS=FRX MANPAGER=cat +DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a APT_LISTCHANGES_FRONTEND=none +EDITOR=false VISUAL=false GIT_TERMINAL_PROMPT=0 +LC_ALL=C.UTF-8 (only if such a locale exists — otherwise unicode breaks) +``` + +This closes the nastiest trap — **the pager**. Verified: with `--no-harden`, +`run box 'git log'` runs into timeout 124 because `less` is waiting for `q`; with +muting on, the same command and `systemctl status` go through normally. `man`, +`journalctl` and `git diff` belong to the same family — all of them silently wait +for a human. `EDITOR=false` means `git commit` without `-m` honestly fails +instead of hanging the session in `vim`. + +### Layer 2: auto-answers + +`run` watches the output and answers known prompts without waiting for a human. +What was answered is written to stderr, so it is visible in the work log too. + +```bash +$S run box 'sudo apt-get install -y nginx' # the password is substituted from a file +$S run box --yes 'apt-get upgrade' # + [Y/n] confirmations +$S run box --no-auto 'something delicate' # answer nothing +``` + +The split is deliberate: **passwords, pagers and "Press ENTER" are answered +immediately**, while confirmations that change something (`[Y/n]`, `Proceed?`, a +`dpkg` conffile conflict) only with `--yes`. An accidental `y` does not roll +back. + +The key property that makes this safe: a rule fires **only if the match sits at +the very end of the output**, i.e. the program really is waiting. Without that +condition a pattern once caught the help text inside `less` and sent Enter three +times into someone else's TUI — and Enter on a highlighted menu entry can +confirm anything at all. Verified: `echo "Do you want to continue? [Y/n]"` as +ordinary output is not answered, a real prompt is. + +### Passwords + +They live in `~/.config/sshsess/secrets.json`, mode strictly `0600` — otherwise +`sshsess` refuses to run. The password is never passed into the chat and never +ends up on a command line. + +```bash +mkdir -p ~/.config/sshsess +cat > ~/.config/sshsess/secrets.json <<'EOF' +{"secrets": {"sudo": "…", "db": "…"}} +EOF +chmod 600 ~/.config/sshsess/secrets.json +``` + +Verified: the password is not in the session log — neither as text nor in the +raw bytes (password prompts turn terminal echo off). The report prints +`` in place of the value. If the required secret is missing, +`sshsess` says so explicitly instead of hanging. + +### An already stuck session, and TUIs + +```bash +$S expect box --dry-run --once # what is it waiting for? without sending anything +$S expect box --once --yes # answer and unblock +$S expect box --list-rules # which rules are in effect +``` + +`expect` also looks backwards through the log, so it catches a prompt printed +**before** it was started — and that is the main scenario. Verified on a stuck +`[Y/n]` and on a password request. + +For TUIs, send keys directly. A single printable character is a key too, and is +sent without Enter (`q` in `less`/`top`, `y` in a dialog): + +```bash +$S send box --key Down --key Enter # pick a whiptail menu entry +$S send box --key q # leave the pager +``` + +Verified on a real `whiptail --menu`: `Down` + `Enter` selected the second entry. +There are `F1`-`F12`, arrows, `Home`/`End`/`PgUp`/`PgDn`, and any `C-`. + +A single key is sent without a newline, so it stays sitting on the input line — +but the next `run` will not break because of it: the wrapper starts with Ctrl-U, +which discards unfinished input. Verified both with a leftover `q` and with a +whole line without Enter — previously that produced `bash: q{: command not found` +plus `syntax error`. + +### Custom rules + +```json +{ + "extend_defaults": true, + "rules": [ + {"label": "mysql-pw", "pattern": "Enter password:", "secret": "db"}, + {"label": "my-installer", "pattern": "Continue\\? \\(yes/no\\)", "send": "yes", + "confirm": true}, + {"label": "dialog-ok", "pattern": "\\s*$", "key": "Enter"} + ] +} +``` + +Goes into `~/.config/sshsess/rules.json` or is passed with `--rules FILE`. +Fields: `pattern` (a regexp) and exactly one of `send` / `key` / `secret`; +`confirm: true` makes the rule depend on `--yes`; `extend_defaults` adds the +rules to the built-in ones instead of replacing them. + +### Layer 3: do not type into something that is not a prompt + +`run` used to print its wrapper into a running `top` or `less` as **keystrokes** — +`less` was observed answering `Pattern not found` and `There is no - option`, and +in `vim` you can write a file that way. Now `run` first checks whether the shell +is reading commands, and on refusal says which key to press. + +The evidence is free — the bracketed-paste state in the log already received, no +request to the server and no byte sent. `readline` turns it on (`?2004h`) while +the shell is reading a line, and turns it off (`?2004l`) before launching any +command. Measured: bash at a prompt → `h`; `less`, `top`, `whiptail` → `l`; after +leaving `less` → `h` again. On top of that, the switch to the alternate screen +used by `whiptail` is detected separately — then the message names the +full-screen program outright. + +One check covers two cases at once: a TUI started through `send` (there is no +timeout there, so the busy mark was not set) and a command that simply has not +finished. The exit code is **126**. + +Limits worth knowing: + +- **A REPL with line-by-line input** (`python3`, `psql`) turns bracketed paste on + itself, so it reads as "a prompt", and `run` will type into it after all. This + is less dangerous — the text will be evaluated as an expression and produce an + error — but it is still better to return to the shell first. +- **A shell without bracketed paste** (`dash`, `sh`, `bash --noediting`) leaves no + evidence: the check honestly answers "unknown" and does not block the work. But + it does not protect either: the probe only fires if the busy mark is set, and a + TUI started through `send` does not set it. On such shells layer 3 simply does + not work — verified on `bash --noediting`, the wrapper went into `top`. +- **The evidence can be forged**: a command printing `ESC[?2004h` into its own + output looks like a returned prompt. That is why the busy mark is cleared by + the probe, not by the log — the log alone cannot clear it. +- **A stuck alternate screen** (a TUI killed with SIGKILL before `rmcup`) no + longer hangs the session forever: the latest evidence by position wins, so a + prompt printed after that overrides the stale flag. +- **`truncate` refuses while a TUI is running** (code 126): the log is the only + evidence, and erasing it means removing the protection. +- The check sits **inside** the lock: outside it, a second parallel `run` would + see the first one's command executing and fail with 126 instead of waiting its + turn. + +### What automation does not solve + +- **A plain `less` with no flags** shows the file name in inverse video rather + than text: once escape sequences are stripped this is indistinguishable from + output, and no rule will catch it — you get 124, and the way out is + `send --key q`. What is caught: `(END)`, `--More--` and `lines N-M/T` + (including `less -M`, where the line ends with a percentage). Muting makes this + almost irrelevant — the pager does not start at all. +- **`expect --list-rules`** shows all rules, including those gated behind + `--yes` — they are marked `[needs --yes]`. +- **A prompt with no secret for it** is printed as a separate + `PROMPT NOT ANSWERED` line rather than as "auto-answered": nothing was sent, + and the answer count is not spoiled by it. +- **`run` into a running TUI is no longer destructive — it refuses.** See below. +- **Accepting a host key is deliberately not automated** — that is a security + decision and it belongs to a human. + diff --git a/skills/ssh-session/references/internals.md b/skills/ssh-session/references/internals.md new file mode 100644 index 0000000..a484965 --- /dev/null +++ b/skills/ssh-session/references/internals.md @@ -0,0 +1,18 @@ +# How it works + +The daemon is a double-forked process that owns the pty master and appends +everything coming from the server to `out.log`. Clients read that file directly, +so `read` works even while a command is still streaming output. Only +side-effecting operations go through the unix socket: `send` / `info` / `kill`. + +`run` rests on two tricks, and both are mandatory: + +1. The marker literals are split by quotes (`'__SS''B...'`), so the **echo** of + the line we send does not contain the text we are searching for. Otherwise the + search would always hit the echo and cut the output in the wrong place. +2. Everything is wrapped in a single `{ ... }` group. The shell will not start + executing until it has read the closing brace, so all the echo (including the + `>` continuation lines) ends up **before** the first marker's output — that is, + outside the region being cut. The command sits on its own physical line for + this: `foo & ; printf` is a syntax error, while `foo &` plus a newline is not, + which is why `run box 'sleep 30 &'` works. diff --git a/skills/ssh-session/references/parallel.md b/skills/ssh-session/references/parallel.md new file mode 100644 index 0000000..234825a --- /dev/null +++ b/skills/ssh-session/references/parallel.md @@ -0,0 +1,68 @@ +# Several sessions at once + +You can have as many sessions as you need, including several to the same host. +Each is a separate daemon with its own connection and its own shell, and they do +not intersect: verified that `cd /etc` in one does not affect another. + +This is the intended way to do parallel work — start a process in one session +and watch it from another. An important detail about pacing: **one Bash call +costs the agent about 15 seconds**, so "look three times with pauses" as +separate calls is impossible for a task shorter than a minute — by the first +measurement everything is already over. Make the pauses with a remote `sleep` +**inside** `run`, and take a series of measurements in a single call: + +```bash +$S new worker server.example.com +$S new watcher server.example.com + +$S run worker 'rm -f /tmp/job.log; (for i in $(seq 1 10); do echo "tick-$i" >> /tmp/job.log; sleep 1; done) &' +$S run watcher 'wc -l < /tmp/job.log; sleep 3; wc -l < /tmp/job.log; sleep 3; wc -l < /tmp/job.log' +$S run worker 'jobs' # meanwhile worker is not blocked +``` + +**A session name is a machine-wide resource.** All sessions live in one +directory, so anyone using the same name lands in the same shell. What exists +and what does not: + +- `ls` and `info` show the owner (`same owner` / `other:5367636a`); +- `new` refuses a name held by a **live** session (code 2) and will not silently + hijack someone else's shell. A dead name is reused by an ordinary `new`; + `--force` is not needed for that; +- `kill --all` will close other people's sessions too — in a shared directory, + kill by name. + +**There is no automatic protection between parallel agents of one chat, and +there cannot be.** Verified: a subagent inherits the parent's +`CLAUDE_CODE_SESSION_ID` unchanged, its environment matches the parent's byte +for byte. So all subagents of one chat see each other as `same owner`, and `ls` +will not tell your session apart from a neighbouring agent's. + +Two consequences follow, both mandatory: + +1. **Name the session after the task, not `box`** — `deploy-web`, `logs-nginx`, + `db-migrate`. In a subagent flow, hand every agent its own name explicitly. + This is the only real protection. +2. If you need genuine isolation, set the owner yourself through + `SSHSESS_OWNER`; it overrides everything else: + +```bash +SSHSESS_OWNER=agent-a $S new a-deploy server.example.com +SSHSESS_OWNER=agent-b $S ls # will see a-deploy as other:agent-a +``` + +Dead sessions stay in `ls` on purpose — their log is needed for the post-mortem. +To drop the records along with the logs: `$S prune`. + +**Parallel `run`s into one session are serialised.** The remote shell executes +one thing at a time, so two simultaneous `run`s used to interleave their output — +A would receive B's wrapper inside itself. Now they queue on a flock and each +gets its own clean output. If there is no time to wait, the second exits with +code **125** saying the session is busy. `--timeout` is the shared budget for +"wait for the queue plus execute", so a call never hangs longer than requested. + +`send` deliberately does **not** take the lock: `send --key C-c` has to get +through precisely when something is running in the session. + +For a subagent flow: give every agent its own session name. Then they physically +do not interfere with each other rather than relying on the queue. + diff --git a/skills/ssh-session/references/pitfalls.md b/skills/ssh-session/references/pitfalls.md new file mode 100644 index 0000000..b957f6a --- /dev/null +++ b/skills/ssh-session/references/pitfalls.md @@ -0,0 +1,130 @@ +# Pitfalls + +Everything below was actually reproduced, not guessed at. + +**`exit` inside `run` destroys the session — it runs in that same shell.** That +is the price of keeping state: `run` works in ssh's login shell, so `exit`, +`logout` or a fragment like `[ -f x ] || exit 1` logs it out and takes the +session with it, along with cwd, venv and background jobs. `run` now **rejects** +such commands (code 2) and offers alternatives; verified that `(exit 42)` and +`sh -c 'exit 7'` still honestly return 42 and 7 with the session alive: + +```bash +$S run box '(exit 42)' # subshell — code 42, session intact +$S run box "sh -c 'exit 7'" # separate process — code 7 +$S run --allow-exit box 'exit' # if closing the session is the actual goal +``` + +**`run` requires a POSIX shell at the prompt.** The marker mechanics rely on +`bash`/`zsh`/`sh` (`{ ... }` and `$?` are needed). It will not work in `fish` — +`new` exits with code 4 and suggests `--shell 'bash -i'`. And if a REPL or a TUI +is running in the session, `run` will simply print its wrapper inside that +program — return to the shell first. + +**After a timeout the session is marked busy, and `run` checks that.** A command +that did not finish by the timeout keeps reading the pty and eats the first line +of the **next** command's wrapper — garbled output and +`bash: syntax error near unexpected token '}'`. Previously this happened +silently. + +The mark is not "sticky": `run` sends a probe and sees whether the shell answers. +If the command has already been dealt with (fed through `send`, answered through +`expect`, or finished on its own), the mark is cleared automatically and work +continues. If the probe is swallowed — refusal with code **126** and the text +"verified: it swallowed a probe". `ls` shows such a session as `busy`, `info` +shows the reason. + +```bash +$S read box --tail 20 # look at what is hanging +$S interrupt box # Ctrl-C plus a check that the shell came back +$S send box --key q # if it is a full-screen program — its own key +``` + +`interrupt` does **not** report success blindly: `less`, `top` and others ignore +SIGINT, so after Ctrl-C it probes the shell and on failure exits with 126, +suggesting you send the program's native exit key. Previously it cleared the +mark, and the next `run` printed the wrapper into `less` as keystrokes. + +`run --force` bypasses the check, but carefully: if the command really is +hanging, `--force` will **not** return its code — it will sit out the whole +`--timeout` and give back 124 with a raw transcript. The default is 120 seconds +of silence, so set a short `--timeout` explicitly. Auto-answers are disabled with +`--force` in a busy session: otherwise the rules match text drawn by the TUI and +start pressing keys inside it (three Enters into a live `less` — observed). + +**`send --key C-c` interrupts the remote command but does not unblock a waiting +`run`.** Ctrl-C tears down the whole `{ ... }` group, so the closing marker is +never executed: `run` sits out its timeout and holds the lock all that time, +giving others a false "busy" although the shell is already free. There is a +separate command for this that does both: + +```bash +$S interrupt box # run returns immediately with code 130, the lock is released +``` + +**A command that reads stdin eats the marker — and the next command's marker +too.** `run box 'read x'` runs into a timeout (code 124), and the next `run` gets +garbled output like `got=[{ printf '__SSB...']` and +`syntax error near unexpected token '}'`. The session recovers on its own +afterwards, but the cure is this: + +```bash +$S run box 'read x < /dev/null; echo "rc=$?"' # close off stdin +$S run box 'echo hello | { read x; echo "[$x]"; }' # or feed it data +``` + +**`wait` sees the echo of what you have just sent.** The shell echoes input back, +so `send box 'sleep 3; echo TRAP_WORD'` + `wait box 'TRAP_WORD'` fires instantly +rather than after 3 s. That is why `wait` looks at **new** output only by default +(`--from-start` restores the old behaviour) — and even so: if you need to wait for +a command to finish, that is `run --timeout`'s job, not `wait`'s. `wait` is good +for a line in the log of an already running process. + +**Full-screen TUIs are read approximately.** `read` is a line-by-line history, +not a screen snapshot: cursor-positioning escape sequences are stripped, so +`top`'s frames overlay each other and the header can end up glued to a line from +the previous frame. For data, use batch modes (`top -b -n1`, +`journalctl --no-pager`), and keep `read` for what streams line by line anyway: +logs, builds, REPLs. + +**One command at a time, but `run` will not always wait.** Distinguish two cases: + +- the command is held by a **parallel `run`** — the second one queues on the + flock and executes after the first (verified: two 5 s `run`s took 10.4 s); +- the command was started through **`send`** — then `run` does not wait but + refuses immediately with code 126 in ~0.05 s, because it sees the shell is not + at a prompt. + +For real parallelism, use separate sessions — see the section above. + +**An unknown host key hangs the connection.** `new` exits with code 4, shows the +tail of the output with the fingerprint question, and suggests the command. +Accepting the key writes to `~/.ssh/known_hosts` — that is a configuration +change, so ask the user before answering `yes`: + +```bash +$S send box yes +``` + +**Sessions do not survive a reboot.** State lives in the directory from the +"Requirements" section — on Linux that is `/run/user//sshsess/`, on macOS +`$TMPDIR`. The system cleans both. Override with `SSHSESS_DIR`. + +Sessions created before the move to `$TMPDIR` lived in `~/.cache/sshsess` and no +longer appear in the listing. There is nothing to migrate deliberately — a +session is a live ssh connection with a daemon process, and after a reboot it is +dead in any case. But if the old directory is not empty, `ls` mentions it and +suggests the command to look inside. +`out.log` is not rotated — a TUI running for a few minutes inflates it to +megabytes. It is cleared without dropping the connection: `truncate NAME` (which +refuses if a command is executing right now, because that command counts output +by offsets in the file). + +**Network drops.** `ServerAliveInterval=15` / `ServerAliveCountMax=4` are set, so +a dead connection is detected in about a minute and the session goes `dead`. +There is no auto-reconnect, but you do not need to reopen it by hand with the +same arguments: `reconnect NAME` takes the target, the ssh arguments, `--shell` +and the pty size from `meta.json`. Verified on a killed ssh process. The remote +state is genuinely lost in the process — a new shell, a new cwd, no variables — +and `reconnect` says so outright. + diff --git a/skills/ssh-session/references/platform.md b/skills/ssh-session/references/platform.md new file mode 100644 index 0000000..26a5e50 --- /dev/null +++ b/skills/ssh-session/references/platform.md @@ -0,0 +1,57 @@ +# Platform and requirements + +`python3` and `ssh` — nothing else. No pip packages, no tmux, no Node. + +Verified on Python 3.8 — the full `new`/`run`/`kill` cycle, including parsing of +`-- ssh arguments`, so any interpreter from 3.8 up will do. + +**The reference platforms are Linux and macOS. Windows is not supported**, and +that is a decision rather than an open debt: there is no pty and no `fork()` +there, and ConPTY would require a third-party dependency such as `pywinpty`, +breaking the skill's main property — stdlib only, nothing to install. The script +exits with code 1 and a clear message. WSL covers Windows: inside it this is +ordinary Linux and everything works as is. + +The same follows for what **not** to do: do not replace the unix socket with a +file-based channel, and do not add a pty-less mode. Neither idea gets any closer +to Windows — the obstacle is not the channel, it is the pty and `fork()`. + +**On the client side** this is tested on Linux; macOS is untested (no machine to +test on), but the parts specific to it are covered: + +- **State directory.** The order is: `SSHSESS_DIR` → `$XDG_RUNTIME_DIR` (Linux) + → `$TMPDIR` on macOS → `~/.cache/sshsess`. On macOS `$TMPDIR` is a private + per-user `0700` directory that the system cleans, i.e. the closest analogue of + `XDG_RUNTIME_DIR`. The persistent `~/.cache` is left only as the last fallback: + a password can land in `out.log` if a remote prompt forgot to turn echo off, + and such a file has no business staying on disk forever. +- **Permissions.** Directories `0700`, files `0600` — verified in every location, + including upgrading a directory created by an older version with `0755`. +- **The AF_UNIX path length** there is 104 bytes against 108 on Linux. With a + real macOS prefix (`/var/folders/xx//T/sshsess`, ~50 bytes) that leaves + ~45 characters for the session name. Verified on an emulation of the same + length: 101 bytes works, 104 refuses with a clear message rather than "daemon + failed to start". + +The test stand is portable too: `sshd` is looked up in several paths including +Homebrew, and port occupancy is checked through Python rather than the +Linux-only `ss`. + +**On the server side** the dependency is not the distribution but the shell: + +| Needed for | Requirement | Where it breaks | +|---|---|---| +| `run` (markers, `$?`) | any POSIX shell | `fish`, `csh` → fixed by `--shell 'bash -i'` | +| layer 3 (busy check) | bracketed paste, i.e. readline/zle | `dash`, `ash`/busybox → the check answers "unknown" and does not protect | + +The wrapper construct is verified in busybox `ash` — markers, `$?` and the +trailing `&` all work, so the core is portable there as well. The muting +variables (`DEBIAN_FRONTEND`, `NEEDRESTART_MODE`) are Debian-specific, but on +other systems they are simply unknown environment variables — no harm done. The +confirmation patterns cover `apt`, `dnf`, `pacman` and `zypper`. + +For checks there is a local stand that needs no remote host at all: +`tests/local-sshd.sh start` brings up a temporary sshd on 127.0.0.1 (no sudo, no +system-wide changes, `~/.ssh` is never touched), and `tests/TESTCASES.md` is the +smoke set. + diff --git a/skills/ssh-session/references/troubleshooting.md b/skills/ssh-session/references/troubleshooting.md new file mode 100644 index 0000000..276e476 --- /dev/null +++ b/skills/ssh-session/references/troubleshooting.md @@ -0,0 +1,32 @@ +# Diagnostics + +| Symptom | What it means and what to do | +|---|---| +| `session 'x' is not running (no live socket ...)`, code 3 | There is no such session, or it died. `ls` shows the status, `new` opens it again. | +| `could not be opened` + a tail of ssh output, code 3 | ssh failed immediately. The message contains the full launch line and ssh's output — read it: `unknown option`, `No address associated with hostname`, `Connection refused`. | +| `started but the remote shell did not respond`, code 4 | The connection is waiting on input — a host key or a password. The output tail is in the message; answer through `send`. | +| `command still running after Ns`, code 124 | The command did not finish. The output above is partial. Investigate with `read`, interrupt with `interrupt`. | +| `session 'x' is busy`, code 125 | A parallel `run` holds the lock. Give a bigger `--timeout` or work through a separate session. | +| `still has a command running`, code 126 | Verified by probe: the previous command is eating input. `interrupt`, or the program's native exit key. | +| `sent Ctrl-C … still not reading`, code 126 | The program ignores SIGINT (`less`, `top`). Send its exit key: `send NAME --key q`. | +| `refusing to run … not at a prompt`, code 126 | Something is executing in the session, or a TUI is open. `read --tail 20`, then `interrupt` or the native exit key. | +| `refusing to run … full-screen program`, code 126 | A full-screen program (`whiptail`, `dialog`). Leave it with its key — usually `Enter` or `q`. | +| `died while running the command`, code 3 | The connection dropped mid-command; the message carries the log tail. | +| Output contains `__SSB...` / `syntax error near '}'` | A program reading stdin ate the marker. See the pitfalls. | +| `refusing to run this: 'exit' would log out...`, code 2 | The command would close the session. Wrap it in `( ... )` or `sh -c`. | +| `session 'x' is already running`, code 2 | The name is held by a live session. Take another one — it may belong to a neighbouring agent. | + +Exit codes: `0`/the remote command's code, `2` — argument error (and the `exit` +guard's refusal), `3` — the session is dead or absent, `4` — connected but the +shell does not answer, `124` — timeout, `125` — the lock is held by a parallel +`run`, `126` — an unfinished command is hanging in the session, `130` — +interrupted through `interrupt`. + +If something is truly strange, look at the raw stream without filtering and at +the daemon's own log: + +```bash +$S read box --raw | tail -c 2000 +cat /run/user/1000/sshsess/box/daemon.log +``` +