From fc1629392418b74119e3fe8110c7c578a4df1c6b Mon Sep 17 00:00:00 2001 From: tak Date: Thu, 5 Nov 2026 15:01:21 +0900 Subject: [PATCH] fix(plugin): pin the CLI launcher and start MCP from a plugin file The plugin directory blocks an unpinned npx launcher and holds an MCP server started through `sh -c` or one that forwards a credential from the user environment. - .mcp.json runs scripts/hwip-mcp.sh: global `hwip mcp` when present, else `npx -y @hwip/cli@0.1.19 mcp`. - hooks/gate.sh pins the same version for the hook fallback; a test keeps the two pins equal. - Drop the env block. The MCP process inherits Claude Code environment, and the CLI reads HWIP_API_KEY or ~/.hwip/config.json itself. --- .mcp.json | 8 +------- README.md | 7 ++++--- docs/HOOKS-EXPLAINED.md | 2 +- hooks/gate.sh | 7 +++++-- scripts/hwip-mcp.sh | 14 ++++++++++++++ tests/README.md | 6 ++++-- tests/test-hook-cli-and-state.sh | 4 ++-- tests/test-mcp-launch.sh | 22 +++++++++++++++++++--- 8 files changed, 50 insertions(+), 20 deletions(-) create mode 100755 scripts/hwip-mcp.sh diff --git a/.mcp.json b/.mcp.json index 0b44fbf..64432dd 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,13 +2,7 @@ "mcpServers": { "hwip": { "command": "sh", - "args": [ - "-c", - "if command -v hwip >/dev/null 2>&1; then exec hwip mcp; else exec npx -y @hwip/cli mcp; fi" - ], - "env": { - "HWIP_API_KEY": "${HWIP_API_KEY:-}" - } + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hwip-mcp.sh"] } } } diff --git a/README.md b/README.md index 5745b5a..2c915be 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,10 @@ hwipit/plugin (Claude Code plugin) ├─ hooks/hwip-approve.sh → PreToolUse(Bash): opt-in phone approval for hard-to-undo commands ├─ hooks/hwip-remote.sh → UserPromptSubmit: phone-sent message → REMOTE mode ├─ hooks/hwip-herdr.sh → agent state → herdr sidebar, for a `hwip cc` pane - ├─ .mcp.json → registers the MCP server (`hwip mcp`, or `npx -y @hwip/cli mcp` - │ when `hwip` is not on PATH); `HWIP_API_KEY` is optional - │ (`${HWIP_API_KEY:-}` — unset, the key comes from ~/.hwip/config.json) + ├─ .mcp.json → registers the MCP server via scripts/hwip-mcp.sh (`hwip mcp`, + │ or `npx -y @hwip/cli@ mcp` when `hwip` is not on + │ PATH); the CLI reads `HWIP_API_KEY` from your environment, else + │ `apiKey` in ~/.hwip/config.json └─ builds on: ├─ @hwip/cli → hooks + notify/list/dismiss + tmux remote control └─ @hwip/mcp-server → hwip_ask / notify / clipboard / file … diff --git a/docs/HOOKS-EXPLAINED.md b/docs/HOOKS-EXPLAINED.md index e72fdf1..76ee5f0 100644 --- a/docs/HOOKS-EXPLAINED.md +++ b/docs/HOOKS-EXPLAINED.md @@ -399,7 +399,7 @@ Check for errors: ```bash # If the hook silently failed, check: # 1. Is hwip CLI installed? -command -v hwip || echo "npx -y @hwip/cli" +command -v hwip || echo "npx -y @hwip/cli@" # 2. Is HWIP_API_KEY set? echo $HWIP_API_KEY diff --git a/hooks/gate.sh b/hooks/gate.sh index 815307d..91dab83 100644 --- a/hooks/gate.sh +++ b/hooks/gate.sh @@ -572,16 +572,19 @@ hwip_probe_cmd() { # hwip_cli_command [seconds] — HWIP_CLI, an ARRAY: the bounded command line that # starts the Hwip CLI. Call it as `"${HWIP_CLI[@]}" notify --title=…`. The CLI is -# `hwip` when it is on PATH, else `npx -y @hwip/cli` (three words). It used to be +# `hwip` when it is on PATH, else `npx -y @hwip/cli@` (three words — +# the plugin directory blocks an unpinned launcher; keep the pin equal to +# scripts/hwip-mcp.sh, which tests/test-mcp-launch.sh checks). It used to be # one string that the hooks left unquoted, so a `hwip` under `/Users/Jane Doe/…` # split into two words and the CLI was never started: no push, and an approval # that could only be denied. +HWIP_CLI_PIN=0.1.19 hwip_cli_command() { local bin if bin=$(command -v hwip 2>/dev/null) && [ -n "$bin" ]; then HWIP_CLI=("$bin") else - HWIP_CLI=(npx -y @hwip/cli) + HWIP_CLI=(npx -y "@hwip/cli@$HWIP_CLI_PIN") fi hwip_bound_prefix "${1:-8}" HWIP_CLI=(${HWIP_BOUND[@]+"${HWIP_BOUND[@]}"} "${HWIP_CLI[@]}") diff --git a/scripts/hwip-mcp.sh b/scripts/hwip-mcp.sh new file mode 100755 index 0000000..8631365 --- /dev/null +++ b/scripts/hwip-mcp.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Starts the plugin's MCP server (.mcp.json runs this file). +# +# A global `hwip` keeps the in-process `hwip mcp` server, on the version the +# user installed. Without one (an `npx @hwip/cli install` user) it falls back +# to npx, pinned to an exact version: the plugin directory blocks an unpinned +# launcher. Keep HWIP_CLI_VERSION equal to the pin in hooks/gate.sh +# (tests/test-mcp-launch.sh checks it) and raise both with each @hwip/cli release. +# +# The API key is not passed here. The CLI reads HWIP_API_KEY from the +# environment Claude Code passes down, else apiKey in ~/.hwip/config.json. +HWIP_CLI_VERSION=0.1.19 +command -v hwip >/dev/null 2>&1 && exec hwip mcp +exec npx -y "@hwip/cli@$HWIP_CLI_VERSION" mcp diff --git a/tests/README.md b/tests/README.md index 0daac43..c03ccc5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -132,7 +132,7 @@ Wall-clock cases, so the suite sleeps for real (~25 s): a stub `tmux` that answe | Test | Catches | |------|---------| | `a hwip whose path holds a space` | The hooks kept the CLI as one string and split it again, so `/Users/Jane Doe/…/hwip` started `/Users/Jane`: no push ever left, and every approval was denied. Now an array (`hwip_cli_command`), checked for the approval, stop and ask hooks. | -| `no hwip, so npx -y @hwip/cli` | The fallback stays three words (`-y`, `@hwip/cli`) after the change to an array. | +| `no hwip, so npx -y @hwip/cli@` | The fallback stays three words (`-y`, `@hwip/cli@`) after the change to an array. | | `the CLI cannot be started` | The approval hook's deny reason follows what the shell saw: exit 126/127 "Could not start the hwip CLI", 124 "did not finish", another code names it; only a CLI that ran and said nothing gets the 75 s message. | | `hwip_remote_active reads a time that bash would take for octal` | A leading 0, 13+ digits and a future time. The first used to stop the shell with "value too great for base"; the last kept REMOTE live until the clock caught up. | | `the denied-question stamp and the phone-origin marker` / `the idle time ioreg reports` | The other places that subtract a number a file or a probe gave them go through `hwip_age_sec` too. | @@ -167,7 +167,9 @@ Wall-clock cases, so the suite sleeps for real (~25 s): a stub `tmux` that answe | Test | Catches | |------|---------| | `hwip on PATH → hwip mcp` | A global install keeps the in-process `hwip mcp` server. | -| `no hwip on PATH → npx -y @hwip/cli mcp` | An `npx @hwip/cli install` user has no `hwip` binary; a bare `"command": "hwip"` never started the MCP server. Stubbed `hwip`/`npx` on a private PATH. | +| `no hwip on PATH → npx -y @hwip/cli@ mcp` | An `npx @hwip/cli install` user has no `hwip` binary; a bare `"command": "hwip"` never started the MCP server. Stubbed `hwip`/`npx` on a private PATH. | +| `the npx pin is an exact version` · `the MCP pin equals the hooks pin` | The plugin directory blocks an unpinned launcher; `scripts/hwip-mcp.sh` and `hooks/gate.sh` must not drift apart. | +| `no env block` | The plugin directory holds a plugin that forwards a credential from the user's environment; the CLI reads `HWIP_API_KEY` itself. | ## Fixtures diff --git a/tests/test-hook-cli-and-state.sh b/tests/test-hook-cli-and-state.sh index 82e1678..b37c8bd 100644 --- a/tests/test-hook-cli-and-state.sh +++ b/tests/test-hook-cli-and-state.sh @@ -102,7 +102,7 @@ assert "the push is sent" called assert "it carries the question" grep -qF 'Proceed?' "$WORK/argv" echo -echo "[P27: no hwip, so npx -y @hwip/cli — still three words, not one]" +echo "[P27: no hwip, so npx -y @hwip/cli@ — still three words, not one]" NPXDIR="$WORK/npxdir"; mkdir -p "$NPXDIR" cat > "$NPXDIR/npx" < ask" bash -c '[ "$(sed -n 1p "$1")" = -y ] && [ "$(sed -n 2p "$1")" = "@hwip/cli@$2" ] && [ "$(sed -n 3p "$1")" = ask ]' _ "$WORK/argv" "$(sed -n 's/^HWIP_CLI_PIN=//p' "$SCRIPT_DIR/../hooks/gate.sh")" assert "allows on approval" decision_is allow echo diff --git a/tests/test-mcp-launch.sh b/tests/test-mcp-launch.sh index 494c1c6..e8a9e45 100644 --- a/tests/test-mcp-launch.sh +++ b/tests/test-mcp-launch.sh @@ -5,7 +5,9 @@ # install` as a notifications-only setup, which leaves no `hwip` on PATH. A bare # `"command": "hwip"` then fails to spawn and the plugin's MCP server never # starts. The launch line must use `hwip` when it exists and fall back to -# `npx -y @hwip/cli` when it does not. +# `npx -y @hwip/cli@` when it does not. The pin must be an exact version +# (the plugin directory blocks an unpinned launcher) and equal to the one the +# hooks use (hooks/gate.sh HWIP_CLI_PIN). # # Both binaries are stubs on a private PATH — no real CLI is ever run. @@ -38,7 +40,8 @@ assert_eq() { LAUNCH=() while IFS= read -r -d '' part; do LAUNCH+=("$part"); done < <(node -e ' const s = require(process.argv[1]).mcpServers.hwip; -process.stdout.write([s.command, ...s.args].map((a) => a + "\0").join("")); +const root = require("path").dirname(process.argv[1]); +process.stdout.write([s.command, ...s.args].map((a) => a.replaceAll("${CLAUDE_PLUGIN_ROOT}", root) + "\0").join("")); ' "$MCP_JSON") TMP=$(mktemp -d) @@ -61,8 +64,21 @@ done OUT=$(PATH="$TMP/with:/usr/bin:/bin" "${LAUNCH[@]}") assert_eq "hwip on PATH → hwip mcp" "hwip mcp" "$OUT" +PIN=$(sed -n 's/^HWIP_CLI_VERSION=//p' "$SCRIPT_DIR/../scripts/hwip-mcp.sh") +GATE_PIN=$(sed -n 's/^HWIP_CLI_PIN=//p' "$SCRIPT_DIR/../hooks/gate.sh") +TOTAL=$((TOTAL + 1)) +if printf '%s' "$PIN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo " ✓ the npx pin is an exact version ($PIN)"; PASS=$((PASS + 1)) +else + echo " ✗ the npx pin is an exact version (got '$PIN')"; FAIL=$((FAIL + 1)); FAILED_TESTS+=("exact pin") +fi +assert_eq "the MCP pin equals the hooks pin" "$GATE_PIN" "$PIN" + OUT=$(PATH="$TMP/without:/usr/bin:/bin" "${LAUNCH[@]}") -assert_eq "no hwip on PATH → npx -y @hwip/cli mcp" "npx -y @hwip/cli mcp" "$OUT" +assert_eq "no hwip on PATH → npx -y @hwip/cli@ mcp" "npx -y @hwip/cli@$PIN mcp" "$OUT" + +assert_eq "no env block — the key is not read from .mcp.json" "undefined" \ + "$(node -e 'console.log(typeof require(process.argv[1]).mcpServers.hwip.env)' "$MCP_JSON")" echo echo "MCP launch: $PASS/$TOTAL passed"