diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0f7c66..bdd5eda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,14 +25,31 @@ jobs: with: node-version: ${{ matrix.node-version }} + # `prepare` compiles src/ -> dist/ as part of the install. - name: Install dependencies run: npm ci + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + # Coverage thresholds are pinned at 100% in vitest.config.mjs, so this # also fails the build if a change adds an untested line or branch. - name: Run tests run: npx vitest run --coverage + # The published entry point loads dist/; make sure the compiled server + # actually starts and answers on STDIO. This chain broke in 1.3.6 and + # 1.3.8, so it is verified rather than assumed. + - name: Smoke-test the compiled server + shell: bash + run: | + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}' \ + | MCP_SILENT=true node bin/mcp-ssh.js \ + | grep -q '"serverInfo"' + # The dependency tree is platform independent — auditing it once is enough. - name: Security audit if: matrix.os == 'ubuntu-latest' && matrix.node-version == 22 diff --git a/.npmignore b/.npmignore index 2b07da4..a83ed6b 100644 --- a/.npmignore +++ b/.npmignore @@ -24,12 +24,17 @@ test-ssh/ vitest.config.mjs coverage/ +# TypeScript sources and build config - the package ships compiled dist/ +tsconfig.json +tsconfig.build.json +eslint.config.mjs + # Generated files *.tgz # Keep these files for npm package !package.json !README.md -!server.mjs +!dist/ !LICENSE !CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a79268..de24bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Ported to TypeScript.** The single self-contained `server.mjs` is now seven typed modules under `src/`, compiled to `dist/` by `tsc`: `server.ts` (MCP wiring and `main()`), `tools.ts` (tool schemas and dispatch), `ssh-client.ts`, `ssh-config-parser.ts`, `config-values.ts`, `platform.ts` (everything with module-load side effects) and `types.ts`. `bin/mcp-ssh.js` and the DXT package load `dist/server.js`; `dist/` is generated, not tracked in git, and built by the `prepare` script on install. + - `tsconfig.json` runs `strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noPropertyAccessFromIndexSignature` and `verbatimModuleSyntax`. Test files are checked under a lighter config (`tsconfig.test.json`). + - Added ESLint with `typescript-eslint` type-aware rules (`strictTypeChecked` + `stylisticTypeChecked`). Relaxations are documented in place; notably `prefer-nullish-coalescing` exempts strings and numbers, because `??` is *not* equivalent to `||` for a stripped launcher environment (an empty `%ProgramData%` must fall through, see #10) or for `timeout || DEFAULT`. + - Removed dead devDependencies `ts-node` and `@types/ssh2` — the repo had no `.ts` files and never used `ssh2`. + - The test suite is split along the same module boundaries (four files plus shared `test-helpers.ts`) and grew from 148 to 152 tests, still at 100% coverage of statements, branches, functions and lines. + - CI now runs `typecheck`, `lint`, the suite, and a smoke test that starts the *compiled* server over STDIO — the delivery chain that broke in 1.3.6 and 1.3.8 is now verified on every push, on Linux and Windows across Node 20/22/24. + - No behavioural changes: every existing test passes unmodified except where a mock had to follow the module split. + ### Security - Resolved all 17 open `npm audit` advisories (12 high, 4 moderate, 1 low) that accumulated since the 1.3.7 cleanup. `npm audit` now reports zero vulnerabilities again. `npm audit fix` could not be used — it aborts with an internal npm error (`Cannot read properties of null (reading 'edgesOut')`) on this tree's `overrides` — so the fixes are pinned explicitly: - Direct bumps, all within the existing semver range: `@modelcontextprotocol/sdk` 1.27.1 → 1.30.0, `vitest`/`@vitest/coverage-v8` 4.1.4 → 4.1.10, `@anthropic-ai/dxt` 0.2.5 → 0.2.6. These cleared the `vite`, `postcss` and `nanoid` advisories. diff --git a/CLAUDE.md b/CLAUDE.md index 933e1e4..3abd05b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,14 +11,23 @@ This is MCP SSH Agent (@aiondadotcom/mcp-ssh) - a Model Context Protocol (MCP) s ### Basic Operations - `npm start` - Start the MCP server (same as `npm run dev`) - `npm run dev` - Start the MCP server with debug output -- `npm run build` - Currently a no-op (echo "Build skipped") -- `npm test` - Run the vitest suite (`server.test.mjs`) with coverage +- `npm run build` - Compile `src/` to `dist/` (`tsc -p tsconfig.build.json`) +- `npm run typecheck` - `tsc --noEmit` for `src/` (strict) and for the tests (relaxed) +- `npm run lint` / `npm run lint:fix` - ESLint with type-aware rules +- `npm test` - Run the vitest suite with coverage - `npm run test:watch` - Vitest in watch mode +**The compiled output must exist before the server can run.** `npm install` builds it via the +`prepare` script; after editing `src/` run `npm run build` (or `npm test`, which reads `src/` +directly and needs no build). + ### Development Scripts - `./start.sh` - Start the server with debug output - `./start-silent.sh` - Start the server in silent mode (no debug output) -- `node bin/mcp-ssh.js` - Direct server execution. **Not** `node server.mjs`: since 1.3.6 that file no longer auto-runs `main()`, so running it directly exits immediately (this caused the 1.3.8 regression) +- `node bin/mcp-ssh.js` - Direct server execution. It loads `dist/server.js` and fails with a + clear message if the build is missing. Do **not** add an `is this module run directly?` check + to the server module: that heuristic compared `process.argv[1]` against forward-slash suffixes, + never matched on Windows, and made the server exit silently (issue #8, regression in 1.3.8) ### Publishing - `npm version patch|minor|major` - Bump version and create git tag @@ -31,21 +40,33 @@ This is MCP SSH Agent (@aiondadotcom/mcp-ssh) - a Model Context Protocol (MCP) s ## Architecture -### Main Entry Point -- `server.mjs` - Self-contained MCP server implementation that includes all functionality inline to avoid module resolution issues +TypeScript under `src/`, compiled to `dist/` by `tsc`. `dist/` is generated and not in git; +`bin/mcp-ssh.js` and the DXT package both load `dist/server.js`. + +### Modules +- `src/server.ts` - Entry point. Wires the MCP server, registers handlers, exports `main()` +- `src/tools.ts` - Tool schemas (`TOOL_DEFINITIONS`) and `callTool()` dispatch +- `src/ssh-client.ts` - `SSHClient`: every ssh/scp operation, plus the security assertions +- `src/ssh-config-parser.ts` - `SSHConfigParser`: host discovery, Includes, permission checks +- `src/config-values.ts` - ssh-config value normalization (see below) and `hostMatchesAlias()` +- `src/platform.ts` - `isWindows`, Windows env normalization, `resolveExecutable()`, + `SSH_BIN`/`SCP_BIN`, `debugLog()`. **Everything with module-load side effects lives here** +- `src/types.ts` - Shared types ### Other Files -- `bin/mcp-ssh.js` - Binary wrapper for npx compatibility; the real entry point (imports and calls `main()`) -- `server.test.mjs` - Test suite -- `vitest.config.mjs` - Test and coverage configuration, including the 100% coverage thresholds +- `bin/mcp-ssh.js` - Executable entry point; loads `dist/server.js` +- `tsconfig.json` (strict, `src/` only) / `tsconfig.build.json` (build) / `tsconfig.test.json` + (relaxed, tests). Test files are checked with a lighter rule set on purpose: mock objects and + index-signature access are normal there, and the tests themselves are the safety net +- `eslint.config.mjs` - typescript-eslint, type-aware. Relaxations are commented in place +- `vitest.config.mjs` - Test and coverage configuration, including the 100% thresholds - `.gitattributes` - Forces LF checkout on every platform (see Testing) ### Key Design Decisions 1. **Native SSH Tools**: Uses system `ssh` and `scp` commands rather than JavaScript SSH libraries for reliability -2. **Self-contained**: `server.mjs` includes all code inline to avoid ESM import issues -3. **Silent Mode**: Controlled by `MCP_SILENT` environment variable to disable debug output when used as MCP server -4. **No shell on spawn**: All `spawn`/`execFile` calls use `shell: false`. On Windows, `ssh.exe`/`scp.exe` are resolved to absolute paths once at startup via `resolveExecutable()` (PATH + PATHEXT walk), so PATH lookup does not require `shell: true`. This is required to prevent local command injection through shell metacharacters in tool arguments. -5. **Strict `hostAlias` whitelist**: `_assertSafeHostAlias()` (`SSHClient`) rejects any `hostAlias` that does not match `^[A-Za-z0-9_.@:][A-Za-z0-9._@:-]*$`. Combined with the `--` argument terminator on every `ssh`/`scp` invocation, this blocks SSH option injection (e.g. `-oProxyCommand=…`) and shell-metacharacter injection. Validation is applied at the public entry points (`runRemoteCommand`, `uploadFile`, `downloadFile`) and transitively covers `checkConnectivity` and `runCommandBatch`. **Do not weaken or bypass this validator** without understanding the security implications — see CHANGELOG entry for 1.3.5. +2. **Silent Mode**: Controlled by `MCP_SILENT` environment variable to disable debug output when used as MCP server +3. **No shell on spawn**: All `spawn`/`execFile` calls use `shell: false`. On Windows, `ssh.exe`/`scp.exe` are resolved to absolute paths once at startup via `resolveExecutable()` (PATH + PATHEXT walk), so PATH lookup does not require `shell: true`. This is required to prevent local command injection through shell metacharacters in tool arguments. +4. **Strict `hostAlias` whitelist**: `_assertSafeHostAlias()` (`SSHClient`) rejects any `hostAlias` that does not match `^[A-Za-z0-9_.@:][A-Za-z0-9._@:-]*$`. Combined with the `--` argument terminator on every `ssh`/`scp` invocation, this blocks SSH option injection (e.g. `-oProxyCommand=…`) and shell-metacharacter injection. Validation is applied at the public entry points (`runRemoteCommand`, `uploadFile`, `downloadFile`) and transitively covers `checkConnectivity` and `runCommandBatch`. **Do not weaken or bypass this validator** without understanding the security implications — see CHANGELOG entry for 1.3.5. ## SSH Configuration Integration @@ -113,19 +134,19 @@ Host myrouter ### Test Suite Invariants -`vitest.config.mjs` pins **100% of statements, branches, functions and lines** of `server.mjs` -and fails the build below that. `bin/mcp-ssh.js` is excluded (top-level await that starts a real -server). When adding code, add the test with it; when a branch turns out to be unreachable, +`vitest.config.mjs` pins **100% of statements, branches, functions and lines** of `src/` and +fails the build below that. `bin/mcp-ssh.js` is excluded (top-level await that starts a real +server), as is `src/types.ts` (compiles to nothing executable). When adding code, add the test with it; when a branch turns out to be unreachable, prefer deleting it over working around the threshold — that is how the dead `process.env.Path` fallback in `resolveExecutable()` was removed. **Platform-specific code is tested from either OS, never skipped.** `loadServerAs(platform, env)` -in `server.test.mjs` re-imports `server.mjs` with `process.platform` (and optionally parts of the +in the test suite re-imports the module graph with `process.platform` (and optionally parts of the environment) faked, so the Windows branches are covered when running on macOS/Linux and vice versa. Things it has to handle, and that new tests must respect: - `vi.resetModules()` re-runs the `vi.mock('fs/promises')` factory, so the returned `fs` spies are **new objects** — the statically imported `readFile`/`stat`/… are a different module instance. -- `server.mjs` writes `ProgramData`/`ALLUSERSPROFILE` to `process.env` at import time. The helper +- `src/platform.ts` writes `ProgramData`/`ALLUSERSPROFILE` to `process.env` at import time. The helper always saves and restores them (`ENV_MUTATED_AT_IMPORT`) and exposes `envAfterImport`, because the restore happens before a test can assert. Without this, one Windows import leaks state into later tests and makes those branches *look* covered while nothing asserts them. @@ -135,8 +156,8 @@ versa. Things it has to handle, and that new tests must respect: process-starting methods are stubbed so tests never spawn actual `ssh`/`scp`. **CI** runs Linux and Windows across Node 20/22/24. `.gitattributes` forces an LF checkout on all -platforms: with CRLF, Vite's SSR transform cannot parse the `#!/usr/bin/env node` shebang in -`server.mjs` and the entire suite dies with a `SyntaxError` before any test runs. Do not remove it. +platforms. It was added because a CRLF checkout broke the suite outright, and it also keeps the +shell scripts executable. Do not remove it. ### Manual Testing ```bash @@ -192,8 +213,8 @@ The LLM driving this MCP server is **not trusted** — its tool arguments can be ## Important Notes -- The project is ESM-only (`"type": "module"` in package.json). The `.mjs` extension on `server.mjs` is historical and redundant given `"type": "module"`; keep it for now to avoid touching `bin/`, `manifest.json`, `package.json` `main`, and the start scripts. -- Production code is in `server.mjs`, not compiled from TypeScript +- The project is ESM-only (`"type": "module"` in package.json), so `tsc` emits `.js` files that Node treats as ESM. Relative imports inside `src/` must carry the `.js` extension (NodeNext resolution), even though the source files are `.ts`. +- Production code is TypeScript in `src/`, compiled to `dist/`. Never edit `dist/` — it is regenerated on every build. - SSH operations require properly configured SSH keys or `@password` annotations - The agent runs over STDIO as an MCP server, not as a standalone application - DXT packages provide one-click installation alternative to manual JSON configuration \ No newline at end of file diff --git a/README.md b/README.md index 707c9f0..b30b292 100644 --- a/README.md +++ b/README.md @@ -660,16 +660,27 @@ The DXT file will be available as a release asset for users to download and inst Contributions are welcome! Please feel free to submit a Pull Request. ```bash -npm install +npm install # also compiles src/ -> dist/ via the prepare script +npm run build # tsc +npm run typecheck # tsc --noEmit, strict for src/ and relaxed for tests +npm run lint # eslint with type-aware rules npm test # vitest with coverage npm run test:watch # watch mode ``` -Two things to know before opening a PR: +The server is written in TypeScript under `src/` and compiled to `dist/`, which is what +`bin/mcp-ssh.js` loads and what ships to npm. `dist/` is not in git — a fresh checkout gets +it from `npm install`. + +Three things to know before opening a PR: - **Coverage is a build gate.** `vitest.config.mjs` pins statements, branches, functions and - lines of `server.mjs` at 100%, so a change that adds an untested line fails CI. If a branch + lines of `src/` at 100%, so a change that adds an untested line fails CI. If a branch is genuinely unreachable, removing it is usually better than working around the threshold. +- **Lint rules are calibrated, not stock.** Where a rule is relaxed, the reason is in a comment + next to it. `prefer-nullish-coalescing` in particular exempts strings and numbers on purpose: + `||` and `??` are *not* interchangeable for environment variables (a stripped launcher env + reports an empty string, see issue #10) or for `timeout || DEFAULT`. - **CI runs on Linux and Windows** across Node 20, 22 and 24. Platform-specific code paths are tested from either OS by re-importing the module with `process.platform` faked — see `loadServerAs()` in `server.test.mjs` — rather than by skipping tests on one platform. @@ -682,8 +693,22 @@ MIT License - see LICENSE file for details. ``` mcp-ssh/ -├── server.mjs # Main MCP server implementation (self-contained) -├── server.test.mjs # Test suite (vitest) +├── src/ # TypeScript sources +│ ├── server.ts # Entry point: MCP server wiring and main() +│ ├── tools.ts # Tool definitions and dispatch +│ ├── ssh-client.ts # All ssh/scp operations +│ ├── ssh-config-parser.ts # Host discovery from config and known_hosts +│ ├── config-values.ts # ssh-config value normalization +│ ├── platform.ts # Platform detection, binary resolution, logging +│ ├── types.ts # Shared types +│ └── server.test.ts # Test suite (vitest) +├── dist/ # Compiled output (generated, not in git) +├── bin/ +│ └── mcp-ssh.js # Executable entry point (loads dist/server.js) +├── tsconfig.json # Strict compiler options for src/ +├── tsconfig.build.json # Build config (excludes tests) +├── tsconfig.test.json # Relaxed options for test files +├── eslint.config.mjs # typescript-eslint, type-aware rules ├── vitest.config.mjs # Test and coverage configuration ├── manifest.json # DXT package manifest ├── package.json # Dependencies and scripts @@ -694,8 +719,6 @@ mcp-ssh/ ├── .gitattributes # Forces LF checkout on every platform ├── start.sh # Development startup script ├── start-silent.sh # Silent startup script -├── bin/ -│ └── mcp-ssh.js # Executable entry point (imports and calls main()) ├── scripts/ │ └── build-dxt.sh # DXT package build script └── doc/ # Documentation assets diff --git a/bin/mcp-ssh.js b/bin/mcp-ssh.js index 5d51e74..785717c 100755 --- a/bin/mcp-ssh.js +++ b/bin/mcp-ssh.js @@ -1,18 +1,26 @@ #!/usr/bin/env node -// Simple wrapper to run the main server.mjs file. +// Wrapper that runs the compiled server (dist/server.js, built from src/ by tsc). // We import main() explicitly and call it here instead of relying on a -// "is this module run directly?" check inside server.mjs. The latter is +// "is this module run directly?" check inside the server module. The latter is // brittle on Windows because process.argv[1] uses backslashes while the // check used forward-slash suffixes (fixes #8). -import path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { existsSync } from 'node:fs'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const serverPath = path.join(__dirname, '..', 'dist', 'server.js'); -const serverUrl = pathToFileURL(path.join(__dirname, '..', 'server.mjs')).href; -const { main } = await import(serverUrl); +if (!existsSync(serverPath)) { + console.error( + `mcp-ssh: ${serverPath} is missing. Run "npm run build" first ` + + `(this happens automatically on npm install from a git checkout).` + ); + process.exit(1); +} + +const { main } = await import(pathToFileURL(serverPath).href); main().catch((error) => { console.error(`Unhandled error: ${error?.message ?? error}`); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2fc6723 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,82 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + // dist/ is generated; the .js/.mjs files at the root (bin wrapper, tool + // configs) are plain ESM outside the TypeScript program, so type-aware + // linting cannot parse them. + ignores: [ + 'dist/**', + 'coverage/**', + 'build/**', + 'node_modules/**', + 'bin/**', + '*.config.mjs', + ], + }, + eslint.configs.recommended, + // Type-aware linting: needs the program, so it only applies to src/. + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // The codebase deliberately uses `_password` and `_spawn`-style names for + // internal fields, and `_` prefixes for intentionally unused catch params. + '@typescript-eslint/naming-convention': 'off', + + // `??` is NOT equivalent to `||` for environment variables: a stripped + // launcher environment reports an *empty string*, not undefined (that is + // exactly what issue #10 describes), and an empty %ProgramData% must fall + // through to the default. Switching these to `??` would silently reinstate + // the bug, so string primitives are exempt. + // Also exempt numbers: `args.timeout || DEFAULT` treats 0 as "not set", + // which is what the tool contract promises. `??` would forward a + // zero-millisecond timeout to ssh. + '@typescript-eslint/prefer-nullish-coalescing': [ + 'error', + { ignorePrimitives: { string: true, number: true } }, + ], + + // process.pid and friends in template literals are unambiguous. + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], + + // Empty catch blocks are load-bearing here: resolveExecutable() probes + // PATH entries that mostly do not exist, and the askpass cleanup runs + // during shutdown when the file may already be gone. Both are commented. + 'no-empty': ['error', { allowEmptyCatch: true }], + + // Non-null assertions are used in a handful of places where an invariant + // guarantees the value (e.g. String.split always yields one element) and + // the alternative would be unreachable fallback code that can never be + // covered by a test. + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, + { + // Tests mock, cast and poke at internals; type-aware strictness there costs + // more than it returns. + files: ['src/**/*.test.ts', 'src/test-helpers.ts'], + ...tseslint.configs.disableTypeChecked, + rules: { + // Spread first: our overrides below extend the disable set rather than + // replacing it, which would leave type-aware rules active without a program. + ...tseslint.configs.disableTypeChecked.rules, + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/unbound-method': 'off', + // Tests add and remove environment variables by computed key on purpose. + '@typescript-eslint/no-dynamic-delete': 'off', + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 9614f87..69c3d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,13 @@ }, "devDependencies": { "@anthropic-ai/dxt": "^0.2.6", + "@eslint/js": "^10.0.1", "@types/node": "^20.11.26", - "@types/ssh2": "^1.15.0", "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.8.1", "tmp": ">=0.2.6", - "ts-node": "^10.9.2", "typescript": "^5.4.3", + "typescript-eslint": "^8.66.0", "vitest": "^4.1.10" }, "engines": { @@ -111,17 +112,132 @@ "node": ">=18" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "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", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hono/node-server": { @@ -136,6 +252,72 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "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.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.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", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/checkbox": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", @@ -487,17 +669,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -800,34 +971,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -846,6 +989,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -853,6 +1003,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -873,39 +1030,232 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/ssh2": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", - "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "^18.11.18" + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.103", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.103.tgz", - "integrity": "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/ssh2/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", @@ -1038,9 +1388,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1050,17 +1400,14 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { @@ -1136,13 +1483,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1387,13 +1727,6 @@ "node": ">= 0.10" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1425,6 +1758,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1444,16 +1784,6 @@ "node": ">=8" } }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1486,57 +1816,249 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/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/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 4" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "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", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=0.10" } }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "es-errors": "^1.3.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, "node_modules/estree-walker": { "version": "3.0.3", @@ -1548,6 +2070,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1684,6 +2216,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", @@ -1725,6 +2271,19 @@ "dev": true, "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1746,6 +2305,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/flora-colossus": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", @@ -1909,6 +2506,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2024,6 +2634,16 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2048,6 +2668,16 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2057,6 +2687,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2139,6 +2782,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2151,6 +2801,13 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -2164,6 +2821,30 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2425,6 +3106,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -2472,13 +3169,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2535,12 +3225,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2593,6 +3283,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2668,6 +3365,56 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2683,6 +3430,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2783,6 +3540,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -2809,6 +3576,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -3258,48 +4035,30 @@ "node": ">=0.6" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" + "engines": { + "node": ">=18.12" }, "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } + "engines": { + "node": ">= 0.8.0" } }, "node_modules/type-fest": { @@ -3360,6 +4119,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -3386,12 +4169,15 @@ "node": ">= 0.8" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } }, "node_modules/vary": { "version": "1.1.2", @@ -3629,6 +4415,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -3668,14 +4464,17 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yoctocolors-cjs": { diff --git a/package.json b/package.json index 1e00723..3719662 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@aiondadotcom/mcp-ssh", "version": "1.3.8", "description": "MCP Agent for managing SSH hosts - A Model Context Protocol server for SSH operations", - "main": "server.mjs", + "main": "dist/server.js", "bin": { "mcp-ssh": "bin/mcp-ssh.js" }, @@ -13,13 +13,17 @@ "scripts": { "start": "node bin/mcp-ssh.js", "dev": "node bin/mcp-ssh.js", - "build": "echo \"Build skipped\"", + "build": "tsc -p tsconfig.build.json", "build:dxt": "./scripts/build-dxt.sh", "test": "vitest run --coverage", "test:watch": "vitest", - "prepublishOnly": "npm run test", + "prepublishOnly": "npm run build && npm test", "version": "git add -A", - "postversion": "git push && git push --tags" + "postversion": "git push && git push --tags", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "prepare": "npm run build", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "keywords": [ "mcp", @@ -51,12 +55,13 @@ }, "devDependencies": { "@anthropic-ai/dxt": "^0.2.6", + "@eslint/js": "^10.0.1", "@types/node": "^20.11.26", - "@types/ssh2": "^1.15.0", "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.8.1", "tmp": ">=0.2.6", - "ts-node": "^10.9.2", "typescript": "^5.4.3", + "typescript-eslint": "^8.66.0", "vitest": "^4.1.10" }, "overrides": { diff --git a/scripts/build-dxt.sh b/scripts/build-dxt.sh index 7872c74..256e47b 100755 --- a/scripts/build-dxt.sh +++ b/scripts/build-dxt.sh @@ -30,6 +30,11 @@ BUILD_DIR="build" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" +# The entry point (bin/mcp-ssh.js) loads dist/, which is compiled from src/ and +# not tracked in git — build it before packing or the extension ships empty. +echo -e "${YELLOW}Building TypeScript sources...${NC}" +npm run build + echo -e "${YELLOW}Creating DXT package...${NC}" # Get version from package.json diff --git a/server.mjs b/server.mjs deleted file mode 100755 index 49f02a6..0000000 --- a/server.mjs +++ /dev/null @@ -1,901 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP SSH Agent - A Model Context Protocol server for managing SSH connections - * - * This is a simplified implementation that directly imports from specific files - * to avoid module resolution issues. - */ - -// Import required Node.js modules -import { homedir } from 'os'; -import { readFile, stat, writeFile, chmod, unlink } from 'fs/promises'; -import { join } from 'path'; -import { createRequire } from 'module'; - -// Use createRequire to work around ESM import issues -const require = createRequire(import.meta.url); - -// Required libraries -const { spawn, exec, execFile } = require('child_process'); -const { promisify } = require('util'); -const { statSync } = require('fs'); -const sshConfig = require('ssh-config'); - -const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); - -const isWindows = process.platform === 'win32'; - -// Windows + Claude Desktop: extensions are launched with a stripped, allow-listed -// environment that omits %ProgramData% and %ALLUSERSPROFILE%. Win32-OpenSSH needs -// %ProgramData% to locate its global config (%ProgramData%\ssh\) at startup and -// exits 255 with no output before it produces anything when the variable is unset, -// which makes every spawned ssh/scp fail. Normalize the vars once at module load so -// every spawned child inherits them. See issue #10. -if (isWindows) { - if (!process.env.ProgramData) { - // Derive the last-resort default from %SystemDrive% rather than hardcoding - // C:, so a Windows install on another drive still gets a valid path. - // SystemDrive is part of the environment Claude Desktop does pass through. - const systemDrive = (process.env.SystemDrive || 'C:').replace(/[\\/]+$/, ''); - process.env.ProgramData = process.env.ALLUSERSPROFILE || `${systemDrive}\\ProgramData`; - } - if (!process.env.ALLUSERSPROFILE) { - process.env.ALLUSERSPROFILE = process.env.ProgramData; - } -} - -// Resolve an executable's absolute path on Windows by walking PATH and PATHEXT. -// This lets us call spawn() with shell:false on Windows — without it we would -// need shell:true to find ssh.exe/scp.exe via PATH, which would route every -// argument through cmd.exe and make characters like &, |, ^, >, " usable for -// local command injection. Returns the bare name on non-Windows (POSIX spawn -// already searches PATH safely). -function resolveExecutable(name) { - if (!isWindows) return name; - // No `|| process.env.Path` fallback: Node exposes process.env case-insensitively - // on Windows, so process.env.PATH already resolves a variable spelled `Path`. - const pathDirs = (process.env.PATH || '').split(';'); - const exts = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';'); - for (const dir of pathDirs) { - if (!dir) continue; - for (const ext of exts) { - const candidate = join(dir, name + ext); - try { - if (statSync(candidate).isFile()) return candidate; - } catch {} - } - } - return name + '.exe'; -} - -const SSH_BIN = resolveExecutable('ssh'); -const SCP_BIN = resolveExecutable('scp'); - -// Silent mode for MCP clients - disable debug output when used as MCP server -const SILENT_MODE = process.env.MCP_SILENT === 'true' || process.argv.includes('--silent'); - -// Debug logging function - only outputs in non-silent mode -function debugLog(message) { - if (!SILENT_MODE) { - process.stderr.write(message); - } -} - -// Import MCP components using proper export paths -const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); -const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); -const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); - -// --------------------------------------------------------------------------- -// ssh-config@5 value normalization. -// -// The parser returns a plain string for a single-token value, but an array of -// token objects ({ val, separator, quoted }) as soon as a multi-value directive -// carries more than one token. Affected directives (ssh-config/lib/ssh-config.js): -// Host, Match, ProxyCommand, SendEnv, IPQoS, CanonicalDomains, -// GlobalKnownHostsFile, UserKnownHostsFile -// Everything downstream must go through these helpers, otherwise a multi-alias -// `Host a b` block is stored with an array where a string is expected and no -// strict comparison against it can ever match. -// --------------------------------------------------------------------------- -function configValueTokens(value) { - if (value == null) return []; - if (Array.isArray(value)) { - return value - .map(v => (v && typeof v === 'object' && 'val' in v ? v.val : String(v))) - .filter(v => v !== ''); - } - return [String(value)]; -} - -function configValueToString(value) { - return configValueTokens(value).join(' '); -} - -// True if `alias` names this host — via any of its aliases or its hostname. -function hostMatchesAlias(host, alias) { - if (!host || !alias) return false; - if (host.hostname === alias) return true; - if (Array.isArray(host.aliases)) return host.aliases.includes(alias); - return host.alias === alias; -} - -// SSH Configuration Parser -class SSHConfigParser { - constructor() { - const homeDir = homedir(); - this.configPath = join(homeDir, '.ssh', 'config'); - this.knownHostsPath = join(homeDir, '.ssh', 'known_hosts'); - } - - async parseConfig() { - try { - const content = await readFile(this.configPath, 'utf-8'); - const config = sshConfig.parse(content); - return this.extractHostsFromConfig(config, this.configPath); - } catch (error) { - debugLog(`Error reading SSH config: ${error.message}\n`); - return []; - } - } - - async processIncludeDirectives(configPath) { - try { - const content = await readFile(configPath, 'utf-8'); - const config = sshConfig.parse(content); - const hosts = []; - - for (const section of config) { - if (section.param === 'Include' && section.value) { - const includePaths = this.expandIncludePath(section.value, configPath); - - for (const includePath of includePaths) { - const includeHosts = await this.processIncludeDirectives(includePath); - hosts.push(...includeHosts); - } - } - } - - // Add hosts from the current config file - const currentHosts = this.extractHostsFromConfig(config, configPath); - hosts.push(...currentHosts); - - return hosts; - } catch (error) { - debugLog(`Error processing config file ${configPath}: ${error.message}\n`); - return []; - } - } - - expandIncludePath(includePath, baseConfigPath) { - const { dirname, resolve, isAbsolute, win32 } = require('path'); - const { glob } = require('glob'); - const { existsSync } = require('fs'); - - // Handle tilde expansion - if (/^~(?=[\\/])/.test(includePath)) { - includePath = includePath.replace(/^~/, homedir()); - } - - // Handle relative paths - if (!isAbsolute(includePath) && !win32.isAbsolute(includePath)) { - const baseDir = dirname(baseConfigPath); - includePath = resolve(baseDir, includePath); - } - - try { - // Handle glob patterns - if (includePath.includes('*') || includePath.includes('?')) { - return glob.sync(includePath).filter(path => existsSync(path)); - } else { - return existsSync(includePath) ? [includePath] : []; - } - } catch (error) { - debugLog(`Error expanding include path ${includePath}: ${error.message}\n`); - return []; - } - } - - async checkFilePermissions(filePath) { - // Windows doesn't support Unix file permissions - skip check - if (isWindows) return; - try { - const fileStat = await stat(filePath); - const mode = fileStat.mode & 0o777; - if (mode !== 0o600) { - throw new Error( - `SSH config file ${filePath} contains @password annotations but has insecure permissions (${mode.toString(8)}). ` + - `Required: 600. Fix with: chmod 600 ${filePath}` - ); - } - } catch (error) { - if (error.code === 'ENOENT') return; - throw error; - } - } - - extractHostsFromConfig(config, configPath) { - const hosts = []; - let hasPasswords = false; - - for (const section of config) { - // Skip Include directives as they are processed separately - if (section.param === 'Include') { - continue; - } - - if (section.param === 'Host') { - const aliases = configValueTokens(section.value); - - // Skip blocks that only carry defaults (`Host *`, `Host * !bastion`): - // they are not connectable hosts. The old `section.value !== '*'` check - // missed these because a multi-token Host value is an array, never '*'. - if (aliases.length === 0 || aliases.every(a => a === '*' || a.startsWith('!'))) { - continue; - } - - const hostInfo = { - hostname: '', - alias: aliases[0], // first alias — keeps the existing output shape - aliases, // full list — used for matching - configFile: configPath - }; - - // Search all entries for this host - for (const param of section.config) { - // Parse @password annotation from comments - if (param.type === 2 && param.content) { - const match = param.content.match(/^#\s*@password:\s*(.+)$/); - if (match) { - hostInfo._password = match[1]; - hasPasswords = true; - continue; - } - } - - // Safety check for undefined param - if (!param || !param.param) { - continue; - } - - // Multi-token directives (ProxyCommand, SendEnv, IPQoS, …) arrive as - // arrays of token objects; flatten so the JSON we hand back is readable. - const value = configValueToString(param.value); - - switch (param.param.toLowerCase()) { - case 'hostname': - hostInfo.hostname = value; - break; - case 'user': - hostInfo.user = value; - break; - case 'port': - hostInfo.port = parseInt(value, 10); - break; - case 'identityfile': - hostInfo.identityFile = value; - break; - default: - // Store other parameters - hostInfo[param.param.toLowerCase()] = value; - } - } - - // Only add hosts with complete information - if (hostInfo.hostname) { - hosts.push(hostInfo); - } - } - } - - // Store whether this config has passwords (for permission check) - if (hasPasswords) { - this._configsWithPasswords = this._configsWithPasswords || new Set(); - this._configsWithPasswords.add(configPath); - } - - return hosts; - } - - async parseKnownHosts() { - try { - const content = await readFile(this.knownHostsPath, 'utf-8'); - const knownHosts = content - .split('\n') - .filter(line => line.trim() !== '') - .map(line => { - // Format: hostname[,hostname2...] key-type public-key - const parts = line.split(' ')[0]; - return parts.split(',')[0]; - }); - - return knownHosts; - } catch (error) { - debugLog(`Error reading known_hosts file: ${error.message}\n`); - return []; - } - } - - async getAllKnownHosts() { - // First: Get all hosts from ~/.ssh/config including Include directives (these are prioritized) - const configHosts = await this.processIncludeDirectives(this.configPath); - - // Check file permissions for configs that contain @password annotations - if (this._configsWithPasswords) { - for (const configPath of this._configsWithPasswords) { - await this.checkFilePermissions(configPath); - } - } - - // Second: Get hostnames from ~/.ssh/known_hosts - const knownHostnames = await this.parseKnownHosts(); - - // Create a comprehensive list starting with config hosts - const allHosts = [...configHosts]; - - // Add hosts from known_hosts that aren't already in the config - // These will appear after the config hosts - for (const hostname of knownHostnames) { - if (!configHosts.some(host => hostMatchesAlias(host, hostname))) { - allHosts.push({ - hostname: hostname, - source: 'known_hosts' - }); - } - } - - // Mark config hosts for clarity - configHosts.forEach(host => { - host.source = 'ssh_config'; - }); - - return allHosts; - } -} - -// SSH Client Implementation -class SSHClient { - constructor() { - this.configParser = new SSHConfigParser(); - this._askpassScript = null; - this._spawn = spawn; - this._execFileAsync = execFileAsync; - } - - async listKnownHosts() { - return await this.configParser.getAllKnownHosts(); - } - - _assertSafeHostAlias(hostAlias) { - if (typeof hostAlias !== 'string' || hostAlias.length === 0) { - throw new Error('hostAlias must be a non-empty string'); - } - // Strict whitelist. Two threats this defends against: - // 1. ssh/scp option injection via leading '-' (e.g. -oProxyCommand=…), - // which would execute arbitrary commands LOCALLY on this machine. - // 2. cmd.exe metacharacter injection on Windows, where spawnOptions.shell - // is true and characters like &, |, ^, >, " would otherwise be - // interpreted by the shell before ssh.exe ever sees them. - // Allowed: alphanumerics, '.', '_', '-', ':', '@'. Must not start with '-'. - if (!/^[A-Za-z0-9_.@:][A-Za-z0-9._@:-]*$/.test(hostAlias)) { - throw new Error( - `Invalid hostAlias: must match [A-Za-z0-9._@:-] and not start with '-'` - ); - } - } - - async _assertKnownHostAlias(hostAlias) { - const cleanAlias = hostAlias.includes('@') ? hostAlias.split('@').pop() : hostAlias; - const knownHosts = await this.configParser.getAllKnownHosts(); - const isKnown = knownHosts.some((host) => - hostMatchesAlias(host, hostAlias) || - hostMatchesAlias(host, cleanAlias) - ); - if (!isKnown) { - throw new Error(`Unknown hostAlias: ${hostAlias} is not defined in ~/.ssh/config or ~/.ssh/known_hosts`); - } - } - - async getPasswordForHost(hostAlias) { - // Strip user@ prefix if present (e.g. "test@ssh-test" -> "ssh-test") - const cleanAlias = hostAlias.includes('@') ? hostAlias.split('@').pop() : hostAlias; - const hosts = await this.configParser.processIncludeDirectives(this.configParser.configPath); - const host = hosts.find(h => hostMatchesAlias(h, cleanAlias)); - return host?._password || null; - } - - async getAskpassScript() { - if (this._askpassScript) return this._askpassScript; - - const { tmpdir } = require('os'); - let scriptPath; - if (isWindows) { - scriptPath = join(tmpdir(), `mcp-ssh-askpass-${process.pid}.cmd`); - await writeFile(scriptPath, '@echo off\r\necho %MCP_SSH_PASS%\r\n'); - } else { - scriptPath = join(tmpdir(), `mcp-ssh-askpass-${process.pid}.sh`); - await writeFile(scriptPath, '#!/bin/sh\necho "$MCP_SSH_PASS"\n'); - await chmod(scriptPath, 0o700); - } - this._askpassScript = scriptPath; - - // Clean up on exit - const cleanup = () => { try { require('fs').unlinkSync(scriptPath); } catch {} }; - process.on('exit', cleanup); - process.on('SIGINT', () => { cleanup(); process.exit(130); }); - process.on('SIGTERM', () => { cleanup(); process.exit(143); }); - - return scriptPath; - } - - async buildSpawnEnv(hostAlias) { - const password = await this.getPasswordForHost(hostAlias); - if (!password) return null; - - // Check file permissions before using password - if (this.configParser._configsWithPasswords) { - for (const configPath of this.configParser._configsWithPasswords) { - await this.configParser.checkFilePermissions(configPath); - } - } - - const askpassScript = await this.getAskpassScript(); - return { - ...process.env, - MCP_SSH_PASS: password, - SSH_ASKPASS: askpassScript, - // `force` tells OpenSSH to use the askpass helper even without a GUI/TTY. - // Avoid injecting a fake DISPLAY value here; that's a POSIX/X11 assumption - // and can break platform-specific behavior, especially on Windows. - SSH_ASKPASS_REQUIRE: 'force' - }; - } - - async runRemoteCommand(hostAlias, command, options = {}) { - this._assertSafeHostAlias(hostAlias); - await this._assertKnownHostAlias(hostAlias); - const timeout = options.timeout || 30000; - const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB limit - - debugLog(`Executing: ssh ${hostAlias} ${command}\n`); - - const passwordEnv = await this.buildSpawnEnv(hostAlias); - - return new Promise((resolve) => { - const spawnOptions = { - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - // shell:false is critical on Windows: with shell:true the args would - // be re-parsed by cmd.exe and metacharacters in `command` could lead - // to local command injection. We rely on resolveExecutable() to find - // ssh.exe on Windows so PATH lookup is not needed. - shell: false - }; - if (passwordEnv) { - spawnOptions.env = passwordEnv; - if (!isWindows) { - // setsid needed on some systems so SSH uses SSH_ASKPASS instead of tty - spawnOptions.detached = true; - } - } - - const child = this._spawn(SSH_BIN, ['-o', 'StrictHostKeyChecking=accept-new', '--', hostAlias, command], spawnOptions); - - let stdout = ''; - let stderr = ''; - let killed = false; - let stdoutTruncated = false; - let stderrTruncated = false; - - const timer = setTimeout(() => { - killed = true; - child.kill('SIGTERM'); - }, timeout); - - child.stdout.on('data', (data) => { - if (stdout.length < MAX_OUTPUT_SIZE) { - stdout += data.toString(); - } else if (!stdoutTruncated) { - stdoutTruncated = true; - stdout += '\n[Output truncated - exceeded 10MB limit]'; - } - }); - - child.stderr.on('data', (data) => { - if (stderr.length < MAX_OUTPUT_SIZE) { - stderr += data.toString(); - } else if (!stderrTruncated) { - stderrTruncated = true; - stderr += '\n[Stderr truncated - exceeded 10MB limit]'; - } - }); - - child.on('close', (code) => { - clearTimeout(timer); - resolve({ - stdout, - stderr: killed ? stderr + '\n[Command timed out]' : stderr, - code: killed ? 124 : (code || 0) - }); - }); - - child.on('error', (error) => { - clearTimeout(timer); - debugLog(`Error executing command on ${hostAlias}: ${error.message}\n`); - resolve({ - stdout, - stderr: error.message, - code: 1 - }); - }); - }); - } - - async getHostInfo(hostAlias) { - const hosts = await this.configParser.processIncludeDirectives(this.configParser.configPath); - const host = hosts.find(host => hostMatchesAlias(host, hostAlias)) || null; - if (host) { - // Never expose password to the LLM - const { _password, ...safeHost } = host; - if (_password) safeHost.passwordAuth = true; - return safeHost; - } - return null; - } - - async checkConnectivity(hostAlias) { - try { - // Simple connectivity test using ssh - const result = await this.runRemoteCommand(hostAlias, 'echo connected'); - const connected = result.code === 0 && result.stdout.trim() === 'connected'; - - return { - connected, - message: connected ? 'Connection successful' : 'Connection failed' - }; - } catch (error) { - debugLog(`Connectivity error with ${hostAlias}: ${error.message}\n`); - return { - connected: false, - message: error instanceof Error ? error.message : String(error) - }; - } - } - - async uploadFile(hostAlias, localPath, remotePath) { - try { - this._assertSafeHostAlias(hostAlias); - await this._assertKnownHostAlias(hostAlias); - debugLog(`Executing: scp ${localPath} ${hostAlias}:${remotePath}\n`); - - const passwordEnv = await this.buildSpawnEnv(hostAlias); - const options = { timeout: 60000, windowsHide: true, shell: false }; - if (passwordEnv) options.env = passwordEnv; - - await this._execFileAsync(SCP_BIN, ['-o', 'StrictHostKeyChecking=accept-new', '--', localPath, `${hostAlias}:${remotePath}`], options); - return true; - } catch (error) { - debugLog(`Error uploading file to ${hostAlias}: ${error.message}\n`); - return false; - } - } - - async downloadFile(hostAlias, remotePath, localPath) { - try { - this._assertSafeHostAlias(hostAlias); - await this._assertKnownHostAlias(hostAlias); - debugLog(`Executing: scp ${hostAlias}:${remotePath} ${localPath}\n`); - - const passwordEnv = await this.buildSpawnEnv(hostAlias); - const options = { timeout: 60000, windowsHide: true, shell: false }; - if (passwordEnv) options.env = passwordEnv; - - await this._execFileAsync(SCP_BIN, ['-o', 'StrictHostKeyChecking=accept-new', '--', `${hostAlias}:${remotePath}`, localPath], options); - return true; - } catch (error) { - debugLog(`Error downloading file from ${hostAlias}: ${error.message}\n`); - return false; - } - } - - async runCommandBatch(hostAlias, commands) { - try { - const results = []; - let success = true; - - for (const command of commands) { - const result = await this.runRemoteCommand(hostAlias, command); - results.push(result); - - if (result.code !== 0) { - success = false; - // Continue executing remaining commands - } - } - - return { - results, - success - }; - } catch (error) { - debugLog(`Error during batch execution on ${hostAlias}: ${error.message}\n`); - return { - results: [{ - stdout: '', - stderr: error instanceof Error ? error.message : String(error), - code: 1 - }], - success: false - }; - } - } -} - -// Main function to start the MCP server -async function main() { - try { - // Create an instance of the SSH client - debugLog("Initializing SSH client...\n"); - const sshClient = new SSHClient(); - - debugLog("Creating MCP server...\n"); - // Create an MCP server - const server = new Server( - { name: "mcp-ssh", version: "1.0.0" }, - { capabilities: { tools: {} } } - ); - - debugLog("Setting up request handlers...\n"); - // Handler for listing available tools - server.setRequestHandler(ListToolsRequestSchema, async () => { - debugLog("Received listTools request\n"); - return { - tools: [ - { - name: "listKnownHosts", - description: "Returns a consolidated list of all known SSH hosts, prioritizing ~/.ssh/config entries first, then additional hosts from ~/.ssh/known_hosts", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - { - name: "runRemoteCommand", - description: "Executes a shell command on an SSH host. For long-running commands, increase the timeout parameter.", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - command: { - type: "string", - description: "The shell command to execute", - }, - timeout: { - type: "number", - description: "Command timeout in milliseconds (default: 120000, max: 300000)", - }, - }, - required: ["hostAlias", "command"], - }, - }, - { - name: "getHostInfo", - description: "Returns all configuration details for an SSH host", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - }, - required: ["hostAlias"], - }, - }, - { - name: "checkConnectivity", - description: "Checks if an SSH connection to the host is possible", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - }, - required: ["hostAlias"], - }, - }, - { - name: "uploadFile", - description: "Uploads a local file to an SSH host", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - localPath: { - type: "string", - description: "Path to the local file", - }, - remotePath: { - type: "string", - description: "Path on the remote host", - }, - }, - required: ["hostAlias", "localPath", "remotePath"], - }, - }, - { - name: "downloadFile", - description: "Downloads a file from an SSH host", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - remotePath: { - type: "string", - description: "Path on the remote host", - }, - localPath: { - type: "string", - description: "Path to the local destination", - }, - }, - required: ["hostAlias", "remotePath", "localPath"], - }, - }, - { - name: "runCommandBatch", - description: "Executes multiple shell commands sequentially on an SSH host", - inputSchema: { - type: "object", - properties: { - hostAlias: { - type: "string", - description: "Alias or hostname of the SSH host", - }, - commands: { - type: "array", - items: { type: "string" }, - description: "List of shell commands to execute", - }, - }, - required: ["hostAlias", "commands"], - }, - }, - ], - }; - }); - - // Handler for tool calls - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - debugLog(`Received callTool request for tool: ${name}\n`); - - if (!args && name !== "listKnownHosts") { - throw new Error(`No arguments provided for tool: ${name}`); - } - - try { - switch (name) { - case "listKnownHosts": { - const hosts = await sshClient.listKnownHosts(); - // Strip passwords before sending to LLM - const safeHosts = hosts.map(({ _password, ...host }) => { - if (_password) host.passwordAuth = true; - return host; - }); - return { - content: [{ type: "text", text: JSON.stringify(safeHosts, null, 2) }], - }; - } - - case "runRemoteCommand": { - const timeout = Math.min(args.timeout || 120000, 300000); // Default 2 min, cap at 5 min - const result = await sshClient.runRemoteCommand( - args.hostAlias, - args.command, - { timeout } - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "getHostInfo": { - const hostInfo = await sshClient.getHostInfo(args.hostAlias); - return { - content: [{ type: "text", text: JSON.stringify(hostInfo, null, 2) }], - }; - } - - case "checkConnectivity": { - const status = await sshClient.checkConnectivity(args.hostAlias); - return { - content: [{ type: "text", text: JSON.stringify(status, null, 2) }], - }; - } - - case "uploadFile": { - const success = await sshClient.uploadFile( - args.hostAlias, - args.localPath, - args.remotePath - ); - return { - content: [{ type: "text", text: JSON.stringify({ success }, null, 2) }], - }; - } - - case "downloadFile": { - const success = await sshClient.downloadFile( - args.hostAlias, - args.remotePath, - args.localPath - ); - return { - content: [{ type: "text", text: JSON.stringify({ success }, null, 2) }], - }; - } - - case "runCommandBatch": { - const result = await sshClient.runCommandBatch( - args.hostAlias, - args.commands - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } - } catch (error) { - debugLog(`Error executing tool ${name}: ${error.message}\n`); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - }; - } - }); - - debugLog("Starting MCP SSH Agent on STDIO...\n"); - const transport = new StdioServerTransport(); - await server.connect(transport); - debugLog("MCP SSH Agent connected and ready!\n"); - - } catch (error) { - debugLog(`Error starting MCP SSH Agent: ${error.message}\n`); - process.exit(1); - } -} - -// Export classes and main() for the bin wrapper and tests. -// We do NOT auto-start main() based on a process.argv[1] heuristic — that -// check was unreliable on Windows (backslashes vs forward slashes) and -// caused the server to silently exit when launched via bin/mcp-ssh.js on -// Windows MCP clients (issue #8). The bin wrapper now imports and calls -// main() explicitly. -// SSH_BIN/SCP_BIN are exported so tests can assert against the binary this -// module actually resolved instead of hardcoding 'ssh'/'scp' — on Windows they -// are absolute paths (see resolveExecutable). -export { SSHConfigParser, SSHClient, debugLog, main, SSH_BIN, SCP_BIN }; diff --git a/server.test.mjs b/server.test.mjs deleted file mode 100644 index 07d348a..0000000 --- a/server.test.mjs +++ /dev/null @@ -1,1982 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; -import { createRequire } from 'module'; -import { EventEmitter } from 'events'; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; - -const require = createRequire(import.meta.url); -const sshConfigLib = require('ssh-config'); - -// Mock fs/promises (used via ESM import in server.mjs) -vi.mock('fs/promises', async () => { - const actual = await vi.importActual('fs/promises'); - return { - ...actual, - readFile: vi.fn(), - stat: vi.fn(), - writeFile: vi.fn(), - chmod: vi.fn(), - unlink: vi.fn(), - }; -}); - -import { readFile, stat, writeFile, chmod } from 'fs/promises'; -import { SSHConfigParser, SSHClient, main, SSH_BIN, SCP_BIN } from './server.mjs'; - -// Load a fresh copy of server.mjs with process.platform (and optionally parts of -// the environment) faked, so both the POSIX and the Windows branches can be -// exercised from any host OS. The module snapshots `isWindows`, SSH_BIN and -// SCP_BIN at load time, so the platform only has to stay patched across the -// import itself — hence the restore in `finally`. -// -// Without this, ~14 tests silently assert POSIX-only behaviour (chmod 600 -// checks, the /bin/sh askpass helper, `detached`, a bare 'ssh' argv[0]) and fail -// when the suite runs on Windows. -// Variables server.mjs writes to process.env at import time (the Windows -// ProgramData normalization). They are always saved and restored, whether or not -// a test overrides them — otherwise the first Windows-flavoured import leaks its -// mutation into every later test and makes those branches look covered when -// nothing asserted them. -const ENV_MUTATED_AT_IMPORT = ['ProgramData', 'ALLUSERSPROFILE']; - -async function loadServerAs(platform, envOverrides = {}) { - const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - const realEnv = {}; - - for (const key of ENV_MUTATED_AT_IMPORT) realEnv[key] = process.env[key]; - - for (const [key, value] of Object.entries(envOverrides)) { - realEnv[key] = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - vi.resetModules(); - - try { - const server = await import('./server.mjs'); - // fs/promises has to be re-imported from the same fresh module graph: - // resetModules re-runs the vi.mock factory, so these are new spies — not the - // ones bound by the static import above. - const fs = await import('fs/promises'); - // Snapshot the variables the module writes at import time. The finally block - // below restores process.env immediately, so a test that wants to assert on - // the normalization has to read it from here. - const envAfterImport = Object.fromEntries( - ENV_MUTATED_AT_IMPORT.map(key => [key, process.env[key]]) - ); - return { ...server, fs, envAfterImport }; - } finally { - Object.defineProperty(process, 'platform', realPlatform); - for (const key of new Set([...ENV_MUTATED_AT_IMPORT, ...Object.keys(envOverrides)])) { - if (realEnv[key] === undefined) delete process.env[key]; - else process.env[key] = realEnv[key]; - } - } -} - -// Helper: create a fake spawn that returns a mock child process -function createMockSpawn({ stdout = '', stderr = '', code = 0, error = null } = {}) { - return vi.fn(() => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(() => { - setTimeout(() => child.emit('close', null), 2); - }); - - setTimeout(() => { - if (error) { - child.emit('error', error); - return; - } - if (stdout) child.stdout.emit('data', Buffer.from(stdout)); - if (stderr) child.stderr.emit('data', Buffer.from(stderr)); - child.emit('close', code); - }, 5); - - return child; - }); -} - -// Helper: create a fake execFileAsync -function createMockExecFileAsync({ error = null } = {}) { - return vi.fn(async () => { - if (error) throw error; - return { stdout: '', stderr: '' }; - }); -} - -const SAMPLE_SSH_CONFIG = ` -Host prod - HostName 157.90.89.149 - Port 42077 - User trashmail - -Host mail - HostName 88.198.170.88 - Port 42078 - User saf - # @password: killer99 - -Host nohost - User nobody -`; - -const SAMPLE_SSH_CONFIG_WITH_INCLUDE = ` -Include ~/.ssh/configs/*.conf - -Host prod - HostName 157.90.89.149 - User trashmail -`; - -const SAMPLE_KNOWN_HOSTS = `157.90.89.149 ssh-ed25519 AAAAC3Nz... -88.198.170.88 ssh-ed25519 AAAAC3Nz... -10.0.0.1 ssh-rsa AAAAB3Nz... -`; - -// ============================================================================= -// SSHConfigParser Tests -// ============================================================================= - -describe('SSHConfigParser', () => { - let parser; - - beforeEach(() => { - parser = new SSHConfigParser(); - vi.clearAllMocks(); - }); - - describe('extractHostsFromConfig', () => { - it('should parse hosts with hostname, user, port', () => { - const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); - const hosts = parser.extractHostsFromConfig(config, '/home/test/.ssh/config'); - - expect(hosts).toHaveLength(2); // nohost has no hostname - expect(hosts[0]).toMatchObject({ - alias: 'prod', - hostname: '157.90.89.149', - port: 42077, - user: 'trashmail', - }); - }); - - it('should parse @password annotation from comments', () => { - const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); - const hosts = parser.extractHostsFromConfig(config, '/test'); - const mail = hosts.find(h => h.alias === 'mail'); - expect(mail._password).toBe('killer99'); - }); - - it('should handle password with colons', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - # @password:pass:with:colons -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts[0]._password).toBe('pass:with:colons'); - }); - - it('should handle password with spaces after colon', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - # @password: spaced -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts[0]._password).toBe('spaced'); - }); - - it('should skip hosts without hostname', () => { - const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts.find(h => h.alias === 'nohost')).toBeUndefined(); - }); - - it('should skip wildcard host', () => { - const config = sshConfigLib.parse(` -Host * - ServerAliveInterval 55 - -Host myhost - HostName 1.2.3.4 -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts).toHaveLength(1); - expect(hosts[0].alias).toBe('myhost'); - }); - - // Regression: ssh-config@5 returns a plain string for a single-token value - // but an array of token objects for `Host a b`. Storing that array in - // `alias` made every strict comparison fail, so a multi-alias host was - // unreachable under *any* of its names. - it('should expose every alias of a multi-alias Host block', () => { - const config = sshConfigLib.parse(` -Host docker-lxc hlab - HostName 10.9.0.105 - User root -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - - expect(hosts).toHaveLength(1); - expect(hosts[0].aliases).toEqual(['docker-lxc', 'hlab']); - expect(hosts[0].alias).toBe('docker-lxc'); - expect(hosts[0].hostname).toBe('10.9.0.105'); - }); - - it('should keep alias a string for single-alias hosts', () => { - const config = sshConfigLib.parse(` -Host solo - HostName 1.2.3.4 -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - - expect(hosts[0].alias).toBe('solo'); - expect(hosts[0].aliases).toEqual(['solo']); - }); - - it('should skip a wildcard block carrying negations', () => { - const config = sshConfigLib.parse(` -Host * !bastion - HostName 7.7.7.7 - -Host myhost - HostName 1.2.3.4 -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - - expect(hosts).toHaveLength(1); - expect(hosts[0].alias).toBe('myhost'); - }); - - it('should flatten multi-token directives into a string', () => { - const config = sshConfigLib.parse(` -Host jump - HostName localhost - ProxyCommand ssh bastion -W %h:%p -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - - expect(hosts[0].proxycommand).toBe('ssh bastion -W %h:%p'); - }); - - it('should skip Include directives', () => { - const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG_WITH_INCLUDE); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts).toHaveLength(1); - expect(hosts[0].alias).toBe('prod'); - }); - - it('should parse identityFile', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - IdentityFile ~/.ssh/id_rsa -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts[0].identityFile).toBe('~/.ssh/id_rsa'); - }); - - it('should store other parameters in lowercase', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - ProxyJump bastion -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts.proxyjump || hosts[0].proxyjump).toBe('bastion'); - }); - - it('should track configs with passwords', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - # @password:secret -`); - parser.extractHostsFromConfig(config, '/my/config'); - expect(parser._configsWithPasswords.has('/my/config')).toBe(true); - }); - - it('should not track configs without passwords', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 -`); - parser.extractHostsFromConfig(config, '/my/config'); - expect(parser._configsWithPasswords).toBeUndefined(); - }); - - it('should ignore comment lines that are not @password', () => { - const config = sshConfigLib.parse(` -Host test - HostName 1.2.3.4 - # This is a regular comment - # Another comment -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - expect(hosts[0]._password).toBeUndefined(); - }); - }); - - describe('parseConfig', () => { - it('should parse SSH config file', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - const hosts = await parser.parseConfig(); - expect(hosts).toHaveLength(2); - }); - - it('should return empty array on read error', async () => { - readFile.mockRejectedValue(new Error('ENOENT')); - const hosts = await parser.parseConfig(); - expect(hosts).toEqual([]); - }); - }); - - describe('parseKnownHosts', () => { - it('should parse known_hosts file', async () => { - readFile.mockResolvedValue(SAMPLE_KNOWN_HOSTS); - const hosts = await parser.parseKnownHosts(); - expect(hosts).toEqual(['157.90.89.149', '88.198.170.88', '10.0.0.1']); - }); - - it('should return empty array on read error', async () => { - readFile.mockRejectedValue(new Error('ENOENT')); - const hosts = await parser.parseKnownHosts(); - expect(hosts).toEqual([]); - }); - - it('should skip empty lines', async () => { - readFile.mockResolvedValue('host1 ssh-rsa key\n\n\nhost2 ssh-rsa key\n'); - const hosts = await parser.parseKnownHosts(); - expect(hosts).toEqual(['host1', 'host2']); - }); - - it('should handle comma-separated hostnames', async () => { - readFile.mockResolvedValue('host1,host2 ssh-rsa key\n'); - const hosts = await parser.parseKnownHosts(); - expect(hosts).toEqual(['host1']); - }); - }); - - // Unix permission bits have no meaning on Windows, so checkFilePermissions is - // a deliberate no-op there. Pin the platform instead of inheriting the host's, - // otherwise every expectation below is wrong on one OS or the other. - describe('checkFilePermissions (POSIX)', () => { - let posixParser; - let posixStat; - - beforeEach(async () => { - const posix = await loadServerAs('linux'); - posixParser = new posix.SSHConfigParser(); - posixStat = posix.fs.stat; - }); - - it('should pass with 600 permissions', async () => { - posixStat.mockResolvedValue({ mode: 0o100600 }); - await expect(posixParser.checkFilePermissions('/test')).resolves.not.toThrow(); - }); - - it('should throw on insecure permissions (644)', async () => { - posixStat.mockResolvedValue({ mode: 0o100644 }); - await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('insecure permissions'); - }); - - it('should throw on insecure permissions (755)', async () => { - posixStat.mockResolvedValue({ mode: 0o100755 }); - await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('insecure permissions'); - }); - - it('should include chmod hint in error message', async () => { - posixStat.mockResolvedValue({ mode: 0o100644 }); - await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('chmod 600'); - }); - - it('should ignore ENOENT errors', async () => { - const err = new Error('not found'); - err.code = 'ENOENT'; - posixStat.mockRejectedValue(err); - await expect(posixParser.checkFilePermissions('/test')).resolves.not.toThrow(); - }); - - it('should rethrow other errors', async () => { - posixStat.mockRejectedValue(new Error('disk failure')); - await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('disk failure'); - }); - }); - - describe('checkFilePermissions (Windows)', () => { - it('should skip the permission check without touching stat', async () => { - const win = await loadServerAs('win32'); - const winParser = new win.SSHConfigParser(); - win.fs.stat.mockResolvedValue({ mode: 0o100777 }); - - await expect(winParser.checkFilePermissions('C:\\Users\\test\\.ssh\\config')).resolves.toBeUndefined(); - expect(win.fs.stat).not.toHaveBeenCalled(); - }); - }); - - describe('getAllKnownHosts', () => { - it('should merge config hosts and known_hosts, deduplicating', async () => { - readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); - stat.mockResolvedValue({ mode: 0o100600 }); - - const hosts = await parser.getAllKnownHosts(); - - const configHosts = hosts.filter(h => h.source === 'ssh_config'); - const knownHosts = hosts.filter(h => h.source === 'known_hosts'); - - expect(configHosts).toHaveLength(2); - expect(knownHosts).toHaveLength(1); - expect(knownHosts[0].hostname).toBe('10.0.0.1'); - }); - - // POSIX-pinned: the permission check is a no-op on Windows (see above), so - // asserting that stat() ran only makes sense for the POSIX build. - it('should check permissions for configs with passwords', async () => { - const posix = await loadServerAs('linux'); - const posixParser = new posix.SSHConfigParser(); - posix.fs.readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); - posix.fs.stat.mockResolvedValue({ mode: 0o100600 }); - - await posixParser.getAllKnownHosts(); - expect(posix.fs.stat).toHaveBeenCalled(); - }); - - it('should work with empty known_hosts', async () => { - readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockRejectedValueOnce(new Error('ENOENT')); - stat.mockResolvedValue({ mode: 0o100600 }); - - const hosts = await parser.getAllKnownHosts(); - expect(hosts).toHaveLength(2); - }); - }); - - describe('processIncludeDirectives', () => { - it('should return empty array on read error', async () => { - readFile.mockRejectedValue(new Error('ENOENT')); - const hosts = await parser.processIncludeDirectives('/nonexistent'); - expect(hosts).toEqual([]); - }); - - it('should parse config without includes', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); - expect(hosts).toHaveLength(2); - }); - - it('should process Include directives and merge hosts', async () => { - const mainConfig = ` -Include /tmp/included.conf - -Host main - HostName 1.2.3.4 -`; - const includedConfig = ` -Host included - HostName 5.6.7.8 -`; - readFile - .mockResolvedValueOnce(mainConfig) - .mockResolvedValueOnce(includedConfig); - - // Mock expandIncludePath to return the include path - parser.expandIncludePath = vi.fn().mockReturnValue(['/tmp/included.conf']); - - const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); - expect(hosts).toHaveLength(2); - expect(hosts.map(h => h.alias)).toContain('included'); - expect(hosts.map(h => h.alias)).toContain('main'); - }); - - it('should handle errors in included files gracefully', async () => { - const mainConfig = ` -Include /tmp/broken.conf - -Host main - HostName 1.2.3.4 -`; - // First call reads main config, second call for included file rejects - // processIncludeDirectives catches this internally and returns [] - readFile - .mockResolvedValueOnce(mainConfig) - .mockRejectedValueOnce(new Error('permission denied')); - - parser.expandIncludePath = vi.fn().mockReturnValue(['/tmp/broken.conf']); - - const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); - // Should still return hosts from main config (included returns [] on error) - expect(hosts).toHaveLength(1); - expect(hosts[0].alias).toBe('main'); - }); - }); - - describe('expandIncludePath', () => { - it('should expand tilde paths', () => { - const result = parser.expandIncludePath('~/nonexistent-path-xyz', '/base'); - expect(result).toEqual([]); - }); - - it('should handle relative paths', () => { - const result = parser.expandIncludePath('relative/path', '/base/config'); - expect(result).toEqual([]); - }); - - it('should return empty for non-existent absolute paths', () => { - const result = parser.expandIncludePath('/nonexistent-absolute-path-xyz', '/base'); - expect(result).toEqual([]); - }); - - it('should treat Windows drive-letter paths as absolute', () => { - const result = parser.expandIncludePath('C:\\nonexistent-absolute-path-xyz', '/base/config'); - expect(result).toEqual([]); - }); - - it('should treat UNC paths as absolute', () => { - const result = parser.expandIncludePath('\\\\server\\share\\nonexistent-path-xyz', '/base/config'); - expect(result).toEqual([]); - }); - - it('should expand tilde paths with backslashes', () => { - const result = parser.expandIncludePath('~\\nonexistent-path-xyz', '/base'); - expect(result).toEqual([]); - }); - - it('should return empty for non-existent glob patterns', () => { - const result = parser.expandIncludePath('/nonexistent-path-xyz/*.conf', '/base'); - expect(result).toEqual([]); - }); - - it('should handle errors in glob/existsSync gracefully', () => { - // Temporarily break require('fs').existsSync to trigger catch - const fs = require('fs'); - const origExistsSync = fs.existsSync; - fs.existsSync = () => { throw new Error('fs broken'); }; - - const result = parser.expandIncludePath('/some/path/file', '/base'); - expect(result).toEqual([]); - - fs.existsSync = origExistsSync; - }); - }); -}); - -// ============================================================================= -// SSHClient Tests -// ============================================================================= - -describe('SSHClient', () => { - let client; - - beforeEach(() => { - client = new SSHClient(); - vi.clearAllMocks(); - }); - - describe('getPasswordForHost', () => { - beforeEach(() => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - }); - - it('should find password by alias', async () => { - const pw = await client.getPasswordForHost('mail'); - expect(pw).toBe('killer99'); - }); - - it('should return null for host without password', async () => { - const pw = await client.getPasswordForHost('prod'); - expect(pw).toBeNull(); - }); - - it('should return null for unknown host', async () => { - const pw = await client.getPasswordForHost('unknown'); - expect(pw).toBeNull(); - }); - - it('should strip user@ prefix', async () => { - const pw = await client.getPasswordForHost('saf@mail'); - expect(pw).toBe('killer99'); - }); - - it('should find password by hostname', async () => { - const pw = await client.getPasswordForHost('88.198.170.88'); - expect(pw).toBe('killer99'); - }); - }); - - // The askpass helper is a /bin/sh script chmod'ed to 700 on POSIX and a .cmd - // batch file on Windows (no chmod — NTFS ACLs, not mode bits). Both variants - // are asserted explicitly so the suite is meaningful on either host OS. - describe('getAskpassScript (POSIX)', () => { - let posixClient; - let posixFs; - - beforeEach(async () => { - const posix = await loadServerAs('linux'); - posixClient = new posix.SSHClient(); - posixFs = posix.fs; - posixFs.writeFile.mockResolvedValue(); - posixFs.chmod.mockResolvedValue(); - }); - - it('should create askpass script and cache it', async () => { - const path1 = await posixClient.getAskpassScript(); - const path2 = await posixClient.getAskpassScript(); - - expect(path1).toBe(path2); - expect(posixFs.writeFile).toHaveBeenCalledTimes(1); - expect(posixFs.chmod).toHaveBeenCalledWith(path1, 0o700); - }); - - it('should write correct script content', async () => { - await posixClient.getAskpassScript(); - - expect(posixFs.writeFile).toHaveBeenCalledWith( - expect.stringContaining('mcp-ssh-askpass'), - '#!/bin/sh\necho "$MCP_SSH_PASS"\n' - ); - }); - - it('should use a .sh extension', async () => { - const scriptPath = await posixClient.getAskpassScript(); - expect(scriptPath).toMatch(/mcp-ssh-askpass-\d+\.sh$/); - }); - }); - - describe('getAskpassScript (Windows)', () => { - let winClient; - let winFs; - - beforeEach(async () => { - const win = await loadServerAs('win32'); - winClient = new win.SSHClient(); - winFs = win.fs; - winFs.writeFile.mockResolvedValue(); - winFs.chmod.mockResolvedValue(); - }); - - it('should write a .cmd batch file with CRLF line endings', async () => { - const scriptPath = await winClient.getAskpassScript(); - - expect(scriptPath).toMatch(/mcp-ssh-askpass-\d+\.cmd$/); - expect(winFs.writeFile).toHaveBeenCalledWith( - scriptPath, - '@echo off\r\necho %MCP_SSH_PASS%\r\n' - ); - }); - - it('should not chmod the script', async () => { - await winClient.getAskpassScript(); - expect(winFs.chmod).not.toHaveBeenCalled(); - }); - - it('should cache the script path', async () => { - const path1 = await winClient.getAskpassScript(); - const path2 = await winClient.getAskpassScript(); - - expect(path1).toBe(path2); - expect(winFs.writeFile).toHaveBeenCalledTimes(1); - }); - }); - - describe('buildSpawnEnv', () => { - it('should return null for host without password', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - const env = await client.buildSpawnEnv('prod'); - expect(env).toBeNull(); - }); - - it('should return env with SSH_ASKPASS for host with password', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - stat.mockResolvedValue({ mode: 0o100600 }); - writeFile.mockResolvedValue(); - chmod.mockResolvedValue(); - - const env = await client.buildSpawnEnv('mail'); - expect(env.MCP_SSH_PASS).toBe('killer99'); - expect(env.SSH_ASKPASS).toContain('mcp-ssh-askpass'); - expect(env.SSH_ASKPASS_REQUIRE).toBe('force'); - expect(env.DISPLAY).toBe(process.env.DISPLAY); - }); - - // POSIX-pinned: relies on the permission check, which is a no-op on Windows. - it('should throw if config has insecure permissions', async () => { - const posix = await loadServerAs('linux'); - const posixClient = new posix.SSHClient(); - posix.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - posix.fs.stat.mockResolvedValue({ mode: 0o100644 }); - - // Trigger password parsing first - await posixClient.getPasswordForHost('mail'); - - await expect(posixClient.buildSpawnEnv('mail')).rejects.toThrow('insecure permissions'); - }); - }); - - describe('runRemoteCommand', () => { - beforeEach(() => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - it('should execute ssh command and return output', async () => { - client._spawn = createMockSpawn({ stdout: 'hello\n', code: 0 }); - - const result = await client.runRemoteCommand('test', 'echo hello'); - - expect(client._spawn).toHaveBeenCalledWith( - SSH_BIN, - ['-o', 'StrictHostKeyChecking=accept-new', '--', 'test', 'echo hello'], - expect.any(Object) - ); - expect(result).toEqual({ stdout: 'hello\n', stderr: '', code: 0 }); - }); - - it('should handle command failure with exit code', async () => { - client._spawn = createMockSpawn({ stderr: 'not found', code: 127 }); - - const result = await client.runRemoteCommand('test', 'badcmd'); - expect(result.code).toBe(127); - expect(result.stderr).toBe('not found'); - }); - - it('should handle spawn error', async () => { - client._spawn = createMockSpawn({ error: new Error('spawn failed') }); - - const result = await client.runRemoteCommand('test', 'cmd'); - expect(result.code).toBe(1); - expect(result.stderr).toBe('spawn failed'); - }); - - it('should handle timeout', async () => { - client._spawn = vi.fn(() => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(() => { - setTimeout(() => child.emit('close', null), 2); - }); - return child; - }); - - const result = await client.runRemoteCommand('test', 'sleep 999', { timeout: 10 }); - expect(result.code).toBe(124); - expect(result.stderr).toContain('Command timed out'); - }); - - // `detached` is POSIX-only: it exists so ssh talks to SSH_ASKPASS instead of - // grabbing a tty, a problem Windows does not have. Pin the platform on both - // sides so neither expectation depends on the host OS. - it('should set detached and env when password is available (POSIX)', async () => { - const posix = await loadServerAs('linux'); - const posixClient = new posix.SSHClient(); - posix.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - posix.fs.stat.mockResolvedValue({ mode: 0o100600 }); - posix.fs.writeFile.mockResolvedValue(); - posix.fs.chmod.mockResolvedValue(); - posixClient._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); - - await posixClient.runRemoteCommand('mail', 'ls'); - - expect(posixClient._spawn).toHaveBeenCalledWith( - posix.SSH_BIN, - expect.any(Array), - expect.objectContaining({ - detached: true, - env: expect.objectContaining({ - MCP_SSH_PASS: 'killer99', - SSH_ASKPASS_REQUIRE: 'force', - }), - }) - ); - }); - - it('should set env but not detached when password is available (Windows)', async () => { - const win = await loadServerAs('win32'); - const winClient = new win.SSHClient(); - win.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - win.fs.writeFile.mockResolvedValue(); - winClient._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); - - await winClient.runRemoteCommand('mail', 'ls'); - - const opts = winClient._spawn.mock.calls[0][2]; - expect(opts.env).toEqual(expect.objectContaining({ MCP_SSH_PASS: 'killer99' })); - expect(opts.detached).toBeUndefined(); - }); - - it('should not set detached without password', async () => { - client._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); - - await client.runRemoteCommand('test', 'ls'); - - expect(client._spawn).toHaveBeenCalledWith( - SSH_BIN, - expect.any(Array), - expect.objectContaining({ - stdio: ['ignore', 'pipe', 'pipe'], - }) - ); - const opts = client._spawn.mock.calls[0][2]; - expect(opts.detached).toBeUndefined(); - expect(opts.env).toBeUndefined(); - }); - - it('should truncate stdout exceeding 10MB', async () => { - client._spawn = vi.fn(() => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - - setTimeout(() => { - // Send in two chunks so the second one triggers truncation - child.stdout.emit('data', Buffer.from('x'.repeat(10 * 1024 * 1024))); - child.stdout.emit('data', Buffer.from('x'.repeat(1024))); - child.emit('close', 0); - }, 5); - - return child; - }); - - const result = await client.runRemoteCommand('test', 'bigcmd'); - expect(result.stdout).toContain('[Output truncated'); - }); - - it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { - client._spawn = createMockSpawn({ stdout: 'pwned', code: 0 }); - - await expect( - client.runRemoteCommand('-oProxyCommand=touch /tmp/pwned', 'echo') - ).rejects.toThrow(/Invalid hostAlias/); - expect(client._spawn).not.toHaveBeenCalled(); - }); - - it('should reject hostAlias containing shell metacharacters (Windows cmd.exe vector)', async () => { - client._spawn = createMockSpawn({ stdout: '', code: 0 }); - - for (const evil of ['foo & calc.exe', 'foo|calc', 'foo;ls', 'foo`id`', 'foo$(id)', 'foo"bar', "foo'bar"]) { - await expect(client.runRemoteCommand(evil, 'ls')).rejects.toThrow(/Invalid hostAlias/); - } - expect(client._spawn).not.toHaveBeenCalled(); - }); - - it('should reject unknown hostAlias that is not in ssh config or known_hosts', async () => { - readFile - .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) - .mockResolvedValueOnce(''); - client._spawn = createMockSpawn({ stdout: '', code: 0 }); - - await expect(client.runRemoteCommand('unknown.example.com', 'ls')).rejects.toThrow(/Unknown hostAlias/); - expect(client._spawn).not.toHaveBeenCalled(); - }); - - it('should allow user@alias when alias exists in ssh config', async () => { - client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); - - const result = await client.runRemoteCommand('root@test', 'whoami'); - - expect(client._spawn).toHaveBeenCalledWith( - SSH_BIN, - ['-o', 'StrictHostKeyChecking=accept-new', '--', 'root@test', 'whoami'], - expect.any(Object) - ); - expect(result.code).toBe(0); - }); - - it('should allow hosts discovered through Include directives', async () => { - readFile.mockImplementation(async (filePath) => { - // Separator-agnostic: configPath is ~/.ssh/config on POSIX but - // C:\Users\…\.ssh\config on Windows, where endsWith('/config') misses. - if (/[\\/]config$/.test(String(filePath))) return SAMPLE_SSH_CONFIG_WITH_INCLUDE; - if (String(filePath).endsWith('.conf')) return `Host included\n HostName 10.10.10.10\n`; - if (String(filePath).endsWith('known_hosts')) return ''; - return ''; - }); - client.configParser.expandIncludePath = vi.fn(() => ['/tmp/included.conf']); - client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); - - const result = await client.runRemoteCommand('included', 'hostname'); - - expect(client._spawn).toHaveBeenCalled(); - expect(result.code).toBe(0); - }); - - it('should truncate stderr exceeding 10MB', async () => { - client._spawn = vi.fn(() => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - - setTimeout(() => { - child.stderr.emit('data', Buffer.from('x'.repeat(10 * 1024 * 1024))); - child.stderr.emit('data', Buffer.from('x'.repeat(1024))); - child.emit('close', 0); - }, 5); - - return child; - }); - - const result = await client.runRemoteCommand('test', 'bigcmd'); - expect(result.stderr).toContain('[Stderr truncated'); - }); - }); - - describe('getHostInfo', () => { - beforeEach(() => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - }); - - it('should return host info without password exposed', async () => { - const info = await client.getHostInfo('mail'); - expect(info.alias).toBe('mail'); - expect(info.hostname).toBe('88.198.170.88'); - expect(info._password).toBeUndefined(); - expect(info.passwordAuth).toBe(true); - }); - - it('should not set passwordAuth flag when no password', async () => { - const info = await client.getHostInfo('prod'); - expect(info.passwordAuth).toBeUndefined(); - }); - - it('should return null for unknown host', async () => { - const info = await client.getHostInfo('nonexistent'); - expect(info).toBeNull(); - }); - - it('should return correct port and user', async () => { - const info = await client.getHostInfo('prod'); - expect(info.port).toBe(42077); - expect(info.user).toBe('trashmail'); - }); - }); - - describe('checkConnectivity', () => { - beforeEach(() => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - it('should return connected on success', async () => { - client._spawn = createMockSpawn({ stdout: 'connected\n', code: 0 }); - - const status = await client.checkConnectivity('test'); - expect(status).toEqual({ connected: true, message: 'Connection successful' }); - }); - - it('should return not connected on failure', async () => { - client._spawn = createMockSpawn({ stderr: 'refused', code: 255 }); - - const status = await client.checkConnectivity('test'); - expect(status).toEqual({ connected: false, message: 'Connection failed' }); - }); - - it('should return not connected when output is unexpected', async () => { - client._spawn = createMockSpawn({ stdout: 'something else', code: 0 }); - - const status = await client.checkConnectivity('test'); - expect(status.connected).toBe(false); - }); - }); - - describe('uploadFile', () => { - beforeEach(() => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - it('should return true on success', async () => { - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.uploadFile('test', '/local/file', '/remote/file'); - expect(result).toBe(true); - expect(client._execFileAsync).toHaveBeenCalledWith( - SCP_BIN, - ['-o', 'StrictHostKeyChecking=accept-new', '--', '/local/file', 'test:/remote/file'], - expect.any(Object) - ); - }); - - it('should return false on error', async () => { - client._execFileAsync = createMockExecFileAsync({ error: new Error('scp failed') }); - - const result = await client.uploadFile('test', '/local/file', '/remote/file'); - expect(result).toBe(false); - }); - - it('should pass password env when available', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - stat.mockResolvedValue({ mode: 0o100600 }); - writeFile.mockResolvedValue(); - chmod.mockResolvedValue(); - client._execFileAsync = createMockExecFileAsync(); - - await client.uploadFile('mail', '/local/file', '/remote/file'); - - const opts = client._execFileAsync.mock.calls[0][2]; - expect(opts.env.MCP_SSH_PASS).toBe('killer99'); - }); - - it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.uploadFile('-oProxyCommand=touch /tmp/pwned', '/local/file', '/remote/file'); - expect(result).toBe(false); - expect(client._execFileAsync).not.toHaveBeenCalled(); - }); - - it('should reject unknown hostAlias for uploads', async () => { - readFile - .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) - .mockResolvedValueOnce(''); - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.uploadFile('unknown.example.com', '/local/file', '/remote/file'); - expect(result).toBe(false); - expect(client._execFileAsync).not.toHaveBeenCalled(); - }); - }); - - describe('downloadFile', () => { - beforeEach(() => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - it('should return true on success', async () => { - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.downloadFile('test', '/remote/file', '/local/file'); - expect(result).toBe(true); - expect(client._execFileAsync).toHaveBeenCalledWith( - SCP_BIN, - ['-o', 'StrictHostKeyChecking=accept-new', '--', 'test:/remote/file', '/local/file'], - expect.any(Object) - ); - }); - - it('should return false on error', async () => { - client._execFileAsync = createMockExecFileAsync({ error: new Error('scp failed') }); - - const result = await client.downloadFile('test', '/remote/file', '/local/file'); - expect(result).toBe(false); - }); - - it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.downloadFile('-oProxyCommand=touch /tmp/pwned', '/remote/file', '/local/file'); - expect(result).toBe(false); - expect(client._execFileAsync).not.toHaveBeenCalled(); - }); - - it('should allow hostnames learned from known_hosts for downloads', async () => { - readFile - .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) - .mockResolvedValueOnce('10.0.0.1 ssh-rsa AAAAB3Nz...\n'); - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.downloadFile('10.0.0.1', '/remote/file', '/local/file'); - expect(result).toBe(true); - expect(client._execFileAsync).toHaveBeenCalled(); - }); - }); - - describe('runCommandBatch', () => { - beforeEach(() => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - it('should execute multiple commands and return results', async () => { - let callCount = 0; - client._spawn = vi.fn(() => { - callCount++; - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - const n = callCount; - setTimeout(() => { - child.stdout.emit('data', Buffer.from(`output${n}\n`)); - child.emit('close', 0); - }, 5); - return child; - }); - - const result = await client.runCommandBatch('test', ['cmd1', 'cmd2']); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(2); - expect(result.results[0].stdout).toBe('output1\n'); - expect(result.results[1].stdout).toBe('output2\n'); - }); - - it('should mark as failed if any command fails but continue', async () => { - let callCount = 0; - client._spawn = vi.fn(() => { - callCount++; - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - const exitCode = callCount === 1 ? 1 : 0; - setTimeout(() => { - child.emit('close', exitCode); - }, 5); - return child; - }); - - const result = await client.runCommandBatch('test', ['fail', 'pass']); - expect(result.success).toBe(false); - expect(result.results).toHaveLength(2); - }); - - it('should handle empty command list', async () => { - const result = await client.runCommandBatch('test', []); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(0); - }); - }); - - describe('listKnownHosts', () => { - it('should delegate to configParser.getAllKnownHosts', async () => { - readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); - stat.mockResolvedValue({ mode: 0o100600 }); - - const hosts = await client.listKnownHosts(); - expect(hosts.length).toBeGreaterThan(0); - }); - }); - - describe('checkConnectivity error handling', () => { - it('should handle thrown errors gracefully', async () => { - readFile.mockRejectedValue(new Error('config read failed')); - client._spawn = createMockSpawn({ stderr: 'error', code: 1 }); - - const status = await client.checkConnectivity('test'); - expect(status.connected).toBe(false); - }); - - it('should catch exceptions from runRemoteCommand', async () => { - client.runRemoteCommand = vi.fn().mockRejectedValue(new Error('ssh crash')); - - const status = await client.checkConnectivity('test'); - expect(status.connected).toBe(false); - expect(status.message).toBe('ssh crash'); - }); - - it('should handle non-Error thrown values in catch', async () => { - client.runRemoteCommand = vi.fn().mockRejectedValue('string error'); - - const status = await client.checkConnectivity('test'); - expect(status.connected).toBe(false); - expect(status.message).toBe('string error'); - }); - }); - - describe('runCommandBatch error handling', () => { - it('should handle thrown errors gracefully', async () => { - // Make runRemoteCommand throw by overriding it - client.runRemoteCommand = vi.fn().mockRejectedValue(new Error('connection lost')); - - const result = await client.runCommandBatch('test', ['cmd1']); - expect(result.success).toBe(false); - expect(result.results[0].stderr).toBe('connection lost'); - expect(result.results[0].code).toBe(1); - }); - - it('should handle non-Error thrown values', async () => { - client.runRemoteCommand = vi.fn().mockRejectedValue('string error'); - - const result = await client.runCommandBatch('test', ['cmd1']); - expect(result.success).toBe(false); - expect(result.results[0].stderr).toBe('string error'); - }); - }); -}); - -// ============================================================================= -// MCP Server Handler Tests (via main()) -// ============================================================================= - -describe('MCP Server Handlers', () => { - let server; - let handlers; - let clientSpies; - - afterEach(() => { - for (const spy of clientSpies) spy.mockRestore(); - }); - - beforeEach(async () => { - vi.clearAllMocks(); - - // These tests drive the real SSHClient that main() constructs, so every tool - // call would otherwise spawn an actual ssh/scp process against 1.2.3.4 and - // block on the network. Stubbing the three process-starting methods keeps - // the block a dispatch test — which is all it asserts — instead of a slow, - // network-dependent one that blows the 5s timeout on Windows CI runners. - // The methods themselves are covered by the SSHClient tests above. - clientSpies = [ - vi.spyOn(SSHClient.prototype, 'runRemoteCommand') - .mockResolvedValue({ stdout: 'connected', stderr: '', code: 0 }), - vi.spyOn(SSHClient.prototype, 'uploadFile').mockResolvedValue(true), - vi.spyOn(SSHClient.prototype, 'downloadFile').mockResolvedValue(true), - ]; - - // Capture the request handlers that main() registers - handlers = {}; - - // Mock the MCP SDK Server and Transport - const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); - const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); - - // Save original and mock - const origSetRequestHandler = Server.prototype.setRequestHandler; - const origConnect = Server.prototype.connect; - - Server.prototype.setRequestHandler = function(schema, handler) { - // Store by schema name - if (schema === require('@modelcontextprotocol/sdk/types.js').ListToolsRequestSchema) { - handlers.listTools = handler; - } else if (schema === require('@modelcontextprotocol/sdk/types.js').CallToolRequestSchema) { - handlers.callTool = handler; - } - }; - Server.prototype.connect = vi.fn().mockResolvedValue(); - - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - stat.mockResolvedValue({ mode: 0o100600 }); - - await main(); - - // Restore - Server.prototype.setRequestHandler = origSetRequestHandler; - Server.prototype.connect = origConnect; - }); - - it('should register listTools handler that returns all tools', async () => { - const result = await handlers.listTools(); - expect(result.tools).toHaveLength(7); - const names = result.tools.map(t => t.name); - expect(names).toContain('listKnownHosts'); - expect(names).toContain('runRemoteCommand'); - expect(names).toContain('getHostInfo'); - expect(names).toContain('checkConnectivity'); - expect(names).toContain('uploadFile'); - expect(names).toContain('downloadFile'); - expect(names).toContain('runCommandBatch'); - }); - - it('should handle listKnownHosts tool call', async () => { - readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); - - const result = await handlers.callTool({ - params: { name: 'listKnownHosts', arguments: {} } - }); - - const hosts = JSON.parse(result.content[0].text); - expect(Array.isArray(hosts)).toBe(true); - // Passwords should be stripped - for (const host of hosts) { - expect(host._password).toBeUndefined(); - } - }); - - it('should handle getHostInfo tool call', async () => { - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - - const result = await handlers.callTool({ - params: { name: 'getHostInfo', arguments: { hostAlias: 'mail' } } - }); - - const info = JSON.parse(result.content[0].text); - expect(info.alias).toBe('mail'); - expect(info._password).toBeUndefined(); - expect(info.passwordAuth).toBe(true); - }); - - it('should throw on missing arguments', async () => { - await expect( - handlers.callTool({ params: { name: 'runRemoteCommand', arguments: undefined } }) - ).rejects.toThrow('No arguments provided'); - }); - - it('should handle unknown tool name', async () => { - const result = await handlers.callTool({ - params: { name: 'unknownTool', arguments: {} } - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('Unknown tool'); - }); - - it('should cap runRemoteCommand timeout at 300000ms', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - const spy = vi.spyOn(SSHClient.prototype, 'runRemoteCommand') - .mockResolvedValue({ stdout: '', stderr: '', code: 0 }); - - try { - await handlers.callTool({ - params: { - name: 'runRemoteCommand', - arguments: { hostAlias: 'test', command: 'echo hi', timeout: 999999 } - } - }); - - expect(spy).toHaveBeenCalledWith('test', 'echo hi', { timeout: 300000 }); - } finally { - spy.mockRestore(); - } - }); - - it('should default the runRemoteCommand timeout to 120000ms', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - const spy = vi.spyOn(SSHClient.prototype, 'runRemoteCommand') - .mockResolvedValue({ stdout: '', stderr: '', code: 0 }); - - try { - await handlers.callTool({ - params: { name: 'runRemoteCommand', arguments: { hostAlias: 'test', command: 'echo hi' } } - }); - - expect(spy).toHaveBeenCalledWith('test', 'echo hi', { timeout: 120000 }); - } finally { - spy.mockRestore(); - } - }); - - it('should stringify non-Error values thrown by a tool', async () => { - const spy = vi.spyOn(SSHClient.prototype, 'listKnownHosts') - .mockRejectedValue('a bare string, not an Error'); - - try { - const result = await handlers.callTool({ - params: { name: 'listKnownHosts', arguments: {} } - }); - - expect(JSON.parse(result.content[0].text).error).toBe('a bare string, not an Error'); - } finally { - spy.mockRestore(); - } - }); - - it('should handle checkConnectivity tool call', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - - const result = await handlers.callTool({ - params: { name: 'checkConnectivity', arguments: { hostAlias: 'test' } } - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty('connected'); - expect(parsed).toHaveProperty('message'); - }); - - it('should handle uploadFile tool call', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - - const result = await handlers.callTool({ - params: { - name: 'uploadFile', - arguments: { hostAlias: 'test', localPath: '/tmp/test', remotePath: '/tmp/dest' } - } - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty('success'); - }); - - it('should handle downloadFile tool call', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - - const result = await handlers.callTool({ - params: { - name: 'downloadFile', - arguments: { hostAlias: 'test', remotePath: '/tmp/src', localPath: '/tmp/dest' } - } - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty('success'); - }); - - it('should handle runCommandBatch tool call', async () => { - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - - const result = await handlers.callTool({ - params: { - name: 'runCommandBatch', - arguments: { hostAlias: 'test', commands: ['echo a', 'echo b'] } - } - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed).toHaveProperty('results'); - expect(parsed).toHaveProperty('success'); - }); - - it('should allow listKnownHosts without arguments', async () => { - readFile - .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) - .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); - - const result = await handlers.callTool({ - params: { name: 'listKnownHosts' } - }); - - const hosts = JSON.parse(result.content[0].text); - expect(Array.isArray(hosts)).toBe(true); - }); -}); - -// ============================================================================= -// main() error handling -// ============================================================================= - -describe('main() error handling', () => { - it('should handle startup errors gracefully', async () => { - const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); - const origConnect = Server.prototype.connect; - - Server.prototype.connect = vi.fn().mockRejectedValue(new Error('transport failed')); - - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); - - await main(); - - expect(exitSpy).toHaveBeenCalledWith(1); - - Server.prototype.connect = origConnect; - exitSpy.mockRestore(); - }); -}); - -// ============================================================================= -// Platform-specific module initialisation -// -// resolveExecutable() runs once at import time and only does real work on -// Windows, where spawn() is called with shell:false and therefore cannot rely -// on PATH lookup. Exercised here by re-importing the module with the platform -// and PATH/PATHEXT faked, so the Windows resolution logic is covered from any -// host OS. -// ============================================================================= - -describe('resolveExecutable (Windows binary resolution)', () => { - let binDir; - - beforeAll(() => { - binDir = mkdtempSync(join(tmpdir(), 'mcp-ssh-bin-')); - writeFileSync(join(binDir, 'ssh.EXE'), ''); - writeFileSync(join(binDir, 'scp.EXE'), ''); - // A directory named like the executable must not be mistaken for one. - mkdirSync(join(binDir, 'ssh.CMD')); - }); - - afterAll(() => { - rmSync(binDir, { recursive: true, force: true }); - }); - - it('should resolve ssh/scp to absolute paths found on PATH', async () => { - const win = await loadServerAs('win32', { - // Trailing separator produces an empty entry, which must be skipped. - PATH: `${binDir};`, - PATHEXT: '.EXE;.CMD', - }); - - expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); - expect(win.SCP_BIN).toBe(join(binDir, 'scp.EXE')); - }); - - it('should skip directory entries that match the name but are not files', async () => { - const win = await loadServerAs('win32', { - PATH: binDir, - PATHEXT: '.CMD;.EXE', // .CMD first: ssh.CMD is a directory, not a match - }); - - expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); - }); - - it('should fall back to a bare .exe name when PATH holds no match', async () => { - const emptyDir = mkdtempSync(join(tmpdir(), 'mcp-ssh-empty-')); - try { - const win = await loadServerAs('win32', { PATH: emptyDir, PATHEXT: '.EXE' }); - - expect(win.SSH_BIN).toBe('ssh.exe'); - expect(win.SCP_BIN).toBe('scp.exe'); - } finally { - rmSync(emptyDir, { recursive: true, force: true }); - } - }); - - it('should fall back when PATH is unset entirely', async () => { - const win = await loadServerAs('win32', { PATH: undefined, PATHEXT: '.EXE' }); - expect(win.SSH_BIN).toBe('ssh.exe'); - }); - - it('should use the default PATHEXT list when the variable is unset', async () => { - const win = await loadServerAs('win32', { PATH: binDir, PATHEXT: undefined }); - // .EXE is part of the built-in default list. - expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); - }); - - it('should use the bare name on POSIX, letting spawn search PATH', async () => { - const posix = await loadServerAs('linux', { PATH: binDir }); - - expect(posix.SSH_BIN).toBe('ssh'); - expect(posix.SCP_BIN).toBe('scp'); - }); -}); - -// ============================================================================= -// ssh-config value normalization edge cases -// -// extractHostsFromConfig is a pure function over the parser's section array, so -// these feed it shapes that ssh-config can emit but that are awkward to produce -// from config text alone. -// ============================================================================= - -describe('config value normalization', () => { - let parser; - - beforeEach(() => { - parser = new SSHConfigParser(); - vi.clearAllMocks(); - }); - - it('should treat a directive without a value as absent', () => { - const hosts = parser.extractHostsFromConfig([ - { - param: 'Host', - value: 'x', - config: [ - { param: 'HostName', value: '1.2.3.4' }, - { param: 'SendEnv', value: null }, - ], - }, - ], '/test'); - - expect(hosts).toHaveLength(1); - expect(hosts[0].sendenv).toBe(''); - }); - - it('should accept plain strings inside a multi-token value', () => { - // ssh-config normally yields {val,…} token objects, but a hand-built or - // future-shaped array of bare strings must normalize the same way. - const hosts = parser.extractHostsFromConfig([ - { - param: 'Host', - value: ['first', 'second'], - config: [{ param: 'HostName', value: '1.2.3.4' }], - }, - ], '/test'); - - expect(hosts[0].aliases).toEqual(['first', 'second']); - expect(hosts[0].alias).toBe('first'); - }); - - it('should skip a Host block whose value is empty', () => { - const hosts = parser.extractHostsFromConfig([ - { param: 'Host', value: [], config: [{ param: 'HostName', value: '1.2.3.4' }] }, - ], '/test'); - - expect(hosts).toEqual([]); - }); - - it('should ignore top-level directives that are not Host or Include', () => { - const config = sshConfigLib.parse(` -ServerAliveInterval 30 - -Host real - HostName 1.2.3.4 -`); - const hosts = parser.extractHostsFromConfig(config, '/test'); - - expect(hosts).toHaveLength(1); - expect(hosts[0].alias).toBe('real'); - }); -}); - -// ============================================================================= -// expandIncludePath — paths that actually exist -// ============================================================================= - -describe('expandIncludePath (existing paths)', () => { - let parser; - let dir; - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'mcp-ssh-inc-')); - writeFileSync(join(dir, 'included.conf'), 'Host inc\n HostName 5.5.5.5\n'); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - beforeEach(() => { - parser = new SSHConfigParser(); - }); - - it('should return an existing absolute path', () => { - const target = join(dir, 'included.conf'); - expect(parser.expandIncludePath(target, '/base/config')).toEqual([target]); - }); - - it('should expand a glob pattern to the files it matches', () => { - // glob patterns are forward-slash based on every platform, including Windows. - const pattern = `${dir.replace(/\\/g, '/')}/*.conf`; - const result = parser.expandIncludePath(pattern, '/base/config'); - - expect(result).toHaveLength(1); - expect(result[0]).toMatch(/included\.conf$/); - }); -}); - -// ============================================================================= -// Remaining branches: argument validation, known_hosts matching, silent mode, -// askpass cleanup handlers and the tool-dispatch catch-all. -// ============================================================================= - -describe('_assertSafeHostAlias argument validation', () => { - let client; - - beforeEach(() => { - client = new SSHClient(); - vi.clearAllMocks(); - }); - - it.each([ - ['undefined', undefined], - ['null', null], - ['a number', 42], - ['an empty string', ''], - ['an array', ['test']], - ])('should reject %s before touching ssh', async (_label, value) => { - expect(() => client._assertSafeHostAlias(value)).toThrow('must be a non-empty string'); - }); - - it('should surface the type error through runRemoteCommand', async () => { - client._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); - - await expect(client.runRemoteCommand(null, 'echo hi')).rejects.toThrow( - 'must be a non-empty string' - ); - expect(client._spawn).not.toHaveBeenCalled(); - }); -}); - -describe('hostMatchesAlias against known_hosts entries', () => { - let client; - - beforeEach(() => { - client = new SSHClient(); - vi.clearAllMocks(); - }); - - it('should keep scanning past a non-matching known_hosts entry', async () => { - // known_hosts entries carry only a hostname (no alias/aliases), so matching - // them falls through to the plain-alias comparison. The host we ask for is - // the *second* entry, so the first one has to be rejected and skipped. - readFile.mockImplementation(async (filePath) => { - if (/known_hosts$/.test(String(filePath))) { - return '10.0.0.1 ssh-ed25519 AAAA...\n10.0.0.2 ssh-ed25519 BBBB...\n'; - } - return `Host other\n HostName 192.168.1.1\n`; - }); - client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); - - const result = await client.runRemoteCommand('10.0.0.2', 'uptime'); - expect(result.code).toBe(0); - }); -}); - -describe('silent mode', () => { - it('should suppress debug output when MCP_SILENT is set', async () => { - const silent = await loadServerAs('linux', { MCP_SILENT: 'true' }); - const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - - try { - silent.debugLog('this must not be written\n'); - expect(writeSpy).not.toHaveBeenCalled(); - } finally { - writeSpy.mockRestore(); - } - }); - - it('should write debug output when MCP_SILENT is not set', async () => { - const loud = await loadServerAs('linux', { MCP_SILENT: undefined }); - const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - - try { - loud.debugLog('hello\n'); - expect(writeSpy).toHaveBeenCalledWith('hello\n'); - } finally { - writeSpy.mockRestore(); - } - }); -}); - -describe('askpass script cleanup handlers', () => { - let client; - let exitSpy; - - beforeEach(async () => { - vi.clearAllMocks(); - writeFile.mockResolvedValue(); - chmod.mockResolvedValue(); - client = new SSHClient(); - exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); - }); - - afterEach(() => { - exitSpy.mockRestore(); - }); - - // The handlers registered by getAskpassScript are invoked directly: they only - // ever run while the process is tearing down, which a unit test cannot trigger. - async function registerAndTake(signal) { - const before = process.listeners(signal).length; - await client.getAskpassScript(); - const listeners = process.listeners(signal); - expect(listeners.length).toBeGreaterThan(before); - const handler = listeners[listeners.length - 1]; - return () => { - handler(); - process.removeListener(signal, handler); - }; - } - - it('should unlink the script on exit', async () => { - const run = await registerAndTake('exit'); - // unlinkSync throws ENOENT (writeFile is mocked, so no file exists) and the - // handler must swallow it. - expect(run).not.toThrow(); - }); - - it('should clean up and exit 130 on SIGINT', async () => { - const run = await registerAndTake('SIGINT'); - run(); - expect(exitSpy).toHaveBeenCalledWith(130); - }); - - it('should clean up and exit 143 on SIGTERM', async () => { - const run = await registerAndTake('SIGTERM'); - run(); - expect(exitSpy).toHaveBeenCalledWith(143); - }); -}); - -describe('password env on the scp paths', () => { - let client; - - beforeEach(() => { - client = new SSHClient(); - vi.clearAllMocks(); - readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); - stat.mockResolvedValue({ mode: 0o100600 }); - writeFile.mockResolvedValue(); - chmod.mockResolvedValue(); - }); - - it('should pass password env to downloadFile', async () => { - client._execFileAsync = createMockExecFileAsync(); - - const result = await client.downloadFile('mail', '/remote/file', '/local/file'); - - expect(result).toBe(true); - expect(client._execFileAsync).toHaveBeenCalledWith( - SCP_BIN, - expect.any(Array), - expect.objectContaining({ - env: expect.objectContaining({ MCP_SSH_PASS: 'killer99' }), - }) - ); - }); - - it('should skip the permission sweep when no config declared a password', async () => { - // A password reached us without extractHostsFromConfig having flagged any - // config file — there is then nothing to check the permissions of. - client.getPasswordForHost = vi.fn().mockResolvedValue('secret'); - client.configParser._configsWithPasswords = undefined; - - const env = await client.buildSpawnEnv('anything'); - - expect(env.MCP_SSH_PASS).toBe('secret'); - expect(stat).not.toHaveBeenCalled(); - }); -}); - -describe('output truncation', () => { - let client; - - beforeEach(() => { - client = new SSHClient(); - vi.clearAllMocks(); - readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - }); - - // Three chunks: the second crosses the limit and appends the marker, the third - // must be dropped silently rather than appending it again. - function spawnEmitting(stream, chunks) { - return vi.fn(() => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - - setTimeout(() => { - for (const chunk of chunks) child[stream].emit('data', Buffer.from(chunk)); - child.emit('close', 0); - }, 5); - - return child; - }); - } - - it('should append the stdout truncation marker only once', async () => { - client._spawn = spawnEmitting('stdout', [ - 'x'.repeat(10 * 1024 * 1024), - 'y'.repeat(1024), - 'z'.repeat(1024), - ]); - - const result = await client.runRemoteCommand('test', 'bigcmd'); - const markers = result.stdout.match(/\[Output truncated/g) || []; - expect(markers).toHaveLength(1); - }); - - it('should append the stderr truncation marker only once', async () => { - client._spawn = spawnEmitting('stderr', [ - 'x'.repeat(10 * 1024 * 1024), - 'y'.repeat(1024), - 'z'.repeat(1024), - ]); - - const result = await client.runRemoteCommand('test', 'bigcmd'); - const markers = result.stderr.match(/\[Stderr truncated/g) || []; - expect(markers).toHaveLength(1); - }); -}); - -// ============================================================================= -// Windows ProgramData normalization (issue #10) -// -// Claude Desktop launches the extension with a stripped, allow-listed -// environment that omits %ProgramData%. Win32-OpenSSH resolves it at startup to -// find its global config (%ProgramData%\ssh\) and exits 255 with no output when -// it is unset, so every spawned ssh/scp fails while the same command works from -// a normal shell. server.mjs restores the variable at import time; these tests -// pin that behaviour, including that it stays out of the way on POSIX. -// ============================================================================= - -describe('Windows ProgramData normalization', () => { - it('should default ProgramData and ALLUSERSPROFILE when both are missing', async () => { - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - SystemDrive: 'C:', - }); - - expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); - expect(win.envAfterImport.ALLUSERSPROFILE).toBe('C:\\ProgramData'); - }); - - it('should derive the default from %SystemDrive% rather than hardcoding C:', async () => { - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - SystemDrive: 'D:', - }); - - expect(win.envAfterImport.ProgramData).toBe('D:\\ProgramData'); - }); - - it('should tolerate a %SystemDrive% that carries a trailing separator', async () => { - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - SystemDrive: 'E:\\', - }); - - expect(win.envAfterImport.ProgramData).toBe('E:\\ProgramData'); - }); - - it('should fall back to C: when %SystemDrive% is missing too', async () => { - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - SystemDrive: undefined, - }); - - expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); - }); - - it('should prefer an existing ALLUSERSPROFILE over the drive-based default', async () => { - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: 'X:\\CustomProgramData', - }); - - expect(win.envAfterImport.ProgramData).toBe('X:\\CustomProgramData'); - }); - - it('should leave an already-set ProgramData untouched and backfill ALLUSERSPROFILE', async () => { - const win = await loadServerAs('win32', { - ProgramData: 'Q:\\Existing', - ALLUSERSPROFILE: undefined, - }); - - expect(win.envAfterImport.ProgramData).toBe('Q:\\Existing'); - expect(win.envAfterImport.ALLUSERSPROFILE).toBe('Q:\\Existing'); - }); - - it('should not touch either variable when both are already set', async () => { - const win = await loadServerAs('win32', { - ProgramData: 'Q:\\Existing', - ALLUSERSPROFILE: 'R:\\Other', - }); - - expect(win.envAfterImport.ProgramData).toBe('Q:\\Existing'); - expect(win.envAfterImport.ALLUSERSPROFILE).toBe('R:\\Other'); - }); - - it('should not invent the variables on POSIX', async () => { - const posix = await loadServerAs('linux', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - }); - - expect(posix.envAfterImport.ProgramData).toBeUndefined(); - expect(posix.envAfterImport.ALLUSERSPROFILE).toBeUndefined(); - }); - - it('should reach the spawned ssh process through the inherited environment', async () => { - // The whole point of issue #10: key-auth hosts get no env override, so the - // child inherits process.env and must find ProgramData there. - const win = await loadServerAs('win32', { - ProgramData: undefined, - ALLUSERSPROFILE: undefined, - SystemDrive: 'C:', - }); - const client = new win.SSHClient(); - win.fs.readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); - client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); - - await client.runRemoteCommand('test', 'echo ok'); - - // No password -> no explicit env, so the child inherits the parent's, which - // the import-time normalization has already repaired. - expect(client._spawn.mock.calls[0][2].env).toBeUndefined(); - expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); - }); -}); diff --git a/src/config-values.ts b/src/config-values.ts new file mode 100644 index 0000000..48cd663 --- /dev/null +++ b/src/config-values.ts @@ -0,0 +1,51 @@ +/** + * ssh-config@5 value normalization. + * + * The parser returns a plain string for a single-token value, but an array of + * token objects ({ val, separator, quoted }) as soon as a multi-value directive + * carries more than one token. Affected directives (ssh-config/lib/ssh-config.js): + * Host, Match, ProxyCommand, SendEnv, IPQoS, CanonicalDomains, + * GlobalKnownHostsFile, UserKnownHostsFile + * + * Everything downstream must go through these helpers, otherwise a multi-alias + * `Host a b` block is stored with an array where a string is expected and no + * strict comparison against it can ever match — the cause of issue #12, where + * such a host was unreachable under *either* alias. + */ +import type { HostInfo } from './types.js'; + +/** A single token as ssh-config reports it inside a multi-value directive. */ +export interface ConfigToken { + val: string; + separator?: string; + quoted?: boolean; +} + +/** Either shape ssh-config can hand back for a directive value. */ +export type ConfigValue = string | (ConfigToken | string)[] | null | undefined; + +function isToken(value: unknown): value is ConfigToken { + return typeof value === 'object' && value !== null && 'val' in value; +} + +/** Normalize either shape into a list of plain string tokens. */ +export function configValueTokens(value: ConfigValue): string[] { + if (value == null) return []; + if (Array.isArray(value)) { + return value.map(v => (isToken(v) ? v.val : v)).filter(v => v !== ''); + } + return [value]; +} + +/** Normalize either shape into a single readable string. */ +export function configValueToString(value: ConfigValue): string { + return configValueTokens(value).join(' '); +} + +/** True if `alias` names this host — via any of its aliases or its hostname. */ +export function hostMatchesAlias(host: HostInfo | undefined, alias: string | undefined): boolean { + if (!host || !alias) return false; + if (host.hostname === alias) return true; + if (Array.isArray(host.aliases)) return host.aliases.includes(alias); + return host.alias === alias; +} diff --git a/src/platform.test.ts b/src/platform.test.ts new file mode 100644 index 0000000..a6afabc --- /dev/null +++ b/src/platform.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// vitest scopes module mocks to the declaring file, so each test file installs +// its own. test-helpers.ts then sees the mocked copies. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn(actual.existsSync) }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + }; +}); + +import { + loadServerAs, + createMockSpawn, +} from './test-helpers.js'; +import type { TestClient } from './test-helpers.js'; + + +// ============================================================================= +// Platform-specific module initialisation +// +// resolveExecutable() runs once at import time and only does real work on +// Windows, where spawn() is called with shell:false and therefore cannot rely +// on PATH lookup. Exercised here by re-importing the module with the platform +// and PATH/PATHEXT faked, so the Windows resolution logic is covered from any +// host OS. +// ============================================================================= + +describe('resolveExecutable (Windows binary resolution)', () => { + let binDir: string; + + beforeAll(() => { + binDir = mkdtempSync(join(tmpdir(), 'mcp-ssh-bin-')); + writeFileSync(join(binDir, 'ssh.EXE'), ''); + writeFileSync(join(binDir, 'scp.EXE'), ''); + // A directory named like the executable must not be mistaken for one. + mkdirSync(join(binDir, 'ssh.CMD')); + }); + + afterAll(() => { + rmSync(binDir, { recursive: true, force: true }); + }); + + it('should resolve ssh/scp to absolute paths found on PATH', async () => { + const win = await loadServerAs('win32', { + // Trailing separator produces an empty entry, which must be skipped. + PATH: `${binDir};`, + PATHEXT: '.EXE;.CMD', + }); + + expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); + expect(win.SCP_BIN).toBe(join(binDir, 'scp.EXE')); + }); + + it('should skip directory entries that match the name but are not files', async () => { + const win = await loadServerAs('win32', { + PATH: binDir, + PATHEXT: '.CMD;.EXE', // .CMD first: ssh.CMD is a directory, not a match + }); + + expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); + }); + + it('should fall back to a bare .exe name when PATH holds no match', async () => { + const emptyDir = mkdtempSync(join(tmpdir(), 'mcp-ssh-empty-')); + try { + const win = await loadServerAs('win32', { PATH: emptyDir, PATHEXT: '.EXE' }); + + expect(win.SSH_BIN).toBe('ssh.exe'); + expect(win.SCP_BIN).toBe('scp.exe'); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + it('should fall back when PATH is unset entirely', async () => { + const win = await loadServerAs('win32', { PATH: undefined, PATHEXT: '.EXE' }); + expect(win.SSH_BIN).toBe('ssh.exe'); + }); + + it('should use the default PATHEXT list when the variable is unset', async () => { + const win = await loadServerAs('win32', { PATH: binDir, PATHEXT: undefined }); + // .EXE is part of the built-in default list. + expect(win.SSH_BIN).toBe(join(binDir, 'ssh.EXE')); + }); + + it('should use the bare name on POSIX, letting spawn search PATH', async () => { + const posix = await loadServerAs('linux', { PATH: binDir }); + + expect(posix.SSH_BIN).toBe('ssh'); + expect(posix.SCP_BIN).toBe('scp'); + }); +}); + +// ============================================================================= +// ssh-config value normalization edge cases +// +// extractHostsFromConfig is a pure function over the parser's section array, so +// these feed it shapes that ssh-config can emit but that are awkward to produce +// from config text alone. +// ============================================================================= + + +describe('silent mode', () => { + it('should suppress debug output when MCP_SILENT is set', async () => { + const silent = await loadServerAs('linux', { MCP_SILENT: 'true' }); + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + silent.debugLog('this must not be written\n'); + expect(writeSpy).not.toHaveBeenCalled(); + } finally { + writeSpy.mockRestore(); + } + }); + + it('should write debug output when MCP_SILENT is not set', async () => { + const loud = await loadServerAs('linux', { MCP_SILENT: undefined }); + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + loud.debugLog('hello\n'); + expect(writeSpy).toHaveBeenCalledWith('hello\n'); + } finally { + writeSpy.mockRestore(); + } + }); +}); + + +// ============================================================================= +// Windows ProgramData normalization (issue #10) +// +// Claude Desktop launches the extension with a stripped, allow-listed +// environment that omits %ProgramData%. Win32-OpenSSH resolves it at startup to +// find its global config (%ProgramData%\ssh\) and exits 255 with no output when +// it is unset, so every spawned ssh/scp fails while the same command works from +// a normal shell. server.mjs restores the variable at import time; these tests +// pin that behaviour, including that it stays out of the way on POSIX. +// ============================================================================= + +describe('Windows ProgramData normalization', () => { + it('should default ProgramData and ALLUSERSPROFILE when both are missing', async () => { + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + SystemDrive: 'C:', + }); + + expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); + expect(win.envAfterImport.ALLUSERSPROFILE).toBe('C:\\ProgramData'); + }); + + it('should derive the default from %SystemDrive% rather than hardcoding C:', async () => { + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + SystemDrive: 'D:', + }); + + expect(win.envAfterImport.ProgramData).toBe('D:\\ProgramData'); + }); + + it('should tolerate a %SystemDrive% that carries a trailing separator', async () => { + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + SystemDrive: 'E:\\', + }); + + expect(win.envAfterImport.ProgramData).toBe('E:\\ProgramData'); + }); + + it('should fall back to C: when %SystemDrive% is missing too', async () => { + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + SystemDrive: undefined, + }); + + expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); + }); + + it('should prefer an existing ALLUSERSPROFILE over the drive-based default', async () => { + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: 'X:\\CustomProgramData', + }); + + expect(win.envAfterImport.ProgramData).toBe('X:\\CustomProgramData'); + }); + + it('should leave an already-set ProgramData untouched and backfill ALLUSERSPROFILE', async () => { + const win = await loadServerAs('win32', { + ProgramData: 'Q:\\Existing', + ALLUSERSPROFILE: undefined, + }); + + expect(win.envAfterImport.ProgramData).toBe('Q:\\Existing'); + expect(win.envAfterImport.ALLUSERSPROFILE).toBe('Q:\\Existing'); + }); + + it('should not touch either variable when both are already set', async () => { + const win = await loadServerAs('win32', { + ProgramData: 'Q:\\Existing', + ALLUSERSPROFILE: 'R:\\Other', + }); + + expect(win.envAfterImport.ProgramData).toBe('Q:\\Existing'); + expect(win.envAfterImport.ALLUSERSPROFILE).toBe('R:\\Other'); + }); + + it('should not invent the variables on POSIX', async () => { + const posix = await loadServerAs('linux', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + }); + + expect(posix.envAfterImport.ProgramData).toBeUndefined(); + expect(posix.envAfterImport.ALLUSERSPROFILE).toBeUndefined(); + }); + + it('should reach the spawned ssh process through the inherited environment', async () => { + // The whole point of issue #10: key-auth hosts get no env override, so the + // child inherits process.env and must find ProgramData there. + const win = await loadServerAs('win32', { + ProgramData: undefined, + ALLUSERSPROFILE: undefined, + SystemDrive: 'C:', + }); + const client = new win.SSHClient() as unknown as TestClient; + win.fs.readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); + + await client.runRemoteCommand('test', 'echo ok'); + + // No password -> no explicit env, so the child inherits the parent's, which + // the import-time normalization has already repaired. + expect(client._spawn.mock.calls[0][2].env).toBeUndefined(); + expect(win.envAfterImport.ProgramData).toBe('C:\\ProgramData'); + }); +}); + +// ============================================================================= +// Defensive paths that only the refactor to explicit modules made reachable +// ============================================================================= diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..aa14e51 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,79 @@ +/** + * Platform detection and process-level setup that has to happen before anything + * spawns ssh or scp. + * + * Everything here runs once, at module load. Tests exercise both the POSIX and + * the Windows path by re-importing this module with `process.platform` faked — + * see loadServerAs() in the test suite. + */ +import { statSync } from 'node:fs'; +import { join } from 'node:path'; + +export const isWindows = process.platform === 'win32'; + +/** + * Windows + restricted MCP hosts: Claude Desktop launches the extension with a + * stripped, allow-listed environment that omits %ProgramData% and + * %ALLUSERSPROFILE%. Win32-OpenSSH resolves %ProgramData% at startup to find its + * global config (%ProgramData%\ssh\) and exits 255 with no output at all when it + * is unset, so every spawned ssh/scp fails while the same command works from a + * normal shell. Normalizing here means every child inherits usable values, + * whether or not the caller passes an explicit env. See issue #10. + */ +if (isWindows) { + if (!process.env['ProgramData']) { + // Derive the last-resort default from %SystemDrive% rather than hardcoding + // C:, so a Windows install on another drive still gets a valid path. + // SystemDrive is part of the environment Claude Desktop does pass through. + const systemDrive = (process.env['SystemDrive'] || 'C:').replace(/[\\/]+$/, ''); + process.env['ProgramData'] = process.env['ALLUSERSPROFILE'] || `${systemDrive}\\ProgramData`; + } + if (!process.env['ALLUSERSPROFILE']) { + process.env['ALLUSERSPROFILE'] = process.env['ProgramData']; + } +} + +/** + * Resolve an executable's absolute path on Windows by walking PATH and PATHEXT. + * + * This lets us call spawn() with shell:false on Windows — without it we would + * need shell:true to find ssh.exe/scp.exe via PATH, which would route every + * argument through cmd.exe and make characters like &, |, ^, >, " usable for + * local command injection. Returns the bare name on non-Windows (POSIX spawn + * already searches PATH safely). + */ +export function resolveExecutable(name: string): string { + if (!isWindows) return name; + // No `|| process.env.Path` fallback: Node exposes process.env case-insensitively + // on Windows, so process.env.PATH already resolves a variable spelled `Path`. + const pathDirs = (process.env['PATH'] || '').split(';'); + const exts = (process.env['PATHEXT'] || '.EXE;.CMD;.BAT;.COM').split(';'); + for (const dir of pathDirs) { + if (!dir) continue; + for (const ext of exts) { + const candidate = join(dir, name + ext); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // Not on this PATH entry — keep looking. + } + } + } + return `${name}.exe`; +} + +export const SSH_BIN = resolveExecutable('ssh'); +export const SCP_BIN = resolveExecutable('scp'); + +/** + * Silent mode for MCP clients: debug output on stdout would corrupt the STDIO + * JSON-RPC stream, so it goes to stderr and can be switched off entirely. + */ +export const SILENT_MODE = + process.env['MCP_SILENT'] === 'true' || process.argv.includes('--silent'); + +export function debugLog(message: string): void { + if (!SILENT_MODE) { + process.stderr.write(message); + } +} diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..b48cba0 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MockInstance } from 'vitest'; + +// vitest scopes module mocks to the declaring file, so each test file installs +// its own. test-helpers.ts then sees the mocked copies. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn(actual.existsSync) }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + }; +}); + +import { SSHConfigParser, SSHClient, main } from './server.js'; +import { + readFile, + stat, + SAMPLE_SSH_CONFIG, + SAMPLE_KNOWN_HOSTS, +} from './test-helpers.js'; + + +// ============================================================================= +// MCP Server Handler Tests (via main()) +// ============================================================================= + +describe('MCP Server Handlers', () => { + let handlers: Record any>; + let clientSpies: MockInstance[]; + + afterEach(() => { + for (const spy of clientSpies) spy.mockRestore(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + + // These tests drive the real SSHClient that main() constructs, so every tool + // call would otherwise spawn an actual ssh/scp process against 1.2.3.4 and + // block on the network. Stubbing the three process-starting methods keeps + // the block a dispatch test — which is all it asserts — instead of a slow, + // network-dependent one that blows the 5s timeout on Windows CI runners. + // The methods themselves are covered by the SSHClient tests above. + clientSpies = [ + vi.spyOn(SSHClient.prototype, 'runRemoteCommand') + .mockResolvedValue({ stdout: 'connected', stderr: '', code: 0 }), + vi.spyOn(SSHClient.prototype, 'uploadFile').mockResolvedValue(true), + vi.spyOn(SSHClient.prototype, 'downloadFile').mockResolvedValue(true), + ]; + + // Capture the request handlers that main() registers + handlers = {}; + + // Mock the MCP SDK Server and Transport + const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); + const sdkTypes = await import('@modelcontextprotocol/sdk/types.js'); + + // Save original and mock + const origSetRequestHandler = Server.prototype.setRequestHandler; + const origConnect = Server.prototype.connect; + + Server.prototype.setRequestHandler = function(schema: unknown, handler: (...args: any[]) => any) { + // Store by schema name + if (schema === sdkTypes.ListToolsRequestSchema) { + handlers.listTools = handler; + } else if (schema === sdkTypes.CallToolRequestSchema) { + handlers.callTool = handler; + } + }; + Server.prototype.connect = vi.fn().mockResolvedValue(undefined); + + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + stat.mockResolvedValue({ mode: 0o100600 }); + + await main(); + + // Restore + Server.prototype.setRequestHandler = origSetRequestHandler; + Server.prototype.connect = origConnect; + }); + + it('should register listTools handler that returns all tools', async () => { + const result = await handlers.listTools(); + expect(result.tools).toHaveLength(7); + const names = result.tools.map(t => t.name); + expect(names).toContain('listKnownHosts'); + expect(names).toContain('runRemoteCommand'); + expect(names).toContain('getHostInfo'); + expect(names).toContain('checkConnectivity'); + expect(names).toContain('uploadFile'); + expect(names).toContain('downloadFile'); + expect(names).toContain('runCommandBatch'); + }); + + it('should handle listKnownHosts tool call', async () => { + readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); + + const result = await handlers.callTool({ + params: { name: 'listKnownHosts', arguments: {} } + }); + + const hosts = JSON.parse(result.content[0].text); + expect(Array.isArray(hosts)).toBe(true); + // Passwords should be stripped + for (const host of hosts) { + expect(host._password).toBeUndefined(); + } + }); + + it('should handle getHostInfo tool call', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + + const result = await handlers.callTool({ + params: { name: 'getHostInfo', arguments: { hostAlias: 'mail' } } + }); + + const info = JSON.parse(result.content[0].text); + expect(info.alias).toBe('mail'); + expect(info._password).toBeUndefined(); + expect(info.passwordAuth).toBe(true); + }); + + it('should throw on missing arguments', async () => { + await expect( + handlers.callTool({ params: { name: 'runRemoteCommand', arguments: undefined } }) + ).rejects.toThrow('No arguments provided'); + }); + + it('should handle unknown tool name', async () => { + const result = await handlers.callTool({ + params: { name: 'unknownTool', arguments: {} } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('Unknown tool'); + }); + + it('should cap runRemoteCommand timeout at 300000ms', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + const spy = vi.spyOn(SSHClient.prototype, 'runRemoteCommand') + .mockResolvedValue({ stdout: '', stderr: '', code: 0 }); + + try { + await handlers.callTool({ + params: { + name: 'runRemoteCommand', + arguments: { hostAlias: 'test', command: 'echo hi', timeout: 999999 } + } + }); + + expect(spy).toHaveBeenCalledWith('test', 'echo hi', { timeout: 300000 }); + } finally { + spy.mockRestore(); + } + }); + + it('should default the runRemoteCommand timeout to 120000ms', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + const spy = vi.spyOn(SSHClient.prototype, 'runRemoteCommand') + .mockResolvedValue({ stdout: '', stderr: '', code: 0 }); + + try { + await handlers.callTool({ + params: { name: 'runRemoteCommand', arguments: { hostAlias: 'test', command: 'echo hi' } } + }); + + expect(spy).toHaveBeenCalledWith('test', 'echo hi', { timeout: 120000 }); + } finally { + spy.mockRestore(); + } + }); + + it('should stringify non-Error values thrown by a tool', async () => { + const spy = vi.spyOn(SSHClient.prototype, 'listKnownHosts') + .mockRejectedValue('a bare string, not an Error'); + + try { + const result = await handlers.callTool({ + params: { name: 'listKnownHosts', arguments: {} } + }); + + expect(JSON.parse(result.content[0].text).error).toBe('a bare string, not an Error'); + } finally { + spy.mockRestore(); + } + }); + + it('should handle checkConnectivity tool call', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + + const result = await handlers.callTool({ + params: { name: 'checkConnectivity', arguments: { hostAlias: 'test' } } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('connected'); + expect(parsed).toHaveProperty('message'); + }); + + it('should handle uploadFile tool call', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + + const result = await handlers.callTool({ + params: { + name: 'uploadFile', + arguments: { hostAlias: 'test', localPath: '/tmp/test', remotePath: '/tmp/dest' } + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('success'); + }); + + it('should handle downloadFile tool call', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + + const result = await handlers.callTool({ + params: { + name: 'downloadFile', + arguments: { hostAlias: 'test', remotePath: '/tmp/src', localPath: '/tmp/dest' } + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('success'); + }); + + it('should handle runCommandBatch tool call', async () => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + + const result = await handlers.callTool({ + params: { + name: 'runCommandBatch', + arguments: { hostAlias: 'test', commands: ['echo a', 'echo b'] } + } + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('results'); + expect(parsed).toHaveProperty('success'); + }); + + it('should allow listKnownHosts without arguments', async () => { + readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); + + const result = await handlers.callTool({ + params: { name: 'listKnownHosts' } + }); + + const hosts = JSON.parse(result.content[0].text); + expect(Array.isArray(hosts)).toBe(true); + }); +}); + +// ============================================================================= +// main() error handling +// ============================================================================= + + +// ============================================================================= +// main() error handling +// ============================================================================= + +describe('main() error handling', () => { + it('should handle startup errors gracefully', async () => { + const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); + const origConnect = Server.prototype.connect; + + Server.prototype.connect = vi.fn().mockRejectedValue(new Error('transport failed')); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await main(); + + expect(exitSpy).toHaveBeenCalledWith(1); + + Server.prototype.connect = origConnect; + exitSpy.mockRestore(); + }); +}); + +// ============================================================================= +// Platform-specific module initialisation +// +// resolveExecutable() runs once at import time and only does real work on +// Windows, where spawn() is called with shell:false and therefore cannot rely +// on PATH lookup. Exercised here by re-importing the module with the platform +// and PATH/PATHEXT faked, so the Windows resolution logic is covered from any +// host OS. +// ============================================================================= + + +// ============================================================================= +// Defensive paths that only the refactor to explicit modules made reachable +// ============================================================================= + +describe('non-Error failures', () => { + let parser: SSHConfigParser; + + beforeEach(() => { + parser = new SSHConfigParser(); + vi.clearAllMocks(); + }); + + it('should stringify a non-Error rejection while reading the config', async () => { + readFile.mockRejectedValue('not an Error object'); + + // Reaches the String(error) side of the shared error formatter. + await expect(parser.parseConfig()).resolves.toEqual([]); + }); + + it('should stringify a non-Error thrown during startup', async () => { + const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); + const origConnect = Server.prototype.connect; + Server.prototype.connect = vi.fn().mockRejectedValue('transport exploded'); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + await main(); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + Server.prototype.connect = origConnect; + exitSpy.mockRestore(); + } + }); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c7fda6a --- /dev/null +++ b/src/server.ts @@ -0,0 +1,60 @@ +/** + * MCP SSH Agent — entry point. + * + * Runs over STDIO. main() is exported rather than auto-started: an + * `is this module run directly?` check based on process.argv[1] was unreliable + * on Windows (backslashes vs forward slashes) and made the server exit silently + * under Windows MCP clients (issue #8). bin/mcp-ssh.js imports and calls it. + */ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { SSHClient } from './ssh-client.js'; +import { debugLog } from './platform.js'; +import { TOOL_DEFINITIONS, callTool } from './tools.js'; + +export async function main(): Promise { + try { + debugLog('Initializing SSH client...\n'); + const sshClient = new SSHClient(); + + debugLog('Creating MCP server...\n'); + // The SDK marks the low-level Server as deprecated in favour of McpServer. + // Migrating changes the registration API and is deliberately out of scope + // for the TypeScript port. + // eslint-disable-next-line @typescript-eslint/no-deprecated + const server = new Server( + { name: 'mcp-ssh', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ); + + debugLog('Setting up request handlers...\n'); + server.setRequestHandler(ListToolsRequestSchema, () => { + debugLog('Received listTools request\n'); + return { tools: TOOL_DEFINITIONS }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + return callTool(sshClient, name, args); + }); + + debugLog('Starting MCP SSH Agent on STDIO...\n'); + const transport = new StdioServerTransport(); + await server.connect(transport); + debugLog('MCP SSH Agent connected and ready!\n'); + } catch (error) { + debugLog(`Error starting MCP SSH Agent: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +export { SSHClient } from './ssh-client.js'; +export { SSHConfigParser } from './ssh-config-parser.js'; +export { debugLog, SSH_BIN, SCP_BIN } from './platform.js'; +export { TOOL_DEFINITIONS, callTool } from './tools.js'; +export * from './types.js'; diff --git a/src/ssh-client.test.ts b/src/ssh-client.test.ts new file mode 100644 index 0000000..9905ea5 --- /dev/null +++ b/src/ssh-client.test.ts @@ -0,0 +1,947 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { EventEmitter } from 'node:events'; + +// vitest scopes module mocks to the declaring file, so each test file installs +// its own. test-helpers.ts then sees the mocked copies. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn(actual.existsSync) }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + }; +}); + +import { SSHClient, SSH_BIN, SCP_BIN } from './server.js'; +import { + readFile, + stat, + writeFile, + chmod, + loadServerAs, + createMockSpawn, + createMockExecFileAsync, + SAMPLE_SSH_CONFIG, + SAMPLE_SSH_CONFIG_WITH_INCLUDE, + SAMPLE_KNOWN_HOSTS, +} from './test-helpers.js'; +import type { MockChild, MockedFs, TestClient } from './test-helpers.js'; + + +// ============================================================================= +// SSHClient Tests +// ============================================================================= + +describe('SSHClient', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + }); + + describe('getPasswordForHost', () => { + beforeEach(() => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + }); + + it('should find password by alias', async () => { + const pw = await client.getPasswordForHost('mail'); + expect(pw).toBe('killer99'); + }); + + it('should return null for host without password', async () => { + const pw = await client.getPasswordForHost('prod'); + expect(pw).toBeNull(); + }); + + it('should return null for unknown host', async () => { + const pw = await client.getPasswordForHost('unknown'); + expect(pw).toBeNull(); + }); + + it('should strip user@ prefix', async () => { + const pw = await client.getPasswordForHost('saf@mail'); + expect(pw).toBe('killer99'); + }); + + it('should find password by hostname', async () => { + const pw = await client.getPasswordForHost('88.198.170.88'); + expect(pw).toBe('killer99'); + }); + }); + + // The askpass helper is a /bin/sh script chmod'ed to 700 on POSIX and a .cmd + // batch file on Windows (no chmod — NTFS ACLs, not mode bits). Both variants + // are asserted explicitly so the suite is meaningful on either host OS. + describe('getAskpassScript (POSIX)', () => { + let posixClient: TestClient; + let posixFs: MockedFs; + + beforeEach(async () => { + const posix = await loadServerAs('linux'); + posixClient = new posix.SSHClient() as unknown as TestClient; + posixFs = posix.fs; + posixFs.writeFile.mockResolvedValue(undefined); + posixFs.chmod.mockResolvedValue(undefined); + }); + + it('should create askpass script and cache it', async () => { + const path1 = await posixClient.getAskpassScript(); + const path2 = await posixClient.getAskpassScript(); + + expect(path1).toBe(path2); + expect(posixFs.writeFile).toHaveBeenCalledTimes(1); + expect(posixFs.chmod).toHaveBeenCalledWith(path1, 0o700); + }); + + it('should write correct script content', async () => { + await posixClient.getAskpassScript(); + + expect(posixFs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('mcp-ssh-askpass'), + '#!/bin/sh\necho "$MCP_SSH_PASS"\n' + ); + }); + + it('should use a .sh extension', async () => { + const scriptPath = await posixClient.getAskpassScript(); + expect(scriptPath).toMatch(/mcp-ssh-askpass-\d+\.sh$/); + }); + }); + + describe('getAskpassScript (Windows)', () => { + let winClient: TestClient; + let winFs: MockedFs; + + beforeEach(async () => { + const win = await loadServerAs('win32'); + winClient = new win.SSHClient() as unknown as TestClient; + winFs = win.fs; + winFs.writeFile.mockResolvedValue(undefined); + winFs.chmod.mockResolvedValue(undefined); + }); + + it('should write a .cmd batch file with CRLF line endings', async () => { + const scriptPath = await winClient.getAskpassScript(); + + expect(scriptPath).toMatch(/mcp-ssh-askpass-\d+\.cmd$/); + expect(winFs.writeFile).toHaveBeenCalledWith( + scriptPath, + '@echo off\r\necho %MCP_SSH_PASS%\r\n' + ); + }); + + it('should not chmod the script', async () => { + await winClient.getAskpassScript(); + expect(winFs.chmod).not.toHaveBeenCalled(); + }); + + it('should cache the script path', async () => { + const path1 = await winClient.getAskpassScript(); + const path2 = await winClient.getAskpassScript(); + + expect(path1).toBe(path2); + expect(winFs.writeFile).toHaveBeenCalledTimes(1); + }); + }); + + describe('buildSpawnEnv', () => { + it('should return null for host without password', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + const env = await client.buildSpawnEnv('prod'); + expect(env).toBeNull(); + }); + + it('should return env with SSH_ASKPASS for host with password', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + stat.mockResolvedValue({ mode: 0o100600 }); + writeFile.mockResolvedValue(undefined); + chmod.mockResolvedValue(undefined); + + const env = await client.buildSpawnEnv('mail'); + expect(env.MCP_SSH_PASS).toBe('killer99'); + expect(env.SSH_ASKPASS).toContain('mcp-ssh-askpass'); + expect(env.SSH_ASKPASS_REQUIRE).toBe('force'); + expect(env.DISPLAY).toBe(process.env.DISPLAY); + }); + + // POSIX-pinned: relies on the permission check, which is a no-op on Windows. + it('should throw if config has insecure permissions', async () => { + const posix = await loadServerAs('linux'); + const posixClient = new posix.SSHClient() as unknown as TestClient; + posix.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + posix.fs.stat.mockResolvedValue({ mode: 0o100644 }); + + // Trigger password parsing first + await posixClient.getPasswordForHost('mail'); + + await expect(posixClient.buildSpawnEnv('mail')).rejects.toThrow('insecure permissions'); + }); + }); + + describe('runRemoteCommand', () => { + beforeEach(() => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should execute ssh command and return output', async () => { + client._spawn = createMockSpawn({ stdout: 'hello\n', code: 0 }); + + const result = await client.runRemoteCommand('test', 'echo hello'); + + expect(client._spawn).toHaveBeenCalledWith( + SSH_BIN, + ['-o', 'StrictHostKeyChecking=accept-new', '--', 'test', 'echo hello'], + expect.any(Object) + ); + expect(result).toEqual({ stdout: 'hello\n', stderr: '', code: 0 }); + }); + + it('should handle command failure with exit code', async () => { + client._spawn = createMockSpawn({ stderr: 'not found', code: 127 }); + + const result = await client.runRemoteCommand('test', 'badcmd'); + expect(result.code).toBe(127); + expect(result.stderr).toBe('not found'); + }); + + it('should handle spawn error', async () => { + client._spawn = createMockSpawn({ error: new Error('spawn failed') }); + + const result = await client.runRemoteCommand('test', 'cmd'); + expect(result.code).toBe(1); + expect(result.stderr).toBe('spawn failed'); + }); + + it('should handle timeout', async () => { + client._spawn = vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => { + setTimeout(() => child.emit('close', null), 2); + }); + return child; + }); + + const result = await client.runRemoteCommand('test', 'sleep 999', { timeout: 10 }); + expect(result.code).toBe(124); + expect(result.stderr).toContain('Command timed out'); + }); + + // `detached` is POSIX-only: it exists so ssh talks to SSH_ASKPASS instead of + // grabbing a tty, a problem Windows does not have. Pin the platform on both + // sides so neither expectation depends on the host OS. + it('should set detached and env when password is available (POSIX)', async () => { + const posix = await loadServerAs('linux'); + const posixClient = new posix.SSHClient() as unknown as TestClient; + posix.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + posix.fs.stat.mockResolvedValue({ mode: 0o100600 }); + posix.fs.writeFile.mockResolvedValue(undefined); + posix.fs.chmod.mockResolvedValue(undefined); + posixClient._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); + + await posixClient.runRemoteCommand('mail', 'ls'); + + expect(posixClient._spawn).toHaveBeenCalledWith( + posix.SSH_BIN, + expect.any(Array), + expect.objectContaining({ + detached: true, + env: expect.objectContaining({ + MCP_SSH_PASS: 'killer99', + SSH_ASKPASS_REQUIRE: 'force', + }), + }) + ); + }); + + it('should set env but not detached when password is available (Windows)', async () => { + const win = await loadServerAs('win32'); + const winClient = new win.SSHClient() as unknown as TestClient; + win.fs.readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + win.fs.writeFile.mockResolvedValue(undefined); + winClient._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); + + await winClient.runRemoteCommand('mail', 'ls'); + + const opts = winClient._spawn.mock.calls[0][2]; + expect(opts.env).toEqual(expect.objectContaining({ MCP_SSH_PASS: 'killer99' })); + expect(opts.detached).toBeUndefined(); + }); + + it('should not set detached without password', async () => { + client._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); + + await client.runRemoteCommand('test', 'ls'); + + expect(client._spawn).toHaveBeenCalledWith( + SSH_BIN, + expect.any(Array), + expect.objectContaining({ + stdio: ['ignore', 'pipe', 'pipe'], + }) + ); + const opts = client._spawn.mock.calls[0][2]; + expect(opts.detached).toBeUndefined(); + expect(opts.env).toBeUndefined(); + }); + + it('should truncate stdout exceeding 10MB', async () => { + client._spawn = vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + + setTimeout(() => { + // Send in two chunks so the second one triggers truncation + child.stdout.emit('data', Buffer.from('x'.repeat(10 * 1024 * 1024))); + child.stdout.emit('data', Buffer.from('x'.repeat(1024))); + child.emit('close', 0); + }, 5); + + return child; + }); + + const result = await client.runRemoteCommand('test', 'bigcmd'); + expect(result.stdout).toContain('[Output truncated'); + }); + + it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { + client._spawn = createMockSpawn({ stdout: 'pwned', code: 0 }); + + await expect( + client.runRemoteCommand('-oProxyCommand=touch /tmp/pwned', 'echo') + ).rejects.toThrow(/Invalid hostAlias/); + expect(client._spawn).not.toHaveBeenCalled(); + }); + + it('should reject hostAlias containing shell metacharacters (Windows cmd.exe vector)', async () => { + client._spawn = createMockSpawn({ stdout: '', code: 0 }); + + for (const evil of ['foo & calc.exe', 'foo|calc', 'foo;ls', 'foo`id`', 'foo$(id)', 'foo"bar', "foo'bar"]) { + await expect(client.runRemoteCommand(evil, 'ls')).rejects.toThrow(/Invalid hostAlias/); + } + expect(client._spawn).not.toHaveBeenCalled(); + }); + + it('should reject unknown hostAlias that is not in ssh config or known_hosts', async () => { + readFile + .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) + .mockResolvedValueOnce(''); + client._spawn = createMockSpawn({ stdout: '', code: 0 }); + + await expect(client.runRemoteCommand('unknown.example.com', 'ls')).rejects.toThrow(/Unknown hostAlias/); + expect(client._spawn).not.toHaveBeenCalled(); + }); + + it('should allow user@alias when alias exists in ssh config', async () => { + client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); + + const result = await client.runRemoteCommand('root@test', 'whoami'); + + expect(client._spawn).toHaveBeenCalledWith( + SSH_BIN, + ['-o', 'StrictHostKeyChecking=accept-new', '--', 'root@test', 'whoami'], + expect.any(Object) + ); + expect(result.code).toBe(0); + }); + + it('should allow hosts discovered through Include directives', async () => { + readFile.mockImplementation(async (filePath) => { + // Separator-agnostic: configPath is ~/.ssh/config on POSIX but + // C:\Users\…\.ssh\config on Windows, where endsWith('/config') misses. + if (/[\\/]config$/.test(String(filePath))) return SAMPLE_SSH_CONFIG_WITH_INCLUDE; + if (String(filePath).endsWith('.conf')) return `Host included\n HostName 10.10.10.10\n`; + if (String(filePath).endsWith('known_hosts')) return ''; + return ''; + }); + client.configParser.expandIncludePath = vi.fn(() => ['/tmp/included.conf']); + client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); + + const result = await client.runRemoteCommand('included', 'hostname'); + + expect(client._spawn).toHaveBeenCalled(); + expect(result.code).toBe(0); + }); + + it('should truncate stderr exceeding 10MB', async () => { + client._spawn = vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + + setTimeout(() => { + child.stderr.emit('data', Buffer.from('x'.repeat(10 * 1024 * 1024))); + child.stderr.emit('data', Buffer.from('x'.repeat(1024))); + child.emit('close', 0); + }, 5); + + return child; + }); + + const result = await client.runRemoteCommand('test', 'bigcmd'); + expect(result.stderr).toContain('[Stderr truncated'); + }); + }); + + describe('getHostInfo', () => { + beforeEach(() => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + }); + + it('should return host info without password exposed', async () => { + const info = await client.getHostInfo('mail'); + expect(info.alias).toBe('mail'); + expect(info.hostname).toBe('88.198.170.88'); + expect(info._password).toBeUndefined(); + expect(info.passwordAuth).toBe(true); + }); + + it('should not set passwordAuth flag when no password', async () => { + const info = await client.getHostInfo('prod'); + expect(info.passwordAuth).toBeUndefined(); + }); + + it('should return null for unknown host', async () => { + const info = await client.getHostInfo('nonexistent'); + expect(info).toBeNull(); + }); + + it('should return correct port and user', async () => { + const info = await client.getHostInfo('prod'); + expect(info.port).toBe(42077); + expect(info.user).toBe('trashmail'); + }); + }); + + describe('checkConnectivity', () => { + beforeEach(() => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should return connected on success', async () => { + client._spawn = createMockSpawn({ stdout: 'connected\n', code: 0 }); + + const status = await client.checkConnectivity('test'); + expect(status).toEqual({ connected: true, message: 'Connection successful' }); + }); + + it('should return not connected on failure', async () => { + client._spawn = createMockSpawn({ stderr: 'refused', code: 255 }); + + const status = await client.checkConnectivity('test'); + expect(status).toEqual({ connected: false, message: 'Connection failed' }); + }); + + it('should return not connected when output is unexpected', async () => { + client._spawn = createMockSpawn({ stdout: 'something else', code: 0 }); + + const status = await client.checkConnectivity('test'); + expect(status.connected).toBe(false); + }); + }); + + describe('uploadFile', () => { + beforeEach(() => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should return true on success', async () => { + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.uploadFile('test', '/local/file', '/remote/file'); + expect(result).toBe(true); + expect(client._execFileAsync).toHaveBeenCalledWith( + SCP_BIN, + ['-o', 'StrictHostKeyChecking=accept-new', '--', '/local/file', 'test:/remote/file'], + expect.any(Object) + ); + }); + + it('should return false on error', async () => { + client._execFileAsync = createMockExecFileAsync({ error: new Error('scp failed') }); + + const result = await client.uploadFile('test', '/local/file', '/remote/file'); + expect(result).toBe(false); + }); + + it('should pass password env when available', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + stat.mockResolvedValue({ mode: 0o100600 }); + writeFile.mockResolvedValue(undefined); + chmod.mockResolvedValue(undefined); + client._execFileAsync = createMockExecFileAsync(); + + await client.uploadFile('mail', '/local/file', '/remote/file'); + + const opts = client._execFileAsync.mock.calls[0][2]; + expect(opts.env.MCP_SSH_PASS).toBe('killer99'); + }); + + it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.uploadFile('-oProxyCommand=touch /tmp/pwned', '/local/file', '/remote/file'); + expect(result).toBe(false); + expect(client._execFileAsync).not.toHaveBeenCalled(); + }); + + it('should reject unknown hostAlias for uploads', async () => { + readFile + .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) + .mockResolvedValueOnce(''); + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.uploadFile('unknown.example.com', '/local/file', '/remote/file'); + expect(result).toBe(false); + expect(client._execFileAsync).not.toHaveBeenCalled(); + }); + }); + + describe('downloadFile', () => { + beforeEach(() => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should return true on success', async () => { + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.downloadFile('test', '/remote/file', '/local/file'); + expect(result).toBe(true); + expect(client._execFileAsync).toHaveBeenCalledWith( + SCP_BIN, + ['-o', 'StrictHostKeyChecking=accept-new', '--', 'test:/remote/file', '/local/file'], + expect.any(Object) + ); + }); + + it('should return false on error', async () => { + client._execFileAsync = createMockExecFileAsync({ error: new Error('scp failed') }); + + const result = await client.downloadFile('test', '/remote/file', '/local/file'); + expect(result).toBe(false); + }); + + it('should reject hostAlias starting with - to block ProxyCommand injection', async () => { + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.downloadFile('-oProxyCommand=touch /tmp/pwned', '/remote/file', '/local/file'); + expect(result).toBe(false); + expect(client._execFileAsync).not.toHaveBeenCalled(); + }); + + it('should allow hostnames learned from known_hosts for downloads', async () => { + readFile + .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`) + .mockResolvedValueOnce('10.0.0.1 ssh-rsa AAAAB3Nz...\n'); + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.downloadFile('10.0.0.1', '/remote/file', '/local/file'); + expect(result).toBe(true); + expect(client._execFileAsync).toHaveBeenCalled(); + }); + }); + + describe('runCommandBatch', () => { + beforeEach(() => { + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should execute multiple commands and return results', async () => { + let callCount = 0; + client._spawn = vi.fn(() => { + callCount++; + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + const n = callCount; + setTimeout(() => { + child.stdout.emit('data', Buffer.from(`output${n}\n`)); + child.emit('close', 0); + }, 5); + return child; + }); + + const result = await client.runCommandBatch('test', ['cmd1', 'cmd2']); + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[0].stdout).toBe('output1\n'); + expect(result.results[1].stdout).toBe('output2\n'); + }); + + it('should mark as failed if any command fails but continue', async () => { + let callCount = 0; + client._spawn = vi.fn(() => { + callCount++; + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + const exitCode = callCount === 1 ? 1 : 0; + setTimeout(() => { + child.emit('close', exitCode); + }, 5); + return child; + }); + + const result = await client.runCommandBatch('test', ['fail', 'pass']); + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('should handle empty command list', async () => { + const result = await client.runCommandBatch('test', []); + expect(result.success).toBe(true); + expect(result.results).toHaveLength(0); + }); + }); + + describe('listKnownHosts', () => { + it('should delegate to configParser.getAllKnownHosts', async () => { + readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); + stat.mockResolvedValue({ mode: 0o100600 }); + + const hosts = await client.listKnownHosts(); + expect(hosts.length).toBeGreaterThan(0); + }); + }); + + describe('checkConnectivity error handling', () => { + it('should handle thrown errors gracefully', async () => { + readFile.mockRejectedValue(new Error('config read failed')); + client._spawn = createMockSpawn({ stderr: 'error', code: 1 }); + + const status = await client.checkConnectivity('test'); + expect(status.connected).toBe(false); + }); + + it('should catch exceptions from runRemoteCommand', async () => { + client.runRemoteCommand = vi.fn().mockRejectedValue(new Error('ssh crash')); + + const status = await client.checkConnectivity('test'); + expect(status.connected).toBe(false); + expect(status.message).toBe('ssh crash'); + }); + + it('should handle non-Error thrown values in catch', async () => { + client.runRemoteCommand = vi.fn().mockRejectedValue('string error'); + + const status = await client.checkConnectivity('test'); + expect(status.connected).toBe(false); + expect(status.message).toBe('string error'); + }); + }); + + describe('runCommandBatch error handling', () => { + it('should handle thrown errors gracefully', async () => { + // Make runRemoteCommand throw by overriding it + client.runRemoteCommand = vi.fn().mockRejectedValue(new Error('connection lost')); + + const result = await client.runCommandBatch('test', ['cmd1']); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toBe('connection lost'); + expect(result.results[0].code).toBe(1); + }); + + it('should handle non-Error thrown values', async () => { + client.runRemoteCommand = vi.fn().mockRejectedValue('string error'); + + const result = await client.runCommandBatch('test', ['cmd1']); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toBe('string error'); + }); + }); +}); + +// ============================================================================= +// MCP Server Handler Tests (via main()) +// ============================================================================= + + +// ============================================================================= +// Remaining branches: argument validation, known_hosts matching, silent mode, +// askpass cleanup handlers and the tool-dispatch catch-all. +// ============================================================================= + +describe('_assertSafeHostAlias argument validation', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ['a number', 42], + ['an empty string', ''], + ['an array', ['test']], + ])('should reject %s before touching ssh', async (_label, value) => { + expect(() => client._assertSafeHostAlias(value)).toThrow('must be a non-empty string'); + }); + + it('should surface the type error through runRemoteCommand', async () => { + client._spawn = createMockSpawn({ stdout: 'ok', code: 0 }); + + await expect(client.runRemoteCommand(null, 'echo hi')).rejects.toThrow( + 'must be a non-empty string' + ); + expect(client._spawn).not.toHaveBeenCalled(); + }); +}); + + +describe('hostMatchesAlias against known_hosts entries', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + }); + + it('should keep scanning past a non-matching known_hosts entry', async () => { + // known_hosts entries carry only a hostname (no alias/aliases), so matching + // them falls through to the plain-alias comparison. The host we ask for is + // the *second* entry, so the first one has to be rejected and skipped. + readFile.mockImplementation(async (filePath) => { + if (/known_hosts$/.test(String(filePath))) { + return '10.0.0.1 ssh-ed25519 AAAA...\n10.0.0.2 ssh-ed25519 BBBB...\n'; + } + return `Host other\n HostName 192.168.1.1\n`; + }); + client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); + + const result = await client.runRemoteCommand('10.0.0.2', 'uptime'); + expect(result.code).toBe(0); + }); +}); + + +describe('askpass script cleanup handlers', () => { + let client: TestClient; + let exitSpy: MockInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + writeFile.mockResolvedValue(undefined); + chmod.mockResolvedValue(undefined); + client = new SSHClient() as unknown as TestClient; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + // The handlers registered by getAskpassScript are invoked directly: they only + // ever run while the process is tearing down, which a unit test cannot trigger. + async function registerAndTake(signal) { + const before = process.listeners(signal).length; + await client.getAskpassScript(); + const listeners = process.listeners(signal); + expect(listeners.length).toBeGreaterThan(before); + const handler = listeners[listeners.length - 1] as (...args: any[]) => void; + return () => { + handler(); + process.removeListener(signal, handler); + }; + } + + it('should unlink the script on exit', async () => { + const run = await registerAndTake('exit'); + // unlinkSync throws ENOENT (writeFile is mocked, so no file exists) and the + // handler must swallow it. + expect(run).not.toThrow(); + }); + + it('should clean up and exit 130 on SIGINT', async () => { + const run = await registerAndTake('SIGINT'); + run(); + expect(exitSpy).toHaveBeenCalledWith(130); + }); + + it('should clean up and exit 143 on SIGTERM', async () => { + const run = await registerAndTake('SIGTERM'); + run(); + expect(exitSpy).toHaveBeenCalledWith(143); + }); +}); + + +describe('password env on the scp paths', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + stat.mockResolvedValue({ mode: 0o100600 }); + writeFile.mockResolvedValue(undefined); + chmod.mockResolvedValue(undefined); + }); + + it('should pass password env to downloadFile', async () => { + client._execFileAsync = createMockExecFileAsync(); + + const result = await client.downloadFile('mail', '/remote/file', '/local/file'); + + expect(result).toBe(true); + expect(client._execFileAsync).toHaveBeenCalledWith( + SCP_BIN, + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ MCP_SSH_PASS: 'killer99' }), + }) + ); + }); + + it('should skip the permission sweep when no config declared a password', async () => { + // A password reached us without extractHostsFromConfig having flagged any + // config file — there is then nothing to check the permissions of. + client.getPasswordForHost = vi.fn().mockResolvedValue('secret'); + client.configParser._configsWithPasswords = undefined; + + const env = await client.buildSpawnEnv('anything'); + + expect(env.MCP_SSH_PASS).toBe('secret'); + expect(stat).not.toHaveBeenCalled(); + }); +}); + + +describe('output truncation', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + // Three chunks: the second crosses the limit and appends the marker, the third + // must be dropped silently rather than appending it again. + function spawnEmitting(stream, chunks) { + return vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + + setTimeout(() => { + for (const chunk of chunks) child[stream].emit('data', Buffer.from(chunk)); + child.emit('close', 0); + }, 5); + + return child; + }); + } + + it('should append the stdout truncation marker only once', async () => { + client._spawn = spawnEmitting('stdout', [ + 'x'.repeat(10 * 1024 * 1024), + 'y'.repeat(1024), + 'z'.repeat(1024), + ]); + + const result = await client.runRemoteCommand('test', 'bigcmd'); + const markers = result.stdout.match(/\[Output truncated/g) || []; + expect(markers).toHaveLength(1); + }); + + it('should append the stderr truncation marker only once', async () => { + client._spawn = spawnEmitting('stderr', [ + 'x'.repeat(10 * 1024 * 1024), + 'y'.repeat(1024), + 'z'.repeat(1024), + ]); + + const result = await client.runRemoteCommand('test', 'bigcmd'); + const markers = result.stderr.match(/\[Stderr truncated/g) || []; + expect(markers).toHaveLength(1); + }); +}); + +// ============================================================================= +// Windows ProgramData normalization (issue #10) +// +// Claude Desktop launches the extension with a stripped, allow-listed +// environment that omits %ProgramData%. Win32-OpenSSH resolves it at startup to +// find its global config (%ProgramData%\ssh\) and exits 255 with no output when +// it is unset, so every spawned ssh/scp fails while the same command works from +// a normal shell. server.mjs restores the variable at import time; these tests +// pin that behaviour, including that it stays out of the way on POSIX. +// ============================================================================= + + +describe('remote command exit codes', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + it('should report code 0 when the process closes with a null code', async () => { + // ssh killed by a signal exits with a null code and no timeout involved. + client._spawn = vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + setTimeout(() => child.emit('close', null), 5); + return child; + }); + + const result = await client.runRemoteCommand('test', 'whatever'); + expect(result.code).toBe(0); + }); +}); + +describe('runRemoteCommand timeout defaulting', () => { + let client: TestClient; + + beforeEach(() => { + client = new SSHClient() as unknown as TestClient; + vi.clearAllMocks(); + readFile.mockResolvedValue(`Host test\n HostName 1.2.3.4\n`); + }); + + // Regression guard: `??` here would hand ssh a zero-millisecond timeout and + // kill the command immediately. Zero means "not specified", as it does in the + // tool dispatcher. + it('should treat a zero timeout as absent rather than immediate', async () => { + vi.useFakeTimers(); + try { + client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 }); + const pending = client.runRemoteCommand('test', 'echo ok', { timeout: 0 }); + + // Well past an immediate timeout, but far short of the 30s default. + await vi.advanceTimersByTimeAsync(1000); + const result = await pending; + + expect(result.code).toBe(0); + expect(result.stderr).not.toContain('timed out'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/ssh-client.ts b/src/ssh-client.ts new file mode 100644 index 0000000..b8ef186 --- /dev/null +++ b/src/ssh-client.ts @@ -0,0 +1,353 @@ +/** + * All SSH operations. Uses the system's native `ssh`/`scp` binaries rather than + * a JavaScript SSH library, so every option in the user's ~/.ssh/config applies. + * + * Security invariants (see CLAUDE.md → Threat Model): + * - No shell, ever. spawn/execFile run with shell:false and an argv array, so + * nothing in a tool argument can reach a local shell. + * - Every hostAlias passes _assertSafeHostAlias() (strict whitelist) and + * _assertKnownHostAlias() (must exist in the user's config or known_hosts). + * - Every invocation carries `--` to terminate option parsing. + */ +import { spawn as nodeSpawn, execFile } from 'node:child_process'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { promisify } from 'node:util'; +import { unlinkSync } from 'node:fs'; +import { writeFile, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { SSHConfigParser } from './ssh-config-parser.js'; +import { hostMatchesAlias } from './config-values.js'; +import { debugLog, isWindows, SSH_BIN, SCP_BIN } from './platform.js'; +import type { + BatchResult, + CommandResult, + ConnectivityResult, + HostInfo, + SafeHostInfo, + SpawnEnv, +} from './types.js'; + +const execFileAsync = promisify(execFile); + +const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB limit +const DEFAULT_TIMEOUT = 30000; +const STRICT_HOST_KEY_CHECKING = ['-o', 'StrictHostKeyChecking=accept-new']; + +type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; +type ExecFileAsyncFn = ( + file: string, + args: readonly string[], + options: Record, +) => Promise<{ stdout: string; stderr: string }>; + +export class SSHClient { + configParser: SSHConfigParser; + /** Injection points for tests — production always uses the real ones. */ + _spawn: SpawnFn; + _execFileAsync: ExecFileAsyncFn; + private _askpassScript: string | null = null; + + constructor() { + this.configParser = new SSHConfigParser(); + this._spawn = nodeSpawn; + this._execFileAsync = execFileAsync; + } + + async listKnownHosts(): Promise { + return this.configParser.getAllKnownHosts(); + } + + /** + * Strict whitelist. Two threats this defends against: + * 1. ssh/scp option injection via leading '-' (e.g. -oProxyCommand=…), + * which would execute arbitrary commands LOCALLY on this machine. + * 2. Shell-metacharacter injection, as defence in depth behind shell:false. + * Allowed: alphanumerics, '.', '_', '-', ':', '@'. Must not start with '-'. + * + * Do not weaken this without understanding the implications — see the + * CHANGELOG entry for 1.3.5. + */ + _assertSafeHostAlias(hostAlias: unknown): asserts hostAlias is string { + if (typeof hostAlias !== 'string' || hostAlias.length === 0) { + throw new Error('hostAlias must be a non-empty string'); + } + if (!/^[A-Za-z0-9_.@:][A-Za-z0-9._@:-]*$/.test(hostAlias)) { + throw new Error( + `Invalid hostAlias: must match [A-Za-z0-9._@:-] and not start with '-'` + ); + } + } + + /** The LLM may only reach hosts the user has actually configured. */ + async _assertKnownHostAlias(hostAlias: string): Promise { + const cleanAlias = stripUserPrefix(hostAlias); + const knownHosts = await this.configParser.getAllKnownHosts(); + const isKnown = knownHosts.some( + host => hostMatchesAlias(host, hostAlias) || hostMatchesAlias(host, cleanAlias) + ); + if (!isKnown) { + throw new Error( + `Unknown hostAlias: ${hostAlias} is not defined in ~/.ssh/config or ~/.ssh/known_hosts` + ); + } + } + + async getPasswordForHost(hostAlias: string): Promise { + const cleanAlias = stripUserPrefix(hostAlias); + const hosts = await this.configParser.processIncludeDirectives(this.configParser.configPath); + const host = hosts.find(h => hostMatchesAlias(h, cleanAlias)); + return host?._password ?? null; + } + + /** + * Write the SSH_ASKPASS helper that echoes the password from the environment. + * A batch file on Windows, a mode-700 shell script on POSIX. + */ + async getAskpassScript(): Promise { + if (this._askpassScript) return this._askpassScript; + + let scriptPath: string; + if (isWindows) { + scriptPath = join(tmpdir(), `mcp-ssh-askpass-${process.pid}.cmd`); + await writeFile(scriptPath, '@echo off\r\necho %MCP_SSH_PASS%\r\n'); + } else { + scriptPath = join(tmpdir(), `mcp-ssh-askpass-${process.pid}.sh`); + await writeFile(scriptPath, '#!/bin/sh\necho "$MCP_SSH_PASS"\n'); + await chmod(scriptPath, 0o700); + } + this._askpassScript = scriptPath; + + const cleanup = (): void => { + try { + unlinkSync(scriptPath); + } catch { + // Already gone, or never written — nothing to clean up. + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { + cleanup(); + process.exit(130); + }); + process.on('SIGTERM', () => { + cleanup(); + process.exit(143); + }); + + return scriptPath; + } + + /** Environment carrying the password, or null when the host uses key auth. */ + async buildSpawnEnv(hostAlias: string): Promise { + const password = await this.getPasswordForHost(hostAlias); + if (!password) return null; + + // Refuse to use a password out of a world-readable config + if (this.configParser._configsWithPasswords) { + for (const configPath of this.configParser._configsWithPasswords) { + await this.configParser.checkFilePermissions(configPath); + } + } + + const askpassScript = await this.getAskpassScript(); + return { + ...process.env, + MCP_SSH_PASS: password, + SSH_ASKPASS: askpassScript, + // `force` tells OpenSSH to use the askpass helper even without a GUI/TTY. + // Avoid injecting a fake DISPLAY value here; that's a POSIX/X11 assumption + // and can break platform-specific behavior, especially on Windows. + SSH_ASKPASS_REQUIRE: 'force', + }; + } + + async runRemoteCommand( + hostAlias: string, + command: string, + options: { timeout?: number } = {}, + ): Promise { + this._assertSafeHostAlias(hostAlias); + await this._assertKnownHostAlias(hostAlias); + // `||`, not `??`: a zero timeout means "not specified" here, as it does in + // the tool dispatcher. With `??` a caller passing 0 would get an immediate + // SIGTERM instead of the default. + const timeout = options.timeout || DEFAULT_TIMEOUT; + + debugLog(`Executing: ssh ${hostAlias} ${command}\n`); + + const passwordEnv = await this.buildSpawnEnv(hostAlias); + + return new Promise((resolve) => { + const spawnOptions: SpawnOptions = { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + // shell:false is critical: with a shell the args would be re-parsed and + // metacharacters in `command` could lead to local command injection. We + // rely on resolveExecutable() to find ssh.exe on Windows, so PATH lookup + // is not needed here. + shell: false, + }; + if (passwordEnv) { + spawnOptions.env = passwordEnv; + if (!isWindows) { + // setsid needed on some systems so SSH uses SSH_ASKPASS instead of tty + spawnOptions.detached = true; + } + } + + const child = this._spawn( + SSH_BIN, + [...STRICT_HOST_KEY_CHECKING, '--', hostAlias, command], + spawnOptions, + ); + + let stdout = ''; + let stderr = ''; + let killed = false; + let stdoutTruncated = false; + let stderrTruncated = false; + + const timer = setTimeout(() => { + killed = true; + child.kill('SIGTERM'); + }, timeout); + + child.stdout?.on('data', (data: Buffer) => { + if (stdout.length < MAX_OUTPUT_SIZE) { + stdout += data.toString(); + } else if (!stdoutTruncated) { + stdoutTruncated = true; + stdout += '\n[Output truncated - exceeded 10MB limit]'; + } + }); + + child.stderr?.on('data', (data: Buffer) => { + if (stderr.length < MAX_OUTPUT_SIZE) { + stderr += data.toString(); + } else if (!stderrTruncated) { + stderrTruncated = true; + stderr += '\n[Stderr truncated - exceeded 10MB limit]'; + } + }); + + child.on('close', (code: number | null) => { + clearTimeout(timer); + resolve({ + stdout, + stderr: killed ? `${stderr}\n[Command timed out]` : stderr, + code: killed ? 124 : (code ?? 0), + }); + }); + + child.on('error', (error: Error) => { + clearTimeout(timer); + debugLog(`Error executing command on ${hostAlias}: ${error.message}\n`); + resolve({ stdout, stderr: error.message, code: 1 }); + }); + }); + } + + async getHostInfo(hostAlias: string): Promise { + const hosts = await this.configParser.processIncludeDirectives(this.configParser.configPath); + const host = hosts.find(h => hostMatchesAlias(h, hostAlias)); + if (!host) return null; + + // Never expose the password to the LLM + const { _password, ...safeHost } = host; + return _password ? { ...safeHost, passwordAuth: true } : safeHost; + } + + async checkConnectivity(hostAlias: string): Promise { + try { + const result = await this.runRemoteCommand(hostAlias, 'echo connected'); + const connected = result.code === 0 && result.stdout.trim() === 'connected'; + + return { + connected, + message: connected ? 'Connection successful' : 'Connection failed', + }; + } catch (error) { + const message = errorMessage(error); + debugLog(`Connectivity error with ${hostAlias}: ${message}\n`); + return { connected: false, message }; + } + } + + async uploadFile(hostAlias: string, localPath: string, remotePath: string): Promise { + return this._scp( + hostAlias, + [localPath, `${hostAlias}:${remotePath}`], + `Executing: scp ${localPath} ${hostAlias}:${remotePath}\n`, + `Error uploading file to ${hostAlias}`, + ); + } + + async downloadFile(hostAlias: string, remotePath: string, localPath: string): Promise { + return this._scp( + hostAlias, + [`${hostAlias}:${remotePath}`, localPath], + `Executing: scp ${hostAlias}:${remotePath} ${localPath}\n`, + `Error downloading file from ${hostAlias}`, + ); + } + + /** Shared body of uploadFile/downloadFile — they differ only in argument order. */ + private async _scp( + hostAlias: string, + paths: [string, string], + logLine: string, + errorPrefix: string, + ): Promise { + try { + this._assertSafeHostAlias(hostAlias); + await this._assertKnownHostAlias(hostAlias); + debugLog(logLine); + + const passwordEnv = await this.buildSpawnEnv(hostAlias); + const options: Record = { timeout: 60000, windowsHide: true, shell: false }; + if (passwordEnv) options['env'] = passwordEnv; + + await this._execFileAsync(SCP_BIN, [...STRICT_HOST_KEY_CHECKING, '--', ...paths], options); + return true; + } catch (error) { + debugLog(`${errorPrefix}: ${errorMessage(error)}\n`); + return false; + } + } + + async runCommandBatch(hostAlias: string, commands: string[]): Promise { + try { + const results: CommandResult[] = []; + let success = true; + + for (const command of commands) { + const result = await this.runRemoteCommand(hostAlias, command); + results.push(result); + + // Keep going on failure — the caller sees every result + if (result.code !== 0) success = false; + } + + return { results, success }; + } catch (error) { + const message = errorMessage(error); + debugLog(`Error during batch execution on ${hostAlias}: ${message}\n`); + return { + results: [{ stdout: '', stderr: message, code: 1 }], + success: false, + }; + } + } +} + +/** "test@ssh-test" -> "ssh-test" */ +function stripUserPrefix(hostAlias: string): string { + const at = hostAlias.lastIndexOf('@'); + return at === -1 ? hostAlias : hostAlias.slice(at + 1); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/ssh-config-parser.test.ts b/src/ssh-config-parser.test.ts new file mode 100644 index 0000000..8a9df5a --- /dev/null +++ b/src/ssh-config-parser.test.ts @@ -0,0 +1,603 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; +import type { Mock } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const sshConfigLib = require('ssh-config'); + +// vitest scopes module mocks to the declaring file, so each test file installs +// its own. test-helpers.ts then sees the mocked copies. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn(actual.existsSync) }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readFile: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + }; +}); + +import { SSHConfigParser } from './server.js'; +import { + readFile, + stat, + loadServerAs, + SAMPLE_SSH_CONFIG, + SAMPLE_SSH_CONFIG_WITH_INCLUDE, + SAMPLE_KNOWN_HOSTS, +} from './test-helpers.js'; + + +// ============================================================================= +// SSHConfigParser Tests +// ============================================================================= + +describe('SSHConfigParser', () => { + let parser: SSHConfigParser; + + beforeEach(() => { + parser = new SSHConfigParser(); + vi.clearAllMocks(); + }); + + describe('extractHostsFromConfig', () => { + it('should parse hosts with hostname, user, port', () => { + const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); + const hosts = parser.extractHostsFromConfig(config, '/home/test/.ssh/config'); + + expect(hosts).toHaveLength(2); // nohost has no hostname + expect(hosts[0]).toMatchObject({ + alias: 'prod', + hostname: '157.90.89.149', + port: 42077, + user: 'trashmail', + }); + }); + + it('should parse @password annotation from comments', () => { + const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); + const hosts = parser.extractHostsFromConfig(config, '/test'); + const mail = hosts.find(h => h.alias === 'mail'); + expect(mail._password).toBe('killer99'); + }); + + it('should handle password with colons', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + # @password:pass:with:colons +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts[0]._password).toBe('pass:with:colons'); + }); + + it('should handle password with spaces after colon', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + # @password: spaced +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts[0]._password).toBe('spaced'); + }); + + it('should skip hosts without hostname', () => { + const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts.find(h => h.alias === 'nohost')).toBeUndefined(); + }); + + it('should skip wildcard host', () => { + const config = sshConfigLib.parse(` +Host * + ServerAliveInterval 55 + +Host myhost + HostName 1.2.3.4 +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts).toHaveLength(1); + expect(hosts[0].alias).toBe('myhost'); + }); + + // Regression: ssh-config@5 returns a plain string for a single-token value + // but an array of token objects for `Host a b`. Storing that array in + // `alias` made every strict comparison fail, so a multi-alias host was + // unreachable under *any* of its names. + it('should expose every alias of a multi-alias Host block', () => { + const config = sshConfigLib.parse(` +Host docker-lxc hlab + HostName 10.9.0.105 + User root +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + + expect(hosts).toHaveLength(1); + expect(hosts[0].aliases).toEqual(['docker-lxc', 'hlab']); + expect(hosts[0].alias).toBe('docker-lxc'); + expect(hosts[0].hostname).toBe('10.9.0.105'); + }); + + it('should keep alias a string for single-alias hosts', () => { + const config = sshConfigLib.parse(` +Host solo + HostName 1.2.3.4 +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + + expect(hosts[0].alias).toBe('solo'); + expect(hosts[0].aliases).toEqual(['solo']); + }); + + it('should skip a wildcard block carrying negations', () => { + const config = sshConfigLib.parse(` +Host * !bastion + HostName 7.7.7.7 + +Host myhost + HostName 1.2.3.4 +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + + expect(hosts).toHaveLength(1); + expect(hosts[0].alias).toBe('myhost'); + }); + + it('should flatten multi-token directives into a string', () => { + const config = sshConfigLib.parse(` +Host jump + HostName localhost + ProxyCommand ssh bastion -W %h:%p +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + + expect(hosts[0].proxycommand).toBe('ssh bastion -W %h:%p'); + }); + + it('should skip Include directives', () => { + const config = sshConfigLib.parse(SAMPLE_SSH_CONFIG_WITH_INCLUDE); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts).toHaveLength(1); + expect(hosts[0].alias).toBe('prod'); + }); + + it('should parse identityFile', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + IdentityFile ~/.ssh/id_rsa +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts[0].identityFile).toBe('~/.ssh/id_rsa'); + }); + + it('should store other parameters in lowercase', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + ProxyJump bastion +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts[0].proxyjump).toBe('bastion'); + }); + + it('should track configs with passwords', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + # @password:secret +`); + parser.extractHostsFromConfig(config, '/my/config'); + expect(parser._configsWithPasswords.has('/my/config')).toBe(true); + }); + + it('should not track configs without passwords', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 +`); + parser.extractHostsFromConfig(config, '/my/config'); + expect(parser._configsWithPasswords).toBeUndefined(); + }); + + it('should ignore comment lines that are not @password', () => { + const config = sshConfigLib.parse(` +Host test + HostName 1.2.3.4 + # This is a regular comment + # Another comment +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + expect(hosts[0]._password).toBeUndefined(); + }); + }); + + describe('parseConfig', () => { + it('should parse SSH config file', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + const hosts = await parser.parseConfig(); + expect(hosts).toHaveLength(2); + }); + + it('should return empty array on read error', async () => { + readFile.mockRejectedValue(new Error('ENOENT')); + const hosts = await parser.parseConfig(); + expect(hosts).toEqual([]); + }); + }); + + describe('parseKnownHosts', () => { + it('should parse known_hosts file', async () => { + readFile.mockResolvedValue(SAMPLE_KNOWN_HOSTS); + const hosts = await parser.parseKnownHosts(); + expect(hosts).toEqual(['157.90.89.149', '88.198.170.88', '10.0.0.1']); + }); + + it('should return empty array on read error', async () => { + readFile.mockRejectedValue(new Error('ENOENT')); + const hosts = await parser.parseKnownHosts(); + expect(hosts).toEqual([]); + }); + + it('should skip empty lines', async () => { + readFile.mockResolvedValue('host1 ssh-rsa key\n\n\nhost2 ssh-rsa key\n'); + const hosts = await parser.parseKnownHosts(); + expect(hosts).toEqual(['host1', 'host2']); + }); + + it('should handle comma-separated hostnames', async () => { + readFile.mockResolvedValue('host1,host2 ssh-rsa key\n'); + const hosts = await parser.parseKnownHosts(); + expect(hosts).toEqual(['host1']); + }); + }); + + // Unix permission bits have no meaning on Windows, so checkFilePermissions is + // a deliberate no-op there. Pin the platform instead of inheriting the host's, + // otherwise every expectation below is wrong on one OS or the other. + describe('checkFilePermissions (POSIX)', () => { + let posixParser: SSHConfigParser; + let posixStat: Mock; + + beforeEach(async () => { + const posix = await loadServerAs('linux'); + posixParser = new posix.SSHConfigParser(); + posixStat = posix.fs.stat; + }); + + it('should pass with 600 permissions', async () => { + posixStat.mockResolvedValue({ mode: 0o100600 }); + await expect(posixParser.checkFilePermissions('/test')).resolves.not.toThrow(); + }); + + it('should throw on insecure permissions (644)', async () => { + posixStat.mockResolvedValue({ mode: 0o100644 }); + await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('insecure permissions'); + }); + + it('should throw on insecure permissions (755)', async () => { + posixStat.mockResolvedValue({ mode: 0o100755 }); + await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('insecure permissions'); + }); + + it('should include chmod hint in error message', async () => { + posixStat.mockResolvedValue({ mode: 0o100644 }); + await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('chmod 600'); + }); + + it('should ignore ENOENT errors', async () => { + const err: NodeJS.ErrnoException = new Error('not found'); + err.code = 'ENOENT'; + posixStat.mockRejectedValue(err); + await expect(posixParser.checkFilePermissions('/test')).resolves.not.toThrow(); + }); + + it('should rethrow other errors', async () => { + posixStat.mockRejectedValue(new Error('disk failure')); + await expect(posixParser.checkFilePermissions('/test')).rejects.toThrow('disk failure'); + }); + }); + + describe('checkFilePermissions (Windows)', () => { + it('should skip the permission check without touching stat', async () => { + const win = await loadServerAs('win32'); + const winParser = new win.SSHConfigParser(); + win.fs.stat.mockResolvedValue({ mode: 0o100777 }); + + await expect(winParser.checkFilePermissions('C:\\Users\\test\\.ssh\\config')).resolves.toBeUndefined(); + expect(win.fs.stat).not.toHaveBeenCalled(); + }); + }); + + describe('getAllKnownHosts', () => { + it('should merge config hosts and known_hosts, deduplicating', async () => { + readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); + stat.mockResolvedValue({ mode: 0o100600 }); + + const hosts = await parser.getAllKnownHosts(); + + const configHosts = hosts.filter(h => h.source === 'ssh_config'); + const knownHosts = hosts.filter(h => h.source === 'known_hosts'); + + expect(configHosts).toHaveLength(2); + expect(knownHosts).toHaveLength(1); + expect(knownHosts[0].hostname).toBe('10.0.0.1'); + }); + + // POSIX-pinned: the permission check is a no-op on Windows (see above), so + // asserting that stat() ran only makes sense for the POSIX build. + it('should check permissions for configs with passwords', async () => { + const posix = await loadServerAs('linux'); + const posixParser = new posix.SSHConfigParser(); + posix.fs.readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockResolvedValueOnce(SAMPLE_KNOWN_HOSTS); + posix.fs.stat.mockResolvedValue({ mode: 0o100600 }); + + await posixParser.getAllKnownHosts(); + expect(posix.fs.stat).toHaveBeenCalled(); + }); + + it('should work with empty known_hosts', async () => { + readFile + .mockResolvedValueOnce(SAMPLE_SSH_CONFIG) + .mockRejectedValueOnce(new Error('ENOENT')); + stat.mockResolvedValue({ mode: 0o100600 }); + + const hosts = await parser.getAllKnownHosts(); + expect(hosts).toHaveLength(2); + }); + }); + + describe('processIncludeDirectives', () => { + it('should return empty array on read error', async () => { + readFile.mockRejectedValue(new Error('ENOENT')); + const hosts = await parser.processIncludeDirectives('/nonexistent'); + expect(hosts).toEqual([]); + }); + + it('should parse config without includes', async () => { + readFile.mockResolvedValue(SAMPLE_SSH_CONFIG); + const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); + expect(hosts).toHaveLength(2); + }); + + it('should process Include directives and merge hosts', async () => { + const mainConfig = ` +Include /tmp/included.conf + +Host main + HostName 1.2.3.4 +`; + const includedConfig = ` +Host included + HostName 5.6.7.8 +`; + readFile + .mockResolvedValueOnce(mainConfig) + .mockResolvedValueOnce(includedConfig); + + // Mock expandIncludePath to return the include path + parser.expandIncludePath = vi.fn().mockReturnValue(['/tmp/included.conf']); + + const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); + expect(hosts).toHaveLength(2); + expect(hosts.map(h => h.alias)).toContain('included'); + expect(hosts.map(h => h.alias)).toContain('main'); + }); + + it('should handle errors in included files gracefully', async () => { + const mainConfig = ` +Include /tmp/broken.conf + +Host main + HostName 1.2.3.4 +`; + // First call reads main config, second call for included file rejects + // processIncludeDirectives catches this internally and returns [] + readFile + .mockResolvedValueOnce(mainConfig) + .mockRejectedValueOnce(new Error('permission denied')); + + parser.expandIncludePath = vi.fn().mockReturnValue(['/tmp/broken.conf']); + + const hosts = await parser.processIncludeDirectives('/test/.ssh/config'); + // Should still return hosts from main config (included returns [] on error) + expect(hosts).toHaveLength(1); + expect(hosts[0].alias).toBe('main'); + }); + }); + + describe('expandIncludePath', () => { + it('should expand tilde paths', () => { + const result = parser.expandIncludePath('~/nonexistent-path-xyz', '/base'); + expect(result).toEqual([]); + }); + + it('should handle relative paths', () => { + const result = parser.expandIncludePath('relative/path', '/base/config'); + expect(result).toEqual([]); + }); + + it('should return empty for non-existent absolute paths', () => { + const result = parser.expandIncludePath('/nonexistent-absolute-path-xyz', '/base'); + expect(result).toEqual([]); + }); + + it('should treat Windows drive-letter paths as absolute', () => { + const result = parser.expandIncludePath('C:\\nonexistent-absolute-path-xyz', '/base/config'); + expect(result).toEqual([]); + }); + + it('should treat UNC paths as absolute', () => { + const result = parser.expandIncludePath('\\\\server\\share\\nonexistent-path-xyz', '/base/config'); + expect(result).toEqual([]); + }); + + it('should expand tilde paths with backslashes', () => { + const result = parser.expandIncludePath('~\\nonexistent-path-xyz', '/base'); + expect(result).toEqual([]); + }); + + it('should return empty for non-existent glob patterns', () => { + const result = parser.expandIncludePath('/nonexistent-path-xyz/*.conf', '/base'); + expect(result).toEqual([]); + }); + + it('should handle errors in glob/existsSync gracefully', () => { + vi.mocked(existsSync).mockImplementationOnce(() => { throw new Error('fs broken'); }); + + const result = parser.expandIncludePath('/some/path/file', '/base'); + expect(result).toEqual([]); + }); + }); +}); + +// ============================================================================= +// SSHClient Tests +// ============================================================================= + + +// ============================================================================= +// ssh-config value normalization edge cases +// +// extractHostsFromConfig is a pure function over the parser's section array, so +// these feed it shapes that ssh-config can emit but that are awkward to produce +// from config text alone. +// ============================================================================= + +describe('config value normalization', () => { + let parser: SSHConfigParser; + + beforeEach(() => { + parser = new SSHConfigParser(); + vi.clearAllMocks(); + }); + + it('should treat a directive without a value as absent', () => { + const hosts = parser.extractHostsFromConfig([ + { + param: 'Host', + value: 'x', + config: [ + { param: 'HostName', value: '1.2.3.4' }, + { param: 'SendEnv', value: null }, + ], + }, + ], '/test'); + + expect(hosts).toHaveLength(1); + expect(hosts[0].sendenv).toBe(''); + }); + + it('should accept plain strings inside a multi-token value', () => { + // ssh-config normally yields {val,…} token objects, but a hand-built or + // future-shaped array of bare strings must normalize the same way. + const hosts = parser.extractHostsFromConfig([ + { + param: 'Host', + value: ['first', 'second'], + config: [{ param: 'HostName', value: '1.2.3.4' }], + }, + ], '/test'); + + expect(hosts[0].aliases).toEqual(['first', 'second']); + expect(hosts[0].alias).toBe('first'); + }); + + it('should skip a Host block whose value is empty', () => { + const hosts = parser.extractHostsFromConfig([ + { param: 'Host', value: [], config: [{ param: 'HostName', value: '1.2.3.4' }] }, + ], '/test'); + + expect(hosts).toEqual([]); + }); + + it('should ignore top-level directives that are not Host or Include', () => { + const config = sshConfigLib.parse(` +ServerAliveInterval 30 + +Host real + HostName 1.2.3.4 +`); + const hosts = parser.extractHostsFromConfig(config, '/test'); + + expect(hosts).toHaveLength(1); + expect(hosts[0].alias).toBe('real'); + }); +}); + +// ============================================================================= +// expandIncludePath — paths that actually exist +// ============================================================================= + + +// ============================================================================= +// expandIncludePath — paths that actually exist +// ============================================================================= + +describe('expandIncludePath (existing paths)', () => { + let parser: SSHConfigParser; + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'mcp-ssh-inc-')); + writeFileSync(join(dir, 'included.conf'), 'Host inc\n HostName 5.5.5.5\n'); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + beforeEach(() => { + parser = new SSHConfigParser(); + }); + + it('should return an existing absolute path', () => { + const target = join(dir, 'included.conf'); + expect(parser.expandIncludePath(target, '/base/config')).toEqual([target]); + }); + + it('should expand a glob pattern to the files it matches', () => { + // glob patterns are forward-slash based on every platform, including Windows. + const pattern = `${dir.replace(/\\/g, '/')}/*.conf`; + const result = parser.expandIncludePath(pattern, '/base/config'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatch(/included\.conf$/); + }); +}); + +// ============================================================================= +// Remaining branches: argument validation, known_hosts matching, silent mode, +// askpass cleanup handlers and the tool-dispatch catch-all. +// ============================================================================= + + +describe('parser edge shapes', () => { + let parser: SSHConfigParser; + + beforeEach(() => { + parser = new SSHConfigParser(); + vi.clearAllMocks(); + }); + + it('should handle a Host section that carries no directives at all', () => { + // `Host x` with nothing under it: ssh-config still yields a section, and it + // has no `config` array to walk. + const hosts = parser.extractHostsFromConfig([{ param: 'Host', value: 'bare' }], '/test'); + expect(hosts).toEqual([]); + }); +}); diff --git a/src/ssh-config-parser.ts b/src/ssh-config-parser.ts new file mode 100644 index 0000000..0d4586e --- /dev/null +++ b/src/ssh-config-parser.ts @@ -0,0 +1,242 @@ +/** + * Discovery of SSH hosts from ~/.ssh/config (including Include directives) and + * ~/.ssh/known_hosts. + */ +import { homedir } from 'node:os'; +import { readFile, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve, win32 } from 'node:path'; +import { existsSync } from 'node:fs'; +import { glob } from 'glob'; +import SSHConfig from 'ssh-config'; + +import { debugLog, isWindows } from './platform.js'; +import { configValueTokens, configValueToString, hostMatchesAlias } from './config-values.js'; +import type { ConfigValue } from './config-values.js'; +import type { HostInfo } from './types.js'; + +/** Shape of a parsed ssh-config line, narrowed to what we consume. */ +interface ConfigLine { + type?: number; + param?: string; + value?: ConfigValue; + content?: string; + config?: ConfigLine[]; +} + +const LINE_TYPE_COMMENT = 2; + +export class SSHConfigParser { + configPath: string; + knownHostsPath: string; + /** Config files that carry `# @password:` annotations, for the permission check. */ + _configsWithPasswords?: Set; + + constructor() { + const homeDir = homedir(); + this.configPath = join(homeDir, '.ssh', 'config'); + this.knownHostsPath = join(homeDir, '.ssh', 'known_hosts'); + } + + async parseConfig(): Promise { + try { + const content = await readFile(this.configPath, 'utf-8'); + const config = SSHConfig.parse(content) as unknown as ConfigLine[]; + return this.extractHostsFromConfig(config, this.configPath); + } catch (error) { + debugLog(`Error reading SSH config: ${errorMessage(error)}\n`); + return []; + } + } + + async processIncludeDirectives(configPath: string): Promise { + try { + const content = await readFile(configPath, 'utf-8'); + const config = SSHConfig.parse(content) as unknown as ConfigLine[]; + const hosts: HostInfo[] = []; + + for (const section of config) { + if (section.param === 'Include' && section.value) { + const includePaths = this.expandIncludePath(configValueToString(section.value), configPath); + + for (const includePath of includePaths) { + const includeHosts = await this.processIncludeDirectives(includePath); + hosts.push(...includeHosts); + } + } + } + + // Add hosts from the current config file + hosts.push(...this.extractHostsFromConfig(config, configPath)); + + return hosts; + } catch (error) { + debugLog(`Error processing config file ${configPath}: ${errorMessage(error)}\n`); + return []; + } + } + + expandIncludePath(includePath: string, baseConfigPath: string): string[] { + // Handle tilde expansion + if (/^~(?=[\\/])/.test(includePath)) { + includePath = includePath.replace(/^~/, homedir()); + } + + // Handle relative paths. Both checks are needed: a Windows drive-letter or + // UNC path is absolute even when this runs on POSIX. + if (!isAbsolute(includePath) && !win32.isAbsolute(includePath)) { + includePath = resolve(dirname(baseConfigPath), includePath); + } + + try { + if (includePath.includes('*') || includePath.includes('?')) { + return glob.sync(includePath).filter(path => existsSync(path)); + } + return existsSync(includePath) ? [includePath] : []; + } catch (error) { + debugLog(`Error expanding include path ${includePath}: ${errorMessage(error)}\n`); + return []; + } + } + + async checkFilePermissions(filePath: string): Promise { + // Windows has no Unix permission bits - skip check + if (isWindows) return; + try { + const fileStat = await stat(filePath); + const mode = fileStat.mode & 0o777; + if (mode !== 0o600) { + throw new Error( + `SSH config file ${filePath} contains @password annotations but has insecure permissions (${mode.toString(8)}). ` + + `Required: 600. Fix with: chmod 600 ${filePath}` + ); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + } + + extractHostsFromConfig(config: ConfigLine[], configPath: string): HostInfo[] { + const hosts: HostInfo[] = []; + let hasPasswords = false; + + for (const section of config) { + // Include directives are processed separately + if (section.param === 'Include') continue; + if (section.param !== 'Host') continue; + + const aliases = configValueTokens(section.value); + const [firstAlias] = aliases; + + // Skip blocks that only carry defaults (`Host *`, `Host * !bastion`): + // they are not connectable hosts. A multi-token Host value is an array, + // so a plain `value !== '*'` check could never match these. An empty + // value (no alias at all) is skipped by the same guard. + if (!firstAlias || aliases.every(a => a === '*' || a.startsWith('!'))) { + continue; + } + + const hostInfo: HostInfo = { + hostname: '', + alias: firstAlias, // first alias — keeps the existing output shape + aliases, // full list — used for matching + configFile: configPath, + }; + + for (const param of section.config ?? []) { + // Parse @password annotation from comments + if (param.type === LINE_TYPE_COMMENT && param.content) { + const match = /^#\s*@password:\s*(.+)$/.exec(param.content); + if (match?.[1]) { + hostInfo._password = match[1]; + hasPasswords = true; + continue; + } + } + + // Comments that are not @password annotations, and anything else + // without a directive name, carry nothing we can store. + if (!param.param) continue; + + // Multi-token directives (ProxyCommand, SendEnv, IPQoS, …) arrive as + // arrays of token objects; flatten so the JSON we hand back is readable. + const value = configValueToString(param.value); + + switch (param.param.toLowerCase()) { + case 'hostname': + hostInfo.hostname = value; + break; + case 'user': + hostInfo.user = value; + break; + case 'port': + hostInfo.port = parseInt(value, 10); + break; + case 'identityfile': + hostInfo.identityFile = value; + break; + default: + hostInfo[param.param.toLowerCase()] = value; + } + } + + // Only add hosts with complete information + if (hostInfo.hostname) hosts.push(hostInfo); + } + + if (hasPasswords) { + this._configsWithPasswords ??= new Set(); + this._configsWithPasswords.add(configPath); + } + + return hosts; + } + + async parseKnownHosts(): Promise { + try { + const content = await readFile(this.knownHostsPath, 'utf-8'); + return content + .split('\n') + .filter(line => line.trim() !== '') + // Format: hostname[,hostname2...] key-type public-key + // Both splits are guaranteed to yield at least one element, so the + // assertions cannot fail — a `?? ''` fallback here would be dead code. + .map(line => line.split(' ')[0]!.split(',')[0]!); + } catch (error) { + debugLog(`Error reading known_hosts file: ${errorMessage(error)}\n`); + return []; + } + } + + async getAllKnownHosts(): Promise { + // Config hosts are prioritized, including everything pulled in via Include + const configHosts = await this.processIncludeDirectives(this.configPath); + + // Check file permissions for configs that contain @password annotations + if (this._configsWithPasswords) { + for (const configPath of this._configsWithPasswords) { + await this.checkFilePermissions(configPath); + } + } + + const knownHostnames = await this.parseKnownHosts(); + const allHosts: HostInfo[] = [...configHosts]; + + // Add hosts from known_hosts that aren't already in the config + for (const hostname of knownHostnames) { + if (!configHosts.some(host => hostMatchesAlias(host, hostname))) { + allHosts.push({ hostname, source: 'known_hosts' }); + } + } + + configHosts.forEach(host => { + host.source = 'ssh_config'; + }); + + return allHosts; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 0000000..be8b5ec --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,166 @@ +/** + * Shared fixtures and helpers for the test suite. + * + * The vi.mock() calls for node:fs and node:fs/promises are NOT here: vitest + * scopes module mocks to the test file that declares them, so every test file + * repeats them and this module then sees the mocked copies. + */ +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { EventEmitter } from 'node:events'; +import * as fsPromises from 'node:fs/promises'; + +import { SSHConfigParser, SSHClient, main } from './server.js'; + +export const readFile = fsPromises.readFile as unknown as Mock; +export const stat = fsPromises.stat as unknown as Mock; +export const writeFile = fsPromises.writeFile as unknown as Mock; +export const chmod = fsPromises.chmod as unknown as Mock; + +/** A stand-in for ChildProcess: an emitter with the streams bolted on. */ +export interface MockChild extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + kill: Mock; +} + +/** The fs/promises functions this suite mocks, as vitest sees them. */ +export interface MockedFs { + readFile: Mock; + stat: Mock; + writeFile: Mock; + chmod: Mock; + unlink: Mock; +} + +/** SSHClient with its spawn/execFile injection points seen as plain mocks. */ +export type TestClient = Omit & { + _spawn: Mock; + _execFileAsync: Mock; +}; + +export type SpawnMock = Mock; +export type ExecFileMock = Mock; + +// Without this, ~14 tests silently assert POSIX-only behaviour (chmod 600 +// checks, the /bin/sh askpass helper, `detached`, a bare 'ssh' argv[0]) and fail +// when the suite runs on Windows. +// Variables server.mjs writes to process.env at import time (the Windows +// ProgramData normalization). They are always saved and restored, whether or not +// a test overrides them — otherwise the first Windows-flavoured import leaks its +// mutation into every later test and makes those branches look covered when +// nothing asserted them. +export const ENV_MUTATED_AT_IMPORT = ['ProgramData', 'ALLUSERSPROFILE']; + +/** What a re-imported copy of the server module graph exposes to a test. */ +export interface LoadedServer { + SSHClient: typeof SSHClient; + SSHConfigParser: typeof SSHConfigParser; + main: typeof main; + debugLog: (message: string) => void; + SSH_BIN: string; + SCP_BIN: string; + fs: MockedFs; + envAfterImport: Record; +} + +export async function loadServerAs( + platform: string, + envOverrides: Record = {}, +): Promise { + const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + const realEnv = {}; + + for (const key of ENV_MUTATED_AT_IMPORT) realEnv[key] = process.env[key]; + + for (const [key, value] of Object.entries(envOverrides)) { + realEnv[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + vi.resetModules(); + + try { + const server = await import('./server.js'); + // fs/promises has to be re-imported from the same fresh module graph: + // resetModules re-runs the vi.mock factory, so these are new spies — not the + // ones bound by the static import above. + const fs = await import('node:fs/promises'); + // Snapshot the variables the module writes at import time. The finally block + // below restores process.env immediately, so a test that wants to assert on + // the normalization has to read it from here. + const envAfterImport = Object.fromEntries( + ENV_MUTATED_AT_IMPORT.map(key => [key, process.env[key]]) + ); + return { ...server, fs, envAfterImport } as unknown as LoadedServer; + } finally { + Object.defineProperty(process, 'platform', realPlatform); + for (const key of new Set([...ENV_MUTATED_AT_IMPORT, ...Object.keys(envOverrides)])) { + if (realEnv[key] === undefined) delete process.env[key]; + else process.env[key] = realEnv[key]; + } + } +} + +// Helper: create a fake spawn that returns a mock child process +export function createMockSpawn({ stdout = '', stderr = '', code = 0, error = null } = {}): SpawnMock { + return vi.fn(() => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => { + setTimeout(() => child.emit('close', null), 2); + }); + + setTimeout(() => { + if (error) { + child.emit('error', error); + return; + } + if (stdout) child.stdout.emit('data', Buffer.from(stdout)); + if (stderr) child.stderr.emit('data', Buffer.from(stderr)); + child.emit('close', code); + }, 5); + + return child; + }) as unknown as SpawnMock; +} + +// Helper: create a fake execFileAsync +export function createMockExecFileAsync({ error = null } = {}): ExecFileMock { + return vi.fn(async () => { + if (error) throw error; + return { stdout: '', stderr: '' }; + }) as unknown as ExecFileMock; +} + +export const SAMPLE_SSH_CONFIG = ` +Host prod + HostName 157.90.89.149 + Port 42077 + User trashmail + +Host mail + HostName 88.198.170.88 + Port 42078 + User saf + # @password: killer99 + +Host nohost + User nobody +`; + +export const SAMPLE_SSH_CONFIG_WITH_INCLUDE = ` +Include ~/.ssh/configs/*.conf + +Host prod + HostName 157.90.89.149 + User trashmail +`; + +export const SAMPLE_KNOWN_HOSTS = `157.90.89.149 ssh-ed25519 AAAAC3Nz... +88.198.170.88 ssh-ed25519 AAAAC3Nz... +10.0.0.1 ssh-rsa AAAAB3Nz... +`; + diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..ad26cb0 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,199 @@ +/** + * MCP tool definitions and dispatch. + * + * Argument values arrive from the LLM and are therefore untrusted: every path + * that reaches ssh/scp goes through SSHClient's validation. Nothing is + * pre-validated here beyond what the schemas declare. + */ +import type { SSHClient } from './ssh-client.js'; +import { debugLog } from './platform.js'; + +const HOST_ALIAS_PROPERTY = { + type: 'string', + description: 'Alias or hostname of the SSH host', +} as const; + +export const TOOL_DEFINITIONS = [ + { + name: 'listKnownHosts', + description: + 'Returns a consolidated list of all known SSH hosts, prioritizing ~/.ssh/config entries first, then additional hosts from ~/.ssh/known_hosts', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'runRemoteCommand', + description: + 'Executes a shell command on an SSH host. For long-running commands, increase the timeout parameter.', + inputSchema: { + type: 'object', + properties: { + hostAlias: HOST_ALIAS_PROPERTY, + command: { type: 'string', description: 'The shell command to execute' }, + timeout: { + type: 'number', + description: 'Command timeout in milliseconds (default: 120000, max: 300000)', + }, + }, + required: ['hostAlias', 'command'], + }, + }, + { + name: 'getHostInfo', + description: 'Returns all configuration details for an SSH host', + inputSchema: { + type: 'object', + properties: { hostAlias: HOST_ALIAS_PROPERTY }, + required: ['hostAlias'], + }, + }, + { + name: 'checkConnectivity', + description: 'Checks if an SSH connection to the host is possible', + inputSchema: { + type: 'object', + properties: { hostAlias: HOST_ALIAS_PROPERTY }, + required: ['hostAlias'], + }, + }, + { + name: 'uploadFile', + description: 'Uploads a local file to an SSH host', + inputSchema: { + type: 'object', + properties: { + hostAlias: HOST_ALIAS_PROPERTY, + localPath: { type: 'string', description: 'Path to the local file' }, + remotePath: { type: 'string', description: 'Path on the remote host' }, + }, + required: ['hostAlias', 'localPath', 'remotePath'], + }, + }, + { + name: 'downloadFile', + description: 'Downloads a file from an SSH host', + inputSchema: { + type: 'object', + properties: { + hostAlias: HOST_ALIAS_PROPERTY, + remotePath: { type: 'string', description: 'Path on the remote host' }, + localPath: { type: 'string', description: 'Path to the local destination' }, + }, + required: ['hostAlias', 'remotePath', 'localPath'], + }, + }, + { + name: 'runCommandBatch', + description: 'Executes multiple shell commands sequentially on an SSH host', + inputSchema: { + type: 'object', + properties: { + hostAlias: HOST_ALIAS_PROPERTY, + commands: { + type: 'array', + items: { type: 'string' }, + description: 'List of shell commands to execute', + }, + }, + required: ['hostAlias', 'commands'], + }, + }, +] as const; + +/** + * The MCP content envelope every tool result is wrapped in. The index signature + * is what makes this assignable to the SDK's ServerResult union. + */ +export interface ToolResponse { + content: { type: 'text'; text: string }[]; + [key: string]: unknown; +} + +type ToolArgs = Record; + +const DEFAULT_COMMAND_TIMEOUT = 120000; // 2 min +const MAX_COMMAND_TIMEOUT = 300000; // 5 min + +function asText(payload: unknown): ToolResponse { + return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; +} + +/** + * Run one tool call. Errors are returned as a JSON payload rather than thrown, + * so the model sees what went wrong instead of a transport-level failure. + */ +export async function callTool( + sshClient: SSHClient, + name: string, + args: ToolArgs | undefined, +): Promise { + debugLog(`Received callTool request for tool: ${name}\n`); + + if (!args && name !== 'listKnownHosts') { + throw new Error(`No arguments provided for tool: ${name}`); + } + const a: ToolArgs = args ?? {}; + + try { + switch (name) { + case 'listKnownHosts': { + const hosts = await sshClient.listKnownHosts(); + // Strip passwords before sending to the LLM + const safeHosts = hosts.map(({ _password, ...host }) => + _password ? { ...host, passwordAuth: true as const } : host + ); + return asText(safeHosts); + } + + case 'runRemoteCommand': { + // Falsy (absent, 0) falls back to the default; `??` would let 0 through + // and ask ssh for a zero-millisecond timeout. + const requested = a['timeout'] as number | undefined; + const timeout = Math.min( + requested ? requested : DEFAULT_COMMAND_TIMEOUT, + MAX_COMMAND_TIMEOUT, + ); + return asText( + await sshClient.runRemoteCommand(a['hostAlias'] as string, a['command'] as string, { + timeout, + }), + ); + } + + case 'getHostInfo': + return asText(await sshClient.getHostInfo(a['hostAlias'] as string)); + + case 'checkConnectivity': + return asText(await sshClient.checkConnectivity(a['hostAlias'] as string)); + + case 'uploadFile': { + const success = await sshClient.uploadFile( + a['hostAlias'] as string, + a['localPath'] as string, + a['remotePath'] as string, + ); + return asText({ success }); + } + + case 'downloadFile': { + const success = await sshClient.downloadFile( + a['hostAlias'] as string, + a['remotePath'] as string, + a['localPath'] as string, + ); + return asText({ success }); + } + + case 'runCommandBatch': + return asText( + await sshClient.runCommandBatch(a['hostAlias'] as string, a['commands'] as string[]), + ); + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLog(`Error executing tool ${name}: ${message}\n`); + return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }] }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..bc431bb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,63 @@ +/** + * Shared types for the MCP SSH agent. + */ + +/** Where a host was discovered. */ +export type HostSource = 'ssh_config' | 'known_hosts'; + +/** + * A host discovered in ~/.ssh/config or ~/.ssh/known_hosts. + * + * `alias` holds the first alias of the block and `aliases` the full list — a + * `Host` directive takes a list of patterns, not a single name. Keeping `alias` + * a plain string preserves the response shape for single-alias hosts. + * + * Directives that are not specifically modelled land in the index signature, + * lowercased (`proxycommand`, `identitiesonly`, …). + */ +export interface HostInfo { + hostname: string; + alias?: string; + aliases?: string[]; + user?: string; + port?: number; + identityFile?: string; + configFile?: string; + source?: HostSource; + /** + * Password read from a `# @password:` annotation. Never leaves the process: + * it is stripped before anything is handed to the LLM, which is what the + * leading underscore marks. + */ + _password?: string; + [directive: string]: unknown; +} + +/** A host as exposed to the LLM: no password, only the fact that one exists. */ +export type SafeHostInfo = Omit & { passwordAuth?: true }; + +/** Result of a single remote command. */ +export interface CommandResult { + stdout: string; + stderr: string; + code: number; +} + +/** Result of runCommandBatch. */ +export interface BatchResult { + results: CommandResult[]; + success: boolean; +} + +/** Result of checkConnectivity. */ +export interface ConnectivityResult { + connected: boolean; + message: string; +} + +/** Environment handed to a spawned ssh/scp process when a password is in play. */ +export interface SpawnEnv extends NodeJS.ProcessEnv { + MCP_SSH_PASS: string; + SSH_ASKPASS: string; + SSH_ASKPASS_REQUIRE: string; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f0af200 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/test-helpers.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ec03511 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts", + "src/test-helpers.ts" + ] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..5da8b63 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,22 @@ +{ + // Test files are checked with a lighter set of rules than the production + // sources. Mock child processes, partially-typed spies and index-signature + // access are the normal vocabulary of a test suite; enforcing the full strict + // set there produces noise rather than safety, and the tests themselves are + // what validates behaviour. src/ stays under the strict config. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "noImplicitAny": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "strictNullChecks": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + // The base config excludes tests; clear that here or the include below + // resolves to nothing. + "exclude": [], + "include": ["src/**/*.test.ts", "src/test-helpers.ts"] +} diff --git a/vitest.config.mjs b/vitest.config.mjs index 50967da..382e0a9 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -2,20 +2,22 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - // Windows CI runners are markedly slower than a dev machine (the suite takes - // ~0.5s locally but ~15s there), and a few tests allocate 10MB buffers or - // re-import the module graph. The 5s default left too little headroom. + // Windows CI runners are markedly slower than a dev machine, and a few tests + // allocate 10MB buffers or re-import the module graph. The 5s default left + // too little headroom. testTimeout: 15000, + include: ['src/**/*.test.ts'], coverage: { provider: 'v8', - // server.mjs is the whole production implementation (it is deliberately - // self-contained — see CLAUDE.md). bin/mcp-ssh.js is excluded: it is a + // The production sources. bin/mcp-ssh.js is excluded: it is a // top-level-await wrapper whose only job is to call main(), so importing - // it in a unit test would start a real MCP server on STDIO. - include: ['server.mjs'], + // it in a unit test would start a real MCP server on STDIO. types.ts is + // excluded because it compiles to nothing executable. + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/test-helpers.ts', 'src/types.ts'], reporter: ['text', 'json-summary', 'html'], - // The suite covers every line, branch and function of server.mjs, on both - // the POSIX and the Windows code paths. Keep it that way. + // The suite covers every line, branch and function, on both the POSIX and + // the Windows code paths. Keep it that way. thresholds: { statements: 100, branches: 100,