From 2a89325edd43e7f43013a0be8497849941189127 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 25 Aug 2026 17:02:46 +0200 Subject: [PATCH 1/6] fix(mcp): provision bridge runtime during setup --- .claude-mcp.json | 9 +- .gitattributes | 4 + .github/workflows/test.yml | 3 + .husky/pre-commit | 1 + .mcp.json | 15 +- README.md | 29 +- eslint.config.js | 2 +- mcp_config.json | 6 +- .../stage-mcp-runtime-during-setup/tasks.md | 62 +- package.json | 1 + packages/core/README.md | 105 +- packages/core/package.json | 3 + packages/core/scripts/setup.mjs | 16 +- packages/core/src/cli.ts | 28 +- packages/core/src/index.ts | 88 +- packages/core/src/mcp/index.ts | 3 + packages/core/src/mcp/mcp-config-writer.ts | 10 +- packages/core/src/mcp/mcp-remote-runtime.ts | 806 +++++++++ packages/core/src/mcp/mcp-runtime-runner.ts | 296 ++++ .../core/src/mcp/mcp-runtime-validation.ts | 259 +++ packages/core/src/types.ts | 32 + packages/core/src/utils/backup.ts | 109 +- packages/core/src/utils/format.ts | 24 +- .../integration/auth/auth-manager.test.ts | 44 +- packages/core/test/integration/auth/ports.ts | 24 + .../core/test/integration/cli-help.test.ts | 8 +- .../core/test/integration/installer.test.ts | 699 +++++++- .../test/unit/mcp/mcp-config-writer.test.ts | 72 + .../test/unit/mcp/mcp-remote-runtime.test.ts | 1465 +++++++++++++++++ .../test/unit/mcp/mcp-runtime-runner.test.ts | 122 ++ .../core/test/unit/mcp/mcp-wrapper.test.ts | 730 ++++++-- packages/core/test/unit/utils/backup.test.ts | 131 +- packages/core/test/unit/utils/format.test.ts | 43 + pnpm-lock.yaml | 23 +- scripts/materialize-github-marketplace.mjs | 14 +- scripts/mcp-wrapper.js | 175 +- scripts/plugin-generators.mjs | 214 ++- 37 files changed, 5190 insertions(+), 485 deletions(-) create mode 100644 .gitattributes create mode 100644 packages/core/src/mcp/mcp-remote-runtime.ts create mode 100644 packages/core/src/mcp/mcp-runtime-runner.ts create mode 100644 packages/core/src/mcp/mcp-runtime-validation.ts create mode 100644 packages/core/test/integration/auth/ports.ts create mode 100644 packages/core/test/unit/mcp/mcp-remote-runtime.test.ts create mode 100644 packages/core/test/unit/mcp/mcp-runtime-runner.test.ts diff --git a/.claude-mcp.json b/.claude-mcp.json index 7a7c3a7..c5d02bc 100644 --- a/.claude-mcp.json +++ b/.claude-mcp.json @@ -4,21 +4,24 @@ "command": "node", "args": [ "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", - "nsolid-console" + "nsolid-console", + "claude" ] }, "ns-benchmark": { "command": "node", "args": [ "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", - "ns-benchmark" + "ns-benchmark", + "claude" ] }, "ncm": { "command": "node", "args": [ "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", - "ncm" + "ncm", + "claude" ] } } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..86c3a94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep text files LF on every platform. The generated MCP wrapper is compared +# byte-for-byte against its committed source (wrapper sync test), so Windows +# CRLF checkouts would break that guarantee. +* text=auto eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9581c34..dc8e388 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + # Bound every matrix job: a hung step fails its own OS in minutes instead + # of consuming runner hours until the global 6h cap cancels the run. + timeout-minutes: 10 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: diff --git a/.husky/pre-commit b/.husky/pre-commit index acd310a..358f7b2 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ pnpm lint +pnpm typecheck pnpm test diff --git a/.mcp.json b/.mcp.json index 246f7e3..0d43958 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,25 +4,28 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "nsolid-console" - ] + ], + "startup_timeout_sec": 60 }, "ns-benchmark": { "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "ns-benchmark" - ] + ], + "startup_timeout_sec": 60 }, "ncm": { "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "ncm" - ] + ], + "startup_timeout_sec": 60 } } } diff --git a/README.md b/README.md index 4f5c8b1..8bb3d41 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,11 @@ Skills are canonical in the repository-root `skills/` directory. The repo root i | Harness | Skill owner | Installer responsibility | |---|---|---| -| **Claude** | Root plugin | Native marketplace/plugin install; `setup` for auth | -| **Codex** | Root plugin | Native marketplace/plugin install; `setup` for auth | -| **Antigravity** | Root plugin | `agy plugin install `; `setup` for auth | +| **Claude** | Root plugin | Native marketplace/plugin install; `setup` for auth + bridge | +| **Codex** | Root plugin | Native marketplace/plugin install; `setup` for auth + bridge | +| **Antigravity** | Root plugin | `agy plugin install `; `setup` for auth + bridge | | **Pi** | Pi npm package (`pi.skills`) | Pi package owns skills; `setup` writes auth/MCP config | -| **OpenCode** | CLI direct install | `setup` authenticates AND writes MCP config/skills; `install` refreshes the direct config | +| **OpenCode** | CLI direct install | `setup` authenticates, prepares the bridge, and writes MCP config/skills; `install` refreshes the direct config as a fallback | ## Authentication @@ -60,17 +60,18 @@ nsolid-plugin setup --harness On setup: -1. Your browser opens `accounts.nodesource.com/sign-in` for login. +1. Your browser opens `accounts.nodesource.com/sign-in` for login (only if credentials are missing or expired). 2. A local HTTP server starts on port **8765** (fallback: 8766–8770) to receive the callback. 3. The OAuth callback provides a `serviceToken`, `consoleId`, `saasToken`, and `consoleUrl`. 4. An `mcpUrl` is derived by combining the callback's `consoleId` (the org UUID) with the trusted environment suffix of `consoleUrl` (e.g. `saas.nodesource.io`, `staging.saas.nodesource.io`), giving `https://.mcp./`. 5. Credentials are stored at `~/.agents/.nodesource-auth.json` with mode `0600`. +6. The shared **MCP bridge runtime** (`mcp-remote`, exact pinned version) is provisioned at `~/.agents/nsolid-plugin/runtime/mcp-remote//` so MCP servers start without touching npm. The first `setup` needs network access for this one-time npm install; subsequent runs detect the valid runtime and skip npm entirely. If a browser does not open automatically (headless CI, devcontainer, agent host, etc.), the CLI prints the sign-in URL to stderr — open it manually in any browser to complete the flow. Nothing sensitive (no tokens) is printed there. **What is stored:** `serviceToken`, `organizationId`, `saasToken`, `consoleUrl`, `mcpUrl`, `expiresAt`, `permissions`, and the `accountsUrl` auth origin used to mint/validate the token. -**Token lifecycle:** Expired credentials trigger re-authentication during explicit setup/login. Runtime MCP wrappers fail with an actionable `Run: nsolid-plugin setup --harness ` message if credentials are missing or expired. Credentials are shared across harnesses — which also means there is only ever one authenticated NodeSource org at a time. If you belong to more than one org, use `nsolid-plugin switch-org --harness ` to force a fresh sign-in and pick a different one; see [Switching organizations](#switching-organizations) below. +**Token lifecycle:** Expired credentials trigger re-authentication during explicit setup/login. Runtime MCP wrappers fail with an actionable, version-pinned `Run: npx -y nsolid-plugin@ setup --harness ` message if credentials are missing/expired **or the MCP bridge runtime is missing/corrupt**. Credentials are shared across harnesses — which also means there is only ever one authenticated NodeSource org at a time. If you belong to more than one org, use `nsolid-plugin switch-org --harness ` to force a fresh sign-in and pick a different one; see [Switching organizations](#switching-organizations) below. **`mcpUrl` derivation:** Always built from the org's UUID (`consoleId`/`organizationId`), never from `consoleUrl`'s hostname label — a console may be reachable at a friendly display alias (e.g. `homedepot-nucleus-stage-1.saas.nodesource.io`), but the underlying MCP ingress route is only ever provisioned under the org's UUID, so using the alias verbatim produces a dead endpoint. `consoleUrl` is only consulted for its environment suffix (`saas.nodesource.io`, `staging.saas.nodesource.io`, etc.), which must be the exact suffix or a dot-delimited deeper suffix — a hostname where `saas` is merely a substring of a larger label (e.g. `foo-saas.nodesource.io`) is rejected. This gives `https://.mcp./`, always over `https`. Computed and stored on every fresh OAuth completion (`setup`, `switch-org`); a stored/explicit `credentials.mcpUrl` — including a legitimate custom operator override — still always takes priority over re-deriving. If `consoleUrl` doesn't match a recognized NodeSource pattern, fresh OAuth fails with an actionable error and never silently persists a guessed production URL, and any previously stored credentials are left unchanged. @@ -91,11 +92,11 @@ npx -y nsolid-plugin setup --harness npx -y nsolid-plugin install --harness ``` -The setup step requires a NodeSource account and writes shared credentials to `~/.agents/.nodesource-auth.json`. The install step is needed only for direct CLI installs such as OpenCode or fallback/repair installs. +The setup step requires a NodeSource account and writes shared credentials to `~/.agents/.nodesource-auth.json`; it also prepares the shared MCP bridge runtime used by the Claude/Codex/Antigravity wrappers (first run downloads it via npm, later runs are offline and idempotent). For OpenCode and Pi, `setup` alone completes onboarding — auth, bridge runtime, skills, and MCP config in one step. The install step is needed only for fallback/repair installs and satisfies the same runtime precondition before copying assets. ### Direct CLI install -`nsolid-plugin install --harness ` is not a native harness plugin install. It directly adds N|Solid skills and MCP server config to the selected harness. Run `setup` first so MCP server credentials are available: +`nsolid-plugin install --harness ` is not a native harness plugin install. It directly adds N|Solid skills and MCP server config to the selected harness, and satisfies the MCP bridge runtime precondition first (same as `setup`). Run `setup` first so MCP server credentials are available: ```bash nsolid-plugin setup --harness @@ -227,7 +228,17 @@ nsolid-plugin doctor --harness nsolid-plugin doctor --harness --json # machine-readable ``` -The output shows green/yellow/red status for credentials, skills, and MCP servers. +The output shows green/yellow/red status for credentials, skills, MCP servers, and the MCP bridge runtime. For harnesses whose MCP servers run through the plugin wrapper (native plugin installed for Claude/Codex/Antigravity), a missing or corrupt bridge makes the report unhealthy; for OpenCode/Pi the bridge line is informational. + +### MCP bridge runtime missing or corrupt + +Wrapper message: + +```text +[nsolid-plugin] MCP bridge runtime is not ready. Run: npx -y nsolid-plugin@ setup --harness +``` + +Fix: run the suggested `setup` command once. The repair command is pinned to the plugin version that generated the wrapper, so rerunning it repairs exactly the wrapper that printed it. This is different from an expired token (`credentials are expired`): the bridge runtime lives at `~/.agents/nsolid-plugin/runtime/mcp-remote//` and survives `uninstall`/`logout`. If `setup` itself reports `MCP runtime setup failed`, npm could not install the runtime (network/registry) — stored credentials remain valid, fix network access and rerun the same command. npm is resolved from the Node.js installation that serves the harness (never from PATH, `npm_execpath`, or the project). MCP wrappers never download dependencies during harness startup; there is intentionally no `npx` fallback. ### Permission denied writing config diff --git a/eslint.config.js b/eslint.config.js index d8b503d..9c6199d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,5 +2,5 @@ import neostandard from 'neostandard' export default neostandard({ ts: true, - ignores: ['dist/**'] + ignores: ['**/dist/**'] }) diff --git a/mcp_config.json b/mcp_config.json index fe9b1a7..f4ec0ff 100644 --- a/mcp_config.json +++ b/mcp_config.json @@ -4,7 +4,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'antigravity'];import(pathToFileURL(wrapper).href)", "nsolid-console" ] }, @@ -12,7 +12,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'antigravity'];import(pathToFileURL(wrapper).href)", "ns-benchmark" ] }, @@ -20,7 +20,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'antigravity'];import(pathToFileURL(wrapper).href)", "ncm" ] } diff --git a/openspec/changes/stage-mcp-runtime-during-setup/tasks.md b/openspec/changes/stage-mcp-runtime-during-setup/tasks.md index c33693b..9a8433f 100644 --- a/openspec/changes/stage-mcp-runtime-during-setup/tasks.md +++ b/openspec/changes/stage-mcp-runtime-during-setup/tasks.md @@ -6,31 +6,31 @@ proposal (scope/rollback), design (module contract, sequences), specs ## 1. OpenSpec change -- [ ] Create `openspec/changes/stage-mcp-runtime-during-setup/` +- [x] Create `openspec/changes/stage-mcp-runtime-during-setup/` (proposal, design, specs delta, tasks) per `ns-workflow`. -- [ ] Register the `submit_plan` (Plannotator) tool limitation in the +- [x] Register the `submit_plan` (Plannotator) tool limitation in the proposal; proceed under the user's explicit approval for this scope. -- [ ] Amend proposal, design, specs delta and tasks per the PR #62 review: +- [x] Amend proposal, design, specs delta and tasks per the PR #62 review: recoverable publication, canonical npm resolution, wrapper import failures, dispatcher precondition, managed-tree timeout cancellation, dependency identity/version ranges, version-pinned repair command, execution-scoped "no npx" wording and editorial fixes. -- [ ] `openspec validate stage-mcp-runtime-during-setup --strict` passes. +- [x] `openspec validate stage-mcp-runtime-during-setup --strict` passes. ## 2. Runtime manager (`packages/core/src/mcp/mcp-remote-runtime.ts`) -- [ ] `MCP_REMOTE_VERSION = '0.1.38'`; paths via `getAgentsDir()` only. -- [ ] `inspectMcpRemoteRuntime()`: read-only readiness probe (name, exact +- [x] `MCP_REMOTE_VERSION = '0.1.38'`; paths via `getAgentsDir()` only. +- [x] `inspectMcpRemoteRuntime()`: read-only readiness probe (name, exact version, `dist/proxy.js`, transitive dependency closure with per-dependency name identity, semver range satisfaction and canonical runtime root confined to the canonical controlled parent, plus canonical package/manifest/proxy targets confined to the runtime root); missing optional dependencies are tolerated, peer/development dependencies ignored, and missing required dependencies rejected. -- [ ] Add `semver` to `packages/core` dependencies for range evaluation; +- [x] Add `semver` to `packages/core` dependencies for range evaluation; unparseable ranges fail closed (documented supported syntax in `design.md`). -- [ ] `resolveNpmCommand()`: canonical Node.js-anchored candidates only — node-dir +- [x] `resolveNpmCommand()`: canonical Node.js-anchored candidates only — node-dir `node_modules/npm/bin/npm-cli.js`, then `../lib/node_modules/npm/bin/ npm-cli.js`, then node-dir `npm` sibling shim; otherwise actionable error. Canonicalize `process.execPath` and each candidate; require a @@ -38,7 +38,7 @@ proposal (scope/rollback), design (module contract, sequences), specs consults `PATH`, `cwd`/project `.bin`, or `npm_execpath` (fake, renamed, symlinked and pnpm/yarn values are ignored by construction). -- [ ] `ensureMcpRemoteRuntime()`: idempotent check, staging sibling under the +- [x] `ensureMcpRemoteRuntime()`: idempotent check, staging sibling under the controlled runtime parent (same filesystem as the versioned root) + private package.json, npm without shell (separated argv and the complete `--omit=dev`, `--ignore-scripts`, `--no-audit`, `--no-fund`, @@ -47,7 +47,7 @@ proposal (scope/rollback), design (module contract, sequences), specs validation, publication under the per-version lock, race convergence, invalid-runtime replacement via rename-aside, actionable error. -- [ ] Publication protocol: `O_EXCL` lock file under the runtime parent +- [x] Publication protocol: `O_EXCL` lock file under the runtime parent keyed by version with unique owner token; bounded-backoff waiting; break a lock older than 10 minutes only when its holder is proven dead, then reacquire with a fresh `O_EXCL` create (moving a stale lock does not @@ -59,7 +59,7 @@ proposal (scope/rollback), design (module contract, sequences), specs limited to operation-created staging/stale/lock paths, except for the lock-held safe-reclamation protocol using ownership/liveness metadata; `EXDEV`/cross-filesystem rename fails closed with no copy fallback. -- [ ] Timeout handling: terminate and confirm the managed npm process tree +- [x] Timeout handling: terminate and confirm the managed npm process tree stopped (Unix: detached process group, SIGTERM → SIGKILL escalation, root close and group-disappearance polling; Windows: await `taskkill /T /F` and root close) before staging cleanup; leave staging @@ -69,29 +69,29 @@ proposal (scope/rollback), design (module contract, sequences), specs `terminationError`; spawn errors (`ENOENT`/`EACCES`/`EPERM`) surfaced as an explicit `spawnError` result, never as exit status. -- [ ] Internal re-export from `packages/core/src/mcp/index.ts`. +- [x] Internal re-export from `packages/core/src/mcp/index.ts`. ## 3. Setup integration & dispatcher precondition -- [ ] `setup()` in `packages/core/src/index.ts`: after credentials are valid +- [x] `setup()` in `packages/core/src/index.ts`: after credentials are valid (or immediately when no auth), call `ensureMcpRemoteRuntime()` before any per-harness install branch; progress lines `Preparing MCP bridge runtime — installed mcp-remote 0.1.38` / `already ready`; failure ⇒ `MCP runtime setup failed: …`, `success: false`, no "setup complete". -- [ ] `packages/core/scripts/setup.mjs`: satisfy the runtime precondition +- [x] `packages/core/scripts/setup.mjs`: satisfy the runtime precondition (credentials-free `ensureMcpRemoteRuntime()`) before delegating to `install()` for opencode/pi, so neither harness bypasses provisioning. -- [ ] `packages/core/src/cli.ts`: satisfy the same precondition before the +- [x] `packages/core/src/cli.ts`: satisfy the same precondition before the fallback `install` command. -- [ ] `install()` still never downloads/authenticates on its own +- [x] `install()` still never downloads/authenticates on its own (regression guard). -- [ ] Update `packages/core/scripts/setup.mjs` and `packages/core/src/cli.ts` +- [x] Update `packages/core/scripts/setup.mjs` and `packages/core/src/cli.ts` wording (setup = credentials **and** bridge). ## 4. Wrapper / generators -- [ ] `scripts/plugin-generators.mjs`: export `MCP_REMOTE_VERSION`; embed +- [x] `scripts/plugin-generators.mjs`: export `MCP_REMOTE_VERSION`; embed `MCP_REMOTE_VERSION` **and** `PLUGIN_VERSION` (the generating release) in the wrapper; wrapper takes ` `, validates both, resolves the stable runtime first, version-matched @@ -109,24 +109,24 @@ proposal (scope/rollback), design (module contract, sequences), specs version-pinned: `npx -y nsolid-plugin@ setup --harness `; Claude config passes `claude`; Codex and Antigravity bootstraps pass `codex`/`antigravity`. -- [ ] Regenerate root artifacts via `pnpm plugin:root` +- [x] Regenerate root artifacts via `pnpm plugin:root` (`.mcp.json`, `.claude-mcp.json`, `mcp_config.json`, `scripts/mcp-wrapper.js`); keep `startup_timeout_sec: 60`. -- [ ] `pnpm plugin:check` reports no drift. +- [x] `pnpm plugin:check` reports no drift. ## 5. Doctor -- [ ] `DoctorReport.bridge` (optional) in `packages/core/src/types.ts`. -- [ ] `doctor()` in `packages/core/src/index.ts`: required ⇔ wrapper-owned +- [x] `DoctorReport.bridge` (optional) in `packages/core/src/types.ts`. +- [x] `doctor()` in `packages/core/src/index.ts`: required ⇔ wrapper-owned (claude/codex/antigravity with native plugin detected); error + unhealthy when required and not ready; informational otherwise; never implies the remote MCP is reachable. -- [ ] `formatDoctorReport` human output + `--json` compatibility; update +- [x] `formatDoctorReport` human output + `--json` compatibility; update `packages/core/test/unit/utils/format.test.ts`. ## 6. Tests -- [ ] New `packages/core/test/unit/mcp/mcp-remote-runtime.test.ts`: paths with +- [x] New `packages/core/test/unit/mcp/mcp-remote-runtime.test.ts`: paths with spaces; initial install via fake runner; idempotence; invalid version; missing proxy; incomplete transitives; wrong-named transitive; incompatible transitive version; missing optional dependency tolerated; @@ -152,7 +152,7 @@ proposal (scope/rollback), design (module contract, sequences), specs point, anchored-candidate symlink/path escape, pnpm/yarn `npm_execpath` ignored, supported Node/npm layouts, `node_modules/.bin` and `PATH` never consulted); no secrets in output. -- [ ] Rewrite `packages/core/test/unit/mcp/mcp-wrapper.test.ts`: stable-runtime +- [x] Rewrite `packages/core/test/unit/mcp/mcp-wrapper.test.ts`: stable-runtime fixture for `source` and `generated` wrappers; hostile URL/token argv boundaries; `npx` sentinel (exit 97) never executed; direct `npm` sentinel never executed in stable-runtime and explicit dev-fallback @@ -173,7 +173,7 @@ proposal (scope/rollback), design (module contract, sequences), specs old-wrapper/new-CLI repair (wrapper of release X prints `nsolid-plugin@X`, which provisions exactly X's pinned runtime version). -- [ ] Update `packages/core/test/integration/installer.test.ts` (seed runtime +- [x] Update `packages/core/test/integration/installer.test.ts` (seed runtime / fake `npm_execpath` harness): setup installs runtime without browser; runtime failure keeps credentials with `success: false`; five-harness convergence; opencode/pi dispatcher scenarios (runtime @@ -186,7 +186,7 @@ proposal (scope/rollback), design (module contract, sequences), specs ## 7. Documentation & lifecycle -- [ ] `README.md` and `packages/core/README.md`: setup authenticates **and** +- [x] `README.md` and `packages/core/README.md`: setup authenticates **and** prepares the bridge; first run needs network, later runs idempotent; troubleshooting entry for "runtime missing/corrupt" (vs expired token); the repair command is version-pinned; npm is resolved from @@ -195,10 +195,10 @@ proposal (scope/rollback), design (module contract, sequences), specs ## 8. Validation & commit -- [ ] `openspec validate stage-mcp-runtime-during-setup --strict` passes after +- [x] `openspec validate stage-mcp-runtime-during-setup --strict` passes after all final spec and task edits. -- [ ] `pnpm --filter nsolid-plugin lint`, `pnpm --filter nsolid-plugin test`, +- [x] `pnpm --filter ./packages/core lint`, `pnpm --filter ./packages/core test`, `pnpm plugin:check`, `pnpm test:marketplace`, `pnpm test`. -- [ ] `git diff --check`, `git status --short` clean of drift. -- [ ] Atomic conventional commit: `fix(mcp): provision bridge runtime during +- [x] `git diff --check`, `git status --short` clean of drift. +- [x] Atomic conventional commit: `fix(mcp): provision bridge runtime during setup`. No push/PR. diff --git a/package.json b/package.json index 69fb67e..913bdaa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:unit": "node --experimental-test-module-mocks --import tsx/esm --test 'packages/*/test/unit/**/*.test.ts'", "test:integration": "node --experimental-test-module-mocks --import tsx/esm --test 'packages/*/test/integration/**/*.test.ts'", "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", "test:marketplace": "node scripts/test-marketplace-install.js", "plugin:sync": "node scripts/sync-plugin-assets.mjs", "plugin:check": "node scripts/sync-plugin-assets.mjs --check && node scripts/materialize-github-marketplace.mjs --check", diff --git a/packages/core/README.md b/packages/core/README.md index 6b7feca..52c5b66 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,11 +8,74 @@ Shared CLI/setup/fallback installation logic for the N|Solid cross-harness plugi Install/setup semantics are intentionally split: -1. `setup()` authenticates with NodeSource and may open a browser. -2. `install()` is a fallback direct asset installer and never starts auth/browser login. -3. Runtime MCP wrappers fail with `Run: nsolid-plugin setup --harness ` if credentials are missing or expired. -4. OpenCode uses the direct CLI path: run `setup --harness opencode` for auth, then `install --harness opencode` to copy skills and write MCP config. -5. Pi package owns skills, while setup writes Pi MCP config for adapter/runtime compatibility. +1. `setup()` authenticates with NodeSource (may open a browser) **and** + provisions the shared MCP bridge runtime (see below). The first run needs + network access for npm; later runs are idempotent and offline with respect + to the npm registry while the runtime stays valid. +2. `install()` is a fallback direct asset installer and never starts + auth/browser login, never downloads dependencies, and never provisions the + bridge runtime. +3. Runtime MCP wrappers fail fast with a version-pinned `Run: npx -y + nsolid-plugin@ setup --harness ` if credentials + are missing/expired or the bridge runtime is missing/corrupt. +4. OpenCode uses the direct CLI path: `setup --harness opencode` completes + auth + bridge + skills + MCP config in one step; `install --harness + opencode` is the fallback asset installer and satisfies the runtime + precondition first. +5. Pi package owns skills, while setup writes Pi MCP config for + adapter/runtime compatibility. + +## MCP bridge runtime (mcp-remote) + +`setup()` for **any** harness provisions a shared, versioned, local +`mcp-remote` runtime used by the STDIO→HTTP wrapper (Claude, Codex, +Antigravity): + +```text +~/.agents/nsolid-plugin/runtime/mcp-remote// + package.json # private manifest anchoring the install + node_modules/mcp-remote/ # exact pinned version + dist/proxy.js + node_modules//... +``` + +Lifecycle and guarantees: + +- **Exact pin**: the version matches `MCP_REMOTE_VERSION` in + `src/mcp/mcp-remote-runtime.ts` and the wrapper generator; a sync test + guards it. Pinning the top-level package does not freeze transitives + declared with ranges — a lockfile/shrinkwrap would be required for + byte-exact reproducibility (known limitation). +- **Atomic**: npm installs into a staging sibling and the result is validated + (name, exact version, `dist/proxy.js`, transitive dependency closure) and + published with a single rename. Partial runtimes are never published, and + concurrent setups converge on one valid runtime. +- **Idempotent**: with a valid runtime present, `setup` never invokes npm. +- **Safe**: npm runs with `shell: false`, separated argv, + `--ignore-scripts`, no audit/fund, resolved from `npm_execpath` (only when + it is npm's own CLI — pnpm/yarn lifecycle scripts set it to their own + binary, which is ignored) or next to `process.execPath` — never from + `PATH`/project `node_modules/.bin`. No + credentials are read, stored, or logged by the runtime module; the runtime + directory contains no secrets. +- **Shared and durable**: `uninstall --harness ` and `logout` never + delete it (other harnesses — including other pinned versions — may still + need it). Old versions are never pruned automatically. +- **Fail-fast consumers**: the generated wrapper resolves only this runtime + (or a version-matched dev checkout copy) and never falls back to + `npx`/npm/cmd.exe during harness startup. A missing/corrupt runtime exits + immediately with the harness-correct, version-pinned repair command (the + wrapper reports the plugin version that generated it). During runtime + provisioning, npm is resolved only from the Node.js installation that + serves the harness — never from PATH, `npm_execpath`, or the project. + +`doctor()` reports the bridge as `report.bridge` (optional JSON field): +`status` (`ready|missing|invalid`), `version`, `root`, `proxyPath`, `reason`, +and `required`. `required` is true only when that harness's MCP servers are +actually served through the wrapper (native plugin installed for +claude/codex/antigravity); for OpenCode/Pi and direct (native-HTTP) installs +the entry is informational and does not affect `healthy`. A ready bridge is +never treated as proof that the remote MCP endpoint is reachable. ## Public API @@ -57,14 +120,14 @@ Each harness has an adapter that provides its config and skills paths: A thin CLI is provided as `nsolid-plugin`: ```bash -nsolid-plugin setup --harness claude # explicit auth/setup; may open browser -nsolid-plugin setup --harness opencode # explicit auth/setup -nsolid-plugin setup --harness pi # explicit auth/setup + Pi MCP config -nsolid-plugin install --harness claude # fallback direct install; no browser -nsolid-plugin install --harness antigravity # fallback direct install; no browser -nsolid-plugin install --harness codex # fallback direct install; no browser -nsolid-plugin install --harness pi # MCP config only; skills come from pi package -nsolid-plugin install --harness opencode # OpenCode: copy skills + write MCP config +nsolid-plugin setup --harness claude # explicit auth + bridge runtime; may open browser +nsolid-plugin setup --harness opencode # explicit auth + bridge + direct install +nsolid-plugin setup --harness pi # explicit auth + bridge + Pi MCP config +nsolid-plugin install --harness claude # fallback direct install; runtime precondition first; no browser +nsolid-plugin install --harness antigravity # fallback direct install; runtime precondition first; no browser +nsolid-plugin install --harness codex # fallback direct install; runtime precondition first; no browser +nsolid-plugin install --harness pi # MCP config only; runtime precondition first; skills from pi package +nsolid-plugin install --harness opencode # OpenCode: runtime precondition first, then skills + MCP config nsolid-plugin uninstall --harness claude nsolid-plugin switch-org --harness claude # force re-auth to pick a different NodeSource org nsolid-plugin doctor --harness claude @@ -74,7 +137,7 @@ nsolid-plugin restore --harness claude --list nsolid-plugin restore --harness claude --backup ~/.agents/.config-backup/claude/1234567890.json ``` -Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer native GitHub plugin install from the repository root; `install --harness` is a fallback direct installer only. For Pi, install `nsolid-pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for auth followed by `install --harness opencode` to copy user-level skills and write MCP config. +Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer native GitHub plugin install from the repository root; `install --harness` is a fallback direct installer only. For Pi, install `nsolid-pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for auth + bridge + skills + MCP config in one step; `install --harness opencode` is the fallback asset path. Credentials are a single shared file (`~/.agents/.nodesource-auth.json`), not per-harness, so a member of more than one NodeSource org can only be authenticated against one org at a time. `switch-org` forces a fresh OAuth round-trip — even if current credentials are still valid — so NodeSource's sign-in flow can show its org picker again; the new org then applies to every installed harness, not just the one named. Native-plugin-installed harnesses (claude/codex/antigravity) pick it up on their next MCP reconnect; fallback-installed harnesses and CLI-direct harnesses (opencode/pi) need `install --harness ` re-run afterward (see `switch-org`'s own output for harness-specific guidance). @@ -103,6 +166,20 @@ If a prior install failed partway through (for example, MCP config could not be Run `nsolid-plugin doctor --harness ` for a health check. Use `--json` for machine-readable output. See the [root README](../../README.md#troubleshooting) for common issues (permissions, port conflicts, stale symlinks, Pi MCP adapter). +`MCP bridge runtime is not ready` (from the wrapper) or `MCP bridge runtime is +missing/invalid` (from doctor) means the shared `mcp-remote` runtime is +absent or corrupt — different from an expired token, which reports +`credentials are expired`. Fix: rerun `npx -y nsolid-plugin@ setup --harness +`. The runtime survives `uninstall`/`logout`, so this only happens +after the directory is removed manually or `setup` never ran for this +machine. A `MCP runtime setup failed` error from setup means npm could not +install the runtime (network/registry); stored credentials stay valid — fix +network access and rerun the same command. + +Development note: the generated wrapper must never download dependencies +during startup. It resolves `mcp-remote` exclusively from the shared runtime +or a version-matched checkout; there is deliberately no `npx` fallback. + ## Development ```bash diff --git a/packages/core/package.json b/packages/core/package.json index 81fc2b8..81fdfc4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,6 +18,7 @@ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc", "test": "node ../../scripts/run-tests.mjs core", "lint": "eslint src/ test/", + "typecheck": "tsc --noEmit", "skills:sync": "node scripts/sync-shared-skill-assets.mjs", "skills:check": "node scripts/sync-shared-skill-assets.mjs --check", "bundle:sync": "node scripts/check-bundle-sync.mjs --sync", @@ -30,12 +31,14 @@ "access": "public" }, "dependencies": { + "semver": "7.8.5", "smol-toml": "^1.3.1", "write-file-atomic": "^5.0.1", "zod": "4.4.3" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/semver": "7.8.0", "@types/write-file-atomic": "4.0.3", "typescript": "^5.7.0" }, diff --git a/packages/core/scripts/setup.mjs b/packages/core/scripts/setup.mjs index 6bb7bce..e865f12 100644 --- a/packages/core/scripts/setup.mjs +++ b/packages/core/scripts/setup.mjs @@ -34,7 +34,7 @@ if (!VALID_HARNESS.includes(harness)) { process.exit(1) } -const { install, setup, uninstall } = await import('nsolid-plugin') +const { installWithRuntime, setup, uninstall } = await import('nsolid-plugin') const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) @@ -49,7 +49,13 @@ try { } console.log(`N|Solid Plugin skills uninstalled for ${harness}`) } else { - const installer = PLUGIN_OWNED_HARNESSES.has(harness) ? setup : install + // Plugin-owned harnesses onboard through setup() (authentication + runtime + // precondition + native plugin assets). OpenCode/Pi route through + // installWithRuntime(): the dispatcher satisfies the credentials-free MCP + // runtime precondition immediately before install(), so neither harness + // can bypass provisioning — while install() itself stays offline and + // auth-free. + const installer = PLUGIN_OWNED_HARNESSES.has(harness) ? setup : installWithRuntime const res = await installer({ harness, bundlePath, @@ -62,11 +68,11 @@ try { process.exit(1) } if (PLUGIN_OWNED_HARNESSES.has(harness)) { - console.log(`N|Solid Plugin credentials ready for ${harness}`) + console.log(`N|Solid Plugin credentials and MCP bridge ready for ${harness}`) } else if (PACKAGE_OWNED_SKILL_HARNESSES.has(harness)) { - console.log(`N|Solid Plugin MCP/auth configured for ${harness}; skills are package-owned`) + console.log(`N|Solid Plugin MCP bridge and MCP config ready for ${harness}; skills are package-owned (authenticate with: nsolid-plugin setup --harness ${harness})`) } else { - console.log(`N|Solid Plugin skills installed for ${harness}: ${res.skillsInstalled} skills`) + console.log(`N|Solid Plugin MCP bridge and skills ready for ${harness}: ${res.skillsInstalled} skills (authenticate with: nsolid-plugin setup --harness ${harness})`) } } } catch (err) { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 1479a45..47f832b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -5,14 +5,13 @@ import { createInterface } from 'node:readline/promises' import path from 'node:path' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { install, setup, uninstall, logout, doctor, restore, loadCredentials } from './index.js' +import { installWithRuntime, setup, uninstall, logout, doctor, restore, loadCredentials } from './index.js' import type { AuthConfirmation, HarnessType } from './types.js' -import { HARNESS_VALUES } from './types.js' +import { HARNESS_VALUES, PLUGIN_OWNED_HARNESSES } from './types.js' import { formatPluginError } from './errors.js' import { listConfigBackups } from './utils/backup.js' import { C, supportsColor } from './utils/format.js' import { createConsoleProgress, silentProgress } from './utils/progress.js' -const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) const HARNESS_SPECIFIC_SKILL_HARNESSES = new Set(['opencode']) @@ -55,13 +54,13 @@ function printUsage (): void { console.log(`Usage: nsolid-plugin [options] Commands: - setup Authenticate with NodeSource (may open a browser) - install Install N|Solid Plugin skills/MCP for a harness (fallback direct installer; does not open a browser) - uninstall Remove N|Solid Plugin skills for a harness - logout Forget your stored NodeSource login (removes credentials only) - switch-org Force re-authentication to switch NodeSource organizations (opens a browser; affects all harnesses) - doctor Check installation health for a harness - restore Restore a harness MCP config from the latest backup + setup Authenticate with NodeSource and prepare the MCP bridge runtime (may open a browser; first run needs npm access) + install Install N|Solid Plugin skills/MCP for a harness (fallback direct installer; prepares the MCP bridge runtime first; does not open a browser) + uninstall Remove N|Solid Plugin skills for a harness + logout Forget your stored NodeSource login (removes credentials only) + switch-org Force re-authentication to switch NodeSource organizations (opens a browser; affects all harnesses) + doctor Check installation health for a harness + restore Restore a harness MCP config from the latest backup Options: --harness Target harness (required in non-interactive mode): ${HARNESS_VALUES.join(', ')} @@ -79,9 +78,10 @@ Options: --help Show this help message Distribution notes: - Claude/Codex/Antigravity: install from the GitHub plugin root; setup is auth-only. + Claude/Codex/Antigravity: install from the GitHub plugin root; setup authenticates and prepares the MCP bridge runtime. Pi: use pi install for package-owned skills; CLI install/setup only writes MCP config. - OpenCode: setup --harness opencode authenticates AND writes its skills/MCP config; install --harness opencode re-runs that direct config. + OpenCode: run setup --harness opencode — it authenticates, prepares the MCP bridge runtime, and installs skills + MCP config in one step. + Install: the fallback direct installer provisions the MCP bridge runtime first, then installs assets; it never opens a browser. After switch-org, a direct-config harness passed to --harness (OpenCode, Pi, fallback CLI installs) has its MCP config refreshed on the spot; Claude/Codex/Antigravity native plugins must be reconnected, and other direct-config harnesses need a later setup/install to re-bake the new org's token. Auth: only setup/switch-org may open a browser.`) } @@ -315,7 +315,7 @@ async function main (): Promise { } const verb = result.hadToAuthenticate ? 'Authenticated' : 'Credentials ready' - console.log(`${paint.green('✓')} ${HARNESS_LABELS[setupHarness]} — ${verb}.`) + console.log(`${paint.green('✓')} ${HARNESS_LABELS[setupHarness]} — ${verb}; MCP bridge ready.`) } if (failures > 0) process.exit(1) @@ -330,7 +330,7 @@ async function main (): Promise { const installHarness = installHarnesses[i] if (i > 0) console.log('') // visual separation between harnesses - const result = await install({ + const result = await installWithRuntime({ harness: installHarness, bundlePath, skillsSource, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index abfed27..ec348ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ import type { Credentials, Logger, } from './types.js' +import { PLUGIN_OWNED_HARNESSES, NATIVE_PLUGIN_HARNESSES } from './types.js' import { validateBundle } from './validate.js' import { ensureAuthenticated, loadCredentials, isExpired, removeCredentials } from './auth/index.js' import { resolveMcpUrl } from './auth/mcp-url.js' @@ -33,6 +34,8 @@ import { addTrackedMcps, removeTrackedMcps, listTrackedMcps, + ensureMcpRemoteRuntime, + inspectMcpRemoteRuntime, } from './mcp/index.js' import { getAdapter } from './harnesses/index.js' import type { HarnessAdapter } from './harnesses/index.js' @@ -46,14 +49,6 @@ import { restoreConfigBackup, type BackupEntry } from './utils/backup.js' import { toPluginError } from './errors.js' const KNOWN_MCP_SERVERS = ['ns-benchmark', 'nsolid-console', 'ncm'] -const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) -/** - * Harnesses that install the nsolid plugin/package natively (owning skills and - * MCP config themselves) rather than via the shared CLI tracking file. The - * doctor probes each via `adapter.detectNativePlugin()`. Superset of - * {@link PLUGIN_OWNED_HARNESSES} plus the package-owned Pi harness. - */ -const NATIVE_PLUGIN_HARNESSES = new Set(['claude', 'codex', 'antigravity', 'pi']) function formatBundleSummary (bundle: BundleDescriptor, options: { packageOwnedSkills?: boolean }): string { if (options.packageOwnedSkills === true) { @@ -186,10 +181,33 @@ export async function setup (options: SetupOptions): Promise { } } + // Provision the shared MCP bridge runtime (mcp-remote) for every harness: + // harness startup must never invoke npm/npx. The first run needs network; + // once a valid runtime exists this is an offline no-op. Credentials may + // already be stored at this point — a runtime failure must finish with + // success:false (they remain valid for a retry of this same command). + try { + const runtime = await ensureMcpRemoteRuntime() + progress.step( + 'Preparing MCP bridge runtime', + runtime.installed ? `installed mcp-remote ${runtime.version}` : 'already ready' + ) + logger.info('setup.mcpRuntime.ready', { + installed: runtime.installed, + version: runtime.version, + root: runtime.root, + }) + } catch (err) { + result.errors.push(`MCP runtime setup failed: ${(err as Error).message}`) + logger.error('setup.mcpRuntime.failed', { message: (err as Error).message }) + return result + } + // For CLI-only/package-owned harnesses, setup also performs the direct // fallback install/MCP config so that `nsolid-plugin setup` is a one-step // onboarding path. Package-owned harnesses can opt out of user-level skill - // copies via packageOwnedSkills while still receiving MCP config. + // copies via packageOwnedSkills while still receiving MCP config. The + // runtime is already ready at this point (guard above). if (!PLUGIN_OWNED_HARNESSES.has(options.harness)) { const installResult = await install({ ...options, @@ -200,13 +218,13 @@ export async function setup (options: SetupOptions): Promise { result.errors.push(...installResult.errors) result.success = installResult.success if (result.success) { - progress.done(`Setup complete — credentials ready for ${options.harness}`) + progress.done(`Setup complete — credentials and MCP bridge ready for ${options.harness}`) } return result } result.success = true - progress.done(`Setup complete — credentials ready for ${options.harness} plugin MCPs`) + progress.done(`Setup complete — credentials and MCP bridge ready for ${options.harness} plugin MCPs`) return result } @@ -379,6 +397,31 @@ export async function install (options: InstallOptions): Promise return result } +/** + * Dispatcher-level onboarding: satisfies the MCP bridge runtime precondition + * (credentials-free) immediately before delegating to `install()`, so paths + * that route OpenCode/Pi and fallback installs through `install()` cannot + * bypass runtime provisioning. `install()` itself stays offline and + * auth-free — the precondition remains the dispatchers' responsibility. + */ +export async function installWithRuntime (options: InstallOptions): Promise { + const logger = options.logger ?? createLogger({ verbose: isVerboseEnabled(options.verbose) }) + try { + await ensureMcpRemoteRuntime() + } catch (err) { + logger.error('install.runtimePrecondition.failed', { harness: options.harness, message: (err as Error).message }) + return { + success: false, + skillsInstalled: 0, + mcpServersConfigured: [], + hadToAuthenticate: false, + authSucceeded: false, + errors: [`MCP runtime setup failed: ${(err as Error).message}`], + } + } + return await install(options) +} + export interface LogoutResult { removed: boolean path: string @@ -661,6 +704,28 @@ export async function doctor ( } } + // Shared MCP bridge (mcp-remote) runtime. Required only when this + // harness's MCP servers are actually served through the generated wrapper + // (native plugin installed for claude/codex/antigravity). For native-HTTP + // transports (opencode, pi, and direct/fallback installs) the line is + // informational: a ready proxy says nothing about remote endpoint health, + // and a missing one does not break those configurations. + const bridge = inspectMcpRemoteRuntime() + const bridgeRequired = nativeOwned && PLUGIN_OWNED_HARNESSES.has(harness) + report.bridge = { + status: bridge.status, + version: bridge.version, + root: bridge.root, + ...(bridge.proxyPath !== undefined ? { proxyPath: bridge.proxyPath } : {}), + ...(bridge.reason !== undefined ? { reason: bridge.reason } : {}), + required: bridgeRequired, + } + if (bridgeRequired && bridge.status !== 'ready') { + report.errors.push( + `MCP bridge runtime is ${bridge.status}${bridge.reason ? ` (${bridge.reason})` : ''}. Run: nsolid-plugin setup --harness ${harness}` + ) + } + if (!bundle) { report.skills.status = 'unknown' report.mcpServers.status = 'unknown' @@ -750,6 +815,7 @@ export async function doctor ( report.credentials.status === 'ok' && report.skills.status === 'ok' && report.mcpServers.status === 'ok' && + (report.bridge?.required !== true || report.bridge.status === 'ready') && report.errors.length === 0 logger.info('doctor.finish', { healthy: report.healthy }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 5ee78f4..3353285 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -3,3 +3,6 @@ export type { McpServerConfig, NormalizedMcpConfig } from './mcp-config-merger.j export { writeMcpConfig, removeMcpConfig } from './mcp-config-writer.js' export { addTrackedMcps, removeTrackedMcps, listTrackedMcps } from './mcp-tracker.js' export type { McpTrackingEntry } from './mcp-tracker.js' +export { MCP_REMOTE_VERSION, getMcpRemoteRuntimeRoot, inspectMcpRemoteRuntime, ensureMcpRemoteRuntime, resolveNpmCommand } from './mcp-remote-runtime.js' +export type { McpRemoteRuntimeStatus, EnsureMcpRemoteRuntimeResult, NpmRunner, NpmRunnerRunResult, InternalRuntimeOptions, PublishTestControls } from './mcp-remote-runtime.js' +export { McpRemoteRuntimeError } from './mcp-remote-runtime.js' diff --git a/packages/core/src/mcp/mcp-config-writer.ts b/packages/core/src/mcp/mcp-config-writer.ts index 8bdc2f9..64c5976 100644 --- a/packages/core/src/mcp/mcp-config-writer.ts +++ b/packages/core/src/mcp/mcp-config-writer.ts @@ -140,10 +140,12 @@ function writeTomlConfig (configPath: string, config: NormalizedMcpConfig): void if (Object.keys(config.mcpServers).length > 0) { const servers: Record = {} for (const [name, srv] of Object.entries(config.mcpServers)) { - servers[name] = { - url: srv.url, - headers: srv.headers, - } + // Preserve the full server object. Rebuilding entries from a url/headers + // whitelist hollowed out third-party stdio servers (command, args, env, + // nested tools.* tables) on every write — the TOML counterpart of the + // JSON fix in normalizeFromJson. smol-toml omits undefined-valued keys, + // so servers without headers never emit an empty headers table. + servers[name] = { ...srv } } tomlData.mcp_servers = servers } else { diff --git a/packages/core/src/mcp/mcp-remote-runtime.ts b/packages/core/src/mcp/mcp-remote-runtime.ts new file mode 100644 index 0000000..f006920 --- /dev/null +++ b/packages/core/src/mcp/mcp-remote-runtime.ts @@ -0,0 +1,806 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, + writeSync, +} from 'node:fs' +import path from 'node:path' +import { getAgentsDir } from '../utils/path.js' +import { isInsideBoundary, validateRuntimeRoot } from './mcp-runtime-validation.js' +import { defaultNpmRunner, formatTail, sleep } from './mcp-runtime-runner.js' +import type { NpmRunner } from './mcp-runtime-runner.js' + +/** + * Shared MCP bridge runtime manager. + * + * `setup` provisions an exact-pinned `mcp-remote` copy (with its transitive + * dependencies) under `~/.agents/nsolid-plugin/runtime/mcp-remote//` + * so the generated MCP wrapper never needs npm/npx during harness startup. + * + * Invariants: + * - The destination only ever appears via an atomic rename of a fully + * validated staging tree (no partial runtimes are published). + * - A valid runtime is never deleted before a replacement staging tree has + * validated (a failed install cannot leave a worse state). + * - Publication is serialized per version by an `O_CREAT | O_EXCL` lock whose + * owner is recorded with a unique token; a stale lock is broken only for a + * holder proven dead, and breaking it never grants ownership — every + * publisher must win a fresh exclusive create. + * - Only paths created by the current operation are removed, and only after + * asserting they live inside the runtime parent directory. + * - npm is resolved exclusively from canonical candidates anchored to the + * running Node.js installation; `PATH`, the current project and + * `npm_execpath` are never consulted. + * - No credentials are read, stored, or logged here. + */ + +/** Exact mcp-remote version this plugin pins. Keep in sync with the wrapper generator. */ +export const MCP_REMOTE_VERSION = '0.1.38' + +/** Setup-time budget for the npm install. NOT the Codex MCP startup timeout. */ +export const DEFAULT_RUNTIME_INSTALL_TIMEOUT_MS = 5 * 60 * 1000 + +/** A lock older than this may be broken — but only for a holder proven dead. */ +const STALE_LOCK_THRESHOLD_MS = 10 * 60 * 1000 + +/** Bounded total wait while the publication lock is held by a live/unknown holder. */ +const DEFAULT_LOCK_WAIT_MS = 15 * 1000 + +/** + * Reclamation grace for orphaned staging/stale trees, measured from the + * ownership-sidecar creation time. Must exceed the npm install budget + * (DEFAULT_RUNTIME_INSTALL_TIMEOUT_MS) plus the termination confirmation + * budget (TERMINATION_CONFIRM_MS) so a live-but-slow operation is never + * reclaimed. Tests inject a short grace via `publish.reclaimGraceMs`. + */ +const RECLAMATION_GRACE_MS = 10 * 60 * 1000 + +/** Suffix identifying the ownership sidecar adjacent to a temporary tree. */ +const SIDECAR_SUFFIX = '.owner.json' + +export interface McpRemoteRuntimeStatus { + status: 'ready' | 'missing' | 'invalid' + version: string + root: string + proxyPath?: string + reason?: string +} + +export interface EnsureMcpRemoteRuntimeResult { + /** false when an already-valid runtime was reused and npm was not invoked */ + installed: boolean + version: string + root: string + proxyPath: string +} + +export type { NpmRunner, NpmRunnerRunResult } from './mcp-runtime-runner.js' + +/** + * Internal testing seam only — not part of the public CLI surface. Inject a + * fake runner to exercise install logic without network, or override the + * resolved npm entry point to point the default (real) runner at a benign + * executable. `publish` tunes the publication protocol deterministically + * (lock thresholds, holder pid, fault injection between the replacement + * renames). + */ +export interface InternalRuntimeOptions { + runner?: NpmRunner + npmCommand?: { command: string; args: string[] } + /** Setup-time npm timeout (default 5 min). NOT the Codex MCP startup timeout. */ + timeoutMs?: number + /** Test-only publication controls (deterministic locking/fault injection). */ + publish?: PublishTestControls +} + +/** Test-only knobs for the publication protocol. Internal seam. */ +export interface PublishTestControls { + /** Lock age (ms) after which a holder-proven-dead lock may be broken. */ + staleLockMs?: number + /** Bounded total wait (ms) while the lock is held by a live/unknown holder. */ + lockWaitMs?: number + /** + * Reclamation grace (ms) for orphaned staging/stale trees — the age their + * ownership sidecar must exceed before safe reclamation may even be + * considered. Production default: RECLAMATION_GRACE_MS. + */ + reclaimGraceMs?: number + /** PID recorded in this operation's lock (tests simulate dead holders). */ + holderPid?: number + /** Publication-only rename implementation (tests inject `EXDEV`). */ + rename?: typeof renameSync + /** + * Deterministic fault injection: called between `root → stale` and + * `staging → root`. Any throw simulates the replacing process dying at that + * point (cleanup is skipped so the on-disk state matches a real crash). + */ + afterRootAside?: () => void +} + +export class McpRemoteRuntimeError extends Error { + override readonly name = 'McpRemoteRuntimeError' + readonly code = 'MCP_REMOTE_RUNTIME_SETUP_FAILED' +} + +/** Marks a deterministic simulated interruption (test fault injection). */ +class SimulatedInterruptionError extends Error { + override readonly name = 'SimulatedInterruptionError' +} + +export function getMcpRemoteRuntimeParent (): string { + return path.join(getAgentsDir(), 'nsolid-plugin', 'runtime', 'mcp-remote') +} + +export function getMcpRemoteRuntimeRoot (): string { + return path.join(getMcpRemoteRuntimeParent(), MCP_REMOTE_VERSION) +} + +/** Read-only inspection: no mutation, no network, no process spawning. */ +export function inspectMcpRemoteRuntime (): McpRemoteRuntimeStatus { + const root = getMcpRemoteRuntimeRoot() + if (!existsSync(root)) { + return { status: 'missing', version: MCP_REMOTE_VERSION, root } + } + const probe = validateRuntimeRoot(root, MCP_REMOTE_VERSION) + if (probe.ok) { + return { status: 'ready', version: MCP_REMOTE_VERSION, root, proxyPath: probe.proxyPath } + } + return { status: 'invalid', version: MCP_REMOTE_VERSION, root, reason: probe.reason } +} + +/** npm entry-point candidates, in trust order. */ +interface NpmCandidate { + candidatePath: string + /** 'cli' → spawn `[node, cli.js]`; 'shim' → spawn the executable directly. */ + kind: 'cli' | 'shim' +} + +/** + * Resolve the npm entry point anchored to the running Node.js installation. + * `PATH`, the current working directory/project, package manifests and + * `process.env.npm_execpath` are never consulted: a basename or + * `node_modules/npm` substring cannot prove an arbitrary path is npm's own + * CLI, and `npm_execpath` is attacker-influenceable environment input. + */ +export function resolveNpmCommand (): { command: string; args: string[] } { + return resolveNpmCommandForExecPath(process.execPath) +} + +/** + * Internal helper accepting the Node executable path (and platform) so tests + * can construct supported layouts without modifying the real installation. + * Not exported from the package barrel. + */ +export function resolveNpmCommandForExecPath ( + execPath: string, + platform: NodeJS.Platform = process.platform +): { command: string; args: string[] } { + let canonicalExec: string + try { + canonicalExec = realpathSync(execPath) + } catch { + throw new McpRemoteRuntimeError( + `Could not resolve the running Node.js executable (${execPath}). Reinstall Node.js with npm, then rerun setup.` + ) + } + const nodeDir = path.dirname(canonicalExec) + // Unix installs put node in /bin, so the installation prefix is the + // parent of the canonical bin directory; Windows installs keep node.exe and + // npm side by side, so the executable directory is the prefix. + const prefix = platform === 'win32' ? nodeDir : path.dirname(nodeDir) + const candidates: NpmCandidate[] = [ + // Windows Node.js installer layout (.cmd shims cannot be spawned without a shell). + { candidatePath: path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), kind: 'cli' }, + // Unix prefix layouts: nvm, Volta images, Homebrew, macOS installer. + { candidatePath: path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), kind: 'cli' }, + ] + if (platform !== 'win32') { + // Unix distro sibling shim (e.g. Debian/Ubuntu), spawned directly. + candidates.push({ candidatePath: path.join(nodeDir, 'npm'), kind: 'shim' }) + } + + for (const candidate of candidates) { + const trusted = inspectTrustedNpmCandidate(candidate, prefix) + if (trusted === null) continue + if (candidate.kind === 'cli') { + return { command: execPath, args: [trusted.canonicalPath] } + } + return { command: trusted.canonicalPath, args: [] } + } + + throw new McpRemoteRuntimeError( + `Could not locate a trusted npm in the Node.js installation (${prefix}). Install Node.js with npm (https://nodejs.org), then rerun setup.` + ) +} + +/** + * Trust check for one anchored npm candidate: it must exist (lstat), its + * canonical target (realpath) must be a regular file inside the canonical + * installation prefix, and an executable Unix shim must carry an execute bit. + * A symlink is trusted only when its target remains inside the boundary. + */ +function inspectTrustedNpmCandidate ( + candidate: NpmCandidate, + prefix: string +): { canonicalPath: string } | null { + try { + lstatSync(candidate.candidatePath) + } catch { + return null // missing → try the next candidate + } + let canonicalPath: string + try { + canonicalPath = realpathSync(candidate.candidatePath) + } catch { + return null + } + let target: ReturnType + try { + target = statSync(canonicalPath) + } catch { + return null + } + if (!target.isFile()) return null // directory, fifo, … — never executable as npm + if (candidate.kind === 'shim' && (target.mode & 0o111) === 0) return null + if (!isInsideBoundary(canonicalPath, prefix)) return null // symlink/path escape + return { canonicalPath } +} + +interface PublishControls { + /** Unique operation token recorded in the publication lock and sidecars. */ + token: string + staleLockMs: number + lockWaitMs: number + /** Reclamation grace (ms) for orphaned temporary trees. */ + reclaimGraceMs: number + holderPid: number + rename: typeof renameSync + afterRootAside?: () => void +} + +/** Ownership metadata adjacent to every temporary tree this operation creates. */ +interface OwnershipSidecarRecord { + token: string + pid: number + createdAt: number + state: 'active' | 'retained-live' + managedPid?: number +} + +interface OwnedPublicationLock { + path: string + token: string +} + +interface LockRecord { + token?: unknown + pid?: unknown + createdAt?: unknown +} + +export async function ensureMcpRemoteRuntime ( + options?: InternalRuntimeOptions +): Promise { + const existing = inspectMcpRemoteRuntime() + if (existing.status === 'ready') { + return { + installed: false, + version: MCP_REMOTE_VERSION, + root: existing.root, + proxyPath: existing.proxyPath as string, + } + } + + const parent = getMcpRemoteRuntimeParent() + const root = getMcpRemoteRuntimeRoot() + mkdirSync(parent, { recursive: true }) + + // Staging lives next to the destination so publish is a same-filesystem + // atomic rename. The private package.json anchors npm to this directory so + // it cannot walk up into unrelated manifests or workspaces. + const staging = path.join(parent, `.staging-${process.pid}-${randomUUID()}`) + // Ownership sidecar adjacent to the staging tree: ties the tree to this + // operation (token), its creator (pid) and the managed npm process, so a + // later setup may reclaim it only through the safe-reclamation protocol. + const sidecar = sidecarPathFor(staging) + const created: string[] = [staging, sidecar] + const timeoutMs = options?.timeoutMs ?? DEFAULT_RUNTIME_INSTALL_TIMEOUT_MS + const publish: PublishControls = { + token: randomUUID(), + staleLockMs: options?.publish?.staleLockMs ?? STALE_LOCK_THRESHOLD_MS, + lockWaitMs: options?.publish?.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS, + reclaimGraceMs: options?.publish?.reclaimGraceMs ?? RECLAMATION_GRACE_MS, + holderPid: options?.publish?.holderPid ?? process.pid, + rename: options?.publish?.rename ?? renameSync, + afterRootAside: options?.publish?.afterRootAside, + } + let skipCleanup = false + const sidecarRecord: OwnershipSidecarRecord = { + token: publish.token, + pid: publish.holderPid, + createdAt: Date.now(), + state: 'active', + } + + try { + mkdirSync(staging) + writeFileSync( + path.join(staging, 'package.json'), + `${JSON.stringify({ name: 'nsolid-plugin-mcp-remote-runtime', private: true }, null, 2)}\n` + ) + // Written before spawning npm (see tryWriteOwnershipSidecar for the + // unclassified-retention policy on write failure). + tryWriteOwnershipSidecar(sidecar, sidecarRecord) + + const npm = options?.npmCommand ?? resolveNpmCommand() + const runner = options?.runner ?? defaultNpmRunner + const args = [ + ...npm.args, + 'install', + '--omit=dev', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--save-exact', + '--no-package-lock', + `mcp-remote@${MCP_REMOTE_VERSION}`, + ] + const result = await runner.run(npm.command, args, { + cwd: staging, + timeoutMs, + // Record the managed process identity immediately after spawn so + // reclamation can prove the managed tree is gone, not just the creator. + onSpawned: (identity) => { + sidecarRecord.managedPid = identity.pid + tryWriteOwnershipSidecar(sidecar, sidecarRecord) + }, + }) + if (result.spawnError) { + // The installer process never started: no download mutated staging, so + // cleanup is safe and nothing was published. + throw new McpRemoteRuntimeError( + `Could not start the npm installer (${result.spawnError}). Install Node.js with npm, then rerun setup.` + ) + } + if (result.terminationError) { + // A surviving process may still mutate staging: mark it retained-live + // and exclude it from publication and cleanup — never validate it, + // never publish it, never delete it. A later setup reclaims it only + // after the safe-reclamation protocol proves that is harmless. + skipCleanup = true + sidecarRecord.state = 'retained-live' + tryWriteOwnershipSidecar(sidecar, sidecarRecord) + throw new McpRemoteRuntimeError( + `npm install of mcp-remote@${MCP_REMOTE_VERSION} timed out and its process tree could not be confirmed stopped ` + + `(${result.terminationError}). The partial download was left untouched at ${staging}; it is marked ` + + 'retained-live and will not be used. Rerun setup once the system is idle.' + ) + } + if (result.status !== 0) { + throw new McpRemoteRuntimeError( + result.timedOut === true + ? `npm install of mcp-remote@${MCP_REMOTE_VERSION} timed out after ${Math.max(1, Math.round(timeoutMs / 1000))}s. Check network/npm registry access and rerun setup.` + : `npm install of mcp-remote@${MCP_REMOTE_VERSION} failed (exit ${result.status ?? 'n/a'}).${formatTail(result.stderr)} Rerun setup once npm/network is available.` + ) + } + + await publishStaging(staging, root, parent, created, publish) + } catch (err) { + if (err instanceof SimulatedInterruptionError) { + // The fault-injection hook simulates the replacing process dying: skip + // cleanup so the on-disk state matches a real interruption. + skipCleanup = true + } + throw err + } finally { + // Clean up only what this operation created; a previously valid runtime + // is never touched here. + if (!skipCleanup) { + for (const target of created) { + if (existsSync(target)) safeRemove(target, parent) + } + } + } + + const final = inspectMcpRemoteRuntime() + if (final.status !== 'ready') { + throw new McpRemoteRuntimeError( + `MCP bridge runtime at ${root} is not ready after install (${final.reason ?? final.status}). Rerun setup.` + ) + } + return { + installed: true, + version: MCP_REMOTE_VERSION, + root: final.root, + proxyPath: final.proxyPath as string, + } +} + +/** + * Publish a validated staging tree into the versioned root under the + * per-version publication lock. `root` only ever appears through one atomic + * rename of a fully validated tree; replacement of an invalid root is a + * rename-aside + rename-in pair (NOT a gap-free swap: `root` is briefly + * absent between them — every interruption state of that pair recovers + * deterministically through the root-absent branch). + */ +async function publishStaging ( + staging: string, + root: string, + parent: string, + created: string[], + publish: PublishControls +): Promise { + const lock = await acquirePublicationLock(parent, root, publish) + let interrupted = false + try { + // Bounded retries: a concurrent publisher may make `root` reappear under + // us; loop back to the re-inspect step while still holding the lock. + for (let attempt = 0; attempt < 4; attempt++) { + // Re-inspect `root` under the lock: accept a valid concurrent winner + // and let the caller's finally remove only this operation's staging. + if (existsSync(root)) { + const winner = validateRuntimeRoot(root, MCP_REMOTE_VERSION) + if (winner.ok) return + } + + // Validate this operation's staging fully before touching `root`. + const stagingProbe = validateRuntimeRoot(staging, MCP_REMOTE_VERSION) + if (!stagingProbe.ok) { + throw new McpRemoteRuntimeError( + `Staged mcp-remote runtime failed validation: ${stagingProbe.reason}. Rerun setup; if this persists, report the staging output above.` + ) + } + + if (!existsSync(root)) { + // Fresh publish (also the deterministic recovery path after an + // interrupted replacement): one atomic rename. + try { + publish.rename(staging, root) + return + } catch (err) { + if (isRenameBlockedError(err)) continue // destination appeared — re-inspect + throw publishError(root, err) + } + } + + // Invalid pre-existing root: rename aside, rename in, then delete this + // operation's stale tree. The stale tree's ownership sidecar exists + // BEFORE the tree does, so an interrupted replacement never leaves an + // unowned stale tree behind. + const stale = `${root}.stale-${randomUUID()}` + const staleSidecar = sidecarPathFor(stale) + created.push(stale, staleSidecar) + writeOwnershipSidecar(staleSidecar, { + token: publish.token, + pid: publish.holderPid, + createdAt: Date.now(), + state: 'active', + }) + try { + publish.rename(root, stale) + } catch (err) { + if (!existsSync(root)) continue // vanished — re-inspect + throw publishError(root, err) + } + if (publish.afterRootAside !== undefined) { + try { + publish.afterRootAside() + } catch (err) { + throw new SimulatedInterruptionError(`between root-aside and staging-rename: ${(err as Error)?.message ?? String(err)}`) + } + } + try { + publish.rename(staging, root) + } catch (err) { + if (isRenameBlockedError(err)) continue // re-inspect under the lock + throw publishError(root, err) + } + // The stale tree is only ever the one this operation just moved aside. + safeRemove(stale, parent) + return + } + throw new McpRemoteRuntimeError( + `Could not publish the mcp-remote runtime to ${root}: repeated concurrent modifications. Rerun setup.` + ) + } catch (err) { + if (err instanceof SimulatedInterruptionError) interrupted = true + throw err + } finally { + // While still holding the lock, conservatively reclaim orphaned temporary + // trees whose ownership/liveness proof is safe (a stale-aside tree only + // after a valid versioned root exists — which this operation may have + // just published). Any doubt retains the tree. + if (!interrupted) { + try { + reclaimOrphans(parent, root, publish) + } catch { + // Reclamation must never fail the publish. + } + // A simulated interruption models the holder dying with the lock in + // place — the record (with its holder pid) must survive for the recovery + // path to exercise the stale-lock protocol. + releasePublicationLock(lock) + } + } +} + +function publishError (root: string, err: unknown): McpRemoteRuntimeError { + return new McpRemoteRuntimeError( + `Could not publish the mcp-remote runtime to ${root}: ${(err as Error).message}. Rerun setup.` + ) +} + +/** Platform refusal of a directory-over-directory rename (destination appeared). */ +function isRenameBlockedError (err: unknown): boolean { + const code = (err as NodeJS.ErrnoException).code + return code === 'EEXIST' || code === 'EPERM' || code === 'ENOTEMPTY' || code === 'ENOTDIR' || code === 'EISDIR' +} + +/** + * Acquire the per-version publication lock. Ownership is granted ONLY by a + * successful `O_CREAT | O_EXCL` create; the recorded holder (token, pid, + * creation time) lets waiters distinguish a live publisher from a dead one. + */ +async function acquirePublicationLock ( + parent: string, + root: string, + publish: PublishControls +): Promise { + const lockPath = path.join(parent, `.publish-${path.basename(root)}.lock`) + const deadline = Date.now() + publish.lockWaitMs + let backoffMs = 25 + + for (;;) { + let fd: number | undefined + try { + fd = openSync(lockPath, 'wx') // O_CREAT | O_EXCL — the only ownership grant + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + // Someone owns it. Decide whether the lock is breakable before waiting. + if (await maybeBreakStaleLock(lockPath, parent, publish)) { + // Broken — but moving the stale lock granted nothing: race back to a + // fresh exclusive create. + continue + } + if (Date.now() >= deadline) { + throw new McpRemoteRuntimeError( + `Another setup is publishing the MCP bridge runtime (${lockPath} is still held). Wait for it to finish, then rerun setup.` + ) + } + await sleep(backoffMs) + backoffMs = Math.min(backoffMs * 2, 500) + continue + } + try { + // The lock records the operation token, so orphaned trees whose sidecar + // carries the same token are visibly owned while this lock exists. + writeSync(fd, JSON.stringify({ token: publish.token, pid: publish.holderPid, createdAt: Date.now() })) + } finally { + closeSync(fd) + } + return { path: lockPath, token: publish.token } + } +} + +/** + * Break a stale lock — only when it is older than the threshold AND its + * recorded holder is proven dead. Contenders race an atomic rename to a + * unique tombstone; the successful breaker deletes only that tombstone. + * Returns true when the lock was broken (the caller must still win a fresh + * `O_EXCL` create). Live holders, young locks, malformed records and + * unprovable liveness never authorize takeover (fail closed). + */ +async function maybeBreakStaleLock ( + lockPath: string, + parent: string, + publish: PublishControls +): Promise { + let record: LockRecord + try { + record = JSON.parse(readFileSync(lockPath, 'utf8')) as LockRecord + } catch { + return false // unreadable/malformed: cannot prove anything — treat as owned + } + const createdAt = typeof record.createdAt === 'number' ? record.createdAt : Number.NaN + const pid = typeof record.pid === 'number' ? record.pid : Number.NaN + if (!Number.isFinite(createdAt) || Date.now() - createdAt <= publish.staleLockMs) { + return false // young lock — never breakable regardless of liveness + } + if (!isProvenGone(pid)) return false // live or unknown holder — owned + + const tombstone = `${lockPath}.steal-${randomUUID()}` + try { + renameSync(lockPath, tombstone) + } catch { + return false // another breaker won the race + } + safeRemove(tombstone, parent) // delete only this breaker's tombstone + return true +} + +/** + * Signal-target liveness, fail closed: only the platform's definite + * not-found result (ESRCH) proves death. Permission errors, malformed records + * and unsupported checks do not authorize takeover or reclamation. + */ +function isProvenGone (target: number): boolean { + if (!Number.isInteger(target) || target <= 0) return false + try { + process.kill(target, 0) + return false // definitely exists + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'ESRCH' + } +} + +/** Release the lock only when its recorded owner token still matches ours. */ +function releasePublicationLock (lock: OwnedPublicationLock): void { + try { + const record = JSON.parse(readFileSync(lock.path, 'utf8')) as LockRecord + if (record.token === lock.token) { + unlinkSync(lock.path) + } + } catch { + // Already gone (or unreadable): nothing to release. Never unlink a lock + // this operation does not own. + } +} + +/** Adjacent ownership sidecar path for a temporary tree. */ +function sidecarPathFor (tree: string): string { + return `${tree}${SIDECAR_SUFFIX}` +} + +/** + * Atomic sidecar write (temp + rename): a reader never observes a partial + * record, and a crash mid-write leaves the previous state intact. + */ +function writeOwnershipSidecar (sidecar: string, record: OwnershipSidecarRecord): void { + const temp = `${sidecar}.tmp-${randomUUID()}` + try { + writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`) + renameSync(temp, sidecar) + } catch (err) { + try { + unlinkSync(temp) + } catch { + // Nothing to clean up. + } + throw err + } +} + +/** + * Best-effort sidecar write: a failure never fails the install — the tree is + * simply retained as unclassified, and unclassified trees are never + * automatically deleted (fail-closed reclamation). + */ +function tryWriteOwnershipSidecar (sidecar: string, record: OwnershipSidecarRecord): void { + try { + writeOwnershipSidecar(sidecar, record) + } catch { + // Retained as unclassified. + } +} + +/** Sidecar record as read back from disk (unknown shapes fail closed). */ +interface ScannedSidecar extends LockRecord { + state?: unknown + managedPid?: unknown +} + +/** + * Safe orphan reclamation, run only while holding the per-version publication + * lock. A staging/stale tree and its sidecar are removed only when EVERY + * guard holds: the grace period elapsed, the metadata parses, the tree path + * is inside the runtime parent, the creator pid is proven dead, no live + * publication lock carries the sidecar's operation token and — when a managed + * process identity was recorded — that process group/tree is proven absent. + * Unknown liveness, permission errors, malformed metadata or a token mismatch + * retain the tree. Stale-aside trees additionally require a valid versioned + * root to exist. Reclamation never restores or promotes an orphan. + */ +function reclaimOrphans (parent: string, root: string, publish: PublishControls): void { + let canonicalParent: string + try { + canonicalParent = realpathSync(parent) + } catch { + return + } + let entries: string[] + try { + entries = readdirSync(parent) + } catch { + return + } + for (const entry of entries) { + if (!entry.endsWith(SIDECAR_SUFFIX)) continue + const treeName = entry.slice(0, -SIDECAR_SUFFIX.length) + const isStaleTree = treeName.includes('.stale-') + if (!isStaleTree && !treeName.startsWith('.staging-')) continue + try { + const treePath = path.join(parent, treeName) + if (!isInsideBoundary(realpathSync(treePath), canonicalParent)) continue + // A stale-aside tree is reclaimed only after a valid versioned root + // exists: recovery never depends on removing it. + if (isStaleTree && (!existsSync(root) || !validateRuntimeRoot(root, MCP_REMOTE_VERSION).ok)) continue + + let raw: ScannedSidecar + try { + raw = JSON.parse(readFileSync(path.join(parent, entry), 'utf8')) as ScannedSidecar + } catch { + continue // malformed metadata — retain + } + if (typeof raw.token !== 'string' || raw.token.length === 0) continue + if (!Number.isInteger(raw.pid) || (raw.pid as number) <= 0) continue + if (typeof raw.createdAt !== 'number' || !Number.isFinite(raw.createdAt)) continue + if (Date.now() - raw.createdAt <= publish.reclaimGraceMs) continue + if (!isProvenGone(raw.pid as number)) continue + if (lockCarriesToken(parent, raw.token)) continue + if (typeof raw.managedPid === 'number' && !isManagedTreeProvenAbsent(raw.managedPid)) continue + + safeRemove(treePath, parent) + safeRemove(path.join(parent, entry), parent) + } catch { + // Reclamation is conservative: any failure retains the tree. + continue + } + } +} + +/** + * Whether any publication lock under `parent` carries `token`. An unreadable + * lock may be the one carrying it, so it blocks reclamation (fail closed). + */ +function lockCarriesToken (parent: string, token: string): boolean { + let entries: string[] + try { + entries = readdirSync(parent) + } catch { + return true + } + for (const entry of entries) { + if (!entry.startsWith('.publish-') || !entry.endsWith('.lock')) continue + try { + const record = JSON.parse(readFileSync(path.join(parent, entry), 'utf8')) as LockRecord + if (record.token === token) return true + } catch { + return true // unreadable lock: cannot prove it is unrelated — retain + } + } + return false +} + +/** + * Managed-tree absence, fail closed: on Unix the managed npm process was a + * detached group leader, so the whole group (-pid) must be gone; on Windows + * the root pid must be gone. + */ +function isManagedTreeProvenAbsent (managedPid: number): boolean { + return isProvenGone(process.platform === 'win32' ? managedPid : -managedPid) +} + +/** + * Recursive delete guarded to only accept paths inside the runtime parent. + * Never call this with an unvalidated or user-supplied path. + */ +function safeRemove (target: string, parent: string): void { + const canonicalTarget = realpathSync(target) + const canonicalParent = realpathSync(parent) + if (!isInsideBoundary(canonicalTarget, canonicalParent)) { + throw new McpRemoteRuntimeError(`Refusing to remove a path outside the runtime directory: ${target}`) + } + rmSync(target, { recursive: true, force: true }) +} diff --git a/packages/core/src/mcp/mcp-runtime-runner.ts b/packages/core/src/mcp/mcp-runtime-runner.ts new file mode 100644 index 0000000..21765fc --- /dev/null +++ b/packages/core/src/mcp/mcp-runtime-runner.ts @@ -0,0 +1,296 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import path from 'node:path' + +/** + * Managed npm execution for the MCP bridge runtime: spawn npm without a + * shell, bound its lifetime, and on timeout terminate the whole managed + * process tree — confirming it actually stopped before the caller is allowed + * to clean up. Neither failure to spawn nor unconfirmed termination is ever + * encoded as a fake exit status. + */ + +/** Bounded tail of npm stderr kept for actionable error messages. */ +const STDERR_TAIL_LIMIT = 4096 + +/** Unix: wait this long after SIGTERM before escalating the group to SIGKILL. */ +const TERMINATION_GRACE_MS = 500 + +/** Bounded deadline to confirm the managed npm process tree actually stopped. */ +const TERMINATION_CONFIRM_MS = 5 * 1000 + +/** Sentinel for a taskkill process that outlived the confirmation deadline. */ +const KILLER_STUCK = Symbol('taskkill-stuck') + +/** + * Minimal surface of the spawned taskkill process that the termination logic + * needs. Structural, so tests can inject a fake killer without spawning a + * real system process. + */ +export interface KillerProcess { + on (event: 'close', listener: (code: number | null) => void): unknown + on (event: 'error', listener: (err: Error) => void): unknown + kill (): boolean +} + +/** + * Test-injectable controls for `cancelManagedTree`: the platform branch, the + * confirmation deadline, and the taskkill spawner can be overridden so the + * Windows termination path is exercised deterministically (a genuinely stuck + * system process is never spawned in tests). + */ +export interface TerminationControls { + platform?: NodeJS.Platform + confirmMs?: number + spawnKiller?: (pid: number) => KillerProcess +} + +/** Spawn the platform process-tree killer (isolated so tests can inject it). */ +function spawnTaskkill (pid: number): ChildProcess { + const systemRoot = process.env.SystemRoot ?? 'C:\\Windows' + const taskkill = path.join(systemRoot, 'System32', 'taskkill.exe') + return spawn(taskkill, ['/PID', String(pid), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + }) +} + +/** + * The single result shape used by the npm runner interface and its internal + * completion helper. `spawnError` represents failure to create the process; + * `terminationError` represents a timed-out process whose managed tree could + * not be confirmed stopped. Neither failure is encoded as a fake exit status. + */ +export interface NpmRunnerRunResult { + status: number | null + stderr: string + timedOut?: boolean + /** Set when the npm process could not be spawned at all (ENOENT/EACCES/EPERM). */ + spawnError?: string + /** Set when timeout cancellation could not confirm that the managed tree stopped. */ + terminationError?: string +} + +export interface NpmRunner { + /** + * Runs npm without a shell. On timeout, terminates the managed npm process + * tree and confirms that it stopped before cleanup is allowed. If that + * confirmation fails, returns `terminationError` and the caller leaves + * staging marked retained-live and excluded from publication/cleanup. Spawn + * failures surface as `spawnError`, never as a fake exit status. + * `onSpawned` is called immediately after the managed process spawns so the + * caller can record the process identity in its ownership sidecar. + */ + run( + command: string, + args: string[], + options: { + cwd: string + timeoutMs: number + /** Reports the managed process identity as soon as it exists. */ + onSpawned?: (identity: { pid: number }) => void + /** + * Test hook: overrides for the termination confirmation (e.g. a fake + * taskkill), so the failure paths of timeout cancellation can be + * exercised deterministically. Never set in production. + */ + terminationControls?: TerminationControls + } + ): Promise +} + +export const defaultNpmRunner: NpmRunner = { + async run (command, args, options) { + const { cwd, timeoutMs } = options + return await new Promise((resolve) => { + const isWindows = process.platform === 'win32' + let stderr = '' + let timedOut = false + let settled = false + // Unix: spawn npm as the leader of a detached process group so the + // whole managed tree can be signalled; Windows relies on taskkill /T. + const child = spawn(command, args, { + cwd, + shell: false, + windowsHide: true, + detached: !isWindows, + stdio: ['ignore', 'ignore', 'pipe'], + }) + // Report the managed process identity immediately after spawn so the + // caller can record it in its ownership sidecar. + if (child.pid !== undefined) { + options.onSpawned?.({ pid: child.pid }) + } + + let termination: Promise | undefined + const closed = new Promise((resolve) => { + child.on('close', () => resolve()) + }) + + const finish = (result: NpmRunnerRunResult) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + // On timeout the result is only complete once termination of the + // managed tree has been confirmed (or definitively failed). + const finishWithTermination = (outcome: Promise) => { + outcome + .then((result) => { + if (result.confirmed) { + finish({ status: null, stderr, timedOut: true }) + } else { + finish({ status: null, stderr, timedOut: true, terminationError: result.error ?? 'unconfirmed' }) + } + }) + .catch(() => { + finish({ status: null, stderr, timedOut: true, terminationError: 'termination confirmation failed' }) + }) + } + + const timer = setTimeout(() => { + timedOut = true + termination = cancelManagedTree(child, closed, options.terminationControls) + // The managed tree can survive cancellation, in which case the + // child's close event never fires: settle the runner from here too, + // so a failed (but bounded) termination can never hang the operation. + finishWithTermination(termination) + }, timeoutMs) + + child.stderr?.on('data', (chunk: Buffer) => { + // Keep only a bounded tail; never capture or log the environment. + stderr = bounded(stderr + chunk.toString('utf8')) + }) + child.on('error', (err) => { + // The process could not be created (ENOENT/EACCES/EPERM): surfaced as + // an explicit spawnError, never as a fake exit status. + finish({ status: null, stderr: bounded(`${stderr}\n${err.message}`), spawnError: err.message }) + }) + child.on('close', (code) => { + if (!timedOut) { + finish({ status: code ?? null, stderr }) + return + } + finishWithTermination(termination ?? Promise.resolve({ confirmed: true })) + }) + }) + }, +} + +/** + * Terminate the managed npm process tree and confirm it stopped. + * - Unix: SIGTERM the detached process group, wait a bounded grace period, + * escalate to SIGKILL, await the root process close, then poll until the + * process group no longer exists. + * - Windows: `taskkill /PID /T /F`, await it and the root close — both + * bounded by the confirmation deadline, so a stuck taskkill surfaces as a + * `terminationError` instead of waiting indefinitely. + * Arbitrary descendants that deliberately detach from the managed group are + * outside this portable guarantee (`--ignore-scripts` prevents package + * lifecycle code from creating such escapees). + * Exported (but not re-exported from the package barrel) so tests can drive + * the termination branches deterministically via `controls`. + */ +export async function cancelManagedTree ( + child: { pid?: number }, + closed: Promise, + controls: TerminationControls = {} +): Promise { + const platform = controls.platform ?? process.platform + const confirmMs = controls.confirmMs ?? TERMINATION_CONFIRM_MS + const deadline = Date.now() + confirmMs + try { + if (child.pid === undefined) return { confirmed: true } + if (platform === 'win32') { + const killer = (controls.spawnKiller ?? spawnTaskkill)(child.pid) + const killerExit = new Promise((resolve) => { + killer.on('close', (code) => resolve(code)) + killer.on('error', () => resolve(null)) + }) + let stuckTimer: NodeJS.Timeout | undefined + const killStatus = await Promise.race([ + killerExit, + new Promise((resolve) => { + stuckTimer = setTimeout(() => resolve(KILLER_STUCK), Math.max(0, deadline - Date.now())) + }), + ]).finally(() => clearTimeout(stuckTimer)) + if (killStatus === KILLER_STUCK) { + // taskkill itself hung: best-effort stop the killer and report a + // bounded termination failure rather than waiting indefinitely. + try { + killer.kill() + } catch { + // Killer already gone. + } + return { confirmed: false, error: 'taskkill did not exit within the termination confirmation deadline' } + } + const rootClosed = await waitFor(closed, deadline) + if (killStatus === 0 && rootClosed) return { confirmed: true } + return { + confirmed: false, + error: `taskkill exited with ${killStatus ?? 'error'}${rootClosed ? '' : '; npm process still running'}`, + } + } + + const groupId = -child.pid + try { + process.kill(groupId, 'SIGTERM') + } catch { + // Group already gone — fall through to the poll below. + } + await waitFor(closed, Date.now() + TERMINATION_GRACE_MS) + try { + process.kill(groupId, 'SIGKILL') + } catch { + // Already terminated. + } + if (!(await waitFor(closed, deadline))) { + return { confirmed: false, error: 'npm process did not exit after SIGKILL' } + } + while (Date.now() < deadline) { + try { + process.kill(groupId, 0) + await sleep(25) // group still exists (e.g. a grandchild) — keep polling + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ESRCH') return { confirmed: true } + return { confirmed: false, error: `process group check failed (${(err as NodeJS.ErrnoException).code})` } + } + } + return { confirmed: false, error: 'managed npm process group still exists after the confirmation deadline' } + } catch (err) { + return { confirmed: false, error: (err as Error).message } + } +} + +interface TerminationOutcome { + confirmed: boolean + error?: string +} + +/** Bounded await: resolves false when the deadline passes first. */ +async function waitFor (promise: Promise, deadline: number): Promise { + let timer: NodeJS.Timeout | undefined + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now())) + }) + // Clear the losing timer so a resolved race never keeps the event loop alive. + return await Promise.race([promise.then(() => true), expired]).finally(() => clearTimeout(timer)) +} + +/** Small timed wait, shared with the publication-lock backoff. */ +export function sleep (ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function bounded (text: string): string { + return text.slice(-STDERR_TAIL_LIMIT) +} + +/** Render a bounded npm stderr tail for install failure messages. */ +export function formatTail (stderr: string): string { + const tail = stderr.trim().split('\n').slice(-6).join('\n').trim().slice(-STDERR_TAIL_LIMIT) + if (!tail) return '' + return ` npm said:\n${tail}\n` +} diff --git a/packages/core/src/mcp/mcp-runtime-validation.ts b/packages/core/src/mcp/mcp-runtime-validation.ts new file mode 100644 index 0000000..444a3db --- /dev/null +++ b/packages/core/src/mcp/mcp-runtime-validation.ts @@ -0,0 +1,259 @@ +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import path from 'node:path' +import semver from 'semver' + +/** + * Canonical read-only validation for MCP bridge runtime trees. + * + * Readiness is judged exclusively on canonical (realpath) paths: a symlinked + * tree cannot smuggle targets from outside the real root, a broken symlink + * fails, and a regular file where a directory is required fails. Dependency + * completeness is proven by a static closure walk (no package code runs). + */ + +export interface RuntimeProbe { + ok: boolean + proxyPath?: string + reason?: string +} + +/** + * Canonical containment per the readiness contract: `target` must resolve + * (realpath) to `kind` with its canonical path equal to or inside the + * canonical `boundary` by a path-segment-aware check. A missing canonical + * target, a broken symlink or a symlink whose target escapes the boundary + * fails. The `failure` discriminator lets callers map each cause to their own + * error wording without re-implementing the probe. + */ +type CanonicalProbe = + | { ok: true; canonical: string } + | { ok: false; failure: 'unreadable'; reason: string } + | { ok: false; failure: 'type'; reason: string } + | { ok: false; failure: 'escape'; reason: string; canonical: string } + +function canonicalTargetInside (target: string, boundary: string, kind: 'dir' | 'file'): CanonicalProbe { + let canonical: string + try { + canonical = realpathSync(target) + } catch { + return { ok: false, failure: 'unreadable', reason: `${target} is missing or unreadable` } + } + let targetStat: ReturnType + try { + targetStat = statSync(canonical) + } catch { + return { ok: false, failure: 'unreadable', reason: `${target} is not statable` } + } + if (kind === 'dir' && !targetStat.isDirectory()) { + return { ok: false, failure: 'type', reason: `${target} is not a directory` } + } + if (kind === 'file' && !targetStat.isFile()) { + return { ok: false, failure: 'type', reason: `${target} is not a regular file` } + } + if (!isInsideBoundary(canonical, boundary)) { + return { ok: false, failure: 'escape', reason: `${target} resolves outside the runtime root (${canonical})`, canonical } + } + return { ok: true, canonical } +} + +/** Validate a runtime tree (a destination root or a staging directory). */ +export function validateRuntimeRoot (root: string, expectedVersion: string): RuntimeProbe { + // Anchor the canonical root to its canonical controlled parent before using + // that root as the boundary for package targets. Otherwise a whole-root + // symlink could move the boundary itself outside the managed tree. + let canonicalParent: string + try { + canonicalParent = realpathSync(path.dirname(root)) + if (!statSync(canonicalParent).isDirectory()) { + return { ok: false, reason: 'controlled runtime parent is not a directory' } + } + } catch { + return { ok: false, reason: 'controlled runtime parent is missing or unreadable' } + } + + const rootProbe = canonicalTargetInside(root, canonicalParent, 'dir') + if (!rootProbe.ok) { + if (rootProbe.failure === 'escape') { + return { ok: false, reason: `runtime root resolves outside the controlled runtime parent (${rootProbe.canonical})` } + } + if (rootProbe.failure === 'type') { + return { ok: false, reason: 'runtime root is not a directory' } + } + return { ok: false, reason: 'runtime root is missing or unreadable' } + } + const canonicalRoot = rootProbe.canonical + + const mcpRemoteDir = path.join(canonicalRoot, 'node_modules', 'mcp-remote') + const packageProbe = canonicalTargetInside(mcpRemoteDir, canonicalRoot, 'dir') + if (!packageProbe.ok) return { ok: false, reason: `node_modules/mcp-remote ${packageProbe.reason}` } + + const manifestProbe = canonicalTargetInside(path.join(mcpRemoteDir, 'package.json'), canonicalRoot, 'file') + if (!manifestProbe.ok) { + return { ok: false, reason: `node_modules/mcp-remote/package.json ${manifestProbe.reason}` } + } + let pkg: { name?: unknown; version?: unknown; dependencies?: unknown; optionalDependencies?: unknown } + try { + pkg = JSON.parse(readFileSync(manifestProbe.canonical, 'utf8')) + } catch { + return { ok: false, reason: 'node_modules/mcp-remote/package.json is missing or unreadable' } + } + if (pkg.name !== 'mcp-remote') { + return { ok: false, reason: `expected package name "mcp-remote", found ${JSON.stringify(pkg.name)}` } + } + if (pkg.version !== expectedVersion) { + return { ok: false, reason: `expected mcp-remote@${expectedVersion}, found ${String(pkg.version)}` } + } + + const proxyProbe = canonicalTargetInside(path.join(mcpRemoteDir, 'dist', 'proxy.js'), canonicalRoot, 'file') + if (!proxyProbe.ok) { + return { ok: false, reason: `node_modules/mcp-remote/dist/proxy.js ${proxyProbe.reason}` } + } + + // Static dependency-closure probe: detects missing, wrong-named and + // version-incompatible transitives without executing package code (installs + // run with --ignore-scripts). + const closure = validateDependencyClosure(packageProbe.canonical, canonicalRoot) + if (!closure.ok) { + return { ok: false, reason: closure.reason } + } + + return { ok: true, proxyPath: proxyProbe.canonical } +} + +/** + * Walk the (non-optional) dependency closure of `mcp-remote` using Node-style + * node_modules resolution confined to `root`. For every resolved dependency: + * its canonical path must remain inside `root`, its `package.json` `name` must + * exactly equal the requested dependency name, and its installed `version` + * must satisfy the range declared by its dependent (unparseable ranges fail + * closed). Missing optional dependencies are tolerated. + */ +function validateDependencyClosure ( + startDir: string, + root: string +): { ok: true } | { ok: false; reason: string } { + const queue: Array<{ name: string; dir: string }> = [{ name: 'mcp-remote', dir: startDir }] + const visited = new Set() + + while (queue.length > 0) { + const entry = queue.shift() as { name: string; dir: string } + if (visited.has(entry.dir)) continue + visited.add(entry.dir) + + const entryManifest = canonicalTargetInside(path.join(entry.dir, 'package.json'), root, 'file') + let pkg: { + dependencies?: Record + optionalDependencies?: Record + } + try { + if (!entryManifest.ok) throw new Error(entryManifest.reason) + pkg = JSON.parse(readFileSync(entryManifest.canonical, 'utf8')) + } catch { + return { ok: false, reason: `package.json of "${entry.name}" is missing or unreadable` } + } + + const optional = new Set(Object.keys(pkg.optionalDependencies ?? {})) + for (const [dependency, declaredRange] of Object.entries(pkg.dependencies ?? {})) { + const resolved = resolveWithinRuntime(entry.dir, dependency, root) + if (!resolved) { + if (optional.has(dependency)) continue + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" is missing inside the runtime root`, + } + } + + // Resolution must stay inside the runtime root after canonicalization — + // a symlinked node_modules entry cannot satisfy the closure from + // outside. The package target must be a directory; its manifest (below) + // a regular file. + const depDirProbe = canonicalTargetInside(resolved, root, 'dir') + if (!depDirProbe.ok) { + if (depDirProbe.failure === 'escape') { + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" resolves outside the runtime root (${resolved} → ${depDirProbe.canonical})`, + } + } + if (depDirProbe.failure === 'type') { + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" resolved to a path that is not a directory (${resolved})`, + } + } + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" resolved to an unreadable path (${resolved})`, + } + } + const canonicalDir = depDirProbe.canonical + + // Read the manifest through its canonical path so a symlinked manifest + // cannot serve package metadata from outside the runtime. + const depManifest = canonicalTargetInside(path.join(canonicalDir, 'package.json'), root, 'file') + let depPkg: { name?: unknown; version?: unknown } + try { + if (!depManifest.ok) throw new Error(depManifest.reason) + depPkg = JSON.parse(readFileSync(depManifest.canonical, 'utf8')) + } catch { + if (optional.has(dependency)) continue + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" has no readable package.json`, + } + } + if (depPkg.name !== dependency) { + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" resolved to a package named ${JSON.stringify(depPkg.name)}`, + } + } + + const installedVersion = typeof depPkg.version === 'string' ? depPkg.version : '' + const supportedRange = semver.validRange(declaredRange) + if (supportedRange === null) { + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" declares the unsupported range "${declaredRange}"`, + } + } + if (!semver.satisfies(installedVersion, supportedRange)) { + return { + ok: false, + reason: `dependency "${dependency}" required by "${entry.name}" has installed version ${JSON.stringify(installedVersion)} which does not satisfy "${declaredRange}"`, + } + } + + queue.push({ name: dependency, dir: canonicalDir }) + } + } + return { ok: true } +} + +/** Node-style node_modules lookup for `name` starting at `fromDir`, never escaping `root`. */ +function resolveWithinRuntime (fromDir: string, name: string, root: string): string | null { + let dir = fromDir + // A dependency of the package at fromDir resolves at + // fromDir/node_modules/name first, then walks up — but no further than the + // runtime root, so nothing outside it can ever satisfy the closure. + for (;;) { + const candidate = path.join(dir, 'node_modules', name) + if (existsSync(path.join(candidate, 'package.json'))) return candidate + if (dir === root || path.dirname(dir) === dir) return null + dir = path.dirname(dir) + } +} + +/** + * Path-aware containment: `target` must equal `boundary` or live underneath + * it. A lexical prefix such as `-evil` is NOT a descendant. On + * Windows the comparison is case-insensitive. + */ +export function isInsideBoundary (target: string, boundary: string): boolean { + const t = path.resolve(target) + const b = path.resolve(boundary) + if (process.platform === 'win32') { + return t.toLowerCase() === b.toLowerCase() || t.toLowerCase().startsWith(b.toLowerCase() + path.sep) + } + return t === b || t.startsWith(b + path.sep) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9001e20..b730b53 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,6 +2,21 @@ export const HARNESS_VALUES = ['claude', 'codex', 'opencode', 'antigravity', 'pi export type HarnessType = (typeof HARNESS_VALUES)[number] +/** + * Harnesses whose installs are owned by the harness's native plugin + * mechanism — the plugin ships skills and MCP config itself, so the CLI + * skips its tracking-file install for them. + */ +export const PLUGIN_OWNED_HARNESSES: ReadonlySet = new Set(['claude', 'codex', 'antigravity']) + +/** + * Harnesses that install the nsolid plugin/package natively (owning skills and + * MCP config themselves) rather than via the shared CLI tracking file. The + * doctor probes each via `adapter.detectNativePlugin()`. Superset of + * {@link PLUGIN_OWNED_HARNESSES} plus the package-owned Pi harness. + */ +export const NATIVE_PLUGIN_HARNESSES: ReadonlySet = new Set(['claude', 'codex', 'antigravity', 'pi']) + export interface SkillRef { name: string; path: string; @@ -150,5 +165,22 @@ export interface DoctorReport { skills: { status: 'ok' | 'partial' | 'missing' | 'unknown'; installed: string[]; missing: string[] }; /** `unknown` when the bundle could not be loaded — the listed `reachable`/`unreachable` arrays are not meaningful. */ mcpServers: { status: 'ok' | 'partial' | 'unreachable' | 'unknown'; reachable: string[]; unreachable: string[] }; + /** + * Shared MCP bridge (`mcp-remote`) runtime status. Optional for backward + * compatibility with older JSON consumers. `required` is true only when + * this harness's MCP servers are actually served through the generated + * wrapper (native plugin installed for claude/codex/antigravity); for + * opencode/pi and direct (native-HTTP) installs the entry is + * informational and never affects `healthy`. + */ + bridge?: { + status: 'ready' | 'missing' | 'invalid'; + /** Pinned mcp-remote version this plugin expects. */ + version: string; + root: string; + proxyPath?: string; + reason?: string; + required: boolean; + }; errors: string[]; } diff --git a/packages/core/src/utils/backup.ts b/packages/core/src/utils/backup.ts index 3666085..493b51e 100644 --- a/packages/core/src/utils/backup.ts +++ b/packages/core/src/utils/backup.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, readFileSync as fsReadFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, readFileSync as fsReadFileSync } from 'node:fs' import type { HarnessType } from '../types.js' import { getConfigBackupDir, resolveHome } from './path.js' import { atomicWriteSync } from './fs.js' @@ -18,9 +18,80 @@ interface BackupMeta { harness: HarnessType originalPath: string createdAt: string + /** + * Monotonic per-directory sequence, reserved through an exclusive, + * immutable marker (see reserveBackupSeq). `createdAt` has millisecond precision + * and filesystem mtimes can be coarse (FAT, network mounts), so neither + * can guarantee newest-first ordering for back-to-back backups; `seq` + * cannot tie. + */ + seq?: number reason?: string } +const SEQ_RESERVATIONS_DIR = '.seq-reservations' + +/** Highest seq persisted in existing backup sidecars (0 when none). */ +function highestMetaSeq (dir: string): number { + let max = 0 + for (const name of readdirSync(dir)) { + if (!name.endsWith('.meta.json')) continue + const meta = readJsonFile(path.join(dir, name)) + if (meta?.seq !== undefined && meta.seq > max) max = meta.seq + } + return max +} + +/** Highest immutable sequence reservation (0 when none). */ +function highestReservedSeq (dir: string): number { + const reservationsDir = path.join(dir, SEQ_RESERVATIONS_DIR) + if (!existsSync(reservationsDir)) return 0 + + let max = 0 + for (const name of readdirSync(reservationsDir)) { + if (!/^[1-9]\d*$/.test(name)) continue + const seq = Number(name) + if (Number.isSafeInteger(seq) && seq > max) max = seq + } + return max +} + +/** Counter floor written by the previous lock-based implementation. */ +function legacyCounterSeq (dir: string): number { + try { + const seq = Number(fsReadFileSync(path.join(dir, '.seq'), 'utf8').trim()) + return Number.isSafeInteger(seq) && seq > 0 ? seq : 0 + } catch { + return 0 + } +} + +/** + * Reserve the next backup sequence for a directory, atomically across + * processes. Every sequence is an immutable directory created with exclusive + * mkdir semantics. Concurrent callers may propose the same number, but only + * one can create its marker; losers advance until their own marker succeeds. + * Reservations are never removed, so a crashed creator leaves a harmless gap + * instead of making the number reusable. Existing sidecars and the counter + * from the previous implementation establish the migration floor. + */ +function reserveBackupSeq (dir: string): number { + const reservationsDir = path.join(dir, SEQ_RESERVATIONS_DIR) + mkdirSync(reservationsDir, { recursive: true }) + + let seq = Math.max(highestMetaSeq(dir), highestReservedSeq(dir), legacyCounterSeq(dir)) + 1 + while (Number.isSafeInteger(seq)) { + try { + mkdirSync(path.join(reservationsDir, String(seq))) + return seq + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + seq++ + } + } + throw new Error('Backup sequence space exhausted') +} + function backupName (originalPath: string, timestamp: number): string { const ext = path.extname(originalPath) || '.bak' return `${timestamp}-${randomUUID()}${ext}` @@ -44,6 +115,10 @@ export function createConfigBackup ( const timestamp = Date.now() const backupPath = path.join(dir, backupName(originalPath, timestamp)) + // Back-to-back backups can share a millisecond (and coarse filesystems can + // share mtimes): reserve a cross-process sequence that cannot tie so + // "latest" is always the backup that was created last. + const seq = reserveBackupSeq(dir) try { copyFileSync(originalPath, backupPath) @@ -51,6 +126,7 @@ export function createConfigBackup ( harness, originalPath, createdAt: new Date(timestamp).toISOString(), + seq, reason: options?.reason, } atomicWriteSync(metaPath(backupPath), JSON.stringify(meta, null, 2) + '\n') @@ -74,21 +150,40 @@ export function listConfigBackups (harness: HarnessType): BackupEntry[] { const dir = getConfigBackupDir(harness) if (!existsSync(dir)) return [] - const entries: BackupEntry[] = [] + const entries: Array<{ entry: BackupEntry; seq: number; metaMtimeMs: number }> = [] for (const name of readdirSync(dir)) { if (name.endsWith('.meta.json')) continue const backupPath = path.join(dir, name) const meta = readJsonFile(metaPath(backupPath)) if (!meta) continue + let metaMtimeMs = 0 + try { + // Legacy tie-break for backups created before the persisted `seq` + // existed (sub-millisecond on ext4/NTFS/APFS). + metaMtimeMs = statSync(metaPath(backupPath)).mtimeMs + } catch { + // Meta file vanished mid-scan — order it last among ties. + } entries.push({ - harness: meta.harness, - originalPath: meta.originalPath, - backupPath, - createdAt: meta.createdAt, + entry: { + harness: meta.harness, + originalPath: meta.originalPath, + backupPath, + createdAt: meta.createdAt, + }, + seq: meta.seq ?? 0, + metaMtimeMs, }) } - return entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + // Newest first: createdAt is primary; ties (same millisecond) break by the + // persisted seq, which cannot tie. Legacy backups without seq (seq = 0) + // fall back to the meta file mtime among themselves. + return entries + .sort((a, b) => + b.entry.createdAt.localeCompare(a.entry.createdAt) || b.seq - a.seq || b.metaMtimeMs - a.metaMtimeMs + ) + .map(({ entry }) => entry) } export function restoreConfigBackup ( diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index 1f76773..92d88f1 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -1,4 +1,5 @@ -import type { DoctorReport } from '../types.js' +import type { DoctorReport, HarnessType } from '../types.js' +import { NATIVE_PLUGIN_HARNESSES } from '../types.js' export const C = { green: (s: string) => `\x1b[32m${s}\x1b[0m`, @@ -7,11 +8,8 @@ export const C = { dim: (s: string) => `\x1b[2m${s}\x1b[0m`, } -/** Harnesses that install the plugin/package natively and get a Plugin line. */ -const NATIVE_PLUGIN_HARNESSES = new Set(['claude', 'codex', 'antigravity', 'pi']) - /** Native install command shown when the plugin is missing for a harness. */ -function nativeInstallHint (harness: string): string { +function nativeInstallHint (harness: HarnessType): string { switch (harness) { case 'claude': return 'claude plugin marketplace add NodeSource/nsolid-plugin && claude plugin install nsolid-plugin@nodesource' @@ -42,7 +40,7 @@ function credLine (creds: DoctorReport['credentials'], color: boolean): string { return line('Credentials', '✗ missing', C.red, 'Run installation to authenticate', color) } -function pluginLine (p: DoctorReport['plugin'], harness: string, color: boolean): string | null { +function pluginLine (p: DoctorReport['plugin'], harness: HarnessType, color: boolean): string | null { // Non-native harnesses (e.g. opencode) have no plugin model — no line shown. if (!NATIVE_PLUGIN_HARNESSES.has(harness)) return null if (p.status === 'ok') { @@ -69,13 +67,24 @@ function mcpLine (m: DoctorReport['mcpServers'], color: boolean): string { return line('MCP servers', '? unknown', C.dim, '', color) } +function bridgeLine (b: NonNullable, harness: string, color: boolean): string { + if (b.status === 'ready') return line('MCP bridge', `✓ ready (mcp-remote ${b.version})`, C.green, '', color) + const label = b.status === 'missing' ? 'not provisioned' : `invalid${b.reason ? ` (${b.reason})` : ''}` + if (b.required) { + return line('MCP bridge', `✗ ${label}`, C.red, `Run: nsolid-plugin setup --harness ${harness}`, color) + } + // Informational for harnesses/configs whose MCP transport is native HTTP: + // a missing bridge does not break them, so never paint them red. + return line('MCP bridge', `? ${label}`, C.dim, 'not used by this harness configuration', color) +} + function line (label: string, value: string, pick: (s: string) => string, fix: string, color: boolean): string { const v = color ? pick(value) : value const tail = fix ? ` ${color ? C.dim('— ' + fix) : '— ' + fix}` : '' return `${label.padEnd(13)} ${v}${tail}` } -export function formatDoctorReport (report: DoctorReport, harness: string, color: boolean): string { +export function formatDoctorReport (report: DoctorReport, harness: HarnessType, color: boolean): string { const out: string[] = [] const title = color ? C.dim(`NodeSource plugin health — ${harness}`) : `NodeSource plugin health — ${harness}` out.push(title, '─'.repeat(34)) @@ -84,6 +93,7 @@ export function formatDoctorReport (report: DoctorReport, harness: string, color if (plugin) out.push(plugin) out.push(skillsLine(report.skills, color)) out.push(mcpLine(report.mcpServers, color)) + if (report.bridge) out.push(bridgeLine(report.bridge, harness, color)) if (harness === 'pi' && report.mcpServers.status !== 'unknown' && report.mcpServers.reachable.length > 0) { const note = 'ℹ Pi needs an MCP adapter extension to use these servers — run: pi install npm:pi-mcp-adapter' diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index c52ad32..60f1e55 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -7,6 +7,7 @@ import http from 'node:http' import { createRequire } from 'node:module' import type { AuthConfig, Credentials } from '../../../src/types.js' import { getAuthFilePath, getAgentsDir } from '../../../src/utils/path.js' +import { getFreePort } from './ports.js' const require = createRequire(import.meta.url) const cp = require('node:child_process') @@ -20,11 +21,17 @@ let originalUserProfile: string | undefined let originalFetch: typeof globalThis.fetch let originalAccountsUrl: string | undefined +// Fresh port per test, drawn from a window below 8765 (src's oauth server +// scans upward and rejects ports above 8770). oauth-server.test.ts keeps the +// 8765-8770 range to itself, so parallel `node --test` files never contend, +// and a lingering server from the previous test can no longer force a +// fallback into that range. +let callbackPort = 0 const authConfig: AuthConfig = { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.example.com', - callbackPort: 8767, + callbackPort: 0, } function getUrlFromExecFileCall (): URL { @@ -74,7 +81,9 @@ function sendCallback (port: number, state: string, overrides?: Record { +beforeEach(async () => { + callbackPort = await getFreePort(8000, 8300) + authConfig.callbackPort = callbackPort tmpDir = mkdtempSync(join(tmpdir(), 'nsolid-test-')) originalHome = process.env.HOME originalUserProfile = process.env.USERPROFILE @@ -186,9 +195,9 @@ describe('ensureAuthenticated', () => { assert.strictEqual(signInUrl.origin, 'https://accounts.example.com') assert.strictEqual(signInUrl.pathname, '/sign-in') assert.strictEqual(signInUrl.searchParams.get('extension'), 'nsolid-plugin') - assert.strictEqual(signInUrl.searchParams.get('port'), '8767') + assert.strictEqual(signInUrl.searchParams.get('port'), String(callbackPort)) const state = getStateFromExecFileCall() - await sendCallback(8767, state) + await sendCallback(callbackPort, state) const result = await promise assert.strictEqual(result.serviceToken, 'oauth-token') @@ -220,7 +229,7 @@ describe('ensureAuthenticated', () => { await new Promise((resolve) => setTimeout(resolve, 50)) const state = getStateFromExecFileCall() - await sendCallback(8767, state) + await sendCallback(callbackPort, state) const result = await promise assert.strictEqual(result.serviceToken, 'oauth-token') @@ -252,7 +261,7 @@ describe('ensureAuthenticated', () => { const state = await pollForState(getStateFromExecFileCall) assert.strictEqual(execFileCalls.length, 1, 'force should open the browser even though valid credentials exist') - await sendCallback(8767, state, { consoleId: 'org-456' }) + await sendCallback(callbackPort, state, { consoleId: 'org-456' }) const result = await promise assert.strictEqual(result.organizationId, 'org-456') @@ -341,7 +350,7 @@ describe('ensureAuthenticated', () => { const promise = ensureAuthenticated(authConfig) const state = await pollForState(getStateFromExecFileCall) - await sendCallback(8767, state) + await sendCallback(callbackPort, state) const result = await promise assert.strictEqual(result.serviceToken, 'oauth-token') @@ -390,6 +399,9 @@ describe('ensureAuthenticated', () => { describe('ensureAuthenticated - requiredPermissions', () => { const authConfigWithPerms: AuthConfig = { ...authConfig, + // Read live: spreading would freeze the pre-beforeEach value (0) of + // callbackPort, since this object literal runs once at describe time. + get callbackPort () { return authConfig.callbackPort }, requiredPermissions: ['nsolid:benchmark:run', 'nsolid:profile:read'], } @@ -537,7 +549,7 @@ describe('ensureAuthenticated - requiredPermissions', () => { ) const state = await pollForState(getStateFromExecFileCall) - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await rejection assert.strictEqual(fetchCalls, 1) assert.strictEqual(execFileCalls.length, 1) @@ -566,7 +578,7 @@ describe('ensureAuthenticated - requiredPermissions', () => { ) const state = await pollForState(getStateFromExecFileCall) - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await rejection assert.strictEqual(fetchCalls, 1) assert.strictEqual(execFileCalls.length, 1) @@ -612,7 +624,7 @@ describe('ensureAuthenticated - Windows browser launch', () => { assert.ok(lastArgs[1].startsWith('https://accounts.example.com/sign-in')) const state = getStateFromExecFileCall() - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await promise } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }) @@ -643,7 +655,7 @@ describe('ensureAuthenticated - consoleId validation', () => { await new Promise((resolve) => setTimeout(resolve, 50)) const state = getStateFromExecFileCall() - await sendCallback(8767, state, { consoleId: 'invalid@console!' }) + await sendCallback(callbackPort, state, { consoleId: 'invalid@console!' }) // Re-throw for assert.rejects to catch throw new Error('Invalid console ID format received from OAuth callback') @@ -688,7 +700,7 @@ describe('ensureAuthenticated - accountsUrl override', () => { assert.strictEqual(signInUrl.host, 'custom.accounts.example.com') assert.strictEqual(signInUrl.pathname, '/sign-in') const state = getStateFromExecFileCall() - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await promise }) @@ -715,7 +727,7 @@ describe('ensureAuthenticated - accountsUrl override', () => { assert.strictEqual(signInUrl.host, 'accounts.nodesource.com') assert.strictEqual(signInUrl.pathname, '/sign-in') const state = getStateFromExecFileCall() - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await promise }) }) @@ -751,7 +763,7 @@ describe('ensureAuthenticated - manual sign-in URL fallback', () => { const promise = ensureAuthenticated(authConfig) try { const state = await pollForState(getStateFromExecFileCall) - await sendCallback(8767, state) + await sendCallback(callbackPort, state) await promise } finally { process.stderr.write = originalWrite @@ -762,7 +774,7 @@ describe('ensureAuthenticated - manual sign-in URL fallback', () => { assert.ok(urlLine, 'sign-in URL must be surfaced on stderr as a manual fallback') const url = new URL(urlLine.trim()) assert.strictEqual(url.pathname, '/sign-in') - assert.strictEqual(url.searchParams.get('port'), '8767') + assert.strictEqual(url.searchParams.get('port'), String(callbackPort)) assert.ok(url.searchParams.get('state'), 'CSRF state must be present') // The manual URL must never leak credential material. assert.ok(!stderr.includes('expired-token')) @@ -798,7 +810,7 @@ describe('ensureAuthenticated - unrecognized console URL', () => { ) const state = await pollForState(getStateFromExecFileCall) - await sendCallback(8767, state, { consoleId: 'org-456', url: 'https://console.example.com' }) + await sendCallback(callbackPort, state, { consoleId: 'org-456', url: 'https://console.example.com' }) await rejection assert.strictEqual(loadCredentials()?.organizationId, 'org-123', 'old credentials must survive a rejected fresh OAuth') diff --git a/packages/core/test/integration/auth/ports.ts b/packages/core/test/integration/auth/ports.ts new file mode 100644 index 0000000..fbea8d0 --- /dev/null +++ b/packages/core/test/integration/auth/ports.ts @@ -0,0 +1,24 @@ +import { createServer } from 'node:http' + +/** + * Picks a random free TCP port inside [minPort, maxPort]. The OAuth server + * only accepts ports at or below its hard ceiling (src MAX_PORT = 8770), so + * OS-assigned ephemeral ports cannot be used; callers must pass a window + * below 8765 disjoint from every other test file's window, keeping parallel + * `node --test` files from contending for the same ports. + */ +export async function getFreePort (minPort: number, maxPort: number): Promise { + for (let attempt = 0; attempt < 25; attempt++) { + const candidate = minPort + Math.floor(Math.random() * (maxPort - minPort + 1)) + if (await isFree(candidate)) return candidate + } + throw new Error(`no free port found in range ${minPort}-${maxPort}`) +} + +function isFree (port: number): Promise { + return new Promise((resolve) => { + const probe = createServer() + probe.once('error', () => resolve(false)) + probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true))) + }) +} diff --git a/packages/core/test/integration/cli-help.test.ts b/packages/core/test/integration/cli-help.test.ts index 7549374..cdcc30a 100644 --- a/packages/core/test/integration/cli-help.test.ts +++ b/packages/core/test/integration/cli-help.test.ts @@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const CLI_PATH = join(__dirname, '..', '..', 'src', 'cli.ts') describe('CLI help', () => { - it('describes native plugin harnesses and the OpenCode setup/install split', () => { + it('describes native plugin harnesses and the one-step OpenCode onboarding', () => { const result = spawnSync(process.execPath, ['--import', 'tsx/esm', CLI_PATH, '--help'], { encoding: 'utf-8', }) @@ -16,8 +16,10 @@ describe('CLI help', () => { assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) const output = result.stdout assert.match(output, /Claude\/Codex\/Antigravity: install from the GitHub plugin root/, 'help must group Codex with root native plugin harnesses') - assert.match(output, /setup is auth-only/, 'help must identify setup as auth-only for native plugin harnesses') - assert.match(output, /OpenCode: setup --harness opencode authenticates AND writes its skills\/MCP config/, 'help must describe OpenCode setup as one-step direct config') + assert.match(output, /setup authenticates and prepares the MCP bridge runtime/, 'help must describe setup as auth + bridge runtime for native plugin harnesses') + assert.doesNotMatch(output, /setup is auth-only/, 'help must not describe setup as auth-only') + assert.match(output, /OpenCode: run setup --harness opencode — it authenticates, prepares the MCP bridge runtime, and installs skills \+ MCP config in one step\./, 'help must describe one-step OpenCode setup') + assert.match(output, /provisions the MCP bridge runtime first, then installs assets/, 'help must describe the install runtime precondition') assert.doesNotMatch(output, /OpenCode\/Codex/, 'help must not list Codex as a user-level skill harness') }) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 0b8b526..1012238 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -1,6 +1,7 @@ -import { describe, it, beforeEach, afterEach } from 'node:test' +import { describe, it, beforeEach, afterEach, before, mock } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import http from 'node:http' @@ -14,6 +15,8 @@ const cp = require('node:child_process') const execFileCalls: unknown[][] = [] cp.execFile = (...args: unknown[]) => { execFileCalls.push(args) } +const authNotices: string[] = [] +const captureAuthNotice = (text: string): void => { authNotices.push(text) } function getUrlFromExecFileCall (): URL { const call = execFileCalls[execFileCalls.length - 1] @@ -29,7 +32,8 @@ async function pollForState (timeoutMs = 5000): Promise<{ state: string; port: n const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { - const url = getUrlFromExecFileCall() + const noticeUrl = authNotices.join('').match(/https?:\/\/\S+\/sign-in\?\S+/)?.[0] + const url = noticeUrl ? new URL(noticeUrl) : getUrlFromExecFileCall() const state = url.searchParams.get('state') const port = url.searchParams.get('port') if (state && port) return { state, port: Number(port) } @@ -59,11 +63,71 @@ function sendCallback (port: number, state: string, overrides?: Record join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote'), + getMcpRemoteRuntimeRoot: () => join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', '0.1.38'), + resolveNpmCommand: () => { throw new Error('resolveNpmCommand is not part of these tests') }, + inspectMcpRemoteRuntime: () => { + const root = join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', '0.1.38') + if (!existsSync(root)) return { status: 'missing', version: '0.1.38', root } + try { + const pkg = JSON.parse(readFileSync(join(root, 'node_modules', 'mcp-remote', 'package.json'), 'utf8')) as { name?: string; version?: string } + const proxyPath = join(root, 'node_modules', 'mcp-remote', 'dist', 'proxy.js') + if (pkg.name !== 'mcp-remote' || pkg.version !== '0.1.38' || !statSync(proxyPath).isFile()) { + return { status: 'invalid', version: '0.1.38', root, reason: 'invalid fixture' } + } + return { status: 'ready', version: '0.1.38', root, proxyPath } + } catch { + return { status: 'invalid', version: '0.1.38', root, reason: 'unreadable fixture' } + } + }, + ensureMcpRemoteRuntime: async () => { + runtimeControl.ensureCalls++ + const root = join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', '0.1.38') + const proxyPath = join(root, 'node_modules', 'mcp-remote', 'dist', 'proxy.js') + if (existsSync(proxyPath)) { + return { installed: false, version: '0.1.38', root, proxyPath } + } + if (runtimeControl.behavior === 'fail') { + throw new Error('simulated npm failure (no network)') + } + seedMcpRemoteRuntime() + runtimeControl.provisions++ + return { installed: true, version: '0.1.38', root, proxyPath } + }, + }, +}) + let tmpDir: string let originalHome: string | undefined let originalUserProfile: string | undefined let originalProgressEnv: string | undefined let originalFetch: typeof globalThis.fetch +let originalNpmExecpath: string | undefined beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'nsolid-installer-')) @@ -71,10 +135,13 @@ beforeEach(() => { originalUserProfile = process.env.USERPROFILE originalProgressEnv = process.env.NSOLID_PLUGIN_PROGRESS originalFetch = globalThis.fetch + originalNpmExecpath = process.env.npm_execpath process.env.HOME = tmpDir process.env.USERPROFILE = tmpDir execFileCalls.length = 0 + authNotices.length = 0 delete process.env.NSOLID_PLUGIN_PROGRESS + delete process.env.npm_execpath }) afterEach(() => { @@ -94,6 +161,11 @@ afterEach(() => { } else { delete process.env.NSOLID_PLUGIN_PROGRESS } + if (originalNpmExecpath !== undefined) { + process.env.npm_execpath = originalNpmExecpath + } else { + delete process.env.npm_execpath + } globalThis.fetch = originalFetch }) @@ -148,6 +220,37 @@ function seedCredentials (overrides: Partial<{ })) } +/** Seed a valid shared MCP bridge runtime so setup() finds it ready (no npm). */ +function seedMcpRemoteRuntime (): void { + const mcpRemoteDir = join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote') + mkdirSync(join(mcpRemoteDir, 'dist'), { recursive: true }) + writeFileSync(join(mcpRemoteDir, 'package.json'), JSON.stringify({ + name: 'mcp-remote', + version: '0.1.38', + dependencies: {}, + })) + writeFileSync(join(mcpRemoteDir, 'dist', 'proxy.js'), '// proxy\n') +} + +const OK_FETCH = (async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), +})) as unknown as typeof fetch + +const SILENT_PROGRESS: ProgressReporter = { + header: () => {}, + step: () => {}, + done: () => {}, + warn: () => {}, +} + +/** Path of the shared MCP bridge runtime under the test HOME. */ +function mcpRuntimeRoot (): string { + return join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', '0.1.38') +} + describe('install()', () => { it('copies skills, links, and tracks on happy path', async () => { const { install } = await import('../../src/index.js') @@ -186,18 +289,9 @@ describe('install()', () => { const bundlePath = writeBundle(bundle) const skillsSource = createSkillSource('ns-test-skill') seedCredentials() - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: [] }), - })) as unknown as typeof fetch - const progress: ProgressReporter = { - header: () => {}, - step: () => {}, - done: () => {}, - warn: () => {}, - } + seedMcpRemoteRuntime() + globalThis.fetch = OK_FETCH + const progress = SILENT_PROGRESS const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress }) @@ -234,10 +328,10 @@ describe('install()', () => { warn: () => {}, } - const promise = setup({ harness: 'claude', bundlePath, skillsSource, progress, force: true }) + const promise = setup({ harness: 'claude', bundlePath, skillsSource, progress, force: true, notify: captureAuthNotice }) const { state, port } = await pollForState() - assert.strictEqual(execFileCalls.length, 1, 'force should open the browser despite valid stored credentials') + assert.match(authNotices.join(''), /\/sign-in\?.*state=/, 'force should start a fresh browser authentication flow') await sendCallback(port, state, { consoleId: 'org-456' }) const result = await promise @@ -271,7 +365,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true }) + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -312,7 +406,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'pi', bundlePath, skillsSource, progress, force: true, packageOwnedSkills: true }) + const promise = setup({ harness: 'pi', bundlePath, skillsSource, progress, force: true, packageOwnedSkills: true, notify: captureAuthNotice }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -355,7 +449,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true }) + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -382,18 +476,9 @@ describe('install()', () => { const bundlePath = writeBundle(bundle) const skillsSource = createSkillSource('ns-test-skill') seedCredentials() - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: [] }), - })) as unknown as typeof fetch - const progress: ProgressReporter = { - header: () => {}, - step: () => {}, - done: () => {}, - warn: () => {}, - } + seedMcpRemoteRuntime() + globalThis.fetch = OK_FETCH + const progress = SILENT_PROGRESS const result = await setup({ harness: 'antigravity', bundlePath, skillsSource, progress }) @@ -416,18 +501,9 @@ describe('install()', () => { const bundlePath = writeBundle(bundle) const skillsSource = createSkillSource('ns-test-skill') seedCredentials() - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: [] }), - })) as unknown as typeof fetch - const progress: ProgressReporter = { - header: () => {}, - step: () => {}, - done: () => {}, - warn: () => {}, - } + seedMcpRemoteRuntime() + globalThis.fetch = OK_FETCH + const progress = SILENT_PROGRESS const result = await setup({ harness: 'codex', bundlePath, skillsSource, progress }) @@ -451,18 +527,9 @@ describe('install()', () => { const bundlePath = writeBundle(bundle) const skillsSource = createSkillSource('ns-test-skill') seedCredentials() - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: [] }), - })) as unknown as typeof fetch - const progress: ProgressReporter = { - header: () => {}, - step: () => {}, - done: () => {}, - warn: () => {}, - } + seedMcpRemoteRuntime() + globalThis.fetch = OK_FETCH + const progress = SILENT_PROGRESS const result = await setup({ harness: 'pi', bundlePath, skillsSource, progress, packageOwnedSkills: true }) @@ -477,6 +544,112 @@ describe('install()', () => { assert.strictEqual(piServer.auth, false) }) + it('setup installs the MCP bridge runtime on first run and is offline-safe afterwards', async () => { + const { setup } = await import('../../src/index.js') + const bundle = createBundle({ + auth: { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com' }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials() + globalThis.fetch = OK_FETCH + resetRuntimeControl('provision') + + const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + + assert.strictEqual(result.success, true) + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true) + assert.strictEqual(result.hadToAuthenticate, false, 'valid credentials: no browser') + assert.strictEqual(runtimeControl.provisions, 1, 'first run installed the runtime') + + // Second run: the runtime is ready, so npm must not be invoked again. + resetRuntimeControl('fail') + const second = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + assert.strictEqual(second.success, true, 'idempotent rerun must not need npm') + assert.strictEqual(runtimeControl.provisions, 0, 'ready runtime reused without provisioning') + }) + + it('setup fails without npm but keeps credentials valid and is retryable', async () => { + const { setup } = await import('../../src/index.js') + const bundle = createBundle({ + auth: { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com' }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials() + globalThis.fetch = OK_FETCH + resetRuntimeControl('fail') + + const failed = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + + assert.strictEqual(failed.success, false) + assert.strictEqual(failed.errors.length, 1) + assert.match(failed.errors[0], /MCP runtime setup failed/) + // Credentials survive for the retry. + assert.strictEqual(existsSync(join(tmpDir, '.agents', '.nodesource-auth.json')), true) + assert.strictEqual(existsSync(mcpRuntimeRoot()), false, 'nothing published') + + // Retry with a working npm completes. + resetRuntimeControl('provision') + const retried = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + assert.strictEqual(retried.success, true) + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote')), true) + }) + + it('setup for all five harnesses converges on the same shared runtime', async () => { + const { setup } = await import('../../src/index.js') + const bundle = createBundle({ + auth: { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com' }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials() + globalThis.fetch = OK_FETCH + resetRuntimeControl('provision') + + for (const harness of ['claude', 'codex', 'antigravity', 'opencode', 'pi'] as const) { + const result = await setup({ + harness, + bundlePath, + skillsSource, + progress: SILENT_PROGRESS, + ...(harness === 'pi' ? { packageOwnedSkills: true } : {}), + ...(harness === 'opencode' ? { harnessSpecificSkills: true } : {}), + }) + assert.strictEqual(result.success, true, `${harness} setup must succeed`) + } + + // Exactly one shared runtime installation for all five. + const runtimeParent = join(tmpDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + assert.deepStrictEqual(readdirSync(runtimeParent), ['0.1.38']) + assert.strictEqual(runtimeControl.provisions, 1, 'one installation, four idempotent reuses') + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true) + // OpenCode wrote its harness-specific skills and MCP config. + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode', 'skills', 'ns-test-skill')), true) + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')), true) + }) + + it('uninstall and logout preserve the shared runtime', async () => { + const { install, uninstall, logout } = await import('../../src/index.js') + const bundle = createBundle({ + auth: { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com' }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials() + seedMcpRemoteRuntime() + globalThis.fetch = OK_FETCH + + await install({ harness: 'opencode', bundlePath, skillsSource, harnessSpecificSkills: true, progress: SILENT_PROGRESS }) + const uninstallResult = await uninstall('opencode', { bundlePath }) + assert.deepStrictEqual(uninstallResult.errors, []) + assert.strictEqual(existsSync(mcpRuntimeRoot()), true, 'runtime survives uninstall') + + await logout() + assert.strictEqual(existsSync(join(tmpDir, '.agents', '.nodesource-auth.json')), false) + assert.strictEqual(existsSync(mcpRuntimeRoot()), true, 'runtime survives logout') + }) + it('prefers stored explicit MCP URL over derived console URL', async () => { const { install } = await import('../../src/index.js') const { readJsonFile } = await import('../../src/utils/config.js') @@ -785,6 +958,282 @@ describe('install()', () => { }) }) +describe('installWithRuntime() dispatcher precondition', () => { + it('provisions the runtime before OpenCode assets (success path)', async () => { + const { installWithRuntime } = await import('../../src/index.js') + const bundlePath = writeBundle(createBundle()) + const skillsSource = createSkillSource('ns-test-skill') + resetRuntimeControl('provision') + + const result = await installWithRuntime({ + harness: 'opencode', + bundlePath, + skillsSource, + harnessSpecificSkills: true, + progress: SILENT_PROGRESS, + }) + + assert.strictEqual(result.success, true) + assert.strictEqual(runtimeControl.ensureCalls, 1, 'dispatcher satisfied the precondition exactly once') + // Runtime ready AND harness assets installed — in that order. + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true) + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')), true) + }) + + it('aborts OpenCode onboarding before any assets when the runtime fails', async () => { + const { installWithRuntime } = await import('../../src/index.js') + const bundlePath = writeBundle(createBundle()) + const skillsSource = createSkillSource('ns-test-skill') + resetRuntimeControl('fail') + + const result = await installWithRuntime({ + harness: 'opencode', + bundlePath, + skillsSource, + harnessSpecificSkills: true, + progress: SILENT_PROGRESS, + }) + + assert.strictEqual(result.success, false) + assert.match(result.errors[0] ?? '', /MCP runtime setup failed: simulated npm failure/) + // Nothing was installed: the precondition aborts onboarding before assets. + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode')), false, 'no OpenCode assets') + assert.strictEqual(existsSync(join(tmpDir, '.agents', 'skills')), false, 'no shared skills') + }) + + it('provisions the runtime before Pi MCP config (success path)', async () => { + const { installWithRuntime } = await import('../../src/index.js') + const bundlePath = writeBundle(createBundle()) + const skillsSource = createSkillSource('ns-test-skill') + resetRuntimeControl('provision') + + const result = await installWithRuntime({ + harness: 'pi', + bundlePath, + skillsSource, + packageOwnedSkills: true, + progress: SILENT_PROGRESS, + }) + + assert.strictEqual(result.success, true) + assert.strictEqual(runtimeControl.ensureCalls, 1) + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true) + assert.strictEqual(existsSync(join(tmpDir, '.pi', 'agent', 'mcp.json')), true) + }) + + it('aborts Pi onboarding before any config when the runtime fails', async () => { + const { installWithRuntime } = await import('../../src/index.js') + const bundlePath = writeBundle(createBundle()) + const skillsSource = createSkillSource('ns-test-skill') + resetRuntimeControl('fail') + + const result = await installWithRuntime({ + harness: 'pi', + bundlePath, + skillsSource, + packageOwnedSkills: true, + progress: SILENT_PROGRESS, + }) + + assert.strictEqual(result.success, false) + assert.match(result.errors[0] ?? '', /MCP runtime setup failed/) + assert.strictEqual(existsSync(join(tmpDir, '.pi')), false, 'no Pi config written') + }) + + it('install() purity: a direct install never consults the runtime manager', async () => { + const { install } = await import('../../src/index.js') + const bundlePath = writeBundle(createBundle()) + const skillsSource = createSkillSource('ns-test-skill') + // Even a failing runtime manager must not be consulted: install() is the + // offline, auth-free direct installer. + resetRuntimeControl('fail') + + const result = await install({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + + assert.strictEqual(result.success, true) + assert.strictEqual(runtimeControl.ensureCalls, 0, 'install() never provisions') + assert.strictEqual(existsSync(mcpRuntimeRoot()), false, 'install() never downloads') + }) +}) + +describe('dispatcher scripts (setup.mjs and the CLI install command)', () => { + const repoRoot = join(import.meta.dirname, '..', '..', '..', '..') + const setupScript = join(repoRoot, 'packages', 'core', 'scripts', 'setup.mjs') + const cliEntry = join(repoRoot, 'packages', 'core', 'src', 'cli.ts') + + before(() => { + // setup.mjs resolves `nsolid-plugin` via package self-reference, so the + // dispatcher tests run against the built package. Always rebuild here: + // an existing dist may belong to an older branch and silently omit newer + // exports, making local/pre-commit results depend on checkout history. + const build = spawnSync('pnpm', ['--filter', './packages/core', 'build'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 180_000, + shell: process.platform === 'win32', + }) + assert.strictEqual(build.status, 0, `core build failed: ${build.error?.message ?? (build.stderr || build.stdout)}`) + }) + + /** + * A --require preload that patches child_process.spawn: npm-shaped spawns + * install the fixture offline; everything else passes through. This lets + * the real dispatcher scripts run the real resolver + runner + install + * flow without network access. (Self-reference resolves `nsolid-plugin` + * from packages/core, so setup.mjs tests run against the built dist — + * exactly what the plugin ships.) + */ + function writeSpawnPreload (file: string): void { + writeFileSync(file, [ + "const cp = require('node:child_process')", + 'const origSpawn = cp.spawn', + 'cp.spawn = function patchedSpawn (command, args, options) {', + " if (Array.isArray(args) && args.some((a) => typeof a === 'string' && a.startsWith('mcp-remote@'))) {", + " const { EventEmitter, PassThrough } = require('node:stream')", + " const fs = require('node:fs')", + " const path = require('node:path')", + ' const child = new EventEmitter()', + ' child.pid = 424242', + ' child.stderr = new PassThrough()', + ' process.nextTick(() => {', + ' try {', + " if (process.env.NSOLID_STUB_NPM === 'fail') {", + " child.stderr.end('simulated npm failure\\n')", + " child.emit('close', 1)", + ' return', + ' }', + ' const cwd = options && options.cwd', + " fs.mkdirSync(path.join(cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true })", + " fs.writeFileSync(path.join(cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: '0.1.38', dependencies: {} }))", + " fs.writeFileSync(path.join(cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy')", + " child.emit('close', 0)", + " } catch (err) { child.emit('error', err) }", + ' })', + ' return child', + ' }', + ' return origSpawn.apply(this, arguments)', + '}', + ].join('\n')) + } + + /** Seed valid stored credentials so install() writes MCP config in spawned dispatchers. */ + function seedSpawnCredentials (): void { + mkdirSync(join(tmpDir, '.agents'), { recursive: true }) + writeFileSync(join(tmpDir, '.agents', '.nodesource-auth.json'), JSON.stringify({ + serviceToken: 'stub-token', + organizationId: 'stub-org', + saasToken: 'stub-saas', + consoleUrl: 'https://console.example.test', + mcpUrl: 'https://mcp.example.test', + expiresAt: '2099-01-01T00:00:00.000Z', + permissions: [], + })) + } + + it('setup.mjs provisions the runtime before OpenCode assets (real chain, offline npm)', () => { + const preload = join(tmpDir, 'stub-spawn.cjs') + writeSpawnPreload(preload) + seedSpawnCredentials() + + const result = spawnSync(process.execPath, ['--require', preload, setupScript, 'install'], { + cwd: tmpDir, + env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, NSOLID_HARNESS: 'opencode' }, + encoding: 'utf8', + timeout: 60_000, + }) + + assert.strictEqual(result.status, 0, result.stderr) + // The real dispatcher satisfied the runtime precondition before assets. + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true, 'runtime provisioned under HOME') + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')), true, 'assets installed after the precondition') + assert.match(result.stdout, /MCP bridge and skills ready for opencode/) + assert.doesNotMatch(result.stdout, /credentials, MCP bridge/, 'install()-routed paths never claim authentication') + }) + + it('setup.mjs aborts OpenCode onboarding on a runtime failure without a success message', () => { + const preload = join(tmpDir, 'stub-spawn.cjs') + writeSpawnPreload(preload) + + const result = spawnSync(process.execPath, ['--require', preload, setupScript, 'install'], { + cwd: tmpDir, + env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, NSOLID_HARNESS: 'opencode', NSOLID_STUB_NPM: 'fail' }, + encoding: 'utf8', + timeout: 60_000, + }) + + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /MCP runtime setup failed/) + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode')), false, 'no assets installed when the precondition fails') + assert.strictEqual(existsSync(mcpRuntimeRoot()), false, 'nothing published') + assert.doesNotMatch(result.stdout, /ready/, 'no success message on failure') + }) + + it('setup.mjs provisions the runtime before Pi MCP config (real chain, offline npm)', () => { + const preload = join(tmpDir, 'stub-spawn.cjs') + writeSpawnPreload(preload) + seedSpawnCredentials() + + const result = spawnSync(process.execPath, ['--require', preload, setupScript, 'install'], { + cwd: tmpDir, + env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, NSOLID_HARNESS: 'pi' }, + encoding: 'utf8', + timeout: 60_000, + }) + + assert.strictEqual(result.status, 0, result.stderr) + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true, 'runtime provisioned') + assert.strictEqual(existsSync(join(tmpDir, '.pi', 'agent', 'mcp.json')), true, 'Pi MCP config written after the precondition') + assert.match(result.stdout, /MCP bridge and MCP config ready for pi/) + }) + + it('the CLI install command provisions the runtime before OpenCode assets (real chain, offline npm)', () => { + const preload = join(tmpDir, 'stub-spawn.cjs') + writeSpawnPreload(preload) + seedSpawnCredentials() + + const result = spawnSync(process.execPath, [ + '--require', preload, + '--import', 'tsx/esm', + cliEntry, 'install', '--harness', 'opencode', '--yes', + '--bundle', join(repoRoot, 'bundle.json'), + '--skills-source', repoRoot, + ], { + cwd: repoRoot, + env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir }, + encoding: 'utf8', + timeout: 60_000, + }) + + assert.strictEqual(result.status, 0, result.stderr) + // The real resolver + real runner ran: the runtime was published under HOME. + assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true, 'runtime provisioned') + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')), true, 'assets installed after the precondition') + }) + + it('the CLI install command aborts onboarding when npm fails (real chain)', () => { + const preload = join(tmpDir, 'stub-spawn.cjs') + writeSpawnPreload(preload) + + const result = spawnSync(process.execPath, [ + '--require', preload, + '--import', 'tsx/esm', + cliEntry, 'install', '--harness', 'opencode', '--yes', + '--bundle', join(repoRoot, 'bundle.json'), + '--skills-source', repoRoot, + ], { + cwd: repoRoot, + env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, NSOLID_STUB_NPM: 'fail' }, + encoding: 'utf8', + timeout: 60_000, + }) + + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /MCP runtime setup failed/) + assert.strictEqual(existsSync(join(tmpDir, '.config', 'opencode')), false, 'no assets on failure') + assert.strictEqual(existsSync(mcpRuntimeRoot()), false, 'nothing published') + }) +}) + describe('uninstall()', () => { it('removes MCP configs, unlinks skills, deletes tracking', async () => { const { install, uninstall } = await import('../../src/index.js') @@ -1204,6 +1653,7 @@ describe('doctor()', () => { mcpUrl: 'https://mcp.nodesource.com', expiresAt: futureDate, })) + seedMcpRemoteRuntime() const bundle = createBundle() const bundlePath = writeBundle(bundle) @@ -1221,9 +1671,146 @@ describe('doctor()', () => { assert.strictEqual(report.plugin.status, 'ok') assert.strictEqual(report.skills.status, 'ok') assert.strictEqual(report.mcpServers.status, 'ok') + assert.strictEqual(report.bridge?.status, 'ready') + assert.strictEqual(report.bridge?.required, true) assert.deepStrictEqual(report.errors, []) }) + it('reports unhealthy for a wrapper-owned harness when the runtime is missing', async () => { + const { doctor } = await import('../../src/index.js') + const { getAuthFilePath, getAgentsDir } = await import('../../src/utils/path.js') + const { ensureDir } = await import('../../src/utils/fs.js') + ensureDir(getAgentsDir()) + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() + writeFileSync(getAuthFilePath(), JSON.stringify({ + serviceToken: 'valid-token', + organizationId: 'valid-org', + saasToken: 'valid-saas', + consoleUrl: 'https://console.nodesource.com', + mcpUrl: 'https://mcp.nodesource.com', + expiresAt: futureDate, + })) + + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + // Native Codex plugin installed: MCPs are served through the wrapper. + mkdirSync(join(tmpDir, '.codex'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', 'config.toml'), [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.healthy, false, 'wrapper-owned harness with missing runtime is never healthy') + assert.strictEqual(report.bridge?.status, 'missing') + assert.strictEqual(report.bridge?.required, true) + assert.ok( + report.errors.some((e) => e.includes('MCP bridge runtime is missing') && e.includes('nsolid-plugin setup --harness codex')), + `errors should carry the repair hint, got: ${JSON.stringify(report.errors)}` + ) + }) + + it('treats the bridge as informational for non-wrapper transports', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + // No runtime anywhere; OpenCode uses native HTTP MCP config. + + const report = await doctor('opencode', bundlePath) + + assert.strictEqual(report.bridge?.required, false) + assert.ok(!report.errors.some((e) => e.includes('MCP bridge runtime'))) + + // Pi with its native package installed is still not wrapper-owned. + const packageRoot = join(tmpDir, 'pi-package') + mkdirSync(join(packageRoot, 'skills', 'ns-test-skill'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'nsolid-pi-plugin', + pi: { skills: ['./skills'] }, + })) + writeFileSync(join(packageRoot, 'skills', 'ns-test-skill', 'SKILL.md'), '# ns-test-skill') + mkdirSync(join(tmpDir, '.pi', 'agent'), { recursive: true }) + writeFileSync(join(tmpDir, '.pi', 'agent', 'settings.json'), JSON.stringify({ + packages: [packageRoot], + })) + + const piReport = await doctor('pi', bundlePath) + assert.strictEqual(piReport.plugin.status, 'ok') + assert.strictEqual(piReport.bridge?.required, false) + assert.ok(!piReport.errors.some((e) => e.includes('MCP bridge runtime'))) + }) + + it('bridge stays informational for claude/codex/antigravity without the native plugin', async () => { + // Direct (fallback) installs of the wrapper-owned harnesses use native + // HTTP MCP config: no native plugin detected means the bridge is not + // required — even when the runtime is missing. + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + + for (const harness of ['claude', 'codex', 'antigravity'] as const) { + const report = await doctor(harness, bundlePath) + assert.strictEqual(report.bridge?.required, false, `${harness}: not wrapper-owned without the native plugin`) + assert.strictEqual(report.bridge?.status, 'missing') + assert.ok( + !report.errors.some((e) => e.includes('MCP bridge runtime')), + `${harness}: a missing bridge never breaks a direct install` + ) + } + }) + + it('a ready bridge never claims remote MCP reachability', async () => { + // Local bridge readiness and remote endpoint health are distinct axes: + // with a ready runtime but no tracked MCP activity, the report must show + // a ready bridge AND unreachable servers — never one proving the other. + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + seedMcpRemoteRuntime() + + const report = await doctor('opencode', bundlePath) + + assert.strictEqual(report.bridge?.status, 'ready') + assert.strictEqual(report.bridge?.required, false) + assert.strictEqual(report.mcpServers.status, 'unreachable') + assert.deepStrictEqual(report.mcpServers.reachable, []) + assert.ok(!report.errors.some((e) => e.includes('MCP bridge runtime'))) + }) + + it('a ready bridge never makes an otherwise unhealthy report healthy', async () => { + const { doctor } = await import('../../src/index.js') + const { getAuthFilePath, getAgentsDir } = await import('../../src/utils/path.js') + const { ensureDir } = await import('../../src/utils/fs.js') + ensureDir(getAgentsDir()) + writeFileSync(getAuthFilePath(), JSON.stringify({ + serviceToken: 'valid-token', + organizationId: 'valid-org', + saasToken: 'valid-saas', + consoleUrl: 'https://console.nodesource.com', + mcpUrl: 'https://mcp.nodesource.com', + expiresAt: '2020-01-01T00:00:00.000Z', // expired + })) + seedMcpRemoteRuntime() + + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.codex'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', 'config.toml'), [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.bridge?.status, 'ready') + assert.strictEqual(report.bridge?.required, true) + assert.strictEqual(report.credentials.status, 'expired') + assert.strictEqual(report.healthy, false, 'bridge readiness cannot mask expired credentials') + }) + it('reports errors when bundle path is invalid', async () => { const { doctor } = await import('../../src/index.js') diff --git a/packages/core/test/unit/mcp/mcp-config-writer.test.ts b/packages/core/test/unit/mcp/mcp-config-writer.test.ts index 44b076e..137c0bb 100644 --- a/packages/core/test/unit/mcp/mcp-config-writer.test.ts +++ b/packages/core/test/unit/mcp/mcp-config-writer.test.ts @@ -212,6 +212,41 @@ describe('writeMcpConfig', () => { assert.ok('ns-benchmark' in servers) }) + it('preserves third-party stdio server fields in TOML writes', async () => { + const { writeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + const { parse: parseToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[mcp_servers.codegraph]', + 'command = "codegraph"', + 'args = ["serve", "--mcp"]', + 'env = { CACHE = "1" }', + '', + '[mcp_servers.codegraph.tools.search]', + 'approval_mode = "approve"', + '', + ].join('\n')) + + await writeMcpConfig('codex', [serverA]) + + const content = parseToml(readFileSync(configPath, 'utf-8')) as Record + const servers = content.mcp_servers as Record> + const codegraph = servers.codegraph + // Regression: the TOML writer rebuilt every entry as { url, headers }, + // hollowing out third-party stdio servers on the first plugin touch. + assert.strictEqual(codegraph.command, 'codegraph') + assert.deepStrictEqual(codegraph.args, ['serve', '--mcp']) + assert.deepStrictEqual(codegraph.env, { CACHE: '1' }) + assert.deepStrictEqual(codegraph.tools, { search: { approval_mode: 'approve' } }) + assert.strictEqual(codegraph.url, undefined) + assert.strictEqual(servers['ns-benchmark'].url, 'https://benchmark.mcp.saas.nodesource.io/mcp') + }) + it('writes Pi MCP servers with adapter OAuth auto-detection disabled', async () => { const { writeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') const { resolveHome } = await import('../../../src/utils/path.js') @@ -598,6 +633,43 @@ describe('removeMcpConfig', () => { assert.ok(!content.includes('ns-benchmark')) }) + it('TOML round-trip: uninstalling own servers keeps third-party stdio fields intact', async () => { + const { writeMcpConfig, removeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + const { parse: parseToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, [ + '[mcp_servers.codegraph]', + 'command = "codegraph"', + 'args = ["serve", "--mcp"]', + 'env = { CACHE = "1" }', + '', + '[mcp_servers.codegraph.tools.search]', + 'approval_mode = "approve"', + '', + ].join('\n')) + + await writeMcpConfig('codex', [serverA]) + await removeMcpConfig('codex', ['ns-benchmark']) + + const raw = readFileSync(configPath, 'utf-8') + assert.ok(!raw.includes('ns-benchmark')) + assert.ok(raw.includes('approval_mode = "approve"')) + + const content = parseToml(raw) as Record + const servers = content.mcp_servers as Record> + const codegraph = servers.codegraph + assert.strictEqual(codegraph.command, 'codegraph') + assert.deepStrictEqual(codegraph.args, ['serve', '--mcp']) + assert.deepStrictEqual(codegraph.env, { CACHE: '1' }) + assert.deepStrictEqual(codegraph.tools, { search: { approval_mode: 'approve' } }) + assert.strictEqual(Object.keys(servers).length, 1) + }) + it('handles nonexistent config file', async () => { const { removeMcpConfig } = await import('../../../src/mcp/mcp-config-writer.js') const { resolveHome } = await import('../../../src/utils/path.js') diff --git a/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts b/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts new file mode 100644 index 0000000..27cf326 --- /dev/null +++ b/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts @@ -0,0 +1,1465 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, sep } from 'node:path' + +import { + MCP_REMOTE_VERSION, + McpRemoteRuntimeError, + ensureMcpRemoteRuntime, + inspectMcpRemoteRuntime, + resolveNpmCommand, + resolveNpmCommandForExecPath, + type NpmRunner, + type PublishTestControls, +} from '../../../src/mcp/mcp-remote-runtime.js' + +let tmpHome: string +let originalHome: string | undefined +let originalUserProfile: string | undefined +let originalNpmExecpath: string | undefined + +beforeEach(() => { + // Include a space in the home path: runtime paths must survive it. + tmpHome = mkdtempSync(join(tmpdir(), 'nsolid runtime-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalNpmExecpath = process.env.npm_execpath + process.env.HOME = tmpHome + process.env.USERPROFILE = tmpHome + delete process.env.npm_execpath +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + if (originalHome !== undefined) process.env.HOME = originalHome + else delete process.env.HOME + if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile + else delete process.env.USERPROFILE + if (originalNpmExecpath !== undefined) process.env.npm_execpath = originalNpmExecpath + else delete process.env.npm_execpath +}) + +function runtimeParent (): string { + return join(tmpHome, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') +} + +function runtimeRoot (): string { + return join(runtimeParent(), MCP_REMOTE_VERSION) +} + +function lockPath (): string { + return join(runtimeParent(), `.publish-${MCP_REMOTE_VERSION}.lock`) +} + +interface SeedOptions { + version?: string + withProxy?: boolean + /** Packages actually present under node_modules; name/version/deps per package. */ + dependencies?: Record }> + /** Ranges mcp-remote declares for its dependencies (default ^1.0.0). */ + ranges?: Record + /** Extra dependencies declared but deliberately NOT installed. */ + declareWithoutInstalling?: string[] +} + +/** Names mcp-remote declares as dependencies in the seeded fixture. */ +const SEED_DECLARED = ['express', 'open', 'strict-url-sanitise', 'undici'] +const EXPECTED_NPM_INSTALL_ARGS = [ + 'install', + '--omit=dev', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--save-exact', + '--no-package-lock', + `mcp-remote@${MCP_REMOTE_VERSION}`, +] + +/** Seed a runtime tree directly (no npm). Defaults produce a fully valid runtime. */ +function seedRuntime (options: SeedOptions = {}): void { + const { + version = MCP_REMOTE_VERSION, + withProxy = true, + dependencies = { + express: {}, + open: {}, + 'strict-url-sanitise': {}, + undici: {}, + }, + ranges = {}, + declareWithoutInstalling = [], + } = options + const mcpRemoteDir = join(runtimeRoot(), 'node_modules', 'mcp-remote') + mkdirSync(mcpRemoteDir, { recursive: true }) + const declared = [...SEED_DECLARED, ...declareWithoutInstalling] + writeFileSync( + join(mcpRemoteDir, 'package.json'), + JSON.stringify({ + name: 'mcp-remote', + version, + dependencies: Object.fromEntries(declared.map((name) => [name, ranges[name] ?? '^1.0.0'])), + }) + ) + if (withProxy) { + mkdirSync(join(mcpRemoteDir, 'dist'), { recursive: true }) + writeFileSync(join(mcpRemoteDir, 'dist', 'proxy.js'), '// proxy\n') + } + for (const [name, pkg] of Object.entries(dependencies)) { + const depDir = join(runtimeRoot(), 'node_modules', name) + mkdirSync(depDir, { recursive: true }) + writeFileSync(join(depDir, 'package.json'), JSON.stringify({ name: pkg.name ?? name, version: pkg.version ?? '1.0.0', ...(pkg.dependencies ? { dependencies: pkg.dependencies } : {}) })) + } + writeFileSync(join(runtimeRoot(), 'package.json'), JSON.stringify({ name: 'nsolid-plugin-mcp-remote-runtime', private: true })) +} + +function runtimeParentEntries (): string[] { + if (!existsSync(runtimeParent())) return [] + return readdirSync(runtimeParent()) +} + +/** Write a publication lock record directly (protocol tests). */ +function seedLock (record: { token: string; pid: number; createdAt: number }): void { + mkdirSync(runtimeParent(), { recursive: true }) + writeFileSync(lockPath(), JSON.stringify(record)) +} + +/** A PID that is definitely dead (spawned, reaped, exited). */ +function deadPid (): number { + const child = spawnSync(process.execPath, ['-e', ''], { timeout: 10_000 }) + assert.strictEqual(child.status, 0, 'probe child must exit cleanly') + const pid = child.pid as number + // The synchronous spawn reaped the child, so its pid is proven gone. + assert.throws(() => process.kill(pid, 0), (err: NodeJS.ErrnoException) => err.code === 'ESRCH', 'probe pid must be dead') + return pid +} + +/** Fake runner: "installs" a valid (or invalid) tree into cwd and records the spawn request. */ +function createFakeRunner (calls: Array<{ command: string; args: string[]; cwd: string }>, behavior: 'ok' | 'fail' | 'invalid' = 'ok'): NpmRunner { + return { + async run (command, args, options) { + calls.push({ command, args: [...args], cwd: options.cwd }) + if (behavior === 'fail') { + return { status: 1, stderr: 'ECONNREFUSED registry ping failed\nplausible npm noise\nmore noise' } + } + const cwd = options.cwd + if (behavior === 'invalid') { + // Installs the wrong version: staging validation must reject it. + mkdirSync(join(cwd, 'node_modules', 'mcp-remote'), { recursive: true }) + writeFileSync(join(cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: '0.0.1' })) + return { status: 0, stderr: '' } + } + mkdirSync(join(cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) + writeFileSync(join(cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + writeFileSync(join(cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy\n') + return { status: 0, stderr: '' } + }, + } +} + +describe('inspectMcpRemoteRuntime()', () => { + it('computes the versioned root under ~/.agents (spaces tolerated)', () => { + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'missing') + assert.strictEqual(status.version, MCP_REMOTE_VERSION) + assert.strictEqual(status.root, runtimeRoot()) + assert.ok(status.root.includes('nsolid runtime-'), 'uses the overridden home with a space') + }) + + it('reports ready for a fully valid runtime', () => { + seedRuntime() + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'ready') + assert.strictEqual(status.proxyPath, realpathSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js'))) + }) + + it('reports invalid for a wrong version', () => { + seedRuntime({ version: '0.1.37' }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /expected mcp-remote@0\.1\.38/) + }) + + it('reports invalid when dist/proxy.js is missing', () => { + seedRuntime({ withProxy: false }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /proxy\.js/) + }) + + it('reports invalid when a transitive dependency is missing', () => { + seedRuntime({ + dependencies: { + express: {}, + // open / strict-url-sanitise / undici deliberately absent + }, + }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "open" required by "mcp-remote"/) + }) + + it('reports invalid when a nested transitive dependency is missing', () => { + seedRuntime({ + dependencies: { + express: { dependencies: { 'body-parser': '^1.0.0' } }, + open: {}, + 'strict-url-sanitise': {}, + undici: {}, + }, + }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "body-parser" required by "express"/) + }) + + it('reports invalid for a wrong-named transitive dependency', () => { + seedRuntime({ + dependencies: { + express: { name: 'not-express' }, + open: {}, + 'strict-url-sanitise': {}, + undici: {}, + }, + }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "express" required by "mcp-remote" resolved to a package named "not-express"/) + }) + + it('reports invalid for an incompatible transitive version', () => { + seedRuntime({ + dependencies: { + express: { version: '1.0.0' }, + open: {}, + 'strict-url-sanitise': {}, + undici: {}, + }, + ranges: { express: '^2.0.0' }, + }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "express".*"1\.0\.0" which does not satisfy "\^2\.0\.0"/) + }) + + it('reports invalid for an unparseable dependency range (fails closed)', () => { + seedRuntime({ + dependencies: { + express: {}, + open: {}, + 'strict-url-sanitise': {}, + undici: {}, + }, + ranges: { express: 'workspace:*' }, + }) + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "express" required by "mcp-remote" declares the unsupported range "workspace:\*"/) + }) + + it('reports invalid when a resolved dependency escapes the runtime root via symlink', () => { + seedRuntime() + const outside = join(tmpHome, 'outside-pkgs', 'express') + mkdirSync(outside, { recursive: true }) + writeFileSync(join(outside, 'package.json'), JSON.stringify({ name: 'express', version: '1.0.0' })) + rmSync(join(runtimeRoot(), 'node_modules', 'express'), { recursive: true, force: true }) + symlinkSync(outside, join(runtimeRoot(), 'node_modules', 'express')) + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "express" required by "mcp-remote" resolves outside the runtime root/) + }) + + it('rejects a whole runtime root symlink whose target escapes the controlled parent', () => { + seedRuntime() + const outside = join(tmpHome, 'outside-version-root') + renameSync(runtimeRoot(), outside) + symlinkSync(outside, runtimeRoot(), process.platform === 'win32' ? 'junction' : 'dir') + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /runtime root .*outside the controlled runtime parent/) + }) + + it('rejects a symlinked mcp-remote package directory whose target escapes the runtime root', () => { + // A complete, valid-looking tree outside the root must not satisfy the + // entry package through a symlink: lexical presence is not readiness. + seedRuntime() + const outside = join(tmpHome, 'outside-root', 'mcp-remote') + mkdirSync(join(outside, 'dist'), { recursive: true }) + writeFileSync(join(outside, 'package.json'), JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + writeFileSync(join(outside, 'dist', 'proxy.js'), '// proxy\n') + rmSync(join(runtimeRoot(), 'node_modules', 'mcp-remote'), { recursive: true, force: true }) + symlinkSync(outside, join(runtimeRoot(), 'node_modules', 'mcp-remote')) + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /node_modules\/mcp-remote .*outside the runtime root/) + }) + + it('rejects a symlinked dist/proxy.js whose target escapes the runtime root', () => { + seedRuntime() + const outsideProxy = join(tmpHome, 'outside-proxy.js') + writeFileSync(outsideProxy, '// evil proxy\n') + const proxy = join(runtimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js') + rmSync(proxy) + symlinkSync(outsideProxy, proxy) + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /proxy\.js .*outside the runtime root/) + }) + + it('rejects a symlinked package manifest whose target escapes the runtime root', () => { + seedRuntime() + const outsideManifest = join(tmpHome, 'outside-manifest.json') + writeFileSync(outsideManifest, JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + const manifest = join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json') + rmSync(manifest) + symlinkSync(outsideManifest, manifest) + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /package\.json .*outside the runtime root/) + }) + + it('ignores peer and dev dependencies while walking the closure', () => { + // Runtime-install semantics: only `dependencies` (required) and + // `optionalDependencies` (tolerated when missing) participate. peer and + // dev entries that are not installed must not invalidate the runtime. + seedRuntime() + const manifest = join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json') + const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as Record + pkg.peerDependencies = { 'peer-only-pkg': '^1.0.0' } + pkg.devDependencies = { 'dev-only-pkg': '^1.0.0' } + writeFileSync(manifest, JSON.stringify(pkg)) + + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + }) + + it('never satisfies the closure from a package.json above the runtime root', () => { + seedRuntime({ + dependencies: { + express: {}, + // open only exists ABOVE the runtime root — resolution must stop at the root. + }, + }) + const above = join(runtimeParent(), 'node_modules', 'open') + mkdirSync(above, { recursive: true }) + writeFileSync(join(above, 'package.json'), JSON.stringify({ name: 'open', version: '1.0.0' })) + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /dependency "open" required by "mcp-remote" is missing inside the runtime root/) + }) + + it('tolerates a missing optional dependency', () => { + const mcpRemoteDir = join(runtimeRoot(), 'node_modules', 'mcp-remote') + mkdirSync(mcpRemoteDir, { recursive: true }) + writeFileSync( + join(mcpRemoteDir, 'package.json'), + JSON.stringify({ + name: 'mcp-remote', + version: MCP_REMOTE_VERSION, + dependencies: { express: '^1.0.0' }, + optionalDependencies: { 'native-thing': '^1.0.0' }, + }) + ) + mkdirSync(join(mcpRemoteDir, 'dist'), { recursive: true }) + writeFileSync(join(mcpRemoteDir, 'dist', 'proxy.js'), '// proxy\n') + const expressDir = join(runtimeRoot(), 'node_modules', 'express') + mkdirSync(expressDir, { recursive: true }) + writeFileSync(join(expressDir, 'package.json'), JSON.stringify({ name: 'express', version: '1.0.0' })) + + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + }) + + it('satisfied ranges use the declared syntax (x-ranges, hyphen, ||)', () => { + seedRuntime({ + dependencies: { + express: { version: '1.4.2' }, + open: { version: '9.0.0' }, + 'strict-url-sanitise': { version: '1.0.1' }, + undici: { version: '5.28.0' }, + }, + ranges: { + express: '1.x', + open: '>=8.0.0 <10.0.0', + 'strict-url-sanitise': '1.0.0 - 1.0.2', + undici: '^5.0.0 || ^6.0.0', + }, + }) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + }) +}) + +describe('ensureMcpRemoteRuntime()', () => { + it('installs the runtime via the runner and publishes atomically', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + const result = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(result.version, MCP_REMOTE_VERSION) + assert.strictEqual(result.root, runtimeRoot()) + assert.strictEqual(result.proxyPath, realpathSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js'))) + assert.strictEqual(calls.length, 1) + assert.strictEqual(dirname(calls[0].cwd), runtimeParent(), 'npm ran inside a same-parent staging sibling') + assert.deepStrictEqual(calls[0].args.slice(-EXPECTED_NPM_INSTALL_ARGS.length), EXPECTED_NPM_INSTALL_ARGS) + + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'staging consumed, lock released') + }) + + it('fails closed on EXDEV publication without copying staging into the runtime root', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + let rejectedPublication = false + + await assert.rejects( + ensureMcpRemoteRuntime({ + runner: createFakeRunner(calls), + publish: { + rename: (from, to) => { + if (String(from).includes('.staging-') && String(to) === runtimeRoot()) { + rejectedPublication = true + throw Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' }) + } + renameSync(from, to) + }, + }, + }), + /Could not publish the mcp-remote runtime.*cross-device link not permitted/ + ) + + assert.strictEqual(rejectedPublication, true, 'the publication rename was attempted') + assert.strictEqual(existsSync(runtimeRoot()), false, 'no copy fallback published a runtime') + assert.deepStrictEqual(runtimeParentEntries(), [], 'owned staging, sidecar and lock were cleaned') + }) + + it('is idempotent: a second call does not invoke the runner', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + const second = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + + assert.strictEqual(second.installed, false) + assert.strictEqual(calls.length, 1) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + }) + + it('replaces an invalid pre-existing runtime only after staging validates', async () => { + seedRuntime({ version: '0.1.37' }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + const result = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'no stale leftovers, lock released') + }) + + it('rejects a staging tree that fails validation (wrong version from npm)', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls, 'invalid') }), + (err: unknown) => { + assert.ok(err instanceof McpRemoteRuntimeError) + assert.match((err as Error).message, /Staged mcp-remote runtime failed validation/) + return true + } + ) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'missing', 'nothing was published') + assert.deepStrictEqual(runtimeParentEntries(), [], 'staging was cleaned up') + }) + + it('returns an actionable error and cleans staging when npm fails', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls, 'fail') }), + (err: unknown) => { + assert.ok(err instanceof McpRemoteRuntimeError) + const message = (err as Error).message + assert.match(message, new RegExp(`npm install of mcp-remote@${MCP_REMOTE_VERSION} failed`)) + assert.match(message, /Rerun setup/) + assert.ok(message.includes('ECONNREFUSED'), 'keeps a bounded stderr tail') + assert.ok(message.length < 8192, 'stderr tail is bounded') + return true + } + ) + assert.deepStrictEqual(runtimeParentEntries(), [], 'staging cleaned on failure') + assert.strictEqual(inspectMcpRemoteRuntime().status, 'missing') + }) + + it('reports timeouts distinctly and cleans staging after confirmed termination', async () => { + const runner: NpmRunner = { + async run () { + return { status: null, stderr: '', timedOut: true } + }, + } + await assert.rejects( + ensureMcpRemoteRuntime({ runner, timeoutMs: 50 }), + (err: unknown) => { + assert.match((err as Error).message, /timed out after \d+s/) + assert.match((err as Error).message, /rerun setup/i) + return true + } + ) + // Termination was confirmed (no terminationError): staging is inert-free. + assert.deepStrictEqual(runtimeParentEntries(), [], 'staging cleaned after confirmed termination') + }) + + it('surfaces a spawn error distinctly and cleans staging (no installer started)', async () => { + const runner: NpmRunner = { + async run () { + return { status: null, stderr: '', spawnError: 'spawn npm ENOENT' } + }, + } + await assert.rejects( + ensureMcpRemoteRuntime({ runner }), + (err: unknown) => { + assert.ok(err instanceof McpRemoteRuntimeError) + const message = (err as Error).message + assert.match(message, /Could not start the npm installer \(spawn npm ENOENT\)/) + assert.doesNotMatch(message, /exit /, 'spawn failure is never encoded as an exit status') + assert.match(message, /Install Node\.js with npm/) + return true + } + ) + assert.deepStrictEqual(runtimeParentEntries(), [], 'staging cleaned when nothing was spawned') + assert.strictEqual(inspectMcpRemoteRuntime().status, 'missing') + }) + + it('marks staging retained-live on an unconfirmed termination and never publishes it', async () => { + // An invalid pre-existing root: ensure must try to replace it, fail on the + // unconfirmed termination, and leave both staging (marked retained-live + // with ownership metadata) and the prior tree untouched. (A ready root + // short-circuits before npm entirely.) + seedRuntime({ version: '0.1.37' }) + const priorPkg = readFileSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json'), 'utf8') + + const runner: NpmRunner = { + async run () { + return { status: null, stderr: '', timedOut: true, terminationError: 'managed npm process group still exists after the confirmation deadline' } + }, + } + await assert.rejects( + ensureMcpRemoteRuntime({ runner, timeoutMs: 50 }), + (err: unknown) => { + const message = (err as Error).message + assert.match(message, /could not be confirmed stopped/) + assert.match(message, /retained-live/) + assert.match(message, /Rerun setup/) + return true + } + ) + + // Staging remains on disk marked retained-live (a survivor may still + // mutate it), with its ownership sidecar recording the state… + const preserved = runtimeParentEntries() + const stagingTrees = preserved.filter((e) => e.startsWith('.staging-') && !e.endsWith('.owner.json')) + assert.strictEqual(stagingTrees.length, 1, 'staging preserved') + const stagingSidecars = preserved.filter((e) => e.startsWith('.staging-') && e.endsWith('.owner.json')) + assert.strictEqual(stagingSidecars.length, 1, 'ownership sidecar preserved') + const sidecar = JSON.parse(readFileSync(join(runtimeParent(), stagingSidecars[0] as string), 'utf8')) as { + state?: string + pid?: number + createdAt?: number + } + assert.strictEqual(sidecar.state, 'retained-live', 'sidecar records retained-live ownership') + assert.ok(typeof sidecar.pid === 'number' && sidecar.pid > 0) + assert.ok(typeof sidecar.createdAt === 'number') + // …nothing was validated or published, and the previous root is intact. + assert.strictEqual(inspectMcpRemoteRuntime().status, 'invalid', 'root never replaced after terminationError') + assert.strictEqual( + readFileSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json'), 'utf8'), + priorPkg, + 'pre-existing runtime untouched' + ) + }) + + it('a failed reinstall never worsens the on-disk state', async () => { + // Valid prior runtime: ensure must not even attempt a reinstall. + seedRuntime() + const idleCalls: Array<{ command: string; args: string[]; cwd: string }> = [] + const kept = await ensureMcpRemoteRuntime({ runner: createFakeRunner(idleCalls, 'fail') }) + assert.strictEqual(kept.installed, false) + assert.strictEqual(idleCalls.length, 0) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + + // Invalid prior runtime: npm fails; the prior tree must remain exactly as + // it was (never deleted to “make room”), and a retry can then fix it. + rmSync(runtimeRoot(), { recursive: true, force: true }) + seedRuntime({ version: '0.1.37' }) + const priorPkg = readFileSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json'), 'utf8') + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + await assert.rejects(ensureMcpRemoteRuntime({ runner: createFakeRunner(calls, 'fail') })) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'invalid') + assert.strictEqual( + readFileSync(join(runtimeRoot(), 'node_modules', 'mcp-remote', 'package.json'), 'utf8'), + priorPkg, + 'invalid prior runtime untouched by the failed attempt' + ) + + const fixed = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + assert.strictEqual(fixed.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION]) + }) + + it('keeps every pre-existing invalid runtime untouched when npm or staging validation fails', async () => { + // The invalidity kind must not matter: wrong version, missing proxy or an + // incomplete dependency closure — a failed attempt (npm error or a + // staging tree that fails validation) never modifies the prior tree. + const variants: Array<[string, () => void]> = [ + ['wrong version', () => seedRuntime({ version: '0.1.37' })], + ['missing proxy', () => seedRuntime({ withProxy: false })], + ['incomplete dependency closure', () => seedRuntime({ dependencies: { express: {}, open: {}, 'strict-url-sanitise': {} } })], + ] + const snapshot = (): string[] => { + const files: string[] = [] + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}` + if (entry.isDirectory()) walk(join(dir, entry.name), rel) + else if (entry.isFile()) files.push(`${rel}::${readFileSync(join(dir, entry.name), 'utf8')}`) + } + } + walk(runtimeRoot(), '') + return files.sort() + } + + for (const [label, seed] of variants) { + rmSync(runtimeParent(), { recursive: true, force: true }) + seed() + const before = snapshot() + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner([], 'fail') }), + McpRemoteRuntimeError, + `npm failure must reject (${label})` + ) + assert.deepStrictEqual(snapshot(), before, `invalid runtime untouched after npm failure (${label})`) + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner([], 'invalid') }), + /Staged mcp-remote runtime failed validation/, + `staging validation failure must reject (${label})` + ) + assert.deepStrictEqual(snapshot(), before, `invalid runtime untouched after staging validation failure (${label})`) + } + }) + + it('concurrent installs converge on a single valid runtime', async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + const runner: NpmRunner = { + async run (command, args, options) { + calls.push({ command, args: [...args], cwd: options.cwd }) + await new Promise((resolve) => setTimeout(resolve, 20)) + mkdirSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy\n') + return { status: 0, stderr: '' } + }, + } + + const [a, b] = await Promise.all([ + ensureMcpRemoteRuntime({ runner }), + ensureMcpRemoteRuntime({ runner }), + ]) + + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'no staging/stale/lock leftovers') + // Both raced before either published, so both staged; one publish won. + assert.strictEqual(calls.length, 2) + assert.strictEqual(a.root, b.root) + assert.ok(statSync(runtimeRoot()).isDirectory()) + }) + + it('two setups replacing the same invalid root converge under the lock', async () => { + seedRuntime({ version: '0.1.37' }) + const runner: NpmRunner = { + async run (_command, _args, options) { + await new Promise((resolve) => setTimeout(resolve, 20)) + mkdirSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy\n') + return { status: 0, stderr: '' } + }, + } + + await Promise.all([ + ensureMcpRemoteRuntime({ runner, publish: { lockWaitMs: 10_000 } }), + ensureMcpRemoteRuntime({ runner, publish: { lockWaitMs: 10_000 } }), + ]) + + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'loser accepted the winner; own staging/stale removed') + }) + + it('never leaks stored credentials in error output', async () => { + const agentsDir = join(tmpHome, '.agents') + mkdirSync(agentsDir, { recursive: true }) + writeFileSync(join(agentsDir, '.nodesource-auth.json'), JSON.stringify({ + serviceToken: 'super-secret-service-token-1234', + organizationId: 'org-id-secret-5678', + })) + process.env.NSOLID_CANARY_SECRET = 'canary-env-secret-9012' + + let captured = '' + const runner: NpmRunner = { + async run () { + return { status: 1, stderr: 'plain npm failure' } + }, + } + try { + await ensureMcpRemoteRuntime({ runner }) + } catch (err) { + captured = `${(err as Error).message}\n${(err as Error).stack ?? ''}` + } + assert.ok(captured.length > 0) + for (const secret of ['super-secret-service-token-1234', 'org-id-secret-5678', 'canary-env-secret-9012']) { + assert.ok(!captured.includes(secret), `output must not contain ${secret}`) + } + }) +}) + +describe('publication lock protocol', () => { + it('never evicts a young lock, whatever the holder', async () => { + const createdAt = Date.now() + const record = JSON.stringify({ token: 'foreign-token', pid: process.pid, createdAt }) + seedLock({ token: 'foreign-token', pid: process.pid, createdAt }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { lockWaitMs: 120, staleLockMs: 60_000 } }), + (err: unknown) => { + assert.match((err as Error).message, /Another setup is publishing/) + return true + } + ) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'missing', 'root untouched') + assert.strictEqual(readFileSync(lockPath(), 'utf8'), record, 'foreign lock byte-for-byte intact (token mismatch never unlinked)') + assert.deepStrictEqual(runtimeParentEntries().sort(), ['.publish-0.1.38.lock'], 'only the foreign lock remains') + }) + + it('never evicts an aged lock held by a live process', async () => { + const foreignToken = 'live-holder-token' + seedLock({ token: foreignToken, pid: process.pid, createdAt: Date.now() - 600_000 }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { lockWaitMs: 120, staleLockMs: 1 } }), + (err: unknown) => { + assert.match((err as Error).message, /Another setup is publishing/) + return true + } + ) + const record = JSON.parse(readFileSync(lockPath(), 'utf8')) as { token: string } + assert.strictEqual(record.token, foreignToken, 'live holder never evicted by age alone') + assert.strictEqual(inspectMcpRemoteRuntime().status, 'missing') + }) + + it('treats a permission-denied liveness check as a live holder (fail closed)', { skip: process.platform === 'win32' }, async () => { + // PID 1 exists and is owned by another user: kill(pid, 0) → EPERM, which + // must NOT authorize takeover. + seedLock({ token: 'root-owned-token', pid: 1, createdAt: Date.now() - 600_000 }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { lockWaitMs: 120, staleLockMs: 1 } }), + (err: unknown) => { + assert.match((err as Error).message, /Another setup is publishing/) + return true + } + ) + assert.ok(existsSync(lockPath()), 'lock intact') + }) + + it('treats a malformed lock record as owned (fail closed)', async () => { + mkdirSync(runtimeParent(), { recursive: true }) + writeFileSync(lockPath(), 'this is not json') + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { lockWaitMs: 120, staleLockMs: 1 } }), + (err: unknown) => { + assert.match((err as Error).message, /Another setup is publishing/) + return true + } + ) + assert.strictEqual(readFileSync(lockPath(), 'utf8'), 'this is not json', 'malformed lock left in place') + }) + + it('breaks a dead stale lock and reacquires with a fresh O_EXCL create', async () => { + const dead = deadPid() + seedLock({ token: 'dead-holder-token', pid: dead, createdAt: Date.now() - 600_000 }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + const result = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { staleLockMs: 1, lockWaitMs: 2_000 } }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'tombstone deleted, fresh lock released') + }) + + it('two simultaneous stale-lock breakers converge: exactly one publishes', async () => { + const dead = deadPid() + seedLock({ token: 'dead-holder-token', pid: dead, createdAt: Date.now() - 600_000 }) + const runner: NpmRunner = { + async run (_command, _args, options) { + await new Promise((resolve) => setTimeout(resolve, 10)) + mkdirSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: MCP_REMOTE_VERSION, dependencies: {} })) + writeFileSync(join(options.cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy\n') + return { status: 0, stderr: '' } + }, + } + const publish: PublishTestControls = { staleLockMs: 1, lockWaitMs: 10_000 } + + const [a, b] = await Promise.all([ + ensureMcpRemoteRuntime({ runner, publish }), + ensureMcpRemoteRuntime({ runner, publish }), + ]) + + assert.strictEqual(a.root, b.root) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'no tombstones, no leftover staging, lock released') + }) + + it('recovers deterministically after an interruption between the replacement renames', async () => { + // An invalid runtime is being replaced; the replacing process dies after + // `root → stale` but before `staging → root`. + seedRuntime({ version: '0.1.37' }) + const dead = deadPid() + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + await assert.rejects( + ensureMcpRemoteRuntime({ + runner: createFakeRunner(calls), + publish: { + holderPid: dead, + afterRootAside: () => { throw new Error('simulated kill -9 between the replacement renames') }, + }, + }), + (err: unknown) => { + assert.match((err as Error).message, /simulated kill -9/) + return true + } + ) + + // Post-crash state: root absent, stale sibling with its ownership + // sidecar, orphaned staging with its sidecar, lock still on disk with the + // dead holder. + assert.strictEqual(existsSync(runtimeRoot()), false, 'root is absent') + const staleSiblings = runtimeParentEntries().filter( + (e) => e.startsWith(`${MCP_REMOTE_VERSION}.stale-`) && !e.endsWith('.owner.json') + ) + assert.strictEqual(staleSiblings.length, 1, 'renamed-aside tree is an inert sibling') + assert.strictEqual( + runtimeParentEntries().filter((e) => e.startsWith(`${MCP_REMOTE_VERSION}.stale-`) && e.endsWith('.owner.json')).length, + 1, + 'stale tree keeps its ownership sidecar' + ) + assert.strictEqual( + runtimeParentEntries().filter((e) => e.startsWith('.staging-') && !e.endsWith('.owner.json')).length, + 1, + 'orphaned staging left in place' + ) + assert.ok(existsSync(lockPath()), 'lock survived the interruption') + + // Retry: breaks the dead-holder lock, publishes through the root-absent + // branch — the orphaned stale sibling is neither promoted nor required. + const retry = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls), publish: { staleLockMs: 1, lockWaitMs: 5_000 } }) + assert.strictEqual(retry.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + + const entries = runtimeParentEntries() + assert.ok(entries.includes(MCP_REMOTE_VERSION), 'exactly one valid runtime published') + assert.ok(entries.includes(staleSiblings[0] as string), 'orphaned stale sibling stays inert (never promoted, never GC)') + assert.strictEqual(entries.filter((e) => e === MCP_REMOTE_VERSION).length, 1) + assert.strictEqual(entries.filter((e) => e.startsWith('.publish-')).length, 0, 'lock released by the retry') + }) +}) + +describe('safe orphan reclamation', () => { + interface OrphanSeed { + kind: 'staging' | 'stale' + state?: 'active' | 'retained-live' + pid?: number + /** Sidecar age (ms); defaults to well past any test grace period. */ + ageMs?: number + managedPid?: number + token?: string + malformed?: boolean + } + + /** Seed an orphaned temporary tree with its adjacent ownership sidecar. */ + function seedOrphan (seed: OrphanSeed): { tree: string; sidecar: string } { + mkdirSync(runtimeParent(), { recursive: true }) + const name = seed.kind === 'staging' + ? `.staging-1-${randomUUID()}` + : `${MCP_REMOTE_VERSION}.stale-${randomUUID()}` + const tree = join(runtimeParent(), name) + mkdirSync(tree, { recursive: true }) + writeFileSync(join(tree, 'marker.txt'), 'orphan\n') + const sidecar = `${tree}.owner.json` + if (seed.malformed) { + writeFileSync(sidecar, '{not json') + } else { + writeFileSync(sidecar, JSON.stringify({ + token: seed.token ?? `orphan-token-${randomUUID()}`, + pid: seed.pid ?? deadPid(), + createdAt: Date.now() - (seed.ageMs ?? 120_000), + state: seed.state ?? 'retained-live', + ...(seed.managedPid !== undefined ? { managedPid: seed.managedPid } : {}), + })) + } + return { tree, sidecar } + } + + it('reclaims a retained-live staging orphan after the grace period once its creator is proven dead', async () => { + seedRuntime({ version: '0.1.37' }) // invalid root forces the publish path + const orphan = seedOrphan({ kind: 'staging' }) + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + const result = await ensureMcpRemoteRuntime({ + runner: createFakeRunner(calls), + publish: { reclaimGraceMs: 1 }, + }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.strictEqual(existsSync(orphan.tree), false, 'orphan tree reclaimed') + assert.strictEqual(existsSync(orphan.sidecar), false, 'orphan sidecar reclaimed') + }) + + it('reclaims an orphan when the configured home resolves through a symlink', async () => { + const linkedHome = `${tmpHome}-linked` + symlinkSync(tmpHome, linkedHome, process.platform === 'win32' ? 'junction' : 'dir') + process.env.HOME = linkedHome + process.env.USERPROFILE = linkedHome + + try { + seedRuntime({ version: '0.1.37' }) + const orphan = seedOrphan({ kind: 'staging' }) + + await ensureMcpRemoteRuntime({ + runner: createFakeRunner([]), + publish: { reclaimGraceMs: 1 }, + }) + + assert.strictEqual(existsSync(orphan.tree), false, 'orphan tree reclaimed through canonical home') + assert.strictEqual(existsSync(orphan.sidecar), false, 'orphan sidecar reclaimed through canonical home') + } finally { + process.env.HOME = tmpHome + process.env.USERPROFILE = tmpHome + rmSync(linkedHome, { recursive: true, force: true }) + } + }) + + it('retains an orphan whenever any single reclamation guard fails', async () => { + // Each variant violates exactly one guard from the safe-reclamation + // protocol; the tree and its sidecar must survive all of them. + const variants: Array<{ + label: string + seed: OrphanSeed + graceMs?: number + /** Runs before the orphan is seeded; may mutate `seed` (e.g. record a live managed pid). Returns a cleanup when needed. */ + setup?: (seed: OrphanSeed) => (() => void | Promise) | void + }> = [ + { label: 'grace period not elapsed', seed: { kind: 'staging', ageMs: 0 }, graceMs: 60_000 }, + { label: 'creator may still be alive', seed: { kind: 'staging', pid: process.pid } }, + { label: 'ownership metadata is malformed', seed: { kind: 'staging', malformed: true } }, + { + label: 'a live publication lock carries the operation token', + seed: { kind: 'staging', token: 'held-orphan-token' }, + setup: () => { + // A lock for a different runtime version, held live, records the + // orphan's operation token: its tree may still belong to a running + // operation. + mkdirSync(runtimeParent(), { recursive: true }) + writeFileSync( + join(runtimeParent(), '.publish-0.1.99.lock'), + JSON.stringify({ token: 'held-orphan-token', pid: process.pid, createdAt: Date.now() }) + ) + }, + }, + { + label: 'the recorded managed process tree still exists', + seed: { kind: 'staging' }, + setup: (seed) => { + // A real detached group leader plays the surviving managed npm tree + // (the runner spawns npm exactly this way). + const survivor = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }) + seed.managedPid = survivor.pid as number + // Register the exit observation before any termination request is + // sent, so a fast exit cannot race the listener. + const exited = new Promise((resolve) => { + survivor.once('exit', () => resolve()) + survivor.once('close', () => resolve()) + }) + return async () => { + const pid = survivor.pid as number + if (process.platform === 'win32') { + // Negative-PID process groups are a Unix-only construct (Node + // throws for them on Windows); terminate the tree with + // taskkill, matching the production runner's strategy. + spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } else { + try { + process.kill(-pid, 'SIGKILL') + } catch { + // Already gone. + } + } + // Bound the confirmation wait: an unterminated survivor fails the + // test instead of hanging the runner with a leaked process. + const confirmed = await Promise.race([ + exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), + ]) + assert.ok(confirmed, `cleanup could not confirm termination of the survivor process (pid ${pid})`) + try { + process.kill(pid, 0) + } catch (err) { + assert.strictEqual((err as NodeJS.ErrnoException).code, 'ESRCH', `survivor pid ${pid} must be fully gone after cleanup`) + return + } + assert.fail(`survivor process (pid ${pid}) still exists after cleanup`) + } + }, + }, + ] + + for (const { label, seed, graceMs = 1, setup } of variants) { + seedRuntime({ version: '0.1.37' }) // invalid root forces the publish path + const cleanup = setup?.(seed) + try { + const orphan = seedOrphan(seed) + await ensureMcpRemoteRuntime({ runner: createFakeRunner([]), publish: { reclaimGraceMs: graceMs } }) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready', `${label}: the setup itself must still succeed`) + assert.ok(existsSync(orphan.tree), `${label}: orphan tree retained`) + assert.ok(existsSync(orphan.sidecar), `${label}: orphan sidecar retained`) + } finally { + await cleanup?.() + } + } + }) + + it('reclaims an orphaned stale tree only after a valid versioned root exists', async () => { + // Phase 1: publish fails staging validation → the scan runs under the + // lock, but the (still invalid) root forbids stale reclamation. + seedRuntime({ version: '0.1.37' }) + const orphan = seedOrphan({ kind: 'stale' }) + await assert.rejects( + ensureMcpRemoteRuntime({ runner: createFakeRunner([], 'invalid'), publish: { reclaimGraceMs: 1 } }), + /Staged mcp-remote runtime failed validation/ + ) + assert.ok(existsSync(orphan.tree), 'stale orphan retained while no valid root exists') + assert.ok(existsSync(orphan.sidecar), 'stale orphan sidecar retained while no valid root exists') + + // Phase 2: publish succeeds → root is valid under the lock → reclamation. + await ensureMcpRemoteRuntime({ runner: createFakeRunner([]), publish: { reclaimGraceMs: 1 } }) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.strictEqual(existsSync(orphan.tree), false, 'stale orphan reclaimed once a valid root exists') + assert.strictEqual(existsSync(orphan.sidecar), false, 'stale orphan sidecar reclaimed') + }) +}) + +describe('default runner and npm resolution', () => { + it('runs npm without a shell, with separated argv, from the injected entry point', async () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'nsolid-npm-fixture-')) + const fakeNpm = join(fixtureDir, 'fake-npm.mjs') + const argvOut = join(fixtureDir, 'argv.json') + process.env.NSOLID_TEST_NPM_ARGV = argvOut + try { + writeFileSync(fakeNpm, [ + "import { mkdirSync, writeFileSync } from 'node:fs'", + "import path from 'node:path'", + 'const out = process.env.NSOLID_TEST_NPM_ARGV', + 'if (out) writeFileSync(out, JSON.stringify(process.argv.slice(2)))', + 'const cwd = process.cwd()', + "mkdirSync(path.join(cwd, 'node_modules', 'mcp-remote', 'dist'), { recursive: true })", + `writeFileSync(path.join(cwd, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: '${MCP_REMOTE_VERSION}', dependencies: {} }))`, + "writeFileSync(path.join(cwd, 'node_modules', 'mcp-remote', 'dist', 'proxy.js'), '// proxy')", + 'process.exit(0)', + ].join('\n')) + + const result = await ensureMcpRemoteRuntime({ + npmCommand: { command: process.execPath, args: [fakeNpm] }, + }) + + assert.strictEqual(result.installed, true) + const recorded = JSON.parse(readFileSync(argvOut, 'utf8')) as string[] + // The real spawn path: one argv element per flag/value, no shell string. + assert.deepStrictEqual(recorded, EXPECTED_NPM_INSTALL_ARGS) + assert.strictEqual(recorded.filter((a) => a.includes('&&') || a.includes(' ; ')).length, 0) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + } finally { + delete process.env.NSOLID_TEST_NPM_ARGV + rmSync(fixtureDir, { recursive: true, force: true }) + } + }) + + it('kills npm and reports a timeout through the real spawn path (staging cleaned)', async () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'nsolid-npm-timeout-')) + try { + const slowNpm = join(fixtureDir, 'slow-npm.mjs') + // Keep the event loop alive so only the kill timer can end this process. + writeFileSync(slowNpm, 'setInterval(() => {}, 1000)\nawait new Promise(() => {})\n') + await assert.rejects( + ensureMcpRemoteRuntime({ + npmCommand: { command: process.execPath, args: [slowNpm] }, + timeoutMs: 300, + }), + (err: unknown) => { + assert.match((err as Error).message, /timed out after \d+s/) + assert.match((err as Error).message, /rerun setup/i) + return true + } + ) + assert.deepStrictEqual(runtimeParentEntries(), [], 'termination confirmed, staging removed') + } finally { + rmSync(fixtureDir, { recursive: true, force: true }) + } + }) + + it('terminates the whole managed tree (grandchild included) before resolving', async () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'nsolid-npm-tree-')) + try { + const treeNpm = join(fixtureDir, 'tree-npm.mjs') + const pidsOut = join(fixtureDir, 'pids.json') + writeFileSync(treeNpm, [ + "import { spawn } from 'node:child_process'", + "import { writeFileSync } from 'node:fs'", + "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })", + 'writeFileSync(process.env.NSOLID_TEST_TREE_PIDS, JSON.stringify({ npm: process.pid, grandchild: grandchild.pid }))', + 'await new Promise(() => {})', + ].join('\n')) + process.env.NSOLID_TEST_TREE_PIDS = pidsOut + + await assert.rejects( + ensureMcpRemoteRuntime({ + npmCommand: { command: process.execPath, args: [treeNpm] }, + timeoutMs: 300, + }), + (err: unknown) => { + assert.match((err as Error).message, /timed out after \d+s/) + return true + } + ) + + const pids = JSON.parse(readFileSync(pidsOut, 'utf8')) as { npm: number; grandchild: number } + assert.ok(Number.isInteger(pids.npm) && pids.npm > 0) + assert.ok(Number.isInteger(pids.grandchild) && pids.grandchild > 0) + for (const [label, pid] of Object.entries(pids)) { + assert.throws( + () => process.kill(pid as number, 0), + (err: NodeJS.ErrnoException) => err.code === 'ESRCH', + `${label} must be gone before the runner resolves` + ) + } + assert.deepStrictEqual(runtimeParentEntries(), [], 'termination confirmed, staging removed') + } finally { + delete process.env.NSOLID_TEST_TREE_PIDS + rmSync(fixtureDir, { recursive: true, force: true }) + } + }) + + it('surfaces a real spawn failure (ENOENT) as spawnError, not an exit status', async () => { + await assert.rejects( + ensureMcpRemoteRuntime({ + npmCommand: { command: join(tmpHome, 'definitely-missing-npm'), args: [] }, + }), + (err: unknown) => { + const message = (err as Error).message + assert.match(message, /Could not start the npm installer/) + assert.match(message, /ENOENT/) + assert.doesNotMatch(message, /exit /) + return true + } + ) + assert.deepStrictEqual(runtimeParentEntries(), [], 'staging cleaned: no installer started') + }) + + it('keeps stderr bounded and never includes the environment', async () => { + process.env.NSOLID_CANARY_SECRET = 'canary-env-secret-9012' + const runner: NpmRunner = { + async run () { + return { status: 1, stderr: `leading-detail-that-must-be-truncated-away\n${'x'.repeat(10_000)}` } + }, + } + await assert.rejects(ensureMcpRemoteRuntime({ runner }), (err: unknown) => { + const message = (err as Error).message + assert.ok(message.length < 8192, 'error message bounded') + assert.ok(!message.includes('leading-detail-that-must-be-truncated-away'), 'only the bounded tail is kept') + assert.ok(!message.includes('canary-env-secret-9012'), 'environment never dumped') + return true + }) + }) +}) + +describe('npm resolution (canonical, Node.js-anchored)', () => { + /** Build a fake Node.js installation layout; the "node" binary is never executed. */ + function layoutDir (): string { + // Canonicalize the fixture root: resolution returns realpath'd paths, and + // on macOS the temp dir is a symlink (/var/folders → /private/var/folders). + return realpathSync(mkdtempSync(join(tmpdir(), 'nsolid-node-layout-'))) + } + function writeNode (dir: string, name = 'node'): string { + mkdirSync(dir, { recursive: true }) + const exec = join(dir, name) + writeFileSync(exec, '#!/bin/sh\nexit 0\n') + chmodSync(exec, 0o755) + return exec + } + function writeCli (dir: string, name = 'npm-cli.js'): string { + const cli = join(dir, name) + mkdirSync(dirname(cli), { recursive: true }) + writeFileSync(cli, '// npm cli\n') + chmodSync(cli, 0o755) // shims are spawned directly and must be executable + return cli + } + + it('resolves the Windows installer layout: node-dir node_modules/npm CLI via [node, cli]', () => { + const dir = layoutDir() + try { + const exec = writeNode(dir, 'node.exe') + const cli = writeCli(join(dir, 'node_modules', 'npm', 'bin')) + const resolved = resolveNpmCommandForExecPath(exec, 'win32') + assert.deepStrictEqual(resolved, { command: exec, args: [cli] }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('resolves the Unix prefix layout: ../lib/node_modules/npm CLI via [node, cli]', () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + const cli = writeCli(join(dir, 'lib', 'node_modules', 'npm', 'bin')) + const resolved = resolveNpmCommandForExecPath(exec, 'linux') + assert.deepStrictEqual(resolved, { command: exec, args: [cli] }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('resolves an executable Unix npm sibling shim directly (no shell)', { skip: process.platform === 'win32' }, () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + const shim = join(bin, 'npm') + writeFileSync(shim, '#!/bin/sh\nexec node npm-cli.js "$@"\n') + chmodSync(shim, 0o755) + const resolved = resolveNpmCommandForExecPath(exec, 'linux') + assert.deepStrictEqual(resolved, { command: shim, args: [] }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('trusts an in-prefix symlink whose canonical target stays inside the prefix', { skip: process.platform === 'win32' }, () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + // Real CLI lives at a non-anchored path INSIDE the prefix; the anchored + // sibling shim symlinks to it — the canonical target stays trusted. + const cli = writeCli(join(dir, 'lib', 'node_modules', 'npm'), 'actual-cli.js') + symlinkSync(cli, join(bin, 'npm')) + const resolved = resolveNpmCommandForExecPath(exec, 'linux') + assert.deepStrictEqual(resolved, { command: cli, args: [] }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects a candidate whose symlink target escapes the canonical prefix', () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + const evil = layoutDir() + const evilCli = writeCli(join(evil, 'node_modules', 'npm', 'bin')) + // Anchored candidate at ../lib/node_modules/npm/bin/npm-cli.js → outside target. + const anchored = join(dir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + mkdirSync(dirname(anchored), { recursive: true }) + symlinkSync(evilCli, anchored) + assert.throws( + () => resolveNpmCommandForExecPath(exec, 'linux'), + (err: unknown) => { + assert.ok(err instanceof McpRemoteRuntimeError) + assert.match((err as Error).message, /Could not locate a trusted npm/) + assert.match((err as Error).message, /Install Node\.js with npm/) + return true + } + ) + rmSync(evil, { recursive: true, force: true }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects a lexical-prefix escape (prefix-evil is not inside prefix)', () => { + const dir = layoutDir() + try { + const install = join(dir, 'node') + const bin = join(install, 'bin') + const exec = writeNode(bin) + const evilCli = writeCli(join(`${install}-evil`, 'lib', 'node_modules', 'npm', 'bin')) + const anchored = join(install, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + mkdirSync(dirname(anchored), { recursive: true }) + symlinkSync(evilCli, anchored) // target starts with -evil, not / + assert.throws(() => resolveNpmCommandForExecPath(exec, 'linux'), McpRemoteRuntimeError) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects a directory at the anchored CLI location', () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + mkdirSync(join(dir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), { recursive: true }) + assert.throws(() => resolveNpmCommandForExecPath(exec, 'linux'), McpRemoteRuntimeError) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects a non-executable sibling shim', () => { + const dir = layoutDir() + try { + const bin = join(dir, 'bin') + const exec = writeNode(bin) + writeFileSync(join(bin, 'npm'), '#!/bin/sh\n') // no execute bit + assert.throws(() => resolveNpmCommandForExecPath(exec, 'linux'), McpRemoteRuntimeError) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('fails closed when no anchored candidate exists', () => { + const dir = layoutDir() + try { + const exec = writeNode(join(dir, 'bin')) + assert.throws( + () => resolveNpmCommandForExecPath(exec, 'linux'), + (err: unknown) => { + assert.match((err as Error).message, /Could not locate a trusted npm/) + return true + } + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('never consults npm_execpath, PATH, or the project .bin (real resolution)', () => { + const projectDir = mkdtempSync(join(tmpdir(), 'nsolid-evil-bin-')) + const originalPath = process.env.PATH + try { + const evilBin = join(projectDir, 'node_modules', '.bin') + mkdirSync(evilBin, { recursive: true }) + writeFileSync(join(evilBin, 'npm'), '#!/bin/sh\necho pwned\n') + process.env.PATH = `${evilBin}${delimiter}${process.env.PATH}` + + // A fake npm-cli.js elsewhere plus npm_execpath pointing at it: the + // basename and node_modules/npm segments must not make it trusted. + const fakeCli = writeCli(join(projectDir, 'node_modules', 'npm', 'bin')) + process.env.npm_execpath = fakeCli + + let resolved: { command: string; args: string[] } | undefined + try { + resolved = resolveNpmCommand() + } catch (err) { + assert.ok(err instanceof McpRemoteRuntimeError, 'unsupported layout fails with the actionable error') + assert.match((err as Error).message, /Install Node\.js with npm/) + } + if (resolved !== undefined) { + assert.ok(!resolved.command.includes(evilBin), 'must not resolve npm from project .bin') + assert.ok(!resolved.command.includes('pwned')) + assert.ok(!resolved.args.some((a) => a.includes(fakeCli) || a.includes(projectDir)), 'npm_execpath is never used') + assert.ok( + resolved.command === process.execPath || resolved.command.startsWith(dirname(process.execPath) + sep), + `resolved npm must live next to node: ${resolved.command}` + ) + } + + // A hostile relative npm_execpath is likewise ignored by construction. + process.env.npm_execpath = 'relative/npm-cli.js' + let relative: { command: string; args: string[] } | undefined + try { + relative = resolveNpmCommand() + } catch { + // Actionable error is fine on layouts without npm. + } + if (relative !== undefined) { + assert.ok(!relative.args.some((a) => a === 'relative/npm-cli.js')) + } + } finally { + process.env.PATH = originalPath + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('ignores a pnpm/yarn npm_execpath and falls back to the node-anchored resolution', () => { + // Mirror real layouts: pnpm under a user prefix, yarn under ~/.yarn/releases. + const pnpmCli = join(tmpHome, '.local', 'share', 'pnpm', 'lib', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + mkdirSync(dirname(pnpmCli), { recursive: true }) + writeFileSync(pnpmCli, '// pnpm cli\n') + const yarnCli = join(tmpHome, '.yarn', 'releases', 'yarn-4.0.0.cjs') + mkdirSync(dirname(yarnCli), { recursive: true }) + writeFileSync(yarnCli, '// yarn cli\n') + + // Baseline: what resolution looks like with no npm_execpath at all. + delete process.env.npm_execpath + let fallback: { command: string; args: string[] } | undefined + try { + fallback = resolveNpmCommand() + } catch { + fallback = undefined + } + + for (const foreign of [pnpmCli, yarnCli]) { + process.env.npm_execpath = foreign + let resolved: { command: string; args: string[] } + try { + resolved = resolveNpmCommand() + } catch (err) { + // Layout without npm: the actionable error is correct; the point is + // that the pnpm/yarn value was never executed. + assert.ok(err instanceof McpRemoteRuntimeError) + continue + } + assert.notStrictEqual(resolved.args[0], foreign, 'pnpm/yarn npm_execpath must not be used as npm') + if (fallback !== undefined) { + assert.deepStrictEqual(resolved, fallback, 'must fall back to the node-anchored resolution') + } + assert.ok( + resolved.command === process.execPath || resolved.command.startsWith(dirname(process.execPath) + sep), + `resolved npm must live next to node: ${resolved.command}` + ) + } + }) + + it('ignores a missing npm_execpath', () => { + process.env.npm_execpath = join(tmpHome, 'definitely-missing-npm.js') + let resolved: { command: string; args: string[] } | undefined + try { + resolved = resolveNpmCommand() + } catch { + resolved = undefined + } + if (resolved !== undefined) { + assert.notStrictEqual(resolved.args[0], process.env.npm_execpath) + } + }) +}) diff --git a/packages/core/test/unit/mcp/mcp-runtime-runner.test.ts b/packages/core/test/unit/mcp/mcp-runtime-runner.test.ts new file mode 100644 index 0000000..c0736e4 --- /dev/null +++ b/packages/core/test/unit/mcp/mcp-runtime-runner.test.ts @@ -0,0 +1,122 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { tmpdir } from 'node:os' + +import { cancelManagedTree, defaultNpmRunner } from '../../../src/mcp/mcp-runtime-runner.js' + +/** + * The Windows termination branch of the default npm runner is exercised + * through injected behaviour: the fake taskkill process never touches the + * real system, so a "stuck taskkill" is deterministic and instant. + */ + +interface FakeKiller extends EventEmitter { + kill: () => boolean + killed: boolean +} + +function fakeKiller (): FakeKiller { + const killer = new EventEmitter() as FakeKiller + killer.killed = false + killer.kill = () => { + killer.killed = true + return true + } + return killer +} + +/** A root-process close promise that never settles (surviving npm). */ +function neverCloses (): Promise { + return new Promise(() => {}) +} + +/** Best-effort kill of a real child spawned by the runner (test cleanup). */ +function killTree (pid: number): void { + if (process.platform === 'win32') { + spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } else { + try { + process.kill(-pid, 'SIGKILL') // runner spawns a detached group leader + } catch { + // Already gone. + } + } +} + +describe('cancelManagedTree (Windows termination branch)', () => { + it('returns a bounded terminationError when taskkill never exits', async () => { + const killer = fakeKiller() + const start = Date.now() + const outcome = await cancelManagedTree({ pid: 1234 }, neverCloses(), { + platform: 'win32', + confirmMs: 50, + spawnKiller: () => killer, + }) + const elapsed = Date.now() - start + + assert.strictEqual(outcome.confirmed, false) + assert.match(outcome.error ?? '', /taskkill did not exit within the termination confirmation deadline/) + assert.ok(elapsed < 5_000, `termination must stay bounded by the deadline (took ${elapsed}ms)`) + assert.strictEqual(killer.killed, true, 'the stuck killer process is stopped on the deadline') + }) + + it('confirms termination when taskkill exits 0 and the root process closes', async () => { + const killer = fakeKiller() + const outcomePromise = cancelManagedTree({ pid: 1234 }, Promise.resolve(), { + platform: 'win32', + confirmMs: 5_000, + spawnKiller: () => killer, + }) + killer.emit('close', 0) + assert.deepStrictEqual(await outcomePromise, { confirmed: true }) + }) + + it('reports the npm process still running when taskkill fails and the root survives', async () => { + const killer = fakeKiller() + const start = Date.now() + const outcomePromise = cancelManagedTree({ pid: 1234 }, neverCloses(), { + platform: 'win32', + confirmMs: 50, + spawnKiller: () => killer, + }) + killer.emit('close', 1) + const outcome = await outcomePromise + const elapsed = Date.now() - start + + assert.strictEqual(outcome.confirmed, false) + assert.match(outcome.error ?? '', /taskkill exited with 1/) + assert.match(outcome.error ?? '', /npm process still running/) + assert.ok(elapsed < 5_000, `termination must stay bounded by the deadline (took ${elapsed}ms)`) + }) +}) + +describe('defaultNpmRunner timeout settlement', () => { + it('settles with terminationError when cancellation fails and the child never closes', async () => { + // A real child that stays alive: cancellation is forced to fail through + // the injected stuck taskkill, so the child's close event never fires. + // The runner must settle from the timeout path instead of waiting for a + // close that never comes. + const killer = fakeKiller() + let pid: number | undefined + const start = Date.now() + try { + const result = await defaultNpmRunner.run(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + cwd: tmpdir(), + timeoutMs: 50, + onSpawned: (identity) => { pid = identity.pid }, + terminationControls: { platform: 'win32', confirmMs: 50, spawnKiller: () => killer }, + }) + const elapsed = Date.now() - start + + assert.strictEqual(result.timedOut, true) + assert.strictEqual(result.status, null) + assert.match(result.terminationError ?? '', /taskkill did not exit within the termination confirmation deadline/) + assert.ok(elapsed < 5_000, `the runner must settle within the termination deadline (took ${elapsed}ms)`) + assert.strictEqual(killer.killed, true, 'the stuck killer process is stopped on the deadline') + } finally { + if (pid !== undefined) killTree(pid) + } + }) +}) diff --git a/packages/core/test/unit/mcp/mcp-wrapper.test.ts b/packages/core/test/unit/mcp/mcp-wrapper.test.ts index 51ee40e..1f9fde6 100644 --- a/packages/core/test/unit/mcp/mcp-wrapper.test.ts +++ b/packages/core/test/unit/mcp/mcp-wrapper.test.ts @@ -1,30 +1,48 @@ import { afterEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { pathToFileURL } from 'node:url' // @ts-expect-error The repository's JavaScript generator intentionally has no TypeScript declarations. -import { generateMcpWrapper } from '../../../../../scripts/plugin-generators.mjs' +import { generateMcpWrapper, MCP_REMOTE_VERSION as GENERATOR_VERSION, PLUGIN_VERSION as GENERATOR_PLUGIN_VERSION, HARNESS_VALUES as GENERATOR_HARNESS_VALUES } from '../../../../../scripts/plugin-generators.mjs' +import { MCP_REMOTE_VERSION as CORE_VERSION } from '../../../src/mcp/mcp-remote-runtime.js' +import { HARNESS_VALUES as CORE_HARNESS_VALUES, PLUGIN_OWNED_HARNESSES, NATIVE_PLUGIN_HARNESSES } from '../../../src/types.js' const repoRoot = join(import.meta.dirname, '..', '..', '..', '..', '..') const sourceWrapper = join(repoRoot, 'scripts', 'mcp-wrapper.js') +const rootPackageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) as { dependencies?: Record } +const corePackageJson = JSON.parse(readFileSync(join(repoRoot, 'packages', 'core', 'package.json'), 'utf8')) as { version?: string } const url = 'https://example.test/a path?q=one&redirect=%PATH%"e="hello world"' const token = 'tok en&%PATH%"quoted value"' const temporaryPaths: string[] = [] +/** Escaped plugin version for building repair-message regexes. */ +const pinnedPlugin = `nsolid-plugin@${GENERATOR_PLUGIN_VERSION.replace(/\./g, '\\.')}` +const repairFor = (harness: string): RegExp => + new RegExp(`MCP bridge runtime is not ready\\. Run: npx -y ${pinnedPlugin} setup --harness ${harness}`) + afterEach(() => { for (const temporaryPath of temporaryPaths.splice(0)) rmSync(temporaryPath, { recursive: true, force: true }) }) -function createWrapperFixture (wrapper: 'source' | 'generated'): { directory: string, wrapperPath: string, home: string, bin: string, output: string } { +interface Fixture { + directory: string + wrapperPath: string + home: string + bin: string + output: string +} + +function createWrapperFixture (wrapper: 'source' | 'generated'): Fixture { const directory = mkdtempSync(join(tmpdir(), 'nsolid-mcp-wrapper-')) temporaryPaths.push(directory) + // The wrapper lives in a directory WITHOUT node_modules so the dev + // createRequire fallback cannot mask a broken stable-runtime path. const wrapperPath = join(directory, 'mcp-wrapper.mjs') if (wrapper === 'source') cpSync(sourceWrapper, wrapperPath) - else writeFileSync(wrapperPath, generateMcpWrapper('claude')) + else writeFileSync(wrapperPath, generateMcpWrapper()) const home = join(directory, 'home') const bin = join(directory, 'bin') const output = join(directory, 'captured') @@ -36,195 +54,655 @@ function createWrapperFixture (wrapper: 'source' | 'generated'): { directory: st return { directory, wrapperPath, home, bin, output } } -function wrapperEnvironment (fixture: ReturnType): NodeJS.ProcessEnv { - const environment: NodeJS.ProcessEnv = { ...process.env, HOME: fixture.home, USERPROFILE: fixture.home, PATH: `${fixture.bin}${delimiter}${process.env.PATH}`, NSOLID_TEST_OUTPUT: fixture.output } - delete environment.NSOLID_TEST_SYSTEM_ROOT +function seedRuntime (home: string, version: string = CORE_VERSION): string { + const dir = join(home, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', version, 'node_modules', 'mcp-remote') + mkdirSync(join(dir, 'dist'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'mcp-remote', version })) + writeFileSync(join(dir, 'dist', 'proxy.js'), "const { writeFileSync } = require('node:fs')\nwriteFileSync(process.env.NSOLID_TEST_OUTPUT, JSON.stringify(process.argv.slice(2)))\n") + return dir +} + +/** + * Seed a runtime whose proxy.js passes the wrapper's light validation but + * fails while being imported or initialized. + */ +function seedBrokenRuntime (home: string, proxySource: string): string { + const dir = seedRuntime(home) + writeFileSync(join(dir, 'dist', 'proxy.js'), proxySource) + return dir +} + +function replaceWithSymlink (target: string, replacement: string, kind: 'file' | 'dir'): boolean { + rmSync(target, { recursive: true, force: true }) + try { + symlinkSync(replacement, target, process.platform === 'win32' && kind === 'dir' ? 'junction' : kind) + return true + } catch (err) { + if (process.platform === 'win32' && (err as NodeJS.ErrnoException).code === 'EPERM') return false + throw err + } +} + +function wrapperEnvironment (fixture: Fixture, extraPath?: string): NodeJS.ProcessEnv { + const pathPrefix = extraPath ? `${extraPath}${delimiter}` : '' + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: fixture.home, + USERPROFILE: fixture.home, + PATH: `${pathPrefix}${fixture.bin}${delimiter}${process.env.PATH}`, + NSOLID_TEST_OUTPUT: fixture.output, + } + // The development fallback is opt-in per test — never inherit a stray flag. + delete env.NSOLID_MCP_RUNTIME_DEV_FALLBACK + return env +} + +/** + * Plant a sentinel `command` (npx/npm) on the fixture PATH: if the wrapper + * ever executes it, a marker appears and the process exits with the + * command-specific code (97 = npx, 98 = npm). + */ +function writeCommandSentinel (fixture: Fixture, command: 'npx' | 'npm'): string { + const sentinelMarker = join(fixture.directory, `${command}-ran`) + const exitCode = command === 'npx' ? 97 : 98 + const fileName = process.platform === 'win32' ? `${command}.cmd` : command + const sentinel = join(fixture.bin, fileName) if (process.platform === 'win32') { - const hook = join(fixture.directory, 'override-exec-path.mjs') - writeFileSync(hook, [ - `Object.defineProperty(process, 'execPath', { value: ${JSON.stringify(join(fixture.bin, 'node.exe'))} })`, - 'if (process.env.NSOLID_TEST_SYSTEM_ROOT !== undefined) {', - ' process.env.SystemRoot = process.env.NSOLID_TEST_SYSTEM_ROOT', - ' delete process.env.NSOLID_TEST_SYSTEM_ROOT', - '}', - ].join('\n')) - const importHook = `--import=${pathToFileURL(hook).href}` - environment.NODE_OPTIONS = [process.env.NODE_OPTIONS, importHook].filter(Boolean).join(' ') + writeFileSync(sentinel, `@echo off\r\necho pwned > "${sentinelMarker}"\r\nexit /b ${exitCode}\r\n`) + } else { + writeFileSync(sentinel, `#!/bin/sh\necho pwned > "${sentinelMarker}"\nexit ${exitCode}\n`) + chmodSync(sentinel, 0o755) } - return environment + return sentinelMarker } -describe('MCP wrapper fallback', () => { - it('bootstrap resolves the proxy from npx\'s node_modules directory', () => { - const directory = mkdtempSync(join(tmpdir(), 'nsolid-mcp-bootstrap-')) - temporaryPaths.push(directory) - const bin = join(directory, 'node_modules', '.bin') - const binName = process.platform === 'win32' ? 'mcp-remote.cmd' : 'mcp-remote' - const proxy = join(directory, 'node_modules', 'mcp-remote', 'dist', 'proxy.js') - const output = join(directory, 'argv.json') - mkdirSync(bin, { recursive: true }) - mkdirSync(join(directory, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) - writeFileSync(join(bin, binName), '') - writeFileSync(proxy, "const { writeFileSync } = require('node:fs')\nwriteFileSync(process.env.NSOLID_TEST_OUTPUT, JSON.stringify(process.argv.slice(2)))\n") - - const source = readFileSync(sourceWrapper, 'utf8') - const bootstrapLiteral = source.match(/const MCP_REMOTE_NPX_BOOTSTRAP = (".*")/) - assert.ok(bootstrapLiteral) - const bootstrap = `data:text/javascript;base64,${Buffer.from(JSON.parse(bootstrapLiteral[1])).toString('base64')}` - const payload = Buffer.from(JSON.stringify({ url, headers: { 'X-Nsolid-Service-Token': token } })).toString('base64url') - const result = spawnSync(process.execPath, ['--input-type=module', '--eval', 'await import(process.env.NSOLID_MCP_REMOTE_BOOTSTRAP)'], { - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, NSOLID_MCP_REMOTE_BOOTSTRAP: bootstrap, NSOLID_MCP_REMOTE_PAYLOAD: payload, NSOLID_TEST_OUTPUT: output }, - encoding: 'utf8', - }) - assert.strictEqual(result.status, 0, result.stderr) - assert.deepStrictEqual(JSON.parse(readFileSync(output, 'utf8')), [url, '--header', `X-Nsolid-Service-Token:${token}`, '--transport', 'http-first', '--silent']) +/** Plant a local mcp-remote copy next to the wrapper (dev-checkout layout). */ +function seedLocalCopy (fixture: Fixture, version: string): string { + const dir = join(fixture.directory, 'node_modules', 'mcp-remote') + mkdirSync(join(dir, 'dist'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'mcp-remote', version })) + writeFileSync( + join(dir, 'dist', 'proxy.js'), + "const { writeFileSync } = require('node:fs')\nwriteFileSync(process.env.NSOLID_TEST_OUTPUT, 'local-proxy ' + JSON.stringify(process.argv.slice(2)))\n" + ) + return dir +} + +/** Sentinel never ran: the marker file was never created. */ +function neverRan (marker: string): boolean { + return !readFileSync(marker, { encoding: 'utf8', flag: 'a+' }).toString().includes('pwned') +} + +describe('MCP wrapper runtime contract', () => { + it('keeps the mcp-remote version in sync across core, generator and package.json', () => { + assert.strictEqual(GENERATOR_VERSION, CORE_VERSION) + assert.strictEqual(rootPackageJson.dependencies?.['mcp-remote'], CORE_VERSION) + // The generated wrapper embeds the version for its stable-path resolution. + assert.ok(generateMcpWrapper().includes(`'${CORE_VERSION}'`)) }) + it('pins the repair command to the generating release', () => { + // The wrapper's embedded plugin version is the release that generated it + // (bundle + core package version), so `npx -y nsolid-plugin@X` provisions + // exactly X's pinned runtime version. + assert.strictEqual(GENERATOR_PLUGIN_VERSION, corePackageJson.version) + const generated = generateMcpWrapper() + assert.ok(generated.includes(`const PLUGIN_VERSION = '${GENERATOR_PLUGIN_VERSION}'`)) + // The wrapper builds the command at runtime from the embedded release. + assert.ok(generated.includes('npx -y nsolid-plugin@')) + const interpolation = '${' + assert.ok(generated.includes(`nsolid-plugin@${interpolation}PLUGIN_VERSION} setup --harness ${interpolation}harness}`)) + }) + + it('keeps the harness lists in sync across core, generator and the generated wrapper', () => { + // The generator's list feeds the wrapper's HARNESS_NAMES literal; core's + // HARNESS_VALUES drives --harness validation. They must not diverge. + assert.deepEqual(GENERATOR_HARNESS_VALUES, CORE_HARNESS_VALUES) + const generated = generateMcpWrapper() + const harnessNames = generated.match(/const HARNESS_NAMES = new Set\(\[([^\]]*)\]\)/)?.[1] + assert.ok(harnessNames, 'generated wrapper embeds a HARNESS_NAMES set') + assert.deepEqual( + harnessNames.split(',').map((s: string) => s.trim().replaceAll("'", '')), + CORE_HARNESS_VALUES + ) + // Ownership semantics: opencode belongs to neither set; pi is native + // (package-owned) but not plugin-owned. + assert.deepEqual([...PLUGIN_OWNED_HARNESSES], ['claude', 'codex', 'antigravity']) + assert.deepEqual([...NATIVE_PLUGIN_HARNESSES], ['claude', 'codex', 'antigravity', 'pi']) + }) + + it('contains no shell, npx or cmd.exe execution paths', () => { + const stripComments = (source: string) => + source.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((line: string) => !line.trim().startsWith('//')).join('\n') + for (const raw of [readFileSync(sourceWrapper, 'utf8'), generateMcpWrapper()]) { + const source = stripComments(raw) + assert.ok(!source.includes('child_process'), 'no child_process import') + assert.ok(!/\bspawn\s*\(/.test(source), 'no spawn call') + assert.ok(!source.includes('cmd.exe'), 'no cmd.exe') + assert.ok(!source.includes('npx.cmd'), 'no npx.cmd') + assert.ok(!source.includes('NSOLID_MCP_REMOTE_PAYLOAD'), 'no npx payload bootstrap') + assert.ok(!/shell\s*:\s*true/.test(source), 'no shell:true') + } + }) + + it('generated wrapper matches the committed source wrapper byte for byte', () => { + // The root artifact must be the generator output — no manual drift. + assert.strictEqual(readFileSync(sourceWrapper, 'utf8'), generateMcpWrapper()) + }) +}) + +describe('MCP wrapper stable runtime', () => { for (const wrapper of ['source', 'generated'] as const) { - it(`${wrapper} wrapper derives the console MCP URL from the org id when no mcpUrl is stored`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper derives the console MCP URL from the org id when no mcpUrl is stored`, () => { const fixture = createWrapperFixture(wrapper) + seedRuntime(fixture.home) // Blank out the stored mcpUrl so the wrapper must derive it from // consoleUrl + org id (matching the TS deriveMcpUrlFromConsoleUrl). writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://pretty-name.saas.nodesource.io', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', })) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') - chmodSync(npx, 0o755) - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) assert.strictEqual(result.status, 0, result.stderr) - const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') - assert.strictEqual(args[2], 'https://org-123.mcp.saas.nodesource.io/') + const args = JSON.parse(readFileSync(fixture.output, 'utf8')) as string[] + assert.strictEqual(args[0], 'https://org-123.mcp.saas.nodesource.io/') }) - it(`${wrapper} wrapper forces https on the derived MCP URL even for an http consoleUrl`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper forces https on the derived MCP URL even for an http consoleUrl`, () => { const fixture = createWrapperFixture(wrapper) + seedRuntime(fixture.home) writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ serviceToken: token, organizationId: 'org-456', consoleUrl: 'http://pretty-name.saas.nodesource.io', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', })) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') - chmodSync(npx, 0o755) - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) assert.strictEqual(result.status, 0, result.stderr) - const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') - assert.strictEqual(args[2], 'https://org-456.mcp.saas.nodesource.io/') + const args = JSON.parse(readFileSync(fixture.output, 'utf8')) as string[] + assert.strictEqual(args[0], 'https://org-456.mcp.saas.nodesource.io/') }) - it(`${wrapper} wrapper rejects a console URL that is not a recognized NodeSource SaaS host`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper rejects a console URL that is not a recognized NodeSource SaaS host`, () => { const fixture = createWrapperFixture(wrapper) + seedRuntime(fixture.home) writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://console.example.com', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', })) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nexit 0\n') - chmodSync(npx, 0o755) - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) assert.notStrictEqual(result.status, 0) assert.match(result.stderr, /Could not derive NodeSource console MCP URL/) }) - it(`${wrapper} wrapper preserves argv boundaries outside Windows`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper imports the stable runtime and preserves argv boundaries`, () => { const fixture = createWrapperFixture(wrapper) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') - chmodSync(npx, 0o755) + seedRuntime(fixture.home) + const sentinel = writeCommandSentinel(fixture, 'npx') - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + }) assert.strictEqual(result.status, 0, result.stderr) - assert.deepStrictEqual(readFileSync(fixture.output, 'utf8').trimEnd().split('\n'), [ - '-y', 'mcp-remote@0.1.38', url, '--header', `X-Nsolid-Service-Token:${token}`, '--transport', 'http-first', '--silent', + // URL/token with spaces, quotes, & and %PATH% arrive as intact argv + // elements — no shell ever re-parsed them. + assert.deepStrictEqual(JSON.parse(readFileSync(fixture.output, 'utf8')), [ + url, '--header', `X-Nsolid-Service-Token:${token}`, '--transport', 'http-first', '--silent', ]) + assert.ok(neverRan(sentinel), 'npx sentinel never ran') }) - it(`${wrapper} wrapper migrates a stored legacy alias-derived mcpUrl to the org-UUID route`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper migrates a stored legacy alias-derived mcpUrl to the org-UUID route`, () => { const fixture = createWrapperFixture(wrapper) + seedRuntime(fixture.home) // The previous release stored the alias-derived (dead) endpoint. It must // be replaced by the org-UUID route even though a value is present. writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://homedepot-nucleus-stage-1.saas.nodesource.io', mcpUrl: 'https://homedepot-nucleus-stage-1.mcp.saas.nodesource.io/', expiresAt: '2099-01-01T00:00:00.000Z', })) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') - chmodSync(npx, 0o755) - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) assert.strictEqual(result.status, 0, result.stderr) - const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') - assert.strictEqual(args[2], 'https://org-123.mcp.saas.nodesource.io/') + const args = JSON.parse(readFileSync(fixture.output, 'utf8')) as string[] + assert.strictEqual(args[0], 'https://org-123.mcp.saas.nodesource.io/') }) - it(`${wrapper} wrapper preserves a genuine custom mcpUrl override`, { skip: process.platform === 'win32' }, () => { + it(`${wrapper} wrapper preserves a genuine custom mcpUrl override`, () => { const fixture = createWrapperFixture(wrapper) + seedRuntime(fixture.home) writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://homedepot-nucleus-stage-1.saas.nodesource.io', mcpUrl: 'https://relay.example.com/mcp', expiresAt: '2099-01-01T00:00:00.000Z', })) - const npx = join(fixture.bin, 'npx') - writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') - chmodSync(npx, 0o755) - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) assert.strictEqual(result.status, 0, result.stderr) - const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') - assert.strictEqual(args[2], 'https://relay.example.com/mcp') + const args = JSON.parse(readFileSync(fixture.output, 'utf8')) as string[] + assert.strictEqual(args[0], 'https://relay.example.com/mcp') }) - it(`${wrapper} wrapper keeps URL and headers out of cmd.exe on Windows`, { skip: process.platform !== 'win32' }, () => { + it(`${wrapper} wrapper passes the org header for ns-benchmark`, () => { const fixture = createWrapperFixture(wrapper) - writeFileSync(join(fixture.bin, 'npx.cmd'), '@echo off\r\n(echo %NSOLID_MCP_REMOTE_PAYLOAD%&echo %NSOLID_MCP_REMOTE_BOOTSTRAP%) > "%NSOLID_TEST_OUTPUT%"\r\n') - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + seedRuntime(fixture.home) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ns-benchmark', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + }) assert.strictEqual(result.status, 0, result.stderr) + assert.deepStrictEqual(JSON.parse(readFileSync(fixture.output, 'utf8')), [ + 'https://benchmark.mcp.saas.nodesource.io/mcp', + '--header', 'X-Nsolid-Org-Id:org', + '--header', `X-Nsolid-Service-Token:${token}`, + '--transport', 'http-first', '--silent', + ]) + }) + + it(`${wrapper} wrapper fails fast when the runtime is missing (version-pinned repair)`, () => { + const fixture = createWrapperFixture(wrapper) + const sentinel = writeCommandSentinel(fixture, 'npx') - const [encodedPayload, bootstrap] = readFileSync(fixture.output, 'utf8').trimEnd().split(/\r?\n/) - assert.deepStrictEqual(JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')), { - url, - headers: { 'X-Nsolid-Service-Token': token }, + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, }) - const bootstrapSource = Buffer.from(bootstrap.replace('data:text/javascript;base64,', ''), 'base64').toString('utf8') - assert.ok(!bootstrapSource.includes(url)) - assert.ok(!bootstrapSource.includes(token)) - assert.match(bootstrapSource, /mcp-remote executable was not installed by npx/) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('codex')) + assert.ok(neverRan(sentinel), 'npx sentinel never ran (repair text may mention npx, never execute it)') + assert.ok(!fixture.output || !readFileSync(fixture.output, { encoding: 'utf8', flag: 'a+' }).toString(), 'no proxy output') }) - it(`${wrapper} wrapper rejects a root-relative Windows system directory`, { skip: process.platform !== 'win32' }, () => { + it(`${wrapper} wrapper fails fast when the runtime version does not match`, () => { const fixture = createWrapperFixture(wrapper) - writeFileSync(join(fixture.bin, 'npx.cmd'), '@echo off\r\nexit /b 0\r\n') - const environment = wrapperEnvironment(fixture) - // Setting SystemRoot before Node starts breaks Windows CSPRNG - // initialization. The preload applies this invalid value after startup, - // immediately before the wrapper validates it. - environment.NSOLID_TEST_SYSTEM_ROOT = '\\Windows' + seedRuntime(fixture.home, '0.1.37') - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: environment, encoding: 'utf8' }) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'antigravity'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) assert.notStrictEqual(result.status, 0) - assert.match(result.stderr, /Could not locate the Windows system directory/) - }) - - it(`${wrapper} wrapper ignores npx.cmd in the project directory on Windows`, { skip: process.platform !== 'win32' }, () => { - const fixture = createWrapperFixture(wrapper) - const attacker = join(fixture.directory, 'attacker') - const mcpBin = join(fixture.directory, 'node_modules', '.bin') - const proxy = join(fixture.directory, 'node_modules', 'mcp-remote', 'dist', 'proxy.js') - mkdirSync(attacker) - mkdirSync(mcpBin, { recursive: true }) - mkdirSync(join(fixture.directory, 'node_modules', 'mcp-remote', 'dist'), { recursive: true }) - writeFileSync(join(attacker, 'npx.cmd'), '@echo off\r\necho malicious > "%NSOLID_TEST_OUTPUT%"\r\nexit /b 97\r\n') - writeFileSync(join(mcpBin, 'npx.cmd'), '@echo off\r\necho malicious-path > "%NSOLID_TEST_OUTPUT%"\r\nexit /b 98\r\n') - writeFileSync(join(fixture.bin, 'npx.cmd'), '@echo off\r\nnode %4 %5 %6\r\n') - writeFileSync(join(mcpBin, 'mcp-remote.cmd'), '') - writeFileSync(proxy, "const { writeFileSync } = require('node:fs')\nwriteFileSync(process.env.NSOLID_TEST_OUTPUT, JSON.stringify(process.argv.slice(2)))\n") - - const environment = wrapperEnvironment(fixture) - environment.PATH = `${mcpBin}${delimiter}${fixture.bin}${delimiter}${process.env.PATH}` - const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { cwd: attacker, env: environment, encoding: 'utf8' }) - assert.strictEqual(result.status, 0, result.stderr) - assert.deepStrictEqual(JSON.parse(readFileSync(fixture.output, 'utf8')), [ - url, '--header', `X-Nsolid-Service-Token:${token}`, '--transport', 'http-first', '--silent', - ]) + assert.match(result.stderr, repairFor('antigravity')) + }) + + it(`${wrapper} wrapper rejects a corrupt runtime (missing dist/proxy.js)`, () => { + const fixture = createWrapperFixture(wrapper) + const dir = seedRuntime(fixture.home) + rmSync(join(dir, 'dist', 'proxy.js')) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('claude')) + }) + + for (const escapedEntry of ['runtime root', 'package directory', 'manifest', 'proxy'] as const) { + it(`${wrapper} wrapper rejects a post-publication ${escapedEntry} symlink escape before import`, (t) => { + const fixture = createWrapperFixture(wrapper) + const runtimeDir = seedRuntime(fixture.home) + const outsideDir = join(fixture.directory, `outside-${escapedEntry.replace(' ', '-')}`) + + let linked: boolean + if (escapedEntry === 'runtime root') { + const outsideRuntimeDir = seedRuntime(outsideDir) + writeFileSync(join(outsideRuntimeDir, 'dist', 'proxy.js'), "throw new Error('escaped runtime root imported')\n") + const versionRoot = join(fixture.home, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', CORE_VERSION) + const outsideRoot = join(outsideDir, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote', CORE_VERSION) + linked = replaceWithSymlink(versionRoot, outsideRoot, 'dir') + } else if (escapedEntry === 'package directory') { + mkdirSync(outsideDir, { recursive: true }) + mkdirSync(join(outsideDir, 'dist')) + writeFileSync(join(outsideDir, 'package.json'), JSON.stringify({ name: 'mcp-remote', version: CORE_VERSION })) + writeFileSync(join(outsideDir, 'dist', 'proxy.js'), "throw new Error('escaped package imported')\n") + linked = replaceWithSymlink(runtimeDir, outsideDir, 'dir') + } else if (escapedEntry === 'manifest') { + mkdirSync(outsideDir, { recursive: true }) + const outsideManifest = join(outsideDir, 'package.json') + writeFileSync(outsideManifest, JSON.stringify({ name: 'mcp-remote', version: CORE_VERSION })) + linked = replaceWithSymlink(join(runtimeDir, 'package.json'), outsideManifest, 'file') + } else { + mkdirSync(outsideDir, { recursive: true }) + writeFileSync(join(outsideDir, 'proxy.js'), "throw new Error('escaped proxy imported')\n") + linked = replaceWithSymlink(join(runtimeDir, 'dist'), outsideDir, 'dir') + } + + if (!linked) { + t.skip('symlinks require additional privileges on this Windows host') + return + } + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0, `${escapedEntry} escape must fail before import`) + assert.match(result.stderr, repairFor('claude')) + assert.doesNotMatch(result.stderr, /escaped (?:runtime root|package|proxy) imported/) + }) + } + + it(`${wrapper} wrapper reports credentials problems before touching the runtime`, () => { + const fixture = createWrapperFixture(wrapper) + writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ + serviceToken: token, organizationId: 'org', consoleUrl: 'https://console.example.test', mcpUrl: url, expiresAt: '2020-01-01T00:00:00.000Z', + })) + const sentinel = writeCommandSentinel(fixture, 'npx') + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, new RegExp(`credentials are expired\\. Run: npx -y ${pinnedPlugin} setup --harness codex`)) + assert.ok(neverRan(sentinel), 'npx sentinel never ran') + }) + + it(`${wrapper} wrapper validates the server name and harness argument`, () => { + const fixture = createWrapperFixture(wrapper) + const invalidServer = spawnSync(process.execPath, [fixture.wrapperPath, 'not-a-server', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + }) + assert.notStrictEqual(invalidServer.status, 0) + assert.match(invalidServer.stderr, /Unknown NodeSource MCP server: not-a-server/) + + const missingHarness = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + }) + assert.notStrictEqual(missingHarness.status, 0) + assert.match(missingHarness.stderr, /Invalid harness argument/) + }) + + it(`${wrapper} wrapper names the requesting harness in every repair message`, () => { + const fixture = createWrapperFixture(wrapper) + for (const harness of ['claude', 'codex', 'antigravity'] as const) { + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', harness], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, new RegExp(`npx -y ${pinnedPlugin} setup --harness ${harness}`)) + } + }) + + it(`${wrapper} wrapper translates a missing transitive into the repair message`, () => { + const fixture = createWrapperFixture(wrapper) + // Light validation passes (name/version/proxy file); the import fails + // with a real ERR_MODULE_NOT_FOUND for a missing transitive. + seedBrokenRuntime(fixture.home, "import { helper } from './helpers.js'\nconsole.log(helper)\n") + const sentinel = writeCommandSentinel(fixture, 'npx') + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('claude')) + // The underlying module error appears only as a secondary cause line. + assert.match(result.stderr, /cause: Cannot find module/) + // The primary guidance is the repair message, never a raw stack. + assert.doesNotMatch(result.stderr.split('\n')[0] ?? '', /Cannot find module|ERR_MODULE_NOT_FOUND/) + assert.ok(neverRan(sentinel), 'npx sentinel never ran') + }) + + it(`${wrapper} wrapper translates an incompatible transitive into the repair message`, () => { + const fixture = createWrapperFixture(wrapper) + // A "helper" exists but itself fails to resolve its own dependency — + // the classic incompatible/missing transitive shape at import time. + const dir = seedRuntime(fixture.home) + mkdirSync(join(dir, 'dist', 'helpers'), { recursive: true }) + writeFileSync(join(dir, 'dist', 'helpers', 'index.js'), "import 'nonexistent-transitive-pkg'\n") + writeFileSync(join(dir, 'dist', 'proxy.js'), "import './helpers/index.js'\n") + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ns-benchmark', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('codex')) + }) + + it(`${wrapper} wrapper translates an arbitrary initialization throw into the repair message`, () => { + const fixture = createWrapperFixture(wrapper) + seedBrokenRuntime(fixture.home, "throw new Error('proxy failed during initialization')\n") + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'antigravity'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('antigravity')) + assert.match(result.stderr, /cause: proxy failed during initialization/) + assert.doesNotMatch(result.stderr.split('\n')[0] ?? '', /proxy failed during initialization/) + }) + + it(`${wrapper} wrapper translates an async initialization rejection into the repair message`, () => { + const fixture = createWrapperFixture(wrapper) + seedBrokenRuntime(fixture.home, "Promise.reject(new Error('async initialization failure'))\nsetInterval(() => {}, 1000)\n") + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('claude')) + }) + + it(`${wrapper} wrapper ignores a hostile PATH (nothing is ever spawned)`, () => { + const fixture = createWrapperFixture(wrapper) + const sentinel = writeCommandSentinel(fixture, 'npx') + // An attacker-controlled directory fronts PATH with npm/node shims. + const hostile = join(fixture.directory, 'hostile-bin') + mkdirSync(hostile) + for (const name of process.platform === 'win32' ? ['npm.cmd', 'node.exe'] : ['npm', 'node']) { + const p = join(hostile, name) + writeFileSync(p, process.platform === 'win32' ? '@echo off\r\necho pwned > "%SENTINEL%"\r\n' : `#!/bin/sh\necho pwned > "${sentinel}"\n`) + if (process.platform !== 'win32') chmodSync(p, 0o755) + } + // Runtime missing: the wrapper must fail with the repair message + // without executing anything from PATH. + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture, hostile), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, repairFor('codex')) + assert.ok(neverRan(sentinel), 'nothing from the hostile PATH ever ran') + }) + } + + it('never resolves mcp-remote from a node_modules next to the wrapper', () => { + // Simulate a "dev checkout" resolution that would mask a missing stable + // runtime: plant a copy next to the wrapper and prove it is NOT used + // unless version-matched — here with the wrong version it must be ignored. + const fixture = createWrapperFixture('source') + const local = join(fixture.directory, 'node_modules', 'mcp-remote', 'dist') + mkdirSync(local, { recursive: true }) + writeFileSync(join(fixture.directory, 'node_modules', 'mcp-remote', 'package.json'), JSON.stringify({ name: 'mcp-remote', version: '0.1.37' })) + writeFileSync(join(local, 'proxy.js'), "require('node:fs').writeFileSync(process.env.NSOLID_TEST_OUTPUT, 'local-proxy')\n") + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0, 'wrong-version local copy must be rejected') + assert.match(result.stderr, repairFor('codex')) + }) + + for (const wrapper of ['source', 'generated'] as const) { + it(`${wrapper} wrapper ignores a matching local node_modules copy without the dev flag`, () => { + const fixture = createWrapperFixture(wrapper) + seedLocalCopy(fixture, CORE_VERSION) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0, 'a project dependency must not bypass the managed runtime') + assert.match(result.stderr, repairFor('codex')) + assert.ok( + !readFileSync(fixture.output, { encoding: 'utf8', flag: 'a+' }).toString().includes('local-proxy'), + 'local proxy never imported' + ) + }) + + it(`${wrapper} wrapper uses the dev fallback only when explicitly enabled and version-matched`, () => { + const fixture = createWrapperFixture(wrapper) + seedLocalCopy(fixture, CORE_VERSION) + + const dev = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: { ...wrapperEnvironment(fixture), NSOLID_MCP_RUNTIME_DEV_FALLBACK: '1' }, + encoding: 'utf8', + timeout: 15000, + }) + assert.strictEqual(dev.status, 0, dev.stderr) + assert.ok(readFileSync(fixture.output, 'utf8').includes('local-proxy'), 'dev fallback imported the pinned checkout') + + // Same flag, wrong pinned version → still rejected with the repair path. + writeFileSync( + join(fixture.directory, 'node_modules', 'mcp-remote', 'package.json'), + JSON.stringify({ name: 'mcp-remote', version: '0.1.37' }) + ) + const wrong = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: { ...wrapperEnvironment(fixture), NSOLID_MCP_RUNTIME_DEV_FALLBACK: '1' }, + encoding: 'utf8', + timeout: 15000, + }) + assert.notStrictEqual(wrong.status, 0) + assert.match(wrong.stderr, repairFor('codex')) + }) + + for (const escapedEntry of ['manifest', 'proxy'] as const) { + it(`${wrapper} wrapper confines the dev fallback ${escapedEntry} to its canonical package directory`, (t) => { + const fixture = createWrapperFixture(wrapper) + const localDir = seedLocalCopy(fixture, CORE_VERSION) + const outsideDir = join(fixture.directory, `outside-dev-${escapedEntry}`) + mkdirSync(outsideDir) + + let linked: boolean + if (escapedEntry === 'manifest') { + const outsideManifest = join(outsideDir, 'package.json') + writeFileSync(outsideManifest, JSON.stringify({ name: 'mcp-remote', version: CORE_VERSION })) + linked = replaceWithSymlink(join(localDir, 'package.json'), outsideManifest, 'file') + } else { + writeFileSync(join(outsideDir, 'proxy.js'), "throw new Error('escaped dev proxy imported')\n") + linked = replaceWithSymlink(join(localDir, 'dist'), outsideDir, 'dir') + } + + if (!linked) { + t.skip('file symlinks require additional privileges on this Windows host') + return + } + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'codex'], { + cwd: fixture.directory, + env: { ...wrapperEnvironment(fixture), NSOLID_MCP_RUNTIME_DEV_FALLBACK: '1' }, + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0, `dev fallback ${escapedEntry} escape must fail before import`) + assert.match(result.stderr, repairFor('codex')) + assert.doesNotMatch(result.stderr, /escaped dev proxy imported/) + }) + } + + it(`${wrapper} wrapper never executes a direct npm sentinel in stable or dev-fallback mode`, () => { + // Stable mode: runtime present, npm on PATH must never run. + const stable = createWrapperFixture(wrapper) + seedRuntime(stable.home) + const stableSentinel = writeCommandSentinel(stable, 'npm') + const stableResult = spawnSync(process.execPath, [stable.wrapperPath, 'nsolid-console', 'claude'], { + cwd: stable.directory, + env: wrapperEnvironment(stable), + encoding: 'utf8', + timeout: 15000, + }) + assert.strictEqual(stableResult.status, 0, stableResult.stderr) + assert.ok(neverRan(stableSentinel), 'npm sentinel never ran (stable mode)') + + // Dev-fallback mode: local checkout in use, npm still must never run. + const dev = createWrapperFixture(wrapper) + seedLocalCopy(dev, CORE_VERSION) + const devSentinel = writeCommandSentinel(dev, 'npm') + const devResult = spawnSync(process.execPath, [dev.wrapperPath, 'nsolid-console', 'claude'], { + cwd: dev.directory, + env: { ...wrapperEnvironment(dev), NSOLID_MCP_RUNTIME_DEV_FALLBACK: '1' }, + encoding: 'utf8', + timeout: 15000, + }) + assert.strictEqual(devResult.status, 0, devResult.stderr) + assert.ok(readFileSync(dev.output, 'utf8').includes('local-proxy'), 'dev fallback in use') + assert.ok(neverRan(devSentinel), 'npm sentinel never ran (dev-fallback mode)') }) } + + it('an old wrapper prints the repair command of its own release, not the newest CLI', () => { + // A wrapper generated by plugin release X (here: an older embedded + // PLUGIN_VERSION) must print nsolid-plugin@X — and release X provisions + // exactly X's pinned runtime version. + const generated = generateMcpWrapper() + const oldVersion = '0.0.1-old-release' + const oldWrapper = generated.replace( + new RegExp(`const PLUGIN_VERSION = '${GENERATOR_PLUGIN_VERSION.replace(/\./g, '\\.')}'`), + `const PLUGIN_VERSION = '${oldVersion}'` + ) + assert.ok(oldWrapper.includes(`const PLUGIN_VERSION = '${oldVersion}'`), 'fixture rewrote the embedded version') + + const fixture = createWrapperFixture('source') + writeFileSync(fixture.wrapperPath, oldWrapper) + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, new RegExp(`Run: npx -y nsolid-plugin@${oldVersion} setup --harness claude`)) + assert.ok(!result.stderr.includes(`nsolid-plugin@${GENERATOR_PLUGIN_VERSION} setup`), 'must not advertise a newer CLI than the wrapper release') + }) }) diff --git a/packages/core/test/unit/utils/backup.test.ts b/packages/core/test/unit/utils/backup.test.ts index db59253..431886b 100644 --- a/packages/core/test/unit/utils/backup.test.ts +++ b/packages/core/test/unit/utils/backup.test.ts @@ -1,13 +1,16 @@ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, utimesSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' import { createConfigBackup, listConfigBackups, restoreConfigBackup, } from '../../../src/utils/backup.js' +import { getConfigBackupDir } from '../../../src/utils/path.js' let tmpDir: string let originalHome: string | undefined @@ -63,6 +66,17 @@ describe('createConfigBackup', () => { assert.notStrictEqual(first.backupPath, second.backupPath) assert.strictEqual(listConfigBackups('claude').length, 2) }) + + it('does not reuse a sequence reserved by a crashed creator', () => { + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'v1', 'utf8') + const reservationsDir = join(getConfigBackupDir('claude'), '.seq-reservations') + mkdirSync(join(reservationsDir, '41'), { recursive: true }) + + const entry = createConfigBackup('claude', configPath)! + const meta = JSON.parse(readFileSync(`${entry.backupPath}.meta.json`, 'utf8')) + assert.strictEqual(meta.seq, 42) + }) }) describe('listConfigBackups', () => { @@ -101,6 +115,36 @@ describe('restoreConfigBackup', () => { assert.strictEqual(entry.originalPath, configPath) }) + it('orders same-millisecond backups by persisted sequence, never by mtime', () => { + // Regression: back-to-back backups can share a createdAt millisecond, + // and on coarse-timestamp filesystems (FAT, network mounts) the meta + // mtimes can tie too. The persisted seq cannot tie, so "latest" must be + // the backup created last even when its meta looks older on disk. + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'v1', 'utf8') + const first = createConfigBackup('claude', configPath)! + writeFileSync(configPath, 'v2', 'utf8') + const second = createConfigBackup('claude', configPath)! + + // Sequences are monotonic within the harness backup directory. + const firstMeta = JSON.parse(readFileSync(`${first.backupPath}.meta.json`, 'utf8')) + const secondMeta = JSON.parse(readFileSync(`${second.backupPath}.meta.json`, 'utf8')) + assert.strictEqual(secondMeta.seq, firstMeta.seq + 1) + + // Force identical createdAt values and give the OLDER backup's meta the + // NEWER mtime: any timestamp-based ordering would pick the wrong one. + const sameInstant = firstMeta.createdAt + secondMeta.createdAt = sameInstant + writeFileSync(`${second.backupPath}.meta.json`, JSON.stringify(secondMeta, null, 2) + '\n') + const future = new Date(Date.now() + 60_000) + utimesSync(`${first.backupPath}.meta.json`, future, future) + + writeFileSync(configPath, 'corrupt', 'utf8') + const entry = restoreConfigBackup('claude') + assert.strictEqual(readFileSync(configPath, 'utf8'), 'v2') + assert.strictEqual(entry.backupPath, second.backupPath) + }) + it('restores a specific backup when given a path', () => { const configPath = join(tmpDir, '.codex', 'config.toml') mkdirSync(join(tmpDir, '.codex'), { recursive: true }) @@ -118,4 +162,89 @@ describe('restoreConfigBackup', () => { it('throws when no backups exist', () => { assert.throws(() => restoreConfigBackup('opencode'), /No backups found/) }) + + it('assigns tie-free sequences under concurrent processes', async () => { + // Regression: seq reservation must be atomic across processes — two + // installers running at once must never receive the same seq, otherwise + // (with same-ms createdAt and coarse mtimes) "latest" is undefined again. + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'shared', 'utf8') + const workers = 6 + const backupDir = getConfigBackupDir('claude') + const readyDir = join(tmpDir, 'worker-ready') + const startPath = join(tmpDir, 'worker-start') + mkdirSync(readyDir) + // Resolve against this file so the test works from any cwd. + const backupModule = pathToFileURL(fileURLToPath(new URL('../../../src/utils/backup.ts', import.meta.url))).href + const repoRoot = fileURLToPath(new URL('../../../../..', import.meta.url)) + const childProcesses: ReturnType[] = [] + const children = Array.from({ length: workers }, (_, worker) => new Promise((resolve, reject) => { + const script = [ + `process.env.HOME = ${JSON.stringify(tmpDir)}`, + `process.env.USERPROFILE = ${JSON.stringify(tmpDir)}`, + 'const { createRequire, syncBuiltinESMExports } = await import(\'node:module\')', + 'const { writeFileSync, existsSync } = await import(\'node:fs\')', + 'const require = createRequire(import.meta.url)', + 'const fs = require(\'node:fs\')', + 'const originalReaddirSync = fs.readdirSync', + 'let delayed = false', + 'fs.readdirSync = function (target, options) {', + ` if (!delayed && String(target) === ${JSON.stringify(backupDir)}) {`, + ' delayed = true', + ' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2500)', + ' }', + ' return originalReaddirSync.call(this, target, options)', + '}', + 'syncBuiltinESMExports()', + `writeFileSync(${JSON.stringify(join(readyDir, String(worker)))}, '')`, + `while (!existsSync(${JSON.stringify(startPath)})) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10)`, + `const { createConfigBackup } = await import(${JSON.stringify(backupModule)})`, + `if (createConfigBackup('claude', ${JSON.stringify(configPath)}) === null) throw new Error('backup returned null')`, + ].join('\n') + const child = spawn( + process.execPath, + ['--import', 'tsx/esm', '--input-type=module', '--eval', script], + { cwd: repoRoot, stdio: ['ignore', 'ignore', 'pipe'] } + ) + childProcesses.push(child) + let stderr = '' + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`backup worker exited with ${code}: ${stderr.trim().slice(-500)}`)) + }) + child.on('error', reject) + })) + + let barrierReleased = false + try { + const readyDeadline = Date.now() + 10_000 + while (readdirSync(readyDir).length < workers) { + if (Date.now() >= readyDeadline) throw new Error('backup workers did not reach the start barrier') + await new Promise((resolve) => setTimeout(resolve, 10)) + } + writeFileSync(startPath, 'go') + barrierReleased = true + await Promise.all(children) + } finally { + if (!barrierReleased) writeFileSync(startPath, 'abort') + for (const child of childProcesses) { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + await Promise.allSettled(children) + } + + const backups = listConfigBackups('claude') + assert.strictEqual(backups.length, workers, 'every concurrent backup was recorded') + const seqs = backups.map((b) => JSON.parse(readFileSync(`${b.backupPath}.meta.json`, 'utf8')).seq as number) + assert.strictEqual(new Set(seqs).size, seqs.length, `sequences must be unique across processes: ${seqs}`) + // Ordering invariant: within a createdAt tie, seq strictly decreases. + for (let i = 1; i < backups.length; i++) { + const [prev, cur] = [backups[i - 1], backups[i]] + assert.ok( + prev.createdAt > cur.createdAt || (prev.createdAt === cur.createdAt && seqs[i - 1] > seqs[i]), + `backups must be newest-first even when createdAt ties: ${prev.createdAt}#${seqs[i - 1]} then ${cur.createdAt}#${seqs[i]}` + ) + } + }) }) diff --git a/packages/core/test/unit/utils/format.test.ts b/packages/core/test/unit/utils/format.test.ts index c87eefd..79728be 100644 --- a/packages/core/test/unit/utils/format.test.ts +++ b/packages/core/test/unit/utils/format.test.ts @@ -205,6 +205,49 @@ describe('formatDoctorReport', () => { assert.ok(out.includes('Pi needs an MCP adapter extension')) }) + it('omits the MCP bridge line when the report has no bridge entry', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const out = formatDoctorReport(makeReport(), 'claude', false) + assert.ok(!out.includes('MCP bridge')) + }) + + it('shows a green ready MCP bridge line', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ + bridge: { status: 'ready', version: '0.1.38', root: '/home/x/.agents/nsolid-plugin/runtime/mcp-remote/0.1.38', required: true }, + }) + const out = formatDoctorReport(report, 'codex', false) + + assert.ok(out.includes('MCP bridge ✓ ready (mcp-remote 0.1.38)')) + assert.ok(out.includes('✓ All checks passed')) + }) + + it('shows a red MCP bridge line with the setup hint when required and missing', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ + healthy: false, + bridge: { status: 'missing', version: '0.1.38', root: '/home/x/.agents/nsolid-plugin/runtime/mcp-remote/0.1.38', required: true }, + errors: ['MCP bridge runtime is missing. Run: nsolid-plugin setup --harness codex'], + }) + const out = formatDoctorReport(report, 'codex', false) + + assert.ok(out.includes('MCP bridge ✗ not provisioned')) + assert.ok(out.includes('Run: nsolid-plugin setup --harness codex')) + assert.ok(out.includes('✗ Problems found')) + }) + + it('shows an informational MCP bridge line when not required', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ + bridge: { status: 'invalid', version: '0.1.38', root: '/r', reason: 'expected mcp-remote@0.1.38, found 0.1.37', required: false }, + }) + const out = formatDoctorReport(report, 'opencode', false) + + assert.ok(out.includes('MCP bridge ? invalid (expected mcp-remote@0.1.38, found 0.1.37)')) + assert.ok(out.includes('not used by this harness configuration')) + assert.ok(out.includes('✓ All checks passed'), 'informational bridge never flips health') + }) + it('does not include Pi adapter notice for claude', async () => { const { formatDoctorReport } = await import('../../../src/utils/format.js') const report = makeReport({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9c3a01..d39287a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: packages/core: dependencies: + semver: + specifier: 7.8.5 + version: 7.8.5 smol-toml: specifier: ^1.3.1 version: 1.6.1 @@ -40,6 +43,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.20 + '@types/semver': + specifier: 7.8.0 + version: 7.8.0 '@types/write-file-atomic': specifier: 4.0.3 version: 4.0.3 @@ -287,6 +293,9 @@ packages: '@types/node@22.19.20': resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/write-file-atomic@4.0.3': resolution: {integrity: sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==} @@ -1273,8 +1282,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -1677,6 +1686,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.8.0': {} + '@types/write-file-atomic@4.0.3': dependencies: '@types/node': 22.19.20 @@ -1749,7 +1760,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.4 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -2154,7 +2165,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.4): dependencies: eslint: 9.39.4 - semver: 7.8.4 + semver: 7.8.5 eslint-plugin-es-x@7.8.0(eslint@9.39.4): dependencies: @@ -2173,7 +2184,7 @@ snapshots: globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.8.4 + semver: 7.8.5 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -2910,7 +2921,7 @@ snapshots: semver@6.3.1: {} - semver@7.8.4: {} + semver@7.8.5: {} send@0.19.2: dependencies: diff --git a/scripts/materialize-github-marketplace.mjs b/scripts/materialize-github-marketplace.mjs index e5c7d08..44a24d9 100644 --- a/scripts/materialize-github-marketplace.mjs +++ b/scripts/materialize-github-marketplace.mjs @@ -42,9 +42,9 @@ import { generateAntigravityPluginJson, generateClaudeMcpJson, generateClaudePluginJson, - generateClaudeWrapper, generateCodexMcpJson, generateCodexPluginJson, + generateMcpWrapper, loadBundle, stableJson, } from './plugin-generators.mjs' @@ -135,7 +135,9 @@ function buildExpectedFiles () { files.set('.mcp.json', generateCodexMcpJson(bundle)) files.set('plugin.json', generateAntigravityPluginJson(bundle)) files.set('mcp_config.json', generateAntigravityMcpJson(bundle)) - files.set('scripts/mcp-wrapper.js', generateSharedWrapper()) + // The wrapper receives the harness as an explicit argument, so a single + // generated artifact serves Claude, Codex, and Antigravity unchanged. + files.set('scripts/mcp-wrapper.js', generateMcpWrapper()) return files } @@ -149,14 +151,6 @@ function validateCanonicalSkills () { } } -function generateSharedWrapper () { - return generateClaudeWrapper() - .replace( - "const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness claude'", - "const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness '" - ) -} - function writeFiles (files) { for (const [relPath, content] of files) writeFile(relPath, content) return [] diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js index 052efae..b04301c 100644 --- a/scripts/mcp-wrapper.js +++ b/scripts/mcp-wrapper.js @@ -1,22 +1,35 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process' +// STDIO→HTTP bridge for the NodeSource MCP servers. Resolves mcp-remote +// exclusively from local copies: the shared runtime provisioned by +// `nsolid-plugin setup` (~/.agents/nsolid-plugin/runtime/mcp-remote/), +// or — only when the explicit internal development flag +// NSOLID_MCP_RUNTIME_DEV_FALLBACK=1 is set — a version-matched development +// checkout. It NEVER invokes npx, npm, a shell, or cmd.exe during startup — +// a missing runtime fails fast with the repair command instead of +// downloading anything. + import { createRequire } from 'node:module' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' +const MCP_REMOTE_VERSION = '0.1.38' +const PLUGIN_VERSION = '1.0.3' +const STARTUP_FAILURE_WINDOW_MS = 15000 const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness ' -const MCP_REMOTE_NPX_BOOTSTRAP = "import{existsSync}from'node:fs';import path from'node:path';import{pathToFileURL}from'node:url';const payload=JSON.parse(Buffer.from(process.env.NSOLID_MCP_REMOTE_PAYLOAD,'base64url'));const binName=process.platform==='win32'?'mcp-remote.cmd':'mcp-remote';const binDir=process.env.PATH.split(path.delimiter).find(dir=>existsSync(path.join(dir,binName)));if(!binDir)throw new Error('mcp-remote executable was not installed by npx');const proxyPath=path.resolve(binDir,'..','mcp-remote','dist','proxy.js');const args=Object.entries(payload.headers).flatMap(([key,value])=>['--header',key+':'+value]);process.argv=[process.execPath,proxyPath,payload.url,...args,'--transport','http-first','--silent'];await import(pathToFileURL(proxyPath).href)" - -const SERVER_NAMES = new Set(["nsolid-console","ns-benchmark","ncm"]) +const SERVER_NAMES = new Set(['nsolid-console', 'ns-benchmark', 'ncm']) +const HARNESS_NAMES = new Set(['claude', 'codex', 'opencode', 'antigravity', 'pi']) const serverName = process.argv[2] +const harness = process.argv[3] if (!SERVER_NAMES.has(serverName)) { fail(`Unknown NodeSource MCP server: ${serverName ?? '(missing)'}`) } +if (!HARNESS_NAMES.has(harness)) { + fail(`Invalid harness argument: ${harness ?? '(missing)'}`) +} const credentials = readCredentials() const server = resolveServer(serverName, credentials) @@ -24,25 +37,25 @@ await runMcpRemote(server.url, server.headers) function readCredentials () { if (!existsSync(AUTH_FILE)) { - fail(`NodeSource credentials not found. Run: ${SETUP_COMMAND}`) + fail(`NodeSource credentials not found. Run: ${SETUP_COMMAND()}`) } let parsed try { parsed = JSON.parse(readFileSync(AUTH_FILE, 'utf8')) } catch (err) { - fail(`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && ${SETUP_COMMAND}. ${err.message}`) + fail(`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && ${SETUP_COMMAND()}. ${err.message}`) } const required = ['serviceToken', 'organizationId', 'consoleUrl', 'expiresAt'] const missing = required.filter((key) => typeof parsed?.[key] !== 'string' || parsed[key].length === 0) if (missing.length > 0) { - fail(`NodeSource credentials are incomplete (${missing.join(', ')} missing). Run: ${SETUP_COMMAND}`) + fail(`NodeSource credentials are incomplete (${missing.join(', ')} missing). Run: ${SETUP_COMMAND()}`) } const expiresAt = Date.parse(parsed.expiresAt) if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { - fail(`NodeSource credentials are expired. Run: ${SETUP_COMMAND}`) + fail(`NodeSource credentials are expired. Run: ${SETUP_COMMAND()}`) } return parsed @@ -56,7 +69,7 @@ function resolveServer (name, credentials) { : null const url = storedUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) if (!url) { - fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND}`) + fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND()}`) } return { url, @@ -120,80 +133,100 @@ function isLegacyAliasMcpUrl (mcpUrl, consoleUrl, organizationId) { return storedHost === legacyHost } -async function runMcpRemote (url, headers) { - const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}:${value}`]) +function SETUP_COMMAND () { + // Version-pinned: a wrapper generated by release X always prints + // nsolid-plugin@X, and that CLI release provisions exactly the runtime + // version this wrapper validates. + return `npx -y nsolid-plugin@${PLUGIN_VERSION} setup --harness ${harness}` +} - const require = createRequire(import.meta.url) - try { - const proxyPath = require.resolve('mcp-remote/dist/proxy.js') - process.argv = [process.execPath, proxyPath, url, ...headerArgs, '--transport', 'http-first', '--silent'] - await import(pathToFileURL(proxyPath).href) - return - } catch (err) { - if (err?.code !== 'MODULE_NOT_FOUND' && !String(err?.message ?? '').includes('Cannot find module')) { - throw err +function resolveProxyPath () { + // 1. Stable shared runtime provisioned by `nsolid-plugin setup`. + const runtimeParent = path.join(os.homedir(), '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + const runtimeRoot = path.join(runtimeParent, MCP_REMOTE_VERSION) + const stable = validateMcpRemote(path.join(runtimeRoot, 'node_modules', 'mcp-remote'), runtimeRoot, runtimeParent) + if (stable) return stable + + // 2. Development fallback — ONLY under the explicit internal development + // flag. Released harness configurations never set it, so a local/project + // node_modules can never mask a missing or invalid managed runtime. + if (process.env.NSOLID_MCP_RUNTIME_DEV_FALLBACK === '1') { + try { + const require = createRequire(import.meta.url) + const checkoutDir = path.dirname(require.resolve('mcp-remote/package.json')) + return validateMcpRemote(checkoutDir, checkoutDir) + } catch { + return null } } + return null +} - const fallback = getMcpRemoteFallback(url, headers) - const options = { - stdio: 'inherit', - ...fallback.options, - windowsHide: true, +function validateMcpRemote (dir, boundary, parentBoundary) { + try { + const canonicalParent = realpathSync(parentBoundary ?? boundary) + if (!statSync(canonicalParent).isDirectory()) return null + const canonicalBoundary = parentBoundary + ? canonicalTargetInside(boundary, canonicalParent, 'dir') + : canonicalParent + if (!canonicalBoundary) return null + const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir') + if (!canonicalDir) return null + const manifestPath = canonicalTargetInside(path.join(dir, 'package.json'), canonicalDir, 'file') + if (!manifestPath) return null + const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (pkg.name !== 'mcp-remote' || pkg.version !== MCP_REMOTE_VERSION) return null + return canonicalTargetInside(path.join(dir, 'dist', 'proxy.js'), canonicalDir, 'file') + } catch { + return null } - const child = fallback.args.length === 0 - ? spawn(fallback.command, options) - : spawn(fallback.command, fallback.args, options) - await new Promise((resolve, reject) => { - child.on('error', reject) - child.on('exit', (code) => code === 0 ? resolve() : reject(new Error('mcp-remote exited with code ' + (code ?? 1)))) - }) } -function getMcpRemoteFallback (url, headers) { - if (process.platform !== 'win32') { - const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}:${value}`]) - return { - command: 'npx', - args: ['-y', 'mcp-remote@0.1.38', url, ...headerArgs, '--transport', 'http-first', '--silent'], - options: { shell: false, env: process.env }, - } +function canonicalTargetInside (target, boundary, kind) { + const canonical = realpathSync(target) + const relative = path.relative(boundary, canonical) + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null + const targetStat = statSync(canonical) + if (kind === 'dir' ? !targetStat.isDirectory() : !targetStat.isFile()) return null + return canonical +} + +async function runMcpRemote (url, headers) { + const proxyPath = resolveProxyPath() + if (!proxyPath) { + fail(`MCP bridge runtime is not ready. Run: ${SETUP_COMMAND()}`) } - // A .cmd file needs cmd.exe. Keep its command line constant and move all - // untrusted values into an encoded environment payload for Node to decode. - const npxCmd = resolveWindowsNpxCmd() - const payload = Buffer.from(JSON.stringify({ url, headers })).toString('base64url') - const bootstrap = `data:text/javascript;base64,${Buffer.from(MCP_REMOTE_NPX_BOOTSTRAP).toString('base64')}` - return { - command: '.\\npx.cmd -y --package=mcp-remote@0.1.38 node --input-type=module --eval "await import(process.env.NSOLID_MCP_REMOTE_BOOTSTRAP)"', - args: [], - options: { - shell: getWindowsCmdShell(), - cwd: path.dirname(npxCmd), - env: { ...process.env, NSOLID_MCP_REMOTE_PAYLOAD: payload, NSOLID_MCP_REMOTE_BOOTSTRAP: bootstrap }, - }, + // URL and headers are handed to the imported proxy as separate argv + // elements; no shell is ever involved. + const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}:${value}`]) + process.argv = [process.execPath, proxyPath, url, ...headerArgs, '--transport', 'http-first', '--silent'] + guardStartupFailures() + try { + await import(pathToFileURL(proxyPath).href) + } catch (err) { + startupFailure(err) } } -function resolveWindowsNpxCmd () { - // Node's own directory is already inside the trust boundary: this process - // was launched from it. Do not search PATH, which may contain project-owned - // .bin directories or other attacker-controlled entries. - const npxCmd = path.join(path.dirname(process.execPath), 'npx.cmd') - if (existsSync(npxCmd)) return npxCmd - throw new Error(`Could not locate npx.cmd next to Node.js at ${npxCmd}. Install Node.js with npm.`) +// Any error thrown while importing or initializing the light-validated proxy +// — including missing or incompatible transitives and arbitrary module +// initialization errors — becomes the harness-specific repair message. A raw +// stack is never the primary guidance. +function guardStartupFailures () { + const handler = (err) => startupFailure(err) + process.on('uncaughtException', handler) + process.on('unhandledRejection', handler) + setTimeout(() => { + process.off('uncaughtException', handler) + process.off('unhandledRejection', handler) + }, STARTUP_FAILURE_WINDOW_MS).unref() } -function getWindowsCmdShell () { - const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR - const root = windowsRoot ? path.win32.parse(windowsRoot).root : '' - if (!windowsRoot || !path.win32.isAbsolute(windowsRoot) || root.length === 1) { - throw new Error('Could not locate the Windows system directory.') - } - const shell = path.join(windowsRoot, 'System32', 'cmd.exe') - if (!existsSync(shell)) throw new Error(`Could not locate Windows command shell at ${shell}.`) - return shell +function startupFailure (err) { + const message = err instanceof Error ? String(err.message) : String(err) + const detail = message.split('\n')[0] + fail(`MCP bridge runtime is not ready. Run: ${SETUP_COMMAND()}\n cause: ${detail}`) } function fail (message) { diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index 724209e..bb2dc41 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -25,6 +25,26 @@ const defaultBundle = loadBundle() export const skillNames = defaultBundle.skills.map((skill) => skill.name) export const skillNamesSet = new Set(skillNames) +/** + * Exact mcp-remote version pinned for the shared bridge runtime. + * Keep in sync with packages/core/src/mcp/mcp-remote-runtime.ts and the root + * package.json dependency (guarded by a unit test). + */ +export const MCP_REMOTE_VERSION = '0.1.38' + +/** + * The plugin release that generates the wrapper. The wrapper's repair message + * pins this version so the printed command always provisions exactly the + * runtime version this wrapper validates, even when a newer CLI exists. + * Kept in sync with packages/core/package.json (guarded by a unit test). + */ +export const PLUGIN_VERSION = defaultBundle.version + +// Keep in sync with packages/core/src/types.ts (guarded by a unit test). +export const HARNESS_VALUES = ['claude', 'codex', 'opencode', 'antigravity', 'pi'] + +const CODEX_MCP_STARTUP_TIMEOUT_SEC = 60 + function getBundle (bundle) { return bundle ?? defaultBundle } @@ -53,11 +73,7 @@ export function generateClaudePluginJson (pluginPkgVersion, bundle) { } export function generateClaudeMcpJson (bundle) { - return generateMcpConfig('$' + '{CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js', bundle) -} - -export function generateClaudeWrapper () { - return generateMcpWrapper('claude') + return generateMcpConfig('$' + '{CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js', bundle, 'claude') } export function generateAntigravityPluginJson (bundle) { @@ -111,6 +127,7 @@ export function generateCodexMcpJson (bundle) { mcpServers[server.name] = { command: 'node', args: ['-e', bootstrap, server.name], + startup_timeout_sec: CODEX_MCP_STARTUP_TIMEOUT_SEC, } } return stableJson({ mcpServers }) @@ -121,46 +138,61 @@ export function generateCodexBootstrap () { // install root (a path segment matching `nsolid-plugin`). Never fall back to // an unrelated discovered scripts/mcp-wrapper.js. // eslint-disable-next-line no-template-curly-in-string -- codegen: ${path.sep} must stay literal in the generated bootstrap string, it is evaluated at runtime in the host process - return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" + return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)" } export function generateAntigravityBootstrap () { - return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" + return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'antigravity'];import(pathToFileURL(wrapper).href)" } -export function generateMcpConfig (wrapperPath, bundle) { +export function generateMcpConfig (wrapperPath, bundle, harness) { const b = getBundle(bundle) const mcpServers = {} for (const server of b.mcpServers) { mcpServers[server.name] = { command: 'node', - args: [wrapperPath, server.name], + args: [wrapperPath, server.name, harness], } } return stableJson({ mcpServers }) } -export function generateMcpWrapper (harness) { +export function generateMcpWrapper () { const serverNames = [...defaultBundle.mcpServers.map((s) => s.name)] + const serverNamesLiteral = serverNames.map((name) => `'${name}'`).join(', ') + const harnessLiteral = HARNESS_VALUES.map((name) => `'${name}'`).join(', ') return `#!/usr/bin/env node -import { spawn } from 'node:child_process' +// STDIO→HTTP bridge for the NodeSource MCP servers. Resolves mcp-remote +// exclusively from local copies: the shared runtime provisioned by +// \`nsolid-plugin setup\` (~/.agents/nsolid-plugin/runtime/mcp-remote/), +// or — only when the explicit internal development flag +// NSOLID_MCP_RUNTIME_DEV_FALLBACK=1 is set — a version-matched development +// checkout. It NEVER invokes npx, npm, a shell, or cmd.exe during startup — +// a missing runtime fails fast with the repair command instead of +// downloading anything. + import { createRequire } from 'node:module' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' +const MCP_REMOTE_VERSION = '${MCP_REMOTE_VERSION}' +const PLUGIN_VERSION = '${PLUGIN_VERSION}' +const STARTUP_FAILURE_WINDOW_MS = 15000 const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness ${harness}' -const MCP_REMOTE_NPX_BOOTSTRAP = "import{existsSync}from'node:fs';import path from'node:path';import{pathToFileURL}from'node:url';const payload=JSON.parse(Buffer.from(process.env.NSOLID_MCP_REMOTE_PAYLOAD,'base64url'));const binName=process.platform==='win32'?'mcp-remote.cmd':'mcp-remote';const binDir=process.env.PATH.split(path.delimiter).find(dir=>existsSync(path.join(dir,binName)));if(!binDir)throw new Error('mcp-remote executable was not installed by npx');const proxyPath=path.resolve(binDir,'..','mcp-remote','dist','proxy.js');const args=Object.entries(payload.headers).flatMap(([key,value])=>['--header',key+':'+value]);process.argv=[process.execPath,proxyPath,payload.url,...args,'--transport','http-first','--silent'];await import(pathToFileURL(proxyPath).href)" - -const SERVER_NAMES = new Set(${JSON.stringify(serverNames)}) +const SERVER_NAMES = new Set([${serverNamesLiteral}]) +const HARNESS_NAMES = new Set([${harnessLiteral}]) const serverName = process.argv[2] +const harness = process.argv[3] if (!SERVER_NAMES.has(serverName)) { fail(\`Unknown NodeSource MCP server: \${serverName ?? '(missing)'}\`) } +if (!HARNESS_NAMES.has(harness)) { + fail(\`Invalid harness argument: \${harness ?? '(missing)'}\`) +} const credentials = readCredentials() const server = resolveServer(serverName, credentials) @@ -168,25 +200,25 @@ await runMcpRemote(server.url, server.headers) function readCredentials () { if (!existsSync(AUTH_FILE)) { - fail(\`NodeSource credentials not found. Run: \${SETUP_COMMAND}\`) + fail(\`NodeSource credentials not found. Run: \${SETUP_COMMAND()}\`) } let parsed try { parsed = JSON.parse(readFileSync(AUTH_FILE, 'utf8')) } catch (err) { - fail(\`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && \${SETUP_COMMAND}. \${err.message}\`) + fail(\`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && \${SETUP_COMMAND()}. \${err.message}\`) } const required = ['serviceToken', 'organizationId', 'consoleUrl', 'expiresAt'] const missing = required.filter((key) => typeof parsed?.[key] !== 'string' || parsed[key].length === 0) if (missing.length > 0) { - fail(\`NodeSource credentials are incomplete (\${missing.join(', ')} missing). Run: \${SETUP_COMMAND}\`) + fail(\`NodeSource credentials are incomplete (\${missing.join(', ')} missing). Run: \${SETUP_COMMAND()}\`) } const expiresAt = Date.parse(parsed.expiresAt) if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { - fail(\`NodeSource credentials are expired. Run: \${SETUP_COMMAND}\`) + fail(\`NodeSource credentials are expired. Run: \${SETUP_COMMAND()}\`) } return parsed @@ -200,7 +232,7 @@ function resolveServer (name, credentials) { : null const url = storedUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) if (!url) { - fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND}\`) + fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND()}\`) } return { url, @@ -264,80 +296,100 @@ function isLegacyAliasMcpUrl (mcpUrl, consoleUrl, organizationId) { return storedHost === legacyHost } -async function runMcpRemote (url, headers) { - const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', \`\${key}:\${value}\`]) +function SETUP_COMMAND () { + // Version-pinned: a wrapper generated by release X always prints + // nsolid-plugin@X, and that CLI release provisions exactly the runtime + // version this wrapper validates. + return \`npx -y nsolid-plugin@\${PLUGIN_VERSION} setup --harness \${harness}\` +} - const require = createRequire(import.meta.url) - try { - const proxyPath = require.resolve('mcp-remote/dist/proxy.js') - process.argv = [process.execPath, proxyPath, url, ...headerArgs, '--transport', 'http-first', '--silent'] - await import(pathToFileURL(proxyPath).href) - return - } catch (err) { - if (err?.code !== 'MODULE_NOT_FOUND' && !String(err?.message ?? '').includes('Cannot find module')) { - throw err +function resolveProxyPath () { + // 1. Stable shared runtime provisioned by \`nsolid-plugin setup\`. + const runtimeParent = path.join(os.homedir(), '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + const runtimeRoot = path.join(runtimeParent, MCP_REMOTE_VERSION) + const stable = validateMcpRemote(path.join(runtimeRoot, 'node_modules', 'mcp-remote'), runtimeRoot, runtimeParent) + if (stable) return stable + + // 2. Development fallback — ONLY under the explicit internal development + // flag. Released harness configurations never set it, so a local/project + // node_modules can never mask a missing or invalid managed runtime. + if (process.env.NSOLID_MCP_RUNTIME_DEV_FALLBACK === '1') { + try { + const require = createRequire(import.meta.url) + const checkoutDir = path.dirname(require.resolve('mcp-remote/package.json')) + return validateMcpRemote(checkoutDir, checkoutDir) + } catch { + return null } } + return null +} - const fallback = getMcpRemoteFallback(url, headers) - const options = { - stdio: 'inherit', - ...fallback.options, - windowsHide: true, +function validateMcpRemote (dir, boundary, parentBoundary) { + try { + const canonicalParent = realpathSync(parentBoundary ?? boundary) + if (!statSync(canonicalParent).isDirectory()) return null + const canonicalBoundary = parentBoundary + ? canonicalTargetInside(boundary, canonicalParent, 'dir') + : canonicalParent + if (!canonicalBoundary) return null + const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir') + if (!canonicalDir) return null + const manifestPath = canonicalTargetInside(path.join(dir, 'package.json'), canonicalDir, 'file') + if (!manifestPath) return null + const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (pkg.name !== 'mcp-remote' || pkg.version !== MCP_REMOTE_VERSION) return null + return canonicalTargetInside(path.join(dir, 'dist', 'proxy.js'), canonicalDir, 'file') + } catch { + return null } - const child = fallback.args.length === 0 - ? spawn(fallback.command, options) - : spawn(fallback.command, fallback.args, options) - await new Promise((resolve, reject) => { - child.on('error', reject) - child.on('exit', (code) => code === 0 ? resolve() : reject(new Error('mcp-remote exited with code ' + (code ?? 1)))) - }) } -function getMcpRemoteFallback (url, headers) { - if (process.platform !== 'win32') { - const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', \`\${key}:\${value}\`]) - return { - command: 'npx', - args: ['-y', 'mcp-remote@0.1.38', url, ...headerArgs, '--transport', 'http-first', '--silent'], - options: { shell: false, env: process.env }, - } +function canonicalTargetInside (target, boundary, kind) { + const canonical = realpathSync(target) + const relative = path.relative(boundary, canonical) + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null + const targetStat = statSync(canonical) + if (kind === 'dir' ? !targetStat.isDirectory() : !targetStat.isFile()) return null + return canonical +} + +async function runMcpRemote (url, headers) { + const proxyPath = resolveProxyPath() + if (!proxyPath) { + fail(\`MCP bridge runtime is not ready. Run: \${SETUP_COMMAND()}\`) } - // A .cmd file needs cmd.exe. Keep its command line constant and move all - // untrusted values into an encoded environment payload for Node to decode. - const npxCmd = resolveWindowsNpxCmd() - const payload = Buffer.from(JSON.stringify({ url, headers })).toString('base64url') - const bootstrap = \`data:text/javascript;base64,\${Buffer.from(MCP_REMOTE_NPX_BOOTSTRAP).toString('base64')}\` - return { - command: '.\\\\npx.cmd -y --package=mcp-remote@0.1.38 node --input-type=module --eval "await import(process.env.NSOLID_MCP_REMOTE_BOOTSTRAP)"', - args: [], - options: { - shell: getWindowsCmdShell(), - cwd: path.dirname(npxCmd), - env: { ...process.env, NSOLID_MCP_REMOTE_PAYLOAD: payload, NSOLID_MCP_REMOTE_BOOTSTRAP: bootstrap }, - }, + // URL and headers are handed to the imported proxy as separate argv + // elements; no shell is ever involved. + const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', \`\${key}:\${value}\`]) + process.argv = [process.execPath, proxyPath, url, ...headerArgs, '--transport', 'http-first', '--silent'] + guardStartupFailures() + try { + await import(pathToFileURL(proxyPath).href) + } catch (err) { + startupFailure(err) } } -function resolveWindowsNpxCmd () { - // Node's own directory is already inside the trust boundary: this process - // was launched from it. Do not search PATH, which may contain project-owned - // .bin directories or other attacker-controlled entries. - const npxCmd = path.join(path.dirname(process.execPath), 'npx.cmd') - if (existsSync(npxCmd)) return npxCmd - throw new Error(\`Could not locate npx.cmd next to Node.js at \${npxCmd}. Install Node.js with npm.\`) +// Any error thrown while importing or initializing the light-validated proxy +// — including missing or incompatible transitives and arbitrary module +// initialization errors — becomes the harness-specific repair message. A raw +// stack is never the primary guidance. +function guardStartupFailures () { + const handler = (err) => startupFailure(err) + process.on('uncaughtException', handler) + process.on('unhandledRejection', handler) + setTimeout(() => { + process.off('uncaughtException', handler) + process.off('unhandledRejection', handler) + }, STARTUP_FAILURE_WINDOW_MS).unref() } -function getWindowsCmdShell () { - const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR - const root = windowsRoot ? path.win32.parse(windowsRoot).root : '' - if (!windowsRoot || !path.win32.isAbsolute(windowsRoot) || root.length === 1) { - throw new Error('Could not locate the Windows system directory.') - } - const shell = path.join(windowsRoot, 'System32', 'cmd.exe') - if (!existsSync(shell)) throw new Error(\`Could not locate Windows command shell at \${shell}.\`) - return shell +function startupFailure (err) { + const message = err instanceof Error ? String(err.message) : String(err) + const detail = message.split('\\n')[0] + fail(\`MCP bridge runtime is not ready. Run: \${SETUP_COMMAND()}\\n cause: \${detail}\`) } function fail (message) { From fcaa1fc43eb75f769a9aad3886a174e7d03df34d Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Thu, 27 Aug 2026 13:53:38 +0200 Subject: [PATCH 2/6] fix(mcp): repair outside-target root symlinks and enforce strict root containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address both review comments on PR #63: - safeRemove: lexically guard removal to strict descendants of the runtime parent (rejecting the parent itself), make absence (ENOENT) the only successful no-op, and unlink symlink deletion targets lexically — never following them. Repairing an invalid root whose symlink points outside the parent now converges instead of throwing after publication and leaking the stale link. The created[] cleanup loop drops its existsSync precondition (it follows terminal symlinks and leaked broken links), and reclaimOrphans treats symlinked orphan entries as lexically reclaimable under the same ownership/liveness proofs. - Validation: shared tri-state boundary relation ('same' | 'descendant' | 'outside') with inclusive and strict predicates; the versioned runtime root and node_modules/mcp-remote must now resolve strictly below their boundaries — canonical equality with the runtime parent is rejected — while files inside the root keep inclusive containment. The same strictness is enforced in both wrapper copies (scripts/mcp-wrapper.js and the generateMcpWrapper template), rejecting self-equality except for the dev fallback where dir === boundary by construction. Regressions: outside-target root symlink repair (referent untouched), symlinked stale-tree reclamation, equality-boundary root rejection in core inspect and in the source/generated wrapper. openspec change docs updated to match. --- .../stage-mcp-runtime-during-setup/design.md | 23 ++++-- .../specs/installation-and-auth/spec.md | 17 ++++- .../stage-mcp-runtime-during-setup/tasks.md | 6 ++ packages/core/src/mcp/mcp-remote-runtime.ts | 48 +++++++++++- .../core/src/mcp/mcp-runtime-validation.ts | 55 +++++++++++--- .../test/unit/mcp/mcp-remote-runtime.test.ts | 73 ++++++++++++++++++- .../core/test/unit/mcp/mcp-wrapper.test.ts | 30 ++++++++ scripts/mcp-wrapper.js | 13 +++- scripts/plugin-generators.mjs | 13 +++- 9 files changed, 247 insertions(+), 31 deletions(-) diff --git a/openspec/changes/stage-mcp-runtime-during-setup/design.md b/openspec/changes/stage-mcp-runtime-during-setup/design.md index b552828..aef9336 100644 --- a/openspec/changes/stage-mcp-runtime-during-setup/design.md +++ b/openspec/changes/stage-mcp-runtime-during-setup/design.md @@ -86,9 +86,13 @@ mutation, no network and no process spawning. A runtime at `root` is ready iff: Before reading package metadata, the probe canonicalizes the controlled runtime -parent and `root`. The canonical root must remain below the canonical parent by -a path-segment-aware boundary check; replacing the whole version root with a -symlink outside that parent makes the runtime invalid. The `mcp-remote` package +parent and `root`. The canonical root must remain strictly below the canonical +parent by a path-segment-aware boundary relation; canonical equality with the +parent is rejected, so a root symlink resolving exactly to the parent is +invalid. The `node_modules/mcp-remote` package directory must also resolve +strictly below the canonical root, while files inside the root keep inclusive +containment; replacing the whole version root with a symlink outside that +parent makes the runtime invalid. The `mcp-remote` package directory, every transitively resolved package directory, each package manifest and `dist/proxy.js` are resolved with `realpath`; each canonical target must equal the canonical runtime root or remain below it by the same boundary rule. @@ -156,8 +160,14 @@ not been vetted/executed yet). Only paths created by the current operation are ever deleted: the staging directory, the stale-aside directory and the lock file this operation owns. -Every recursive deletion target is asserted to live inside the validated -runtime parent. +Every recursive deletion target is asserted to be a strict descendant of the +validated runtime parent, lexically and (for non-links) canonically — the +target may never equal the parent itself. A deletion target that is itself a +symlink is unlinked lexically and never followed (its referent is never +touched), which is exactly what lets setup repair an invalid runtime root +whose symlink points outside the managed runtime parent. Absence (`ENOENT`) +is the only successful no-op in cleanup; any other I/O error stays visible +instead of counting as removed. ### Publication protocol (staging → root) @@ -246,6 +256,9 @@ published, versioned runtimes: for staging with a recorded managed process identity, the platform-specific check confirms that process group/tree no longer exists. Unknown liveness, permission errors, missing/malformed metadata or token mismatch means retain. + A staging/stale tree whose path is itself a symlink is reclaimed lexically + (the link is removed, its referent untouched) under the same ownership/ + liveness proofs. - A stale-aside tree is reclaimed only after a valid versioned root exists. Reclamation never restores or promotes an orphan. Tests use a short injected grace period; production uses a fixed conservative grace period documented diff --git a/openspec/changes/stage-mcp-runtime-during-setup/specs/installation-and-auth/spec.md b/openspec/changes/stage-mcp-runtime-during-setup/specs/installation-and-auth/spec.md index 74f6b73..8ad47e2 100644 --- a/openspec/changes/stage-mcp-runtime-during-setup/specs/installation-and-auth/spec.md +++ b/openspec/changes/stage-mcp-runtime-during-setup/specs/installation-and-auth/spec.md @@ -118,6 +118,7 @@ runtime root — so every state called `ready` is loadable by the wrapper. - **WHEN** readiness is evaluated - **THEN** the runtime is reported invalid before package code is imported - **AND** the same canonical boundary rule applies to package directories, manifests and the proxy file +- **AND** a runtime root whose canonical path equals the controlled runtime parent (e.g. a root symlink resolving to the parent itself) is rejected; the versioned root must be a strict descendant #### Scenario: Dependency kinds follow runtime-install semantics @@ -142,6 +143,14 @@ renames of an invalid-runtime replacement. - **THEN** the invalid runtime is replaced under the publication lock by the validated staging tree - **AND** readiness reports the runtime as ready afterwards +#### Scenario: Outside-target root symlinks are repaired lexically + +- **GIVEN** the versioned root is a symlink whose target lies outside the runtime parent +- **WHEN** setup runs +- **THEN** the replacement runtime is published and the moved-aside link is removed lexically without ever touching its referent +- **AND** no stale tree, sidecar or lock remains +- **AND** the operation succeeds instead of failing containment cleanup + #### Scenario: Interrupted installation never publishes a partial runtime - **GIVEN** runtime provisioning is interrupted (process killed, npm crash) @@ -178,6 +187,12 @@ renames of an invalid-runtime replacement. - **THEN** the stale tree may be removed only after the creator is proven dead and no live lock carries its operation token - **AND** missing or malformed metadata, unknown liveness, permission errors or token mismatch retain the tree instead of guessing ownership +#### Scenario: Symlinked orphan trees are reclaimed lexically + +- **GIVEN** a stale/staging tree path is a symlink resolving outside the runtime parent with otherwise valid aged ownership metadata from a dead creator +- **WHEN** a later setup holds the publication lock and applies the reclamation proof +- **THEN** the orphan is reclaimed by removing the link while its referent is untouched, under the same ownership/liveness proofs as any other orphan + #### Scenario: Concurrent setups converge on a valid runtime - **GIVEN** two `setup` processes provision the runtime concurrently @@ -293,7 +308,7 @@ message may contain an `npx` command as text. - **GIVEN** a valid runtime provisioned by setup - **WHEN** the harness starts an MCP server through the wrapper - **THEN** immediately before import the wrapper canonicalizes the controlled runtime parent, version root, package directory, package manifest and `dist/proxy.js` -- **AND** the canonical version root remains within the canonical controlled parent, while the package directory, manifest and proxy remain within the canonical version root with their required directory/file types +- **AND** the canonical version root remains strictly within the canonical controlled parent (canonical equality with the parent is rejected), while the package directory, manifest and proxy remain within the canonical version root with their required directory/file types - **AND** replacing the whole version root, package directory, manifest or proxy with a symlink or path that escapes its required boundary fails with the repair message before any package code is imported - **AND** the wrapper validates the runtime's package name and exact version, imports `dist/proxy.js` locally, and passes URL/headers as separate arguments - **AND** an `npx` sentinel on PATH is never executed diff --git a/openspec/changes/stage-mcp-runtime-during-setup/tasks.md b/openspec/changes/stage-mcp-runtime-during-setup/tasks.md index 9a8433f..91bca97 100644 --- a/openspec/changes/stage-mcp-runtime-during-setup/tasks.md +++ b/openspec/changes/stage-mcp-runtime-during-setup/tasks.md @@ -202,3 +202,9 @@ proposal (scope/rollback), design (module contract, sequences), specs - [x] `git diff --check`, `git status --short` clean of drift. - [x] Atomic conventional commit: `fix(mcp): provision bridge runtime during setup`. No push/PR. + +## 9. Review follow-ups (PR #63) + +- [x] safeRemove: lexical strict-descendant guard (rejects the parent itself), ENOENT-only idempotence, lexical unlink of symlink targets; created-loop existsSync precondition dropped; reclaimOrphans treats symlink entries as lexically reclaimable +- [x] validation: shared tri-state boundary relation; version root and node_modules/mcp-remote must resolve strictly below their boundaries (canonical equality rejected) while inner files stay inclusive — core + both wrapper copies +- [x] regressions: outside-target root symlink repair (referent untouched), equality-boundary root rejection (core inspect + wrapper source/generated), symlinked stale-tree reclamation diff --git a/packages/core/src/mcp/mcp-remote-runtime.ts b/packages/core/src/mcp/mcp-remote-runtime.ts index f006920..1af47d7 100644 --- a/packages/core/src/mcp/mcp-remote-runtime.ts +++ b/packages/core/src/mcp/mcp-remote-runtime.ts @@ -8,6 +8,7 @@ import { readdirSync, readFileSync, realpathSync, + rmdirSync, renameSync, rmSync, statSync, @@ -407,8 +408,10 @@ export async function ensureMcpRemoteRuntime ( // Clean up only what this operation created; a previously valid runtime // is never touched here. if (!skipCleanup) { + // safeRemove is ENOENT-idempotent; no existsSync precondition (it would + // follow a terminal symlink and leak a broken link this operation made). for (const target of created) { - if (existsSync(target)) safeRemove(target, parent) + safeRemove(target, parent) } } } @@ -732,7 +735,15 @@ function reclaimOrphans (parent: string, root: string, publish: PublishControls) if (!isStaleTree && !treeName.startsWith('.staging-')) continue try { const treePath = path.join(parent, treeName) - if (!isInsideBoundary(realpathSync(treePath), canonicalParent)) continue + let stat: ReturnType + try { + stat = lstatSync(treePath) + } catch { + continue // vanished — retain its sidecar + } + // A symlink entry is deleted lexically (its referent is never touched), + // so it is reclaimable regardless of where the link resolves. + if (!stat.isSymbolicLink() && !isInsideBoundary(realpathSync(treePath), canonicalParent)) continue // A stale-aside tree is reclaimed only after a valid versioned root // exists: recovery never depends on removing it. if (isStaleTree && (!existsSync(root) || !validateRuntimeRoot(root, MCP_REMOTE_VERSION).ok)) continue @@ -793,10 +804,39 @@ function isManagedTreeProvenAbsent (managedPid: number): boolean { } /** - * Recursive delete guarded to only accept paths inside the runtime parent. - * Never call this with an unvalidated or user-supplied path. + * Recursive delete guarded to only accept strict descendants of the runtime + * parent (lexically and, for non-links, canonically). A deletion target that + * is itself a symlink is unlinked lexically — its referent is never touched — + * which is what makes an invalid root whose link points outside the parent + * repairable. Absence is the only successful no-op; every other failure stays + * visible. Never call this with an unvalidated or user-supplied path. */ function safeRemove (target: string, parent: string): void { + // Lexical guard first: `target` must name a strict descendant of `parent`. + // This helper is the security boundary — never rely on caller discipline, + // and never accept the parent itself (recursive delete of the whole parent). + const lexicalTarget = path.resolve(target) + const lexicalParent = path.resolve(parent) + if (lexicalTarget === lexicalParent || !isInsideBoundary(lexicalTarget, lexicalParent)) { + throw new McpRemoteRuntimeError(`Refusing to remove a path outside the runtime directory: ${target}`) + } + let stat: ReturnType + try { + stat = lstatSync(target) + } catch (err) { + // Absence is the only successful no-op (rmSync `force` semantics); + // EACCES/EPERM/EIO stay visible instead of becoming silent no-ops. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return + throw err + } + if (stat.isSymbolicLink()) { + try { + unlinkSync(target) + } catch { + rmdirSync(target) // junction/dir-symlink fallback for platforms where unlink refuses + } + return + } const canonicalTarget = realpathSync(target) const canonicalParent = realpathSync(parent) if (!isInsideBoundary(canonicalTarget, canonicalParent)) { diff --git a/packages/core/src/mcp/mcp-runtime-validation.ts b/packages/core/src/mcp/mcp-runtime-validation.ts index 444a3db..d34467a 100644 --- a/packages/core/src/mcp/mcp-runtime-validation.ts +++ b/packages/core/src/mcp/mcp-runtime-validation.ts @@ -22,16 +22,24 @@ export interface RuntimeProbe { * (realpath) to `kind` with its canonical path equal to or inside the * canonical `boundary` by a path-segment-aware check. A missing canonical * target, a broken symlink or a symlink whose target escapes the boundary - * fails. The `failure` discriminator lets callers map each cause to their own + * fails. `containment: 'descendant'` additionally rejects canonical equality + * with the boundary, i.e. the target must live strictly underneath it. The + * `failure` discriminator lets callers map each cause to their own * error wording without re-implementing the probe. */ type CanonicalProbe = | { ok: true; canonical: string } | { ok: false; failure: 'unreadable'; reason: string } | { ok: false; failure: 'type'; reason: string } + | { ok: false; failure: 'boundary'; reason: string; canonical: string } | { ok: false; failure: 'escape'; reason: string; canonical: string } -function canonicalTargetInside (target: string, boundary: string, kind: 'dir' | 'file'): CanonicalProbe { +function canonicalTargetInside ( + target: string, + boundary: string, + kind: 'dir' | 'file', + containment: 'inside' | 'descendant' = 'inside' +): CanonicalProbe { let canonical: string try { canonical = realpathSync(target) @@ -50,7 +58,13 @@ function canonicalTargetInside (target: string, boundary: string, kind: 'dir' | if (kind === 'file' && !targetStat.isFile()) { return { ok: false, failure: 'type', reason: `${target} is not a regular file` } } - if (!isInsideBoundary(canonical, boundary)) { + // Classify once: only canonical equality maps to the strict-descendant + // failure — a target that is merely outside must still fail as an escape. + const relation = boundaryRelation(canonical, boundary) + if (containment === 'descendant' && relation === 'same') { + return { ok: false, failure: 'boundary', reason: `${target} is not strictly below the required boundary (${canonical})`, canonical } + } + if (relation === 'outside') { return { ok: false, failure: 'escape', reason: `${target} resolves outside the runtime root (${canonical})`, canonical } } return { ok: true, canonical } @@ -71,8 +85,11 @@ export function validateRuntimeRoot (root: string, expectedVersion: string): Run return { ok: false, reason: 'controlled runtime parent is missing or unreadable' } } - const rootProbe = canonicalTargetInside(root, canonicalParent, 'dir') + const rootProbe = canonicalTargetInside(root, canonicalParent, 'dir', 'descendant') if (!rootProbe.ok) { + if (rootProbe.failure === 'boundary') { + return { ok: false, reason: `runtime root must resolve strictly below the controlled runtime parent (${rootProbe.canonical})` } + } if (rootProbe.failure === 'escape') { return { ok: false, reason: `runtime root resolves outside the controlled runtime parent (${rootProbe.canonical})` } } @@ -84,7 +101,7 @@ export function validateRuntimeRoot (root: string, expectedVersion: string): Run const canonicalRoot = rootProbe.canonical const mcpRemoteDir = path.join(canonicalRoot, 'node_modules', 'mcp-remote') - const packageProbe = canonicalTargetInside(mcpRemoteDir, canonicalRoot, 'dir') + const packageProbe = canonicalTargetInside(mcpRemoteDir, canonicalRoot, 'dir', 'descendant') if (!packageProbe.ok) return { ok: false, reason: `node_modules/mcp-remote ${packageProbe.reason}` } const manifestProbe = canonicalTargetInside(path.join(mcpRemoteDir, 'package.json'), canonicalRoot, 'file') @@ -244,16 +261,32 @@ function resolveWithinRuntime (fromDir: string, name: string, root: string): str } } +/** + * Tri-state path relation, the single place that knows path-segment awareness + * and Windows case folding: `same` (target IS the boundary), `descendant` + * (strictly underneath it) or `outside`. A lexical prefix such as + * `-evil` is NOT a descendant. + */ +type BoundaryRelation = 'same' | 'descendant' | 'outside' + +function boundaryRelation (target: string, boundary: string): BoundaryRelation { + const t = path.resolve(target) + const b = path.resolve(boundary) + const [tt, bb] = process.platform === 'win32' ? [t.toLowerCase(), b.toLowerCase()] : [t, b] + if (tt === bb) return 'same' + return tt.startsWith(bb + path.sep) ? 'descendant' : 'outside' +} + /** * Path-aware containment: `target` must equal `boundary` or live underneath * it. A lexical prefix such as `-evil` is NOT a descendant. On * Windows the comparison is case-insensitive. */ export function isInsideBoundary (target: string, boundary: string): boolean { - const t = path.resolve(target) - const b = path.resolve(boundary) - if (process.platform === 'win32') { - return t.toLowerCase() === b.toLowerCase() || t.toLowerCase().startsWith(b.toLowerCase() + path.sep) - } - return t === b || t.startsWith(b + path.sep) + return boundaryRelation(target, boundary) !== 'outside' +} + +/** Strict containment: `target` must live strictly underneath `boundary`. */ +export function isStrictlyInsideBoundary (target: string, boundary: string): boolean { + return boundaryRelation(target, boundary) === 'descendant' } diff --git a/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts b/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts index 27cf326..be86726 100644 --- a/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts +++ b/packages/core/test/unit/mcp/mcp-remote-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join, sep } from 'node:path' @@ -56,6 +56,8 @@ function lockPath (): string { } interface SeedOptions { + /** Tree root to seed (defaults to the versioned runtime root). */ + root?: string version?: string withProxy?: boolean /** Packages actually present under node_modules; name/version/deps per package. */ @@ -82,6 +84,7 @@ const EXPECTED_NPM_INSTALL_ARGS = [ /** Seed a runtime tree directly (no npm). Defaults produce a fully valid runtime. */ function seedRuntime (options: SeedOptions = {}): void { const { + root = runtimeRoot(), version = MCP_REMOTE_VERSION, withProxy = true, dependencies = { @@ -93,7 +96,7 @@ function seedRuntime (options: SeedOptions = {}): void { ranges = {}, declareWithoutInstalling = [], } = options - const mcpRemoteDir = join(runtimeRoot(), 'node_modules', 'mcp-remote') + const mcpRemoteDir = join(root, 'node_modules', 'mcp-remote') mkdirSync(mcpRemoteDir, { recursive: true }) const declared = [...SEED_DECLARED, ...declareWithoutInstalling] writeFileSync( @@ -109,11 +112,11 @@ function seedRuntime (options: SeedOptions = {}): void { writeFileSync(join(mcpRemoteDir, 'dist', 'proxy.js'), '// proxy\n') } for (const [name, pkg] of Object.entries(dependencies)) { - const depDir = join(runtimeRoot(), 'node_modules', name) + const depDir = join(root, 'node_modules', name) mkdirSync(depDir, { recursive: true }) writeFileSync(join(depDir, 'package.json'), JSON.stringify({ name: pkg.name ?? name, version: pkg.version ?? '1.0.0', ...(pkg.dependencies ? { dependencies: pkg.dependencies } : {}) })) } - writeFileSync(join(runtimeRoot(), 'package.json'), JSON.stringify({ name: 'nsolid-plugin-mcp-remote-runtime', private: true })) + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'nsolid-plugin-mcp-remote-runtime', private: true })) } function runtimeParentEntries (): string[] { @@ -284,6 +287,18 @@ describe('inspectMcpRemoteRuntime()', () => { assert.match(status.reason ?? '', /runtime root .*outside the controlled runtime parent/) }) + it('rejects a runtime root symlink that resolves to exactly the controlled parent', () => { + // Canonical equality with the parent must not satisfy "below the parent": + // the versioned root is a strict descendant, so a tree living directly in + // the parent can never stand in for the versioned runtime. + seedRuntime({ root: runtimeParent() }) + symlinkSync(runtimeParent(), runtimeRoot(), process.platform === 'win32' ? 'junction' : 'dir') + + const status = inspectMcpRemoteRuntime() + assert.strictEqual(status.status, 'invalid') + assert.match(status.reason ?? '', /runtime root must resolve strictly below the controlled runtime parent/) + }) + it('rejects a symlinked mcp-remote package directory whose target escapes the runtime root', () => { // A complete, valid-looking tree outside the root must not satisfy the // entry package through a symlink: lexical presence is not readiness. @@ -458,6 +473,28 @@ describe('ensureMcpRemoteRuntime()', () => { assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'no stale leftovers, lock released') }) + it('repairs a runtime root symlink whose target escapes the controlled parent', async () => { + // The invalid root is a symlink pointing outside the managed parent. + // Repair must still converge: publish the replacement, remove the + // moved-aside link lexically (never following it — the referent must + // survive untouched) and leave no stale artifacts behind. + const outside = join(tmpHome, 'outside-root-referent') + mkdirSync(outside, { recursive: true }) + const sentinel = join(outside, 'sentinel.txt') + writeFileSync(sentinel, 'do not delete\n') + mkdirSync(runtimeParent(), { recursive: true }) + symlinkSync(outside, runtimeRoot(), process.platform === 'win32' ? 'junction' : 'dir') + const calls: Array<{ command: string; args: string[]; cwd: string }> = [] + + const result = await ensureMcpRemoteRuntime({ runner: createFakeRunner(calls) }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.ok(lstatSync(runtimeRoot()).isDirectory(), 'published root is a real directory, not the link') + assert.deepStrictEqual(runtimeParentEntries(), [MCP_REMOTE_VERSION], 'stale link and sidecar removed, lock released') + assert.strictEqual(readFileSync(sentinel, 'utf8'), 'do not delete\n', 'link referent was never touched') + }) + it('rejects a staging tree that fails validation (wrong version from npm)', async () => { const calls: Array<{ command: string; args: string[]; cwd: string }> = [] await assert.rejects( @@ -1067,6 +1104,34 @@ describe('safe orphan reclamation', () => { assert.strictEqual(existsSync(orphan.tree), false, 'stale orphan reclaimed once a valid root exists') assert.strictEqual(existsSync(orphan.sidecar), false, 'stale orphan sidecar reclaimed') }) + + it('reclaims a stale tree whose path is a symlink resolving outside the runtime parent', async () => { + // A symlinked orphan is deleted lexically (its referent is never touched), + // so it is reclaimable under the same ownership/liveness proofs even when + // the link resolves outside the managed parent. + seedRuntime({ version: '0.1.37' }) // invalid root forces the publish path + const outside = join(tmpHome, 'outside-stale-referent') + mkdirSync(outside, { recursive: true }) + const sentinel = join(outside, 'sentinel.txt') + writeFileSync(sentinel, 'do not delete\n') + const tree = join(runtimeParent(), `${MCP_REMOTE_VERSION}.stale-${randomUUID()}`) + symlinkSync(outside, tree, process.platform === 'win32' ? 'junction' : 'dir') + const sidecar = `${tree}.owner.json` + writeFileSync(sidecar, JSON.stringify({ + token: `stale-token-${randomUUID()}`, + pid: deadPid(), + createdAt: Date.now() - 120_000, + state: 'retained-live', + })) + + const result = await ensureMcpRemoteRuntime({ runner: createFakeRunner([]), publish: { reclaimGraceMs: 1 } }) + + assert.strictEqual(result.installed, true) + assert.strictEqual(inspectMcpRemoteRuntime().status, 'ready') + assert.strictEqual(existsSync(tree), false, 'symlinked stale tree reclaimed lexically') + assert.strictEqual(existsSync(sidecar), false, 'symlinked stale tree sidecar reclaimed') + assert.strictEqual(readFileSync(sentinel, 'utf8'), 'do not delete\n', 'link referent was never touched') + }) }) describe('default runner and npm resolution', () => { diff --git a/packages/core/test/unit/mcp/mcp-wrapper.test.ts b/packages/core/test/unit/mcp/mcp-wrapper.test.ts index 1f9fde6..50afa74 100644 --- a/packages/core/test/unit/mcp/mcp-wrapper.test.ts +++ b/packages/core/test/unit/mcp/mcp-wrapper.test.ts @@ -394,6 +394,36 @@ describe('MCP wrapper stable runtime', () => { }) } + it(`${wrapper} wrapper rejects a runtime root symlink that resolves to exactly the runtime parent`, (t) => { + // The versioned root must be a strict descendant of the runtime parent: + // canonical equality (a root symlink pointing at the parent itself) + // must fail before import even when a valid-looking tree lives directly + // in the parent. + const fixture = createWrapperFixture(wrapper) + const runtimeParent = join(fixture.home, '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + const dir = join(runtimeParent, 'node_modules', 'mcp-remote') + mkdirSync(join(dir, 'dist'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'mcp-remote', version: CORE_VERSION })) + writeFileSync(join(dir, 'dist', 'proxy.js'), "throw new Error('parent-level proxy imported')\n") + const linked = replaceWithSymlink(join(runtimeParent, CORE_VERSION), runtimeParent, 'dir') + + if (!linked) { + t.skip('symlinks require additional privileges on this Windows host') + return + } + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'ncm', 'claude'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0, 'root/parent canonical equality must fail before import') + assert.match(result.stderr, repairFor('claude')) + assert.doesNotMatch(result.stderr, /parent-level proxy imported/) + }) + it(`${wrapper} wrapper reports credentials problems before touching the runtime`, () => { const fixture = createWrapperFixture(wrapper) writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js index b04301c..ca1c178 100644 --- a/scripts/mcp-wrapper.js +++ b/scripts/mcp-wrapper.js @@ -170,7 +170,10 @@ function validateMcpRemote (dir, boundary, parentBoundary) { ? canonicalTargetInside(boundary, canonicalParent, 'dir') : canonicalParent if (!canonicalBoundary) return null - const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir') + // Strict on the stable path (the package dir must sit strictly below the + // versioned root); the dev fallback validates the checkout itself, where + // dir === boundary by construction. + const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir', !parentBoundary) if (!canonicalDir) return null const manifestPath = canonicalTargetInside(path.join(dir, 'package.json'), canonicalDir, 'file') if (!manifestPath) return null @@ -182,10 +185,14 @@ function validateMcpRemote (dir, boundary, parentBoundary) { } } -function canonicalTargetInside (target, boundary, kind) { +function canonicalTargetInside (target, boundary, kind, allowSelf = false) { const canonical = realpathSync(target) const relative = path.relative(boundary, canonical) - if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null + // `relative === ''` means the target IS the boundary: rejected unless the + // caller explicitly allows it (the dev fallback validates the checkout + // itself, where dir === boundary by construction). The versioned runtime + // root must be a strict descendant of the runtime parent. + if (relative === '' ? !allowSelf : (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))) return null const targetStat = statSync(canonical) if (kind === 'dir' ? !targetStat.isDirectory() : !targetStat.isFile()) return null return canonical diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index bb2dc41..779513d 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -333,7 +333,10 @@ function validateMcpRemote (dir, boundary, parentBoundary) { ? canonicalTargetInside(boundary, canonicalParent, 'dir') : canonicalParent if (!canonicalBoundary) return null - const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir') + // Strict on the stable path (the package dir must sit strictly below the + // versioned root); the dev fallback validates the checkout itself, where + // dir === boundary by construction. + const canonicalDir = canonicalTargetInside(dir, canonicalBoundary, 'dir', !parentBoundary) if (!canonicalDir) return null const manifestPath = canonicalTargetInside(path.join(dir, 'package.json'), canonicalDir, 'file') if (!manifestPath) return null @@ -345,10 +348,14 @@ function validateMcpRemote (dir, boundary, parentBoundary) { } } -function canonicalTargetInside (target, boundary, kind) { +function canonicalTargetInside (target, boundary, kind, allowSelf = false) { const canonical = realpathSync(target) const relative = path.relative(boundary, canonical) - if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null + // \`relative === ''\` means the target IS the boundary: rejected unless the + // caller explicitly allows it (the dev fallback validates the checkout + // itself, where dir === boundary by construction). The versioned runtime + // root must be a strict descendant of the runtime parent. + if (relative === '' ? !allowSelf : (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))) return null const targetStat = statSync(canonical) if (kind === 'dir' ? !targetStat.isDirectory() : !targetStat.isFile()) return null return canonical From 9a70877969a8d7985825d3669dd1fb02a3362de3 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 14:20:03 +0200 Subject: [PATCH 3/6] fix(auth): prevent browser launches in OAuth tests --- packages/core/src/auth/auth-manager.ts | 12 +- packages/core/src/index.ts | 4 +- packages/core/src/types.ts | 17 ++ .../integration/auth/auth-manager.test.ts | 173 ++++++------------ .../core/test/integration/installer.test.ts | 77 ++++---- 5 files changed, 129 insertions(+), 154 deletions(-) diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index dc34992..3b4f590 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { execFile } from 'node:child_process' import { win32 } from 'node:path' -import type { AuthConfig, Credentials, Logger, AuthConfirmation, HarnessType } from '../types.js' +import type { AuthConfig, Credentials, Logger, AuthConfirmation, HarnessType, BrowserLauncher } from '../types.js' import { loadCredentials, saveCredentials, isExpired } from './token-storage.js' import { validateToken } from './token-validator.js' import { startOAuthServer } from './oauth-server.js' @@ -100,6 +100,13 @@ export interface EnsureAuthenticatedOptions { force?: boolean; /** Injectable stdout/stderr sink for headless OAuth sign-in instructions. Defaults to `process.stderr.write`. */ notify?: (text: string) => void; + /** + * Injectable browser launcher for the sign-in URL. Defaults to the + * production `openBrowser()` (rundll32/open/xdg-open). Tests inject a + * capture-only launcher so authentication never spawns a real browser. + * Must not throw (see {@link BrowserLauncher}). + */ + browserLauncher?: BrowserLauncher; } export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logger, options: EnsureAuthenticatedOptions = {}): Promise { @@ -174,7 +181,8 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg notify('If a browser did not open automatically, open this sign-in URL manually:\n') notify(`${signInUrl.toString()}\n\n`) - openBrowser(signInUrl.toString(), logger) + const launchBrowser = options.browserLauncher ?? openBrowser + launchBrowser(signInUrl.toString(), logger) const callback = await server.waitForCallback() await server.close() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec348ad..2e7f3e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -169,7 +169,7 @@ export async function setup (options: SetupOptions): Promise { } try { - await ensureAuthenticated(authConfig, logger, { harness: options.harness, confirmAuth: options.confirmAuth, force: options.force, notify: options.notify }) + await ensureAuthenticated(authConfig, logger, { harness: options.harness, confirmAuth: options.confirmAuth, force: options.force, notify: options.notify, browserLauncher: options.browserLauncher }) // Credentials are authenticated now — the active org is set (freshly // stored, or already valid). This is the "org switch succeeded" signal, // independent of the harness install/config refresh that follows. @@ -822,7 +822,7 @@ export async function doctor ( return report } -export type { HarnessType, InstallOptions, InstallResult, DoctorReport, BundleDescriptor, Credentials } from './types.js' +export type { HarnessType, InstallOptions, InstallResult, SetupOptions, SetupResult, DoctorReport, BundleDescriptor, Credentials, BrowserLauncher } from './types.js' export type { LinkResult, LinkStatus } from './skills/skill-linker.js' export type { SkillTrackingEntry, McpTrackingEntry, TrackingData } from './skills/skill-tracker.js' export type { BackupEntry } from './utils/backup.js' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b730b53..79785d6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -75,6 +75,16 @@ export interface Logger { error(message: string, meta?: Record): void } +/** + * Opens the OAuth sign-in URL in a browser (or headless equivalent). Defaults + * to the production `openBrowser()` implementation (rundll32/open/xdg-open). + * Injectable for tests and embedders that must never spawn an external + * process. Must not throw synchronously: the OAuth callback server is already + * running when it is invoked, so a throwing launcher would leak the server and + * abort the manual sign-in URL flow. + */ +export type BrowserLauncher = (url: string, logger?: Logger) => void + import type { ProgressReporter } from './utils/progress.js' export interface AuthConfirmationContext { @@ -148,6 +158,13 @@ export interface SetupOptions extends InstallOptions { * capture or suppress OAuth sign-in URL messages without touching stderr. */ notify?: (text: string) => void; + /** + * Injectable browser launcher for the OAuth sign-in URL. Defaults to the + * production `openBrowser()` (rundll32/open/xdg-open). Tests inject a + * capture-only launcher so authentication never spawns a real browser. + * Must not throw (see {@link BrowserLauncher}). + */ + browserLauncher?: BrowserLauncher; } export type SetupResult = InstallResult diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 60f1e55..1ba5724 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -4,16 +4,16 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import http from 'node:http' -import { createRequire } from 'node:module' -import type { AuthConfig, Credentials } from '../../../src/types.js' +import type { AuthConfig, Credentials, BrowserLauncher } from '../../../src/types.js' import { getAuthFilePath, getAgentsDir } from '../../../src/utils/path.js' import { getFreePort } from './ports.js' -const require = createRequire(import.meta.url) -const cp = require('node:child_process') - -const execFileCalls: unknown[][] = [] -cp.execFile = (...args: unknown[]) => { execFileCalls.push(args) } +// Capture-only browser launcher: the OAuth sign-in URL is recorded here and +// never passed to an OS browser launcher (rundll32/open/xdg-open), so a +// regression that routes authentication around the injected seam cannot +// silently spawn a real browser process from these tests. +const browserLaunches: string[] = [] +const captureBrowserLauncher: BrowserLauncher = (url: string) => { browserLaunches.push(url) } let tmpDir: string let originalHome: string | undefined @@ -34,19 +34,12 @@ const authConfig: AuthConfig = { callbackPort: 0, } -function getUrlFromExecFileCall (): URL { - const call = execFileCalls[execFileCalls.length - 1] - const cmd = call[0] as string - const args = call[1] as string[] - const urlStr = cmd === 'rundll32' - ? args[args.length - 1] - : args.find((a: string) => a.startsWith('http') || a.startsWith('"http'))! - const cleaned = urlStr.replace(/^"|"$/g, '') - return new URL(cleaned) +function getUrlFromBrowserLaunch (): URL { + return new URL(browserLaunches[browserLaunches.length - 1]!) } -function getStateFromExecFileCall (): string { - return getUrlFromExecFileCall().searchParams.get('state')! +function getStateFromBrowserLaunch (): string { + return getUrlFromBrowserLaunch().searchParams.get('state')! } async function pollForState (getStateFn: () => string, timeoutMs = 5000): Promise { @@ -90,7 +83,7 @@ beforeEach(async () => { process.env.HOME = tmpDir process.env.USERPROFILE = tmpDir originalFetch = globalThis.fetch - execFileCalls.length = 0 + browserLaunches.length = 0 originalAccountsUrl = process.env.NSOLID_ACCOUNTS_URL delete process.env.NSOLID_ACCOUNTS_URL @@ -135,11 +128,11 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig) + const result = await ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) assert.deepStrictEqual(result, creds) assert.strictEqual(fetchCalls, 1, 'fast path should attempt token validation') - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('returns validated permissions with stored credentials', async () => { @@ -168,12 +161,12 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig) + const result = await ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) assert.deepStrictEqual(result, { ...creds, permissions: ['completely-different:perm'] }) assert.deepStrictEqual(loadCredentials()?.permissions, ['completely-different:perm']) assert.strictEqual(fetchCalls, 1, 'stored credentials should be validated when possible') - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('re-authenticates when credentials file is corrupt', { timeout: 10000 }, async () => { @@ -188,15 +181,15 @@ describe('ensureAuthenticated', () => { })) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) await new Promise((resolve) => setTimeout(resolve, 50)) - const signInUrl = getUrlFromExecFileCall() + const signInUrl = getUrlFromBrowserLaunch() assert.strictEqual(signInUrl.origin, 'https://accounts.example.com') assert.strictEqual(signInUrl.pathname, '/sign-in') assert.strictEqual(signInUrl.searchParams.get('extension'), 'nsolid-plugin') assert.strictEqual(signInUrl.searchParams.get('port'), String(callbackPort)) - const state = getStateFromExecFileCall() + const state = getStateFromBrowserLaunch() await sendCallback(callbackPort, state) const result = await promise @@ -225,10 +218,10 @@ describe('ensureAuthenticated', () => { })) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) await new Promise((resolve) => setTimeout(resolve, 50)) - const state = getStateFromExecFileCall() + const state = getStateFromBrowserLaunch() await sendCallback(callbackPort, state) const result = await promise @@ -257,10 +250,10 @@ describe('ensureAuthenticated', () => { })) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig, undefined, { force: true }) + const promise = ensureAuthenticated(authConfig, undefined, { force: true, browserLauncher: captureBrowserLauncher }) - const state = await pollForState(getStateFromExecFileCall) - assert.strictEqual(execFileCalls.length, 1, 'force should open the browser even though valid credentials exist') + const state = await pollForState(getStateFromBrowserLaunch) + assert.strictEqual(browserLaunches.length, 1, 'force should open the browser even though valid credentials exist') await sendCallback(callbackPort, state, { consoleId: 'org-456' }) const result = await promise @@ -290,10 +283,10 @@ describe('ensureAuthenticated', () => { })) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig, undefined, { force: false }) + const result = await ensureAuthenticated(authConfig, undefined, { force: false, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.organizationId, 'org-123') - assert.strictEqual(execFileCalls.length, 0, 'force: false should still take the fast path') + assert.strictEqual(browserLaunches.length, 0, 'force: false should still take the fast path') }) it('trusts stored credentials when validation API is unavailable during fast path', async () => { @@ -316,12 +309,12 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig) + const result = await ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') assert.strictEqual(fetchCalls, 1, 'fast path should try validation before falling back') - assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API is unavailable') + assert.strictEqual(browserLaunches.length, 0, 'browser must not open when API is unavailable') }) it('re-authenticates when validation rejects stored credentials', { timeout: 10000 }, async () => { @@ -347,16 +340,16 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) - const state = await pollForState(getStateFromExecFileCall) + const state = await pollForState(getStateFromBrowserLaunch) await sendCallback(callbackPort, state) const result = await promise assert.strictEqual(result.serviceToken, 'oauth-token') assert.strictEqual(result.organizationId, 'org-456') assert.strictEqual(fetchCalls, 2, 'stored token rejection should be followed by OAuth token validation') - assert.strictEqual(execFileCalls.length, 1, 'browser should open for re-authentication') + assert.strictEqual(browserLaunches.length, 1, 'browser should open for re-authentication') }) it('trusts stored credentials when validation API returns an HTML shell (200 text/html)', async () => { @@ -387,12 +380,12 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig) + const result = await ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') assert.strictEqual(fetchCalls, 1, 'fast path should try validation before falling back') - assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API returns an HTML shell') + assert.strictEqual(browserLaunches.length, 0, 'browser must not open when API returns an HTML shell') }) }) @@ -433,11 +426,11 @@ describe('ensureAuthenticated - requiredPermissions', () => { const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') await assert.rejects( - ensureAuthenticated(authConfigWithPerms), + ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }), /Missing required permissions: nsolid:profile:read/ ) assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('returns credentials when all required permissions are present', async () => { @@ -466,11 +459,11 @@ describe('ensureAuthenticated - requiredPermissions', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfigWithPerms) + const result = await ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }) assert.deepStrictEqual(result.permissions, ['nsolid:benchmark:run', 'nsolid:profile:read']) assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('checks known cached permissions when validation is unavailable', async () => { @@ -496,11 +489,11 @@ describe('ensureAuthenticated - requiredPermissions', () => { const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') await assert.rejects( - ensureAuthenticated(authConfigWithPerms), + ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }), /Missing required permissions: nsolid:profile:read/ ) assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('rejects stored credentials with unknown cached permissions when validation is unavailable', async () => { @@ -525,11 +518,11 @@ describe('ensureAuthenticated - requiredPermissions', () => { const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') await assert.rejects( - ensureAuthenticated(authConfigWithPerms), + ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }), /Cannot verify required permissions: nsolid:benchmark:run, nsolid:profile:read/ ) assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 0) + assert.strictEqual(browserLaunches.length, 0) }) it('does not store fresh OAuth credentials when required permissions cannot be verified', { timeout: 10000 }, async () => { @@ -542,17 +535,17 @@ describe('ensureAuthenticated - requiredPermissions', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfigWithPerms) + const promise = ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }) const rejection = assert.rejects( promise, /Cannot verify required permissions: nsolid:benchmark:run, nsolid:profile:read/ ) - const state = await pollForState(getStateFromExecFileCall) + const state = await pollForState(getStateFromBrowserLaunch) await sendCallback(callbackPort, state) await rejection assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 1) + assert.strictEqual(browserLaunches.length, 1) assert.strictEqual(loadCredentials(), null) }) @@ -571,69 +564,21 @@ describe('ensureAuthenticated - requiredPermissions', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfigWithPerms) + const promise = ensureAuthenticated(authConfigWithPerms, undefined, { browserLauncher: captureBrowserLauncher }) const rejection = assert.rejects( promise, /Missing required permissions: nsolid:profile:read/ ) - const state = await pollForState(getStateFromExecFileCall) + const state = await pollForState(getStateFromBrowserLaunch) await sendCallback(callbackPort, state) await rejection assert.strictEqual(fetchCalls, 1) - assert.strictEqual(execFileCalls.length, 1) + assert.strictEqual(browserLaunches.length, 1) assert.strictEqual(loadCredentials(), null) }) }) -describe('ensureAuthenticated - Windows browser launch', () => { - it('uses the trusted rundll32 path with url.dll,FileProtocolHandler on Windows', { timeout: 10000 }, async () => { - const originalPlatform = process.platform - const originalSystemRoot = process.env.SystemRoot - Object.defineProperty(process, 'platform', { value: 'win32' }) - process.env.SystemRoot = 'C:\\Windows' - - try { - const { saveCredentials } = await import('../../../src/auth/token-storage.js') - const expiredCreds: Credentials = { - serviceToken: 'expired-token', - organizationId: 'org-123', - saasToken: 'expired-saas', - consoleUrl: 'https://expired.saas.nodesource.io', - mcpUrl: 'https://org-123.mcp.saas.nodesource.io', - expiresAt: new Date(Date.now() - 1000).toISOString(), - } - saveCredentials(expiredCreds) - - globalThis.fetch = mock.fn(async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: [] }), - })) as unknown as typeof fetch - - const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) - - await new Promise((resolve) => setTimeout(resolve, 50)) - - const lastCall = execFileCalls[execFileCalls.length - 1] - assert.strictEqual(lastCall[0], 'C:\\Windows\\System32\\rundll32.exe') - const lastArgs = lastCall[1] as string[] - assert.strictEqual(lastArgs[0], 'url.dll,FileProtocolHandler') - assert.ok(lastArgs[1].startsWith('https://accounts.example.com/sign-in')) - - const state = getStateFromExecFileCall() - await sendCallback(callbackPort, state) - await promise - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - if (originalSystemRoot === undefined) delete process.env.SystemRoot - else process.env.SystemRoot = originalSystemRoot - } - }) -}) - describe('ensureAuthenticated - consoleId validation', () => { it('throws on invalid consoleId format', { timeout: 10000 }, async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') @@ -650,11 +595,11 @@ describe('ensureAuthenticated - consoleId validation', () => { const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') await assert.rejects(async () => { - const promise = ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) promise.catch(() => {}) await new Promise((resolve) => setTimeout(resolve, 50)) - const state = getStateFromExecFileCall() + const state = getStateFromBrowserLaunch() await sendCallback(callbackPort, state, { consoleId: 'invalid@console!' }) // Re-throw for assert.rejects to catch @@ -693,13 +638,13 @@ describe('ensureAuthenticated - accountsUrl override', () => { } const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(explicitConfig) + const promise = ensureAuthenticated(explicitConfig, undefined, { browserLauncher: captureBrowserLauncher }) await new Promise((resolve) => setTimeout(resolve, 50)) - const signInUrl = getUrlFromExecFileCall() + const signInUrl = getUrlFromBrowserLaunch() assert.strictEqual(signInUrl.host, 'custom.accounts.example.com') assert.strictEqual(signInUrl.pathname, '/sign-in') - const state = getStateFromExecFileCall() + const state = getStateFromBrowserLaunch() await sendCallback(callbackPort, state) await promise }) @@ -720,13 +665,13 @@ describe('ensureAuthenticated - accountsUrl override', () => { } const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(prodConfig) + const promise = ensureAuthenticated(prodConfig, undefined, { browserLauncher: captureBrowserLauncher }) await new Promise((resolve) => setTimeout(resolve, 50)) - const signInUrl = getUrlFromExecFileCall() + const signInUrl = getUrlFromBrowserLaunch() assert.strictEqual(signInUrl.host, 'accounts.nodesource.com') assert.strictEqual(signInUrl.pathname, '/sign-in') - const state = getStateFromExecFileCall() + const state = getStateFromBrowserLaunch() await sendCallback(callbackPort, state) await promise }) @@ -760,9 +705,9 @@ describe('ensureAuthenticated - manual sign-in URL fallback', () => { }) as typeof process.stderr.write const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig, undefined, { browserLauncher: captureBrowserLauncher }) try { - const state = await pollForState(getStateFromExecFileCall) + const state = await pollForState(getStateFromBrowserLaunch) await sendCallback(callbackPort, state) await promise } finally { @@ -803,13 +748,13 @@ describe('ensureAuthenticated - unrecognized console URL', () => { })) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig, undefined, { force: true }) + const promise = ensureAuthenticated(authConfig, undefined, { force: true, browserLauncher: captureBrowserLauncher }) const rejection = assert.rejects( promise, /Could not determine the N\|Solid MCP endpoint from the console URL/ ) - const state = await pollForState(getStateFromExecFileCall) + const state = await pollForState(getStateFromBrowserLaunch) await sendCallback(callbackPort, state, { consoleId: 'org-456', url: 'https://console.example.com' }) await rejection diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 1012238..8b7ce52 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -5,35 +5,26 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import http from 'node:http' -import { createRequire } from 'node:module' -import type { BundleDescriptor } from '../../src/types.js' +import type { BundleDescriptor, BrowserLauncher } from '../../src/types.js' import type { ProgressReporter } from '../../src/utils/progress.js' import type { TrackingData } from '../../src/skills/skill-tracker.js' - -const require = createRequire(import.meta.url) -const cp = require('node:child_process') - -const execFileCalls: unknown[][] = [] -cp.execFile = (...args: unknown[]) => { execFileCalls.push(args) } +import { getFreePort } from './auth/ports.js' + +// Capture-only browser launcher: the OAuth sign-in URL is recorded here and +// never passed to an OS browser launcher (rundll32/open/xdg-open), so a +// regression that routes authentication around the injected seam cannot +// silently spawn a real browser process from these tests. +const browserLaunches: string[] = [] +const captureBrowserLauncher: BrowserLauncher = (url: string) => { browserLaunches.push(url) } const authNotices: string[] = [] const captureAuthNotice = (text: string): void => { authNotices.push(text) } -function getUrlFromExecFileCall (): URL { - const call = execFileCalls[execFileCalls.length - 1] - const cmd = call[0] as string - const args = call[1] as string[] - const urlStr = cmd === 'rundll32' - ? args[args.length - 1] - : args.find((a: string) => a.startsWith('http') || a.startsWith('"http'))! - return new URL(urlStr.replace(/^"|"$/g, '')) -} - async function pollForState (timeoutMs = 5000): Promise<{ state: string; port: number }> { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const noticeUrl = authNotices.join('').match(/https?:\/\/\S+\/sign-in\?\S+/)?.[0] - const url = noticeUrl ? new URL(noticeUrl) : getUrlFromExecFileCall() + const url = noticeUrl ? new URL(noticeUrl) : new URL(browserLaunches[browserLaunches.length - 1]!) const state = url.searchParams.get('state') const port = url.searchParams.get('port') if (state && port) return { state, port: Number(port) } @@ -138,7 +129,7 @@ beforeEach(() => { originalNpmExecpath = process.env.npm_execpath process.env.HOME = tmpDir process.env.USERPROFILE = tmpDir - execFileCalls.length = 0 + browserLaunches.length = 0 authNotices.length = 0 delete process.env.NSOLID_PLUGIN_PROGRESS delete process.env.npm_execpath @@ -293,7 +284,7 @@ describe('install()', () => { globalThis.fetch = OK_FETCH const progress = SILENT_PROGRESS - const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress }) + const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.success, true) assert.strictEqual(result.skillsInstalled, 0) @@ -309,7 +300,7 @@ describe('install()', () => { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com', - callbackPort: 8769, + callbackPort: await getFreePort(8400, 8500), }, }) const bundlePath = writeBundle(bundle) @@ -328,10 +319,17 @@ describe('install()', () => { warn: () => {}, } - const promise = setup({ harness: 'claude', bundlePath, skillsSource, progress, force: true, notify: captureAuthNotice }) + const promise = setup({ harness: 'claude', bundlePath, skillsSource, progress, force: true, notify: captureAuthNotice, browserLauncher: captureBrowserLauncher }) const { state, port } = await pollForState() + assert.strictEqual(browserLaunches.length, 1, 'the injected launcher must be used exactly once') + const launchUrl = new URL(browserLaunches[0]!) + assert.strictEqual(launchUrl.pathname, '/sign-in') + assert.strictEqual(launchUrl.searchParams.get('extension'), 'nsolid-plugin') + assert.strictEqual(launchUrl.searchParams.get('state'), state) + assert.strictEqual(launchUrl.searchParams.get('port'), String(port)) assert.match(authNotices.join(''), /\/sign-in\?.*state=/, 'force should start a fresh browser authentication flow') + assert.ok(authNotices.join('').includes(browserLaunches[0]!), 'the manual notice must still surface the sign-in URL') await sendCallback(port, state, { consoleId: 'org-456' }) const result = await promise @@ -351,7 +349,7 @@ describe('install()', () => { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com', - callbackPort: 8769, + callbackPort: await getFreePort(8400, 8500), }, }) const bundlePath = writeBundle(bundle) @@ -365,7 +363,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice }) + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice, browserLauncher: captureBrowserLauncher }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -373,6 +371,7 @@ describe('install()', () => { assert.strictEqual(result.authSucceeded, true) assert.strictEqual(result.success, true) + assert.strictEqual(browserLaunches.length, 1, 'the injected launcher must be used exactly once') assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'shared credentials must be switched') const cfg = readJsonFile>(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')) const server = (cfg?.mcp as Record }>)?.['nsolid-console'] @@ -392,7 +391,7 @@ describe('install()', () => { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com', - callbackPort: 8769, + callbackPort: await getFreePort(8400, 8500), }, }) const bundlePath = writeBundle(bundle) @@ -406,7 +405,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'pi', bundlePath, skillsSource, progress, force: true, packageOwnedSkills: true, notify: captureAuthNotice }) + const promise = setup({ harness: 'pi', bundlePath, skillsSource, progress, force: true, packageOwnedSkills: true, notify: captureAuthNotice, browserLauncher: captureBrowserLauncher }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -414,6 +413,7 @@ describe('install()', () => { assert.strictEqual(result.authSucceeded, true) assert.strictEqual(result.success, true) + assert.strictEqual(browserLaunches.length, 1, 'the injected launcher must be used exactly once') assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'shared credentials must be switched') const cfg = readJsonFile>(join(tmpDir, '.pi', 'agent', 'mcp.json')) const server = (cfg?.mcpServers as Record }>)?.['nsolid-console'] @@ -432,7 +432,7 @@ describe('install()', () => { type: 'oauth', provider: 'nodesource', accountsUrl: 'https://accounts.nodesource.com', - callbackPort: 8769, + callbackPort: await getFreePort(8400, 8500), }, }) const bundlePath = writeBundle(bundle) @@ -449,7 +449,7 @@ describe('install()', () => { })) as unknown as typeof fetch const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } - const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice }) + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true, notify: captureAuthNotice, browserLauncher: captureBrowserLauncher }) const { state, port } = await pollForState() await sendCallback(port, state, { consoleId: 'org-456' }) @@ -459,6 +459,7 @@ describe('install()', () => { assert.strictEqual(result.authSucceeded, true) // ...but the config refresh after it failed. assert.strictEqual(result.success, false) + assert.strictEqual(browserLaunches.length, 1, 'the injected launcher must be used exactly once') assert.ok(result.errors.some((e) => e.includes('MCP configuration failed')), 'config write failure must be surfaced') // The switched credentials MUST NOT be rolled back. assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'globally switched credentials are kept despite the refresh failure') @@ -480,7 +481,7 @@ describe('install()', () => { globalThis.fetch = OK_FETCH const progress = SILENT_PROGRESS - const result = await setup({ harness: 'antigravity', bundlePath, skillsSource, progress }) + const result = await setup({ harness: 'antigravity', bundlePath, skillsSource, progress, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.success, true) assert.strictEqual(result.skillsInstalled, 0) @@ -505,7 +506,7 @@ describe('install()', () => { globalThis.fetch = OK_FETCH const progress = SILENT_PROGRESS - const result = await setup({ harness: 'codex', bundlePath, skillsSource, progress }) + const result = await setup({ harness: 'codex', bundlePath, skillsSource, progress, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.success, true) assert.strictEqual(result.skillsInstalled, 0) @@ -531,7 +532,7 @@ describe('install()', () => { globalThis.fetch = OK_FETCH const progress = SILENT_PROGRESS - const result = await setup({ harness: 'pi', bundlePath, skillsSource, progress, packageOwnedSkills: true }) + const result = await setup({ harness: 'pi', bundlePath, skillsSource, progress, packageOwnedSkills: true, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.success, true) assert.strictEqual(result.skillsInstalled, 0) @@ -555,17 +556,19 @@ describe('install()', () => { globalThis.fetch = OK_FETCH resetRuntimeControl('provision') - const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + const result = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS, browserLauncher: captureBrowserLauncher }) assert.strictEqual(result.success, true) assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote', 'dist', 'proxy.js')), true) assert.strictEqual(result.hadToAuthenticate, false, 'valid credentials: no browser') + assert.strictEqual(browserLaunches.length, 0, 'valid credentials must never reach the browser launcher') assert.strictEqual(runtimeControl.provisions, 1, 'first run installed the runtime') // Second run: the runtime is ready, so npm must not be invoked again. resetRuntimeControl('fail') - const second = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + const second = await setup({ harness: 'claude', bundlePath, skillsSource, progress: SILENT_PROGRESS, browserLauncher: captureBrowserLauncher }) assert.strictEqual(second.success, true, 'idempotent rerun must not need npm') + assert.strictEqual(browserLaunches.length, 0, 'valid credentials must never reach the browser launcher') assert.strictEqual(runtimeControl.provisions, 0, 'ready runtime reused without provisioning') }) @@ -580,10 +583,11 @@ describe('install()', () => { globalThis.fetch = OK_FETCH resetRuntimeControl('fail') - const failed = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + const failed = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS, browserLauncher: captureBrowserLauncher }) assert.strictEqual(failed.success, false) assert.strictEqual(failed.errors.length, 1) + assert.strictEqual(browserLaunches.length, 0, 'valid credentials must never reach the browser launcher') assert.match(failed.errors[0], /MCP runtime setup failed/) // Credentials survive for the retry. assert.strictEqual(existsSync(join(tmpDir, '.agents', '.nodesource-auth.json')), true) @@ -591,7 +595,7 @@ describe('install()', () => { // Retry with a working npm completes. resetRuntimeControl('provision') - const retried = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS }) + const retried = await setup({ harness: 'codex', bundlePath, skillsSource, progress: SILENT_PROGRESS, browserLauncher: captureBrowserLauncher }) assert.strictEqual(retried.success, true) assert.strictEqual(existsSync(join(mcpRuntimeRoot(), 'node_modules', 'mcp-remote')), true) }) @@ -613,6 +617,7 @@ describe('install()', () => { bundlePath, skillsSource, progress: SILENT_PROGRESS, + browserLauncher: captureBrowserLauncher, ...(harness === 'pi' ? { packageOwnedSkills: true } : {}), ...(harness === 'opencode' ? { harnessSpecificSkills: true } : {}), }) From 5e891bb12065451fee9d25c0b1c40f6d35909b58 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 16:22:43 +0200 Subject: [PATCH 4/6] fix: address MCP runtime review feedback --- .github/workflows/test.yml | 2 + .husky/pre-commit | 2 + packages/core/README.md | 7 +- packages/core/src/utils/backup.ts | 55 +++++++-- .../core/test/integration/installer.test.ts | 9 +- .../core/test/unit/mcp/mcp-wrapper.test.ts | 114 ++++++++++++++++-- packages/core/test/unit/utils/backup.test.ts | 46 +++++++ scripts/materialize-github-marketplace.mjs | 2 +- scripts/mcp-wrapper.js | 80 ++++++------ scripts/plugin-generators.mjs | 93 +++++++------- 10 files changed, 299 insertions(+), 111 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc8e388..4398568 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,4 +33,6 @@ jobs: - run: pnpm lint - run: pnpm build - run: pnpm test + env: + NSOLID_TEST_CORE_ALREADY_BUILT: '1' - run: node scripts/test-marketplace-install.js diff --git a/.husky/pre-commit b/.husky/pre-commit index 358f7b2..2789cdf 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,5 @@ +set -e + pnpm lint pnpm typecheck pnpm test diff --git a/packages/core/README.md b/packages/core/README.md index 52c5b66..f8ab479 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -52,10 +52,9 @@ Lifecycle and guarantees: concurrent setups converge on one valid runtime. - **Idempotent**: with a valid runtime present, `setup` never invokes npm. - **Safe**: npm runs with `shell: false`, separated argv, - `--ignore-scripts`, no audit/fund, resolved from `npm_execpath` (only when - it is npm's own CLI — pnpm/yarn lifecycle scripts set it to their own - binary, which is ignored) or next to `process.execPath` — never from - `PATH`/project `node_modules/.bin`. No + `--ignore-scripts`, and no audit/fund. It is resolved only from canonical + candidates next to the running Node.js installation — never from `PATH`, + `npm_execpath`, the current directory, or project `node_modules/.bin`. No credentials are read, stored, or logged by the runtime module; the runtime directory contains no secrets. - **Shared and durable**: `uninstall --harness ` and `logout` never diff --git a/packages/core/src/utils/backup.ts b/packages/core/src/utils/backup.ts index 493b51e..fdb541b 100644 --- a/packages/core/src/utils/backup.ts +++ b/packages/core/src/utils/backup.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, readFileSync as fsReadFileSync } from 'node:fs' -import type { HarnessType } from '../types.js' +import { HARNESS_VALUES, type HarnessType } from '../types.js' import { getConfigBackupDir, resolveHome } from './path.js' import { atomicWriteSync } from './fs.js' import { readJsonFile } from './config.js' @@ -31,12 +31,45 @@ interface BackupMeta { const SEQ_RESERVATIONS_DIR = '.seq-reservations' +function normalizeBackupMeta (value: unknown): BackupMeta | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + + const candidate = value as Record + const createdAtMs = typeof candidate.createdAt === 'string' ? Date.parse(candidate.createdAt) : Number.NaN + if ( + typeof candidate.harness !== 'string' || + !HARNESS_VALUES.includes(candidate.harness as HarnessType) || + typeof candidate.originalPath !== 'string' || + !Number.isFinite(createdAtMs) + ) return null + + const seq = typeof candidate.seq === 'number' && + Number.isSafeInteger(candidate.seq) && + candidate.seq > 0 && + candidate.seq < Number.MAX_SAFE_INTEGER + ? candidate.seq + : undefined + + return { + harness: candidate.harness as HarnessType, + originalPath: candidate.originalPath, + createdAt: new Date(createdAtMs).toISOString(), + seq, + reason: typeof candidate.reason === 'string' ? candidate.reason : undefined, + } +} + /** Highest seq persisted in existing backup sidecars (0 when none). */ function highestMetaSeq (dir: string): number { let max = 0 for (const name of readdirSync(dir)) { if (!name.endsWith('.meta.json')) continue - const meta = readJsonFile(path.join(dir, name)) + let meta: BackupMeta | null + try { + meta = normalizeBackupMeta(readJsonFile(path.join(dir, name))) + } catch { + continue + } if (meta?.seq !== undefined && meta.seq > max) max = meta.seq } return max @@ -115,12 +148,11 @@ export function createConfigBackup ( const timestamp = Date.now() const backupPath = path.join(dir, backupName(originalPath, timestamp)) - // Back-to-back backups can share a millisecond (and coarse filesystems can - // share mtimes): reserve a cross-process sequence that cannot tie so - // "latest" is always the backup that was created last. - const seq = reserveBackupSeq(dir) - try { + // Back-to-back backups can share a millisecond (and coarse filesystems can + // share mtimes): reserve a cross-process sequence that cannot tie so + // "latest" is always the backup that was created last. + const seq = reserveBackupSeq(dir) copyFileSync(originalPath, backupPath) const meta: BackupMeta = { harness, @@ -154,8 +186,13 @@ export function listConfigBackups (harness: HarnessType): BackupEntry[] { for (const name of readdirSync(dir)) { if (name.endsWith('.meta.json')) continue const backupPath = path.join(dir, name) - const meta = readJsonFile(metaPath(backupPath)) - if (!meta) continue + let meta: BackupMeta | null + try { + meta = normalizeBackupMeta(readJsonFile(metaPath(backupPath))) + } catch { + continue + } + if (!meta || meta.harness !== harness) continue let metaMtimeMs = 0 try { // Legacy tie-break for backups created before the persisted `seq` diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 8b7ce52..653bf7e 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -1067,10 +1067,11 @@ describe('dispatcher scripts (setup.mjs and the CLI install command)', () => { const cliEntry = join(repoRoot, 'packages', 'core', 'src', 'cli.ts') before(() => { - // setup.mjs resolves `nsolid-plugin` via package self-reference, so the - // dispatcher tests run against the built package. Always rebuild here: - // an existing dist may belong to an older branch and silently omit newer - // exports, making local/pre-commit results depend on checkout history. + // CI may attest that a clean repository build completed immediately before + // the test run. Standalone/local runs still rebuild because an existing + // dist may belong to an older branch and silently omit newer exports. + if (process.env.NSOLID_TEST_CORE_ALREADY_BUILT === '1') return + const build = spawnSync('pnpm', ['--filter', './packages/core', 'build'], { cwd: repoRoot, encoding: 'utf8', diff --git a/packages/core/test/unit/mcp/mcp-wrapper.test.ts b/packages/core/test/unit/mcp/mcp-wrapper.test.ts index 50afa74..aefda1e 100644 --- a/packages/core/test/unit/mcp/mcp-wrapper.test.ts +++ b/packages/core/test/unit/mcp/mcp-wrapper.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, it } from 'node:test' import assert from 'node:assert/strict' import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { delimiter, join } from 'node:path' // @ts-expect-error The repository's JavaScript generator intentionally has no TypeScript declarations. -import { generateMcpWrapper, MCP_REMOTE_VERSION as GENERATOR_VERSION, PLUGIN_VERSION as GENERATOR_PLUGIN_VERSION, HARNESS_VALUES as GENERATOR_HARNESS_VALUES } from '../../../../../scripts/plugin-generators.mjs' -import { MCP_REMOTE_VERSION as CORE_VERSION } from '../../../src/mcp/mcp-remote-runtime.js' +import { CODEX_MCP_STARTUP_TIMEOUT_SEC, generateCodexMcpJson, generateMcpWrapper, HARNESS_VALUES as GENERATOR_HARNESS_VALUES, MCP_REMOTE_RUNTIME_PARENT_SEGMENTS, MCP_REMOTE_VERSION as GENERATOR_VERSION, PLUGIN_VERSION as GENERATOR_PLUGIN_VERSION } from '../../../../../scripts/plugin-generators.mjs' +import { getMcpRemoteRuntimeParent, MCP_REMOTE_VERSION as CORE_VERSION } from '../../../src/mcp/mcp-remote-runtime.js' import { HARNESS_VALUES as CORE_HARNESS_VALUES, PLUGIN_OWNED_HARNESSES, NATIVE_PLUGIN_HARNESSES } from '../../../src/types.js' const repoRoot = join(import.meta.dirname, '..', '..', '..', '..', '..') @@ -138,7 +138,7 @@ describe('MCP wrapper runtime contract', () => { assert.strictEqual(GENERATOR_VERSION, CORE_VERSION) assert.strictEqual(rootPackageJson.dependencies?.['mcp-remote'], CORE_VERSION) // The generated wrapper embeds the version for its stable-path resolution. - assert.ok(generateMcpWrapper().includes(`'${CORE_VERSION}'`)) + assert.ok(generateMcpWrapper().includes(`const MCP_REMOTE_VERSION = ${JSON.stringify(CORE_VERSION)}`)) }) it('pins the repair command to the generating release', () => { @@ -147,13 +147,104 @@ describe('MCP wrapper runtime contract', () => { // exactly X's pinned runtime version. assert.strictEqual(GENERATOR_PLUGIN_VERSION, corePackageJson.version) const generated = generateMcpWrapper() - assert.ok(generated.includes(`const PLUGIN_VERSION = '${GENERATOR_PLUGIN_VERSION}'`)) + assert.ok(generated.includes(`const PLUGIN_VERSION = ${JSON.stringify(GENERATOR_PLUGIN_VERSION)}`)) // The wrapper builds the command at runtime from the embedded release. assert.ok(generated.includes('npx -y nsolid-plugin@')) const interpolation = '${' assert.ok(generated.includes(`nsolid-plugin@${interpolation}PLUGIN_VERSION} setup --harness ${interpolation}harness}`)) }) + it('executes custom bundle servers and safely escapes generated strings', () => { + const customServerName = "custom'server\\path\nnext" + const placeholder = (name: string) => '$' + '{' + name + '}' + const customUrlTemplate = `https://${placeholder('AUTH_ORG_ID')}.custom.example.test/mcp` + const customUrl = 'https://org.custom.example.test/mcp' + const customHeader = "value'\\path\nnext" + const inheritedPlaceholder = placeholder('constructor') + const customHeaderTemplate = `Bearer ${placeholder('AUTH_TOKEN')}; ${placeholder('MCP_URL')}; ${inheritedPlaceholder}; ${customHeader}` + const customVersion = "9.9.9-'test" + const generated = generateMcpWrapper({ + version: customVersion, + mcpServers: [{ + name: customServerName, + url: customUrlTemplate, + headers: { 'X-Custom': customHeaderTemplate }, + }], + }) + const fixture = createWrapperFixture('generated') + writeFileSync(fixture.wrapperPath, generated) + seedRuntime(fixture.home) + const credentialToken = placeholder('AUTH_ORG_ID') + const authPath = join(fixture.home, '.agents', '.nodesource-auth.json') + const credentials = JSON.parse(readFileSync(authPath, 'utf8')) + credentials.serviceToken = credentialToken + writeFileSync(authPath, JSON.stringify(credentials)) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, customServerName, 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.strictEqual(result.status, 0, result.stderr) + assert.ok(generated.includes(`const PLUGIN_VERSION = ${JSON.stringify(customVersion)}`)) + assert.deepEqual(JSON.parse(readFileSync(fixture.output, 'utf8')), [ + customUrl, + '--header', + `X-Custom:Bearer ${credentialToken}; ${url}; ${inheritedPlaceholder}; ${customHeader}`, + '--transport', + 'http-first', + '--silent', + ]) + }) + + it('rejects an unresolved MCP_URL placeholder in a custom header', () => { + const mcpUrlPlaceholder = '$' + '{MCP_URL}' + const generated = generateMcpWrapper({ + version: '9.9.9-test', + mcpServers: [{ + name: 'custom-server', + url: 'https://custom.example.test/mcp', + headers: { 'X-MCP-URL': `endpoint=${mcpUrlPlaceholder}` }, + }], + }) + const fixture = createWrapperFixture('generated') + writeFileSync(fixture.wrapperPath, generated) + const authPath = join(fixture.home, '.agents', '.nodesource-auth.json') + const credentials = JSON.parse(readFileSync(authPath, 'utf8')) + credentials.mcpUrl = '' + credentials.consoleUrl = 'https://example.test' + writeFileSync(authPath, JSON.stringify(credentials)) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'custom-server', 'codex'], { + cwd: fixture.directory, + env: wrapperEnvironment(fixture), + encoding: 'utf8', + timeout: 15000, + }) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, /Could not derive NodeSource console MCP URL/) + }) + + it('keeps the generated runtime parent equal to the core runtime parent', () => { + assert.strictEqual( + join(homedir(), ...MCP_REMOTE_RUNTIME_PARENT_SEGMENTS), + getMcpRemoteRuntimeParent() + ) + }) + + it('keeps the committed Codex startup timeout in sync with the generator', () => { + const generated = JSON.parse(generateCodexMcpJson()) + const committed = JSON.parse(readFileSync(join(repoRoot, '.mcp.json'), 'utf8')) + + assert.deepEqual(committed, generated) + for (const server of Object.values(generated.mcpServers) as Array<{ startup_timeout_sec: number }>) { + assert.strictEqual(server.startup_timeout_sec, CODEX_MCP_STARTUP_TIMEOUT_SEC) + } + }) + it('keeps the harness lists in sync across core, generator and the generated wrapper', () => { // The generator's list feeds the wrapper's HARNESS_NAMES literal; core's // HARNESS_VALUES drives --harness validation. They must not diverge. @@ -161,10 +252,7 @@ describe('MCP wrapper runtime contract', () => { const generated = generateMcpWrapper() const harnessNames = generated.match(/const HARNESS_NAMES = new Set\(\[([^\]]*)\]\)/)?.[1] assert.ok(harnessNames, 'generated wrapper embeds a HARNESS_NAMES set') - assert.deepEqual( - harnessNames.split(',').map((s: string) => s.trim().replaceAll("'", '')), - CORE_HARNESS_VALUES - ) + assert.deepEqual(JSON.parse(`[${harnessNames}]`), CORE_HARNESS_VALUES) // Ownership semantics: opencode belongs to neither set; pi is native // (package-owned) but not plugin-owned. assert.deepEqual([...PLUGIN_OWNED_HARNESSES], ['claude', 'codex', 'antigravity']) @@ -558,7 +646,7 @@ describe('MCP wrapper stable runtime', () => { mkdirSync(hostile) for (const name of process.platform === 'win32' ? ['npm.cmd', 'node.exe'] : ['npm', 'node']) { const p = join(hostile, name) - writeFileSync(p, process.platform === 'win32' ? '@echo off\r\necho pwned > "%SENTINEL%"\r\n' : `#!/bin/sh\necho pwned > "${sentinel}"\n`) + writeFileSync(p, process.platform === 'win32' ? `@echo off\r\necho pwned > "${sentinel}"\r\n` : `#!/bin/sh\necho pwned > "${sentinel}"\n`) if (process.platform !== 'win32') chmodSync(p, 0o755) } // Runtime missing: the wrapper must fail with the repair message @@ -717,10 +805,10 @@ describe('MCP wrapper stable runtime', () => { const generated = generateMcpWrapper() const oldVersion = '0.0.1-old-release' const oldWrapper = generated.replace( - new RegExp(`const PLUGIN_VERSION = '${GENERATOR_PLUGIN_VERSION.replace(/\./g, '\\.')}'`), - `const PLUGIN_VERSION = '${oldVersion}'` + `const PLUGIN_VERSION = ${JSON.stringify(GENERATOR_PLUGIN_VERSION)}`, + `const PLUGIN_VERSION = ${JSON.stringify(oldVersion)}` ) - assert.ok(oldWrapper.includes(`const PLUGIN_VERSION = '${oldVersion}'`), 'fixture rewrote the embedded version') + assert.ok(oldWrapper.includes(`const PLUGIN_VERSION = ${JSON.stringify(oldVersion)}`), 'fixture rewrote the embedded version') const fixture = createWrapperFixture('source') writeFileSync(fixture.wrapperPath, oldWrapper) diff --git a/packages/core/test/unit/utils/backup.test.ts b/packages/core/test/unit/utils/backup.test.ts index 431886b..e17cabd 100644 --- a/packages/core/test/unit/utils/backup.test.ts +++ b/packages/core/test/unit/utils/backup.test.ts @@ -77,6 +77,24 @@ describe('createConfigBackup', () => { const meta = JSON.parse(readFileSync(`${entry.backupPath}.meta.json`, 'utf8')) assert.strictEqual(meta.seq, 42) }) + + it('ignores malformed sidecars when reserving a sequence', () => { + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'v1', 'utf8') + const backupDir = getConfigBackupDir('claude') + mkdirSync(backupDir, { recursive: true }) + writeFileSync(join(backupDir, 'truncated.meta.json'), '{', 'utf8') + writeFileSync(join(backupDir, 'fractional.meta.json'), JSON.stringify({ seq: 1.5 }), 'utf8') + writeFileSync(join(backupDir, 'negative.meta.json'), JSON.stringify({ seq: -1 }), 'utf8') + writeFileSync(join(backupDir, 'exhausted.meta.json'), JSON.stringify({ seq: Number.MAX_SAFE_INTEGER }), 'utf8') + + const entry = createConfigBackup('claude', configPath) + + assert.ok(entry) + assert.ok(existsSync(`${entry.backupPath}.meta.json`)) + const meta = JSON.parse(readFileSync(`${entry.backupPath}.meta.json`, 'utf8')) + assert.strictEqual(meta.seq, 1) + }) }) describe('listConfigBackups', () => { @@ -87,6 +105,11 @@ describe('listConfigBackups', () => { writeFileSync(configPath, 'v2', 'utf8') const second = createConfigBackup('claude', configPath)! + const firstMetaPath = `${first.backupPath}.meta.json` + const firstMeta = JSON.parse(readFileSync(firstMetaPath, 'utf8')) + firstMeta.seq = 'invalid-but-non-fatal' + firstMeta.createdAt = new Date(firstMeta.createdAt).toUTCString() + writeFileSync(firstMetaPath, JSON.stringify(firstMeta), 'utf8') const list = listConfigBackups('claude') assert.strictEqual(list.length, 2) @@ -97,6 +120,24 @@ describe('listConfigBackups', () => { it('returns an empty array when no backups exist', () => { assert.deepStrictEqual(listConfigBackups('codex'), []) }) + + it('keeps valid backups while skipping malformed or structurally invalid sidecars', () => { + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'valid', 'utf8') + const valid = createConfigBackup('claude', configPath)! + const backupDir = getConfigBackupDir('claude') + + writeFileSync(join(backupDir, 'truncated.json'), '{}', 'utf8') + writeFileSync(join(backupDir, 'truncated.json.meta.json'), '{', 'utf8') + writeFileSync(join(backupDir, 'empty.json'), '{}', 'utf8') + writeFileSync(join(backupDir, 'empty.json.meta.json'), '{}', 'utf8') + writeFileSync(join(backupDir, 'bad-date.json'), '{}', 'utf8') + writeFileSync(join(backupDir, 'bad-date.json.meta.json'), JSON.stringify({ + harness: 'claude', originalPath: configPath, createdAt: 42, seq: 'not-a-number', + }), 'utf8') + + assert.deepStrictEqual(listConfigBackups('claude'), [valid]) + }) }) describe('restoreConfigBackup', () => { @@ -108,6 +149,11 @@ describe('restoreConfigBackup', () => { writeFileSync(configPath, 'v2', 'utf8') createConfigBackup('claude', configPath) + const backupDir = getConfigBackupDir('claude') + writeFileSync(join(backupDir, 'foreign.json'), 'foreign', 'utf8') + writeFileSync(join(backupDir, 'foreign.json.meta.json'), JSON.stringify({ + harness: 'claude', originalPath: configPath, createdAt: {}, seq: Number.MAX_SAFE_INTEGER, + }), 'utf8') writeFileSync(configPath, 'corrupt', 'utf8') const entry = restoreConfigBackup('claude') diff --git a/scripts/materialize-github-marketplace.mjs b/scripts/materialize-github-marketplace.mjs index 44a24d9..b4b4b45 100644 --- a/scripts/materialize-github-marketplace.mjs +++ b/scripts/materialize-github-marketplace.mjs @@ -137,7 +137,7 @@ function buildExpectedFiles () { files.set('mcp_config.json', generateAntigravityMcpJson(bundle)) // The wrapper receives the harness as an explicit argument, so a single // generated artifact serves Claude, Codex, and Antigravity unchanged. - files.set('scripts/mcp-wrapper.js', generateMcpWrapper()) + files.set('scripts/mcp-wrapper.js', generateMcpWrapper(bundle)) return files } diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js index ca1c178..36393bd 100644 --- a/scripts/mcp-wrapper.js +++ b/scripts/mcp-wrapper.js @@ -15,12 +15,14 @@ import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' -const MCP_REMOTE_VERSION = '0.1.38' -const PLUGIN_VERSION = '1.0.3' +const MCP_REMOTE_VERSION = "0.1.38" +const PLUGIN_VERSION = "1.0.3" const STARTUP_FAILURE_WINDOW_MS = 15000 const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SERVER_NAMES = new Set(['nsolid-console', 'ns-benchmark', 'ncm']) -const HARNESS_NAMES = new Set(['claude', 'codex', 'opencode', 'antigravity', 'pi']) +const SERVER_DEFINITIONS = {"nsolid-console":{"url":"${MCP_URL}","headers":{"X-Nsolid-Service-Token":"${AUTH_TOKEN}"}},"ns-benchmark":{"url":"https://benchmark.mcp.saas.nodesource.io/mcp","headers":{"X-Nsolid-Org-Id":"${AUTH_ORG_ID}","X-Nsolid-Service-Token":"${AUTH_TOKEN}"}},"ncm":{"url":"https://mcp.ncm.nodesource.com","headers":{"X-Nsolid-Service-Token":"${AUTH_TOKEN}"}}} +const SERVER_NAMES = new Set(Object.keys(SERVER_DEFINITIONS)) +const VARIABLE_PATTERN = new RegExp("\\$\\{(\\w+)\\}", 'g') +const HARNESS_NAMES = new Set(["claude", "codex", "opencode", "antigravity", "pi"]) const serverName = process.argv[2] const harness = process.argv[3] @@ -62,40 +64,42 @@ function readCredentials () { } function resolveServer (name, credentials) { - switch (name) { - case 'nsolid-console': { - const storedUrl = credentials.mcpUrl && !isLegacyAliasMcpUrl(credentials.mcpUrl, credentials.consoleUrl, credentials.organizationId) - ? credentials.mcpUrl - : null - const url = storedUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) - if (!url) { - fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND()}`) - } - return { - url, - headers: { - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - } - case 'ns-benchmark': - return { - url: 'https://benchmark.mcp.saas.nodesource.io/mcp', - headers: { - 'X-Nsolid-Org-Id': credentials.organizationId, - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - case 'ncm': - return { - url: 'https://mcp.ncm.nodesource.com', - headers: { - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - default: - fail(`Unknown NodeSource MCP server: ${name}`) + const definition = SERVER_DEFINITIONS[name] + if (!definition) fail(`Unknown NodeSource MCP server: ${name}`) + if (typeof definition.url !== 'string') { + fail(`Invalid URL for NodeSource MCP server: ${name}`) + } + + const storedMcpUrl = credentials.mcpUrl && !isLegacyAliasMcpUrl(credentials.mcpUrl, credentials.consoleUrl, credentials.organizationId) + ? credentials.mcpUrl + : null + const mcpUrl = storedMcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) + const variables = { + AUTH_TOKEN: credentials.serviceToken, + AUTH_ORG_ID: credentials.organizationId, + MCP_URL: mcpUrl ?? ('$' + '{MCP_URL}'), + } + const mcpUrlPlaceholder = '$' + '{MCP_URL}' + if (!mcpUrl && definition.url.includes(mcpUrlPlaceholder)) { + fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND()}`) } + const url = expandTemplate(definition.url, variables) + if (url.length === 0) fail(`Invalid URL for NodeSource MCP server: ${name}`) + + const headers = Object.fromEntries(Object.entries(definition.headers ?? {}).map(([key, value]) => { + if (typeof value !== 'string') fail(`Invalid header for NodeSource MCP server: ${name}`) + if (!mcpUrl && value.includes(mcpUrlPlaceholder)) { + fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND()}`) + } + return [key, expandTemplate(value, variables)] + })) + return { url, headers } +} + +function expandTemplate (value, variables) { + return value.replace(VARIABLE_PATTERN, (placeholder, name) => + Object.hasOwn(variables, name) ? variables[name] : placeholder + ) } function deriveMcpUrlFromConsoleUrl (consoleUrl, organizationId) { @@ -142,7 +146,7 @@ function SETUP_COMMAND () { function resolveProxyPath () { // 1. Stable shared runtime provisioned by `nsolid-plugin setup`. - const runtimeParent = path.join(os.homedir(), '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + const runtimeParent = path.join(os.homedir(), ".agents", "nsolid-plugin", "runtime", "mcp-remote") const runtimeRoot = path.join(runtimeParent, MCP_REMOTE_VERSION) const stable = validateMcpRemote(path.join(runtimeRoot, 'node_modules', 'mcp-remote'), runtimeRoot, runtimeParent) if (stable) return stable diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index 779513d..0b273b6 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -31,6 +31,7 @@ export const skillNamesSet = new Set(skillNames) * package.json dependency (guarded by a unit test). */ export const MCP_REMOTE_VERSION = '0.1.38' +export const MCP_REMOTE_RUNTIME_PARENT_SEGMENTS = ['.agents', 'nsolid-plugin', 'runtime', 'mcp-remote'] /** * The plugin release that generates the wrapper. The wrapper's repair message @@ -43,7 +44,7 @@ export const PLUGIN_VERSION = defaultBundle.version // Keep in sync with packages/core/src/types.ts (guarded by a unit test). export const HARNESS_VALUES = ['claude', 'codex', 'opencode', 'antigravity', 'pi'] -const CODEX_MCP_STARTUP_TIMEOUT_SEC = 60 +export const CODEX_MCP_STARTUP_TIMEOUT_SEC = 60 function getBundle (bundle) { return bundle ?? defaultBundle @@ -157,10 +158,14 @@ export function generateMcpConfig (wrapperPath, bundle, harness) { return stableJson({ mcpServers }) } -export function generateMcpWrapper () { - const serverNames = [...defaultBundle.mcpServers.map((s) => s.name)] - const serverNamesLiteral = serverNames.map((name) => `'${name}'`).join(', ') - const harnessLiteral = HARNESS_VALUES.map((name) => `'${name}'`).join(', ') +export function generateMcpWrapper (bundle) { + const b = getBundle(bundle) + const serverDefinitionsLiteral = JSON.stringify(Object.fromEntries( + b.mcpServers.map((server) => [server.name, { url: server.url, headers: server.headers ?? {} }]) + )) + const harnessLiteral = HARNESS_VALUES.map((name) => JSON.stringify(name)).join(', ') + const runtimeParentSegmentsLiteral = MCP_REMOTE_RUNTIME_PARENT_SEGMENTS.map((segment) => JSON.stringify(segment)).join(', ') + const variablePatternLiteral = JSON.stringify('\\$\\{(\\w+)\\}') return `#!/usr/bin/env node // STDIO→HTTP bridge for the NodeSource MCP servers. Resolves mcp-remote @@ -178,11 +183,13 @@ import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' -const MCP_REMOTE_VERSION = '${MCP_REMOTE_VERSION}' -const PLUGIN_VERSION = '${PLUGIN_VERSION}' +const MCP_REMOTE_VERSION = ${JSON.stringify(MCP_REMOTE_VERSION)} +const PLUGIN_VERSION = ${JSON.stringify(b.version)} const STARTUP_FAILURE_WINDOW_MS = 15000 const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SERVER_NAMES = new Set([${serverNamesLiteral}]) +const SERVER_DEFINITIONS = ${serverDefinitionsLiteral} +const SERVER_NAMES = new Set(Object.keys(SERVER_DEFINITIONS)) +const VARIABLE_PATTERN = new RegExp(${variablePatternLiteral}, 'g') const HARNESS_NAMES = new Set([${harnessLiteral}]) const serverName = process.argv[2] const harness = process.argv[3] @@ -225,40 +232,42 @@ function readCredentials () { } function resolveServer (name, credentials) { - switch (name) { - case 'nsolid-console': { - const storedUrl = credentials.mcpUrl && !isLegacyAliasMcpUrl(credentials.mcpUrl, credentials.consoleUrl, credentials.organizationId) - ? credentials.mcpUrl - : null - const url = storedUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) - if (!url) { - fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND()}\`) - } - return { - url, - headers: { - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - } - case 'ns-benchmark': - return { - url: 'https://benchmark.mcp.saas.nodesource.io/mcp', - headers: { - 'X-Nsolid-Org-Id': credentials.organizationId, - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - case 'ncm': - return { - url: 'https://mcp.ncm.nodesource.com', - headers: { - 'X-Nsolid-Service-Token': credentials.serviceToken, - }, - } - default: - fail(\`Unknown NodeSource MCP server: \${name}\`) + const definition = SERVER_DEFINITIONS[name] + if (!definition) fail(\`Unknown NodeSource MCP server: \${name}\`) + if (typeof definition.url !== 'string') { + fail(\`Invalid URL for NodeSource MCP server: \${name}\`) + } + + const storedMcpUrl = credentials.mcpUrl && !isLegacyAliasMcpUrl(credentials.mcpUrl, credentials.consoleUrl, credentials.organizationId) + ? credentials.mcpUrl + : null + const mcpUrl = storedMcpUrl || deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) + const variables = { + AUTH_TOKEN: credentials.serviceToken, + AUTH_ORG_ID: credentials.organizationId, + MCP_URL: mcpUrl ?? ('$' + '{MCP_URL}'), + } + const mcpUrlPlaceholder = '$' + '{MCP_URL}' + if (!mcpUrl && definition.url.includes(mcpUrlPlaceholder)) { + fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND()}\`) } + const url = expandTemplate(definition.url, variables) + if (url.length === 0) fail(\`Invalid URL for NodeSource MCP server: \${name}\`) + + const headers = Object.fromEntries(Object.entries(definition.headers ?? {}).map(([key, value]) => { + if (typeof value !== 'string') fail(\`Invalid header for NodeSource MCP server: \${name}\`) + if (!mcpUrl && value.includes(mcpUrlPlaceholder)) { + fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND()}\`) + } + return [key, expandTemplate(value, variables)] + })) + return { url, headers } +} + +function expandTemplate (value, variables) { + return value.replace(VARIABLE_PATTERN, (placeholder, name) => + Object.hasOwn(variables, name) ? variables[name] : placeholder + ) } function deriveMcpUrlFromConsoleUrl (consoleUrl, organizationId) { @@ -305,7 +314,7 @@ function SETUP_COMMAND () { function resolveProxyPath () { // 1. Stable shared runtime provisioned by \`nsolid-plugin setup\`. - const runtimeParent = path.join(os.homedir(), '.agents', 'nsolid-plugin', 'runtime', 'mcp-remote') + const runtimeParent = path.join(os.homedir(), ${runtimeParentSegmentsLiteral}) const runtimeRoot = path.join(runtimeParent, MCP_REMOTE_VERSION) const stable = validateMcpRemote(path.join(runtimeRoot, 'node_modules', 'mcp-remote'), runtimeRoot, runtimeParent) if (stable) return stable From 261c3dc7990230ab65620cd7a2ec455839fae2fb Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Mon, 31 Aug 2026 17:15:13 +0200 Subject: [PATCH 5/6] fix(backup): order sequenced backups by persisted seq over wall-clock createdAt listConfigBackups used createdAt as the primary sort key with seq as a tie-breaker, contradicting the documented invariant that the reserved seq guarantees 'latest is always the backup that was created last'. When the system clock steps backwards between two backups (NTP correction, manual change, VM snapshot restore), the default restoreConfigBackup selection picked an older backup. Sequenced backups are now compared by seq (monotonic, cannot tie); comparisons involving legacy entries without a seq fall back to createdAt, tie-broken by meta mtime, as before. Adds a regression test simulating a backwards clock step and tightens the concurrent-processes ordering invariant to strictly decreasing seq. --- packages/core/src/utils/backup.ts | 15 ++++---- packages/core/test/unit/utils/backup.test.ts | 38 +++++++++++++++++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/core/src/utils/backup.ts b/packages/core/src/utils/backup.ts index fdb541b..c9cb2d6 100644 --- a/packages/core/src/utils/backup.ts +++ b/packages/core/src/utils/backup.ts @@ -213,13 +213,16 @@ export function listConfigBackups (harness: HarnessType): BackupEntry[] { }) } - // Newest first: createdAt is primary; ties (same millisecond) break by the - // persisted seq, which cannot tie. Legacy backups without seq (seq = 0) - // fall back to the meta file mtime among themselves. + // Newest first: the persisted seq is the source of truth for sequenced + // backups — createdAt is wall-clock time that can step backwards (NTP + // corrections, manual changes, VM snapshot restores), while seq is monotonic + // and cannot tie. Comparisons involving legacy entries without a seq + // (seq = 0) fall back to createdAt, tie-broken by the meta file mtime. return entries - .sort((a, b) => - b.entry.createdAt.localeCompare(a.entry.createdAt) || b.seq - a.seq || b.metaMtimeMs - a.metaMtimeMs - ) + .sort((a, b) => { + if (a.seq > 0 && b.seq > 0) return b.seq - a.seq + return b.entry.createdAt.localeCompare(a.entry.createdAt) || b.metaMtimeMs - a.metaMtimeMs + }) .map(({ entry }) => entry) } diff --git a/packages/core/test/unit/utils/backup.test.ts b/packages/core/test/unit/utils/backup.test.ts index e17cabd..813d670 100644 --- a/packages/core/test/unit/utils/backup.test.ts +++ b/packages/core/test/unit/utils/backup.test.ts @@ -191,6 +191,34 @@ describe('restoreConfigBackup', () => { assert.strictEqual(entry.backupPath, second.backupPath) }) + it('orders by persisted sequence when the clock steps backwards between backups', () => { + // Regression: createdAt is wall-clock time and can move backwards (NTP + // step corrections, manual clock changes, VM snapshot restores). The + // persisted seq is monotonic, so it — not createdAt — decides which + // sequenced backup is newest. + const configPath = join(tmpDir, '.claude.json') + writeFileSync(configPath, 'v1', 'utf8') + const first = createConfigBackup('claude', configPath)! + writeFileSync(configPath, 'v2', 'utf8') + const second = createConfigBackup('claude', configPath)! + + // Simulate a backwards clock step: the backup created LAST now claims an + // older createdAt. Any timestamp-based ordering would pick `first`. + const secondMetaPath = `${second.backupPath}.meta.json` + const secondMeta = JSON.parse(readFileSync(secondMetaPath, 'utf8')) + secondMeta.createdAt = new Date(new Date(secondMeta.createdAt).getTime() - 3_600_000).toISOString() + writeFileSync(secondMetaPath, JSON.stringify(secondMeta, null, 2) + '\n') + + const list = listConfigBackups('claude') + assert.strictEqual(list[0].backupPath, second.backupPath) + assert.strictEqual(list[1].backupPath, first.backupPath) + + writeFileSync(configPath, 'corrupt', 'utf8') + const restored = restoreConfigBackup('claude') + assert.strictEqual(restored.backupPath, second.backupPath) + assert.strictEqual(readFileSync(configPath, 'utf8'), 'v2') + }) + it('restores a specific backup when given a path', () => { const configPath = join(tmpDir, '.codex', 'config.toml') mkdirSync(join(tmpDir, '.codex'), { recursive: true }) @@ -284,12 +312,12 @@ describe('restoreConfigBackup', () => { assert.strictEqual(backups.length, workers, 'every concurrent backup was recorded') const seqs = backups.map((b) => JSON.parse(readFileSync(`${b.backupPath}.meta.json`, 'utf8')).seq as number) assert.strictEqual(new Set(seqs).size, seqs.length, `sequences must be unique across processes: ${seqs}`) - // Ordering invariant: within a createdAt tie, seq strictly decreases. - for (let i = 1; i < backups.length; i++) { - const [prev, cur] = [backups[i - 1], backups[i]] + // Ordering invariant: sequenced backups are newest-first strictly by seq + // (createdAt can interleave across processes; the persisted seq cannot). + for (let i = 1; i < seqs.length; i++) { assert.ok( - prev.createdAt > cur.createdAt || (prev.createdAt === cur.createdAt && seqs[i - 1] > seqs[i]), - `backups must be newest-first even when createdAt ties: ${prev.createdAt}#${seqs[i - 1]} then ${cur.createdAt}#${seqs[i]}` + seqs[i - 1] > seqs[i], + `sequences must strictly decrease in newest-first order: ${seqs.join(' > ')}` ) } }) From c36d50f587c9fda7a3327b09c610bcb2df68ec90 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 1 Sep 2026 13:28:32 +0200 Subject: [PATCH 6/6] fix(codex): bound MCP bootstrap wrapper search to the plugin cache (QA-05) Codex launches every plugin MCP server with cwd set to the user's home directory. The generated bootstrap located scripts/mcp-wrapper.js by recursively walking [~/.codex/plugins/cache, process.cwd()], so on Windows it traversed the entire home tree (546k entries, ~9.4s per server in QA) before the proxy could start; with three servers scanning concurrently, total startup exceeded the 60s startup_timeout_sec and codex never saw them ready. Bound the readdirSync walk to the Codex plugin cache (the only root that actually contains the installed wrapper) and probe cwd only at fixed dev candidate paths with existsSync, mirroring the antigravity bootstrap. The nsolid-plugin path-segment fail-closed filter is unchanged and installed cache copies keep precedence over dev checkouts. Wrapper discovery drops from ~9.4s to ~12ms. --- .mcp.json | 6 +++--- packages/core/test/unit/mcp/mcp-wrapper.test.ts | 16 +++++++++++++++- scripts/plugin-generators.mjs | 6 +++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.mcp.json b/.mcp.json index 0d43958..44be7af 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,7 +4,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache')];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}for(const dev of [path.join(process.cwd(),...rel),path.join(process.cwd(),'nsolid-plugin',...rel)]){try{if(fs.existsSync(dev))candidates.push(dev)}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "nsolid-console" ], "startup_timeout_sec": 60 @@ -13,7 +13,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache')];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}for(const dev of [path.join(process.cwd(),...rel),path.join(process.cwd(),'nsolid-plugin',...rel)]){try{if(fs.existsSync(dev))candidates.push(dev)}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "ns-benchmark" ], "startup_timeout_sec": 60 @@ -22,7 +22,7 @@ "command": "node", "args": [ "-e", - "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache')];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}for(const dev of [path.join(process.cwd(),...rel),path.join(process.cwd(),'nsolid-plugin',...rel)]){try{if(fs.existsSync(dev))candidates.push(dev)}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)", "ncm" ], "startup_timeout_sec": 60 diff --git a/packages/core/test/unit/mcp/mcp-wrapper.test.ts b/packages/core/test/unit/mcp/mcp-wrapper.test.ts index aefda1e..2ab4527 100644 --- a/packages/core/test/unit/mcp/mcp-wrapper.test.ts +++ b/packages/core/test/unit/mcp/mcp-wrapper.test.ts @@ -6,7 +6,7 @@ import { homedir, tmpdir } from 'node:os' import { delimiter, join } from 'node:path' // @ts-expect-error The repository's JavaScript generator intentionally has no TypeScript declarations. -import { CODEX_MCP_STARTUP_TIMEOUT_SEC, generateCodexMcpJson, generateMcpWrapper, HARNESS_VALUES as GENERATOR_HARNESS_VALUES, MCP_REMOTE_RUNTIME_PARENT_SEGMENTS, MCP_REMOTE_VERSION as GENERATOR_VERSION, PLUGIN_VERSION as GENERATOR_PLUGIN_VERSION } from '../../../../../scripts/plugin-generators.mjs' +import { CODEX_MCP_STARTUP_TIMEOUT_SEC, generateCodexBootstrap, generateCodexMcpJson, generateMcpWrapper, HARNESS_VALUES as GENERATOR_HARNESS_VALUES, MCP_REMOTE_RUNTIME_PARENT_SEGMENTS, MCP_REMOTE_VERSION as GENERATOR_VERSION, PLUGIN_VERSION as GENERATOR_PLUGIN_VERSION } from '../../../../../scripts/plugin-generators.mjs' import { getMcpRemoteRuntimeParent, MCP_REMOTE_VERSION as CORE_VERSION } from '../../../src/mcp/mcp-remote-runtime.js' import { HARNESS_VALUES as CORE_HARNESS_VALUES, PLUGIN_OWNED_HARNESSES, NATIVE_PLUGIN_HARNESSES } from '../../../src/types.js' @@ -245,6 +245,20 @@ describe('MCP wrapper runtime contract', () => { } }) + it('bounds the Codex bootstrap search to the plugin cache, never cwd (QA-05)', () => { + const bootstrap = generateCodexBootstrap() + // The readdirSync walk may only run over the Codex plugin cache: codex + // launches servers with cwd set to the user's home, and recursively + // walking it blew past the 60s startup timeout on Windows (QA-05). + const roots = bootstrap.match(/const roots=\[([^\]]*)\]/)?.[1] ?? '' + assert.ok(roots.includes("path.join(os.homedir(),'.codex','plugins','cache')"), 'searches the Codex plugin cache') + assert.ok(!roots.includes('process.cwd()'), 'cwd must never be a recursive walk root') + // cwd is allowed only as a fixed dev candidate probed with existsSync. + for (const tail of bootstrap.split('process.cwd()').slice(1)) { + assert.match(tail, /^,...rel\)|^,'nsolid-plugin',...rel\)/, `cwd use must be a fixed existsSync candidate: ${tail.slice(0, 40)}`) + } + }) + it('keeps the harness lists in sync across core, generator and the generated wrapper', () => { // The generator's list feeds the wrapper's HARNESS_NAMES literal; core's // HARNESS_VALUES drives --harness validation. They must not diverge. diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index 0b273b6..b997942 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -138,8 +138,12 @@ export function generateCodexBootstrap () { // Fail closed: only trust wrappers positively identified as this plugin's // install root (a path segment matching `nsolid-plugin`). Never fall back to // an unrelated discovered scripts/mcp-wrapper.js. + // The recursive search is bounded to the Codex plugin cache; cwd is probed + // only at fixed dev candidate paths. Recursively walking cwd made startup + // exceed codex's 60s timeout when codex launched the servers from the + // user's home directory (QA-05). // eslint-disable-next-line no-template-curly-in-string -- codegen: ${path.sep} must stay literal in the generated bootstrap string, it is evaluated at runtime in the host process - return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)" + return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache')];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}for(const dev of [path.join(process.cwd(),...rel),path.join(process.cwd(),'nsolid-plugin',...rel)]){try{if(fs.existsSync(dev))candidates.push(dev)}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName,'codex'];import(pathToFileURL(wrapper).href)" } export function generateAntigravityBootstrap () {