diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..ce86252e 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -41,13 +41,34 @@ on: # # `[code]smith` is SKIPPED on this repository, so this windows-latest # job is the CI evidence for the round-5 review. +# +# Round-9 review (hetaoBackend, 2026-09-10T01:43:05Z) on commit +# 5a0040e (PR #37 mcode-island v0.4.0): +# 1. path-traversal guard in apply.mjs / restore.mjs +# (`--release` flag, symlink escape, realpath containment) +# 2. atomic write in apply.mjs (staging + rename, permission +# preservation, mid-write failure isolation) +# 3. negative-injection tests covering both contracts +# +# Step 5 below runs `hooks/win32-ava-patch/test-apply.mjs`, which +# exercises all three contracts in a sandboxed temp dir (no real +# ~/.minimax-code/releases/ is touched). The test file is the same +# one a developer runs locally; CI just confirms the contract on +# the real windows-latest image. 13 cases must pass. +# +# Step 6 below runs `hooks/win32-ava-patch/smoke-runtime.mjs`, which +# is the host-level "parse and dispatch" smoke the round-9 review +# asked for. 7 cases must pass: bundled hooks.json well-formed, +# Fwe allowlist intersection, and the 5 in-Fwe hook scripts exit +# 0 via a Node-replica of the patched `Ava` (`Ava` + +# `|| process.platform === "win32"` -> `Dva(bZ())`). permissions: contents: read jobs: mcode-island-windows: - name: mcode-island on windows-latest (parse + token + hook + mock-API) + name: mcode-island on windows-latest (parse + token + hook + mock-API + win32-ava-patch contract + runtime smoke) runs-on: windows-latest timeout-minutes: 10 defaults: @@ -411,3 +432,64 @@ jobs: } Write-Host "test c (no token): OK returned null" Write-Host "Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK" + + # 5) win32-ava-patch contract (PR #37 round-9 review): + # path validation + atomic write + idempotent round-trip. + # test-apply.mjs runs entirely inside os.tmpdir(); the real + # ~/.minimax-code/releases/ install is never touched. A + # failure here means a contract regressed. The test summary + # is at the end of the step output; we additionally fail + # the job on non-zero exit. + # + # Negative-injection coverage (one entry per case the + # review called out): + # Test 1 (7 cases) -- --release value validation: + # ../ traversal, absolute path, semver-violating + # name with shell meta, NUL byte (blocked at the OS + # layer), empty string, drive letter, \\?\ extended path + # Test 2 -- symlink escape containment (realpath check) + # Test 3 (2 cases) -- atomic write contract: mid-write + # failure leaves target byte-identical; permission mode + # preserved across apply + # Test 4 (2 cases) -- idempotent round-trip: + # apply -> apply(no-op) -> restore -> apply cycle; + # restore on unpatched is a no-op + # Test 5 -- listReleases() filters non-semver and + # dot-prefixed directory entries + - name: win32-ava-patch contract: path validation + atomic write (round-9 review) + run: | + cd '${{ github.workspace }}' + $r = node 'plugins/antianqi/mcode-island/hooks/win32-ava-patch/test-apply.mjs' + if ($LASTEXITCODE -ne 0) { + throw "test-apply.mjs exited $LASTEXITCODE (expected 0; see step output for failing case)" + } + + # 6) Host-level runtime smoke (PR #37 round-9 review): + # the review asked for "a real host-level smoke: parse and + # dispatch the submitted io.minimax.mcode/hooks/hooks.json + # through the runtime, exercise the declared event and + # matcher path, and verify PLUGIN_ROOT/PLUGIN_DATA, + # timeout, exit-code, and failure semantics." + # + # smoke-runtime.mjs replicates the patched `Ava` (Ava + # with `|| process.platform === "win32"`) in pure Node and + # exercises the 5 in-Fwe hook scripts (SessionStart, + # SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse) + # with a synthetic event payload. The 0.3.10 dispatch + # allowlist (`Fwe`) covers exactly those 5 events; the + # other 7 declared in hooks.json (Stop, PreCompact, + # Notification, SubagentStart, SubagentStop, + # PermissionRequest, PermissionDenied) are forward-only + # and are recorded as such. + # + # This is the "parse and dispatch" assertion the review + # asked for. The mcode runtime itself is not required + # to be installed in CI; the smoke validates the hook + # document contract that the runtime would enforce. + - name: Runtime smoke: bundled hooks.json + 5 in-Fwe hook scripts via patched Ava (round-9 review) + run: | + cd '${{ github.workspace }}' + $r = node 'plugins/antianqi/mcode-island/hooks/win32-ava-patch/smoke-runtime.mjs' + if ($LASTEXITCODE -ne 0) { + throw "smoke-runtime.mjs exited $LASTEXITCODE (expected 0; see step output for failing case)" + } diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..03f661ee 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -25,44 +25,51 @@ visible at a glance, without forcing the user to switch back. ## How the pill is driven -`mcode-island` v0.3.0 supports two modes. The widget behaves the same in +`mcode-island` v0.4.0 supports two modes. The widget behaves the same in both — what changes is who decides the state. -### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) +### Mode A — Hook-driven (mcode 0.3.10+ with `io.minimax.mcode`) -mcode 0.2.4 ships a `io.minimax.mcode` client-extension namespace for +mcode 0.3.10 ships a `io.minimax.mcode` client-extension namespace for lifecycle Hooks. When the registry accepts it (companion proposal: -[`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)), -the runtime spawns a script from this plugin for every matching event: - -| event | pill state | script | 0.2.4 dispatch | -| ----------------- | ----------- | ------------------------------- | -------------- | -| `SessionStart` | `idle` | `session-start.ps1` | yes | -| `SessionEnd` | `idle` | `session-end.ps1` | yes | -| `UserPromptSubmit`| `thinking` | `user-prompt-submit.ps1` | yes | -| `PreToolUse` | `working` | `pre-tool-use.ps1` | yes | -| `PostToolUse` | `done`/`error` | `post-tool-use.ps1` | yes | -| `Stop` | `done` | `stop.ps1` | **forward** — see below | -| `PreCompact` | `thinking` | `pre-compact.ps1` | **forward** — see below | -| `Notification` | `idle` | `notification.ps1` | **forward** — see below | -| `SubagentStart` | `working` (CODEX only) | `subagent-start.ps1` | **forward** — see below | -| `SubagentStop` | `done` (CODEX only) | `subagent-stop.ps1` | **forward** — see below | -| `PermissionRequest`| `waiting` | `permission-request.ps1` | **forward** — see below | -| `PermissionDenied`| `error` | `permission-denied.ps1` | **forward** — see below | +[`MiniMax-Code-Plugins` PR #36](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/36), +the 0.3.10-runtime-compat follow-up to the original +[PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)), +the runtime spawns a script from this plugin for every matching event. +**Verified on `@minimax-ai/code@0.3.10` (chunk-CTHP2I62.js) and +`@minimax-ai/code@0.3.11` (chunk-P2ZQPHDU.js); the hook schema, the +`Ava` dispatch wrapper, the `Fwe` allowlist, and the `Uwe` parser are +byte-identical between the two releases.** + +| event | pill state | script | 0.3.10 / 0.3.11 dispatch | +| ----------------- | ----------- | ------------------------------- | ----------------------- | +| `SessionStart` | `idle` | `session-start.ps1` | yes (`Fwe` set) | +| `SessionEnd` | `idle` | `session-end.ps1` | yes (`Fwe` set) | +| `UserPromptSubmit`| `thinking` | `user-prompt-submit.ps1` | yes (`Fwe` set) | +| `PreToolUse` | `working` | `pre-tool-use.ps1` | yes (`Fwe` set) | +| `PostToolUse` | `done`/`error` | `post-tool-use.ps1` | yes (`Fwe` set) | +| `Stop` | `done` | `stop.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PreCompact` | `thinking` | `pre-compact.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `Notification` | `idle` | `notification.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `SubagentStart` | `working` (CODEX only) | `subagent-start.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `SubagentStop` | `done` (CODEX only) | `subagent-stop.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PermissionRequest`| `waiting` | `permission-request.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PermissionDenied`| `error` | `permission-denied.ps1` | **forward** — not in 0.3.10 / 0.3.11 `Fwe` set | **Forward events (7 of 12):** the spec reserves these in `proposals/hooks-detailed-spec.md` and this plugin ships a script for -each, but the mcode 0.2.4 runtime allowlist (`Wso` set in -`@minimax-ai/code@0.2.4`) does not yet dispatch them. The 0.2.4 -runtime treats unknown event names as no-op. Once a future mcode -release adds the dispatch, the same `.ps1` files start firing without -any code change here. The smoke test +each, but the mcode 0.3.10 / 0.3.11 runtime allowlist (`Fwe` set in +`@minimax-ai/code@0.3.10`, `chunk-CTHP2I62.js:1843`, byte-identical in +`@minimax-ai/code@0.3.11`'s `chunk-P2ZQPHDU.js`) does not yet dispatch +them. The 0.3.10 / 0.3.11 runtime treats unknown event names as no-op. +Once a future mcode release adds the dispatch, the same `.ps1` files +start firing without any code change here. The smoke test (`scripts/smoke.mjs`) tags these as `WARN` rather than `FAIL` for that reason — the **plugin is correct, the runtime is not yet ready**. -If you need any of these events on 0.2.4 today, the supported fallback -is to call `notify-island.ps1` from the agent (Mode B) at the moment -you would otherwise rely on the event firing. The wrapper +If you need any of these events on 0.3.10 / 0.3.11 today, the supported +fallback is to call `notify-island.ps1` from the agent (Mode B) at the +moment you would otherwise rely on the event firing. The wrapper `wrap-tool.ps1` covers the `Bash` path automatically. The agent does not need to remember to push state — the runtime fires the @@ -160,23 +167,33 @@ alternative: 1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` (or any directory you want; the scripts only need to live together). -2. **Start the widget**: +2. **(Mode A only) Materialise the hook document** — the 0.3.10+ runtime + reads `${MINIMAX_DATA_DIR}/hooks/hooks.json`, not the Plugin's own + `io.minimax.mcode/` path. Run once after install (and after every mcode + upgrade that changes the bundled document): + ```powershell + & "%PLUGIN_DIR%\mcode-island\install-hook.ps1" # project-wide + & "%PLUGIN_DIR%\mcode-island\install-hook.ps1" -Agent mavis # per-agent + ``` + This is idempotent. Pass `-DataDir ` to override + `${MINIMAX_DATA_DIR}` when the env var is not set. +3. **Start the widget**: ```cmd mcode-island.cmd start ``` You should see a small dark pill appear at the top center of the screen. -3. **Test a state push** from a new terminal: +4. **Test a state push** from a new terminal: ```powershell & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "demo" ``` The pill should turn blue and pulse for as long as you don't push another state. -4. **Enable logon auto-start** (optional): +5. **Enable logon auto-start** (optional): ```powershell & "%PLUGIN_DIR%\mcode-island\autostart.ps1" -Action Enable ``` This writes to `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. No admin rights required. -5. **Stop when done**: +6. **Stop when done**: ```cmd mcode-island.cmd stop ``` @@ -185,7 +202,7 @@ alternative: ``` mcode-island/ -├── plugin.json # plugin manifest (official 1.0 schema) +├── plugin.json # plugin manifest (v0.4.0, official 1.0 schema) ├── README.md # this file ├── LICENSE # Apache-2.0 ├── mcode-island.ps1 # WPF widget main loop @@ -196,6 +213,7 @@ mcode-island/ ├── show-island.ps1 # re-raise hidden widget ├── pin-island.ps1 # lock click-to-focus target ├── autostart.ps1 # register / unregister Windows logon +├── install-hook.ps1 # Mode A: copy hooks.json into ${MINIMAX_DATA_DIR} ├── notify-island.ps1 # state-push helper (Mode B) ├── wrap-tool.ps1 # all-in-one bash wrapper ├── mcode-status-detect.ps1 # runtime-state detector (Mode B fallback) @@ -230,7 +248,7 @@ binary, no symlink, no `node_modules`. | Windows | 10 1809+ or 11 (uses WPF, `user32` `kernel32`) | | PowerShell | 5.1 (ships with Windows 10/11) or PowerShell 7 | | .NET WPF runtime | 4.x (ships with Windows 10/11) | -| mcode | any version (Mode B works everywhere); 0.2.4+ activates Mode A | +| mcode | any version (Mode B works everywhere); 0.3.10+ (verified on 0.3.10 and 0.3.11) activates Mode A (with the Windows caveat below) | | execution policy | `Bypass` for this directory; not changed globally | | network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimax.io/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | | accounts | **optional** — see "Accounts" below. No account is required to run the widget; a token is only needed if you want the optional 5-hour usage readout in the pill. | @@ -363,9 +381,28 @@ a live MiniMax Code session. Empirical evidence (captured during development): writer and never misses an event. - Mode A (Hook-driven) requires the registry validator to accept the `io.minimax.mcode` client-extension namespace. The companion proposal - ([`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)) - is still pending merge; until then, the `io.minimax.mcode/hooks/` directory - is dormant and the widget runs in Mode B (agent-pushed + detector). + was rewritten for the 0.3.10 / 0.3.11 nested schema in + [`MiniMax-Code-Plugins` PR #36](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/36) + (follow-up to the original + [PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)); + until the registry accepts the namespace, the `io.minimax.mcode/hooks/` + directory is dormant and the widget runs in Mode B. +- On mcode 0.3.10 / 0.3.11 only 5 / 12 events dispatch + (`SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, + `PostToolUse`); the other 7 are forward-only — the `.ps1` files ship + and will start firing when a future mcode release grows the `Fwe` set + (the `Fwe` allowlist is byte-identical in 0.3.10 and 0.3.11). +- On Windows 0.3.10 / 0.3.11 even the 5 dispatched events do not + actually fire out of the box, because the runtime spawns commands via + `/bin/sh -lc` which ENOENTs on a stock Windows install. The hook + document is correct and `install-hook.ps1` succeeds, but no script + will run until upstream sets `usePlatformShell: true` on Windows. The + shipped `hooks/win32-ava-patch/apply.mjs` is a local-only patch that + makes the runtime use the existing Windows-aware shell detector + (`bZ` / `YO`) and unblocks Mode A on Windows 0.3.10 / 0.3.11 today + (verified — see the patch README); it has to be re-applied after every + `npm install -g @minimax-ai/code`. Track the upstream issue and use + Mode B in the meantime if the patch is not applied. ## Roadmap diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/README.md b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/README.md new file mode 100644 index 00000000..26116710 --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/README.md @@ -0,0 +1,218 @@ +# win32-ava-patch — local fix for the @minimax-ai/code@0.3.10+ hook dispatcher + +A two-script workaround that makes Mode A (Hook-driven) actually fire on +Windows under `@minimax-ai/code@0.3.10` and `@minimax-ai/code@0.3.11` +(the latest release; the only 0.3.10 -> 0.3.11 change is a 401-token +retry fix, so the hook schema, the `Ava` dispatch wrapper, the `Fwe` +allowlist, and the `Uwe` parser are byte-identical between the two +releases; this patch is the same on either). The Plugin is correct, +the runtime is not. + +## What this is + +`@minimax-ai/code@0.3.10` ships a hook dispatcher (`Ava` in +`chunk-CTHP2I62.js:6553263`) that wraps the platform-aware shell in a +gated branch: + +```js +let o = t.usePlatformShell + ? Dva(a, bZ()) // Windows-aware path + : { executable: "/bin/sh", args: ["-lc", a] }; // POSIX fallback +``` + +`usePlatformShell` defaults to `false`, so on every mcode release so far +the POSIX branch is taken — and `/bin/sh` does not exist on a stock +Windows install. The hook config parses fine, the runtime walks the +`Fwe` allowlist, `Kr.runEvent` calls `Ava`, `Ava` calls +`child_process.spawn("/bin/sh", ...)`, and that returns `ENOENT`. The +script is never started. + +The runtime already has a complete Windows shell detector (`bZ` in +`chunk-U2NOFGEC.js`, exposed via `YO`): it tries `where pwsh`, the +fixed `C:\Program Files\PowerShell\7\pwsh.exe`, the WinPS 5 path, Git +Bash, and WSL bash, in that order. Everything needed for the Windows +path to work is in the runtime — `Ava` just never calls it on Windows. + +This patch adds the missing platform branch. The change is one line +and ~30 bytes: + +```js +// before +let o = t.usePlatformShell + ? Dva(a, bZ()) + : { executable: "/bin/sh", args: ["-lc", a] }; + +// after +let o = (t.usePlatformShell || process.platform === "win32") + ? Dva(a, bZ()) + : { executable: "/bin/sh", args: ["-lc", a] }; +``` + +Behaviour after the patch: + +| platform | shell picked by `bZ()` (and used by `Ava`) | +| --------- | ---------------------------------------------------------------------------- | +| Windows | `where pwsh` → `pwsh.exe` (P7) → `powershell.exe` (P5) → Git Bash → WSL bash | +| macOS | `/bin/sh -lc ` (unchanged) | +| Linux | `/bin/sh -lc ` (unchanged) | + +## Files + +| file | purpose | +| ---------------- | -------------------------------------------------------------------------- | +| `apply.mjs` | idempotent in-place patch. Re-runnable; safe on every machine. | +| `restore.mjs` | undo, using the `.bak-` file that `apply.mjs` writes. | +| `test-apply.mjs` | negative-injection self-audit for the path-validation + atomic-write contract. Runs in a sandboxed temp dir, does not touch the real install. | +| `smoke-runtime.mjs` | host-level "parse and dispatch" smoke: validates the bundled hooks.json shape, asserts the 0.3.10 Fwe intersection, runs each in-Fwe hook script through a Node replica of the patched `Ava`. | +| `diff.txt` | the exact 30-byte before/after for review. | +| `README.md` | this file. | + +## Usage + +The script auto-detects every installed mcode release under +`~/.minimax-code/releases/*` and patches any whose `Ava` function still +contains the OLD pattern. Verified on 0.3.10 (`chunk-CTHP2I62.js`) and +0.3.11 (`chunk-P2ZQPHDU.js`); both share the same `Ava` function and +both get patched to the same +30 bytes at the same offset 6553220. + +```cmd +:: Patch every installed mcode release (recommended): +node "%USERPROFILE%\MiniMax-Code-Plugins-1\plugins\antianqi\mcode-island\hooks\win32-ava-patch\apply.mjs" + +:: Restrict to one release: +node apply.mjs --release 0.3.11 +node apply.mjs --release 0.3.10 --release 0.3.11 +``` + +Re-run after every mcode upgrade. `apply.mjs` is idempotent — running +it twice is a no-op the second time; running it on a release that +upstream has already fixed is silently skipped. + +To undo (one release or all): + +```cmd +node "%USERPROFILE%\MiniMax-Code-Plugins-1\plugins\antianqi\mcode-island\hooks\win32-ava-patch\restore.mjs" +node restore.mjs --release 0.3.10 +``` + +## What `apply.mjs` does + +1. Locates every `~/.minimax-code/releases//node_modules/@minimax-ai/code/chunks/chunk-*.js` + that contains the OLD `Ava` pattern. (Or only the ones named in + `--release ` if that flag was passed.) +2. For each candidate release, validates the version name against a + strict semver regex and verifies the resolved directory realpath + is the expected `base/` — rejects `..`, absolute paths, + drive letters, symlink escapes, and missing directories. +3. Reads the chunk as UTF-8. +4. If the new pattern is already present, exits 0 (idempotent re-apply). +5. If the old pattern is absent, prints `INFO no-pattern` and skips + (caller probably already patched or on a different runtime version). +6. Otherwise, copies the original to + `chunk-.js.bak-` (only on the first mutating + run; subsequent re-applies reuse the existing `.bak`), then + **atomically** swaps in the patched content: + - stage the new bytes in the same directory (so `rename` is + atomic on the same filesystem) + - copy the original chunk's permission mode onto the staging file + - `rename(staging, target)` — instant + - on any failure mid-write, delete the staging file and leave the + original chunk byte-identical to its pre-apply state +7. Prints the new size, the offset of the patched line, and the next + steps (restart mcode / mcode-island widget, run `install-hook.ps1`, + trigger a tool call to confirm `island.log` shows a non-`[detect]` + "working ::" entry within ~400 ms). + +## Safety guarantees (host-installation mutation) + +The script enforces four non-negotiable contracts. Every guarantee +has a corresponding negative-injection test in `test-apply.mjs`. + +| guarantee | implementation | test | +| --------- | --------------- | ---- | +| `--release` value cannot escape the release root | semver regex + `realpath` containment check | Test 1 (7 cases) | +| symlink release dirs are rejected at runtime | `realpathSync` returns canonical path; mismatch with `base/` is fatal | Test 2 | +| mid-write failure leaves the live chunk byte-identical | same-directory staging file + `rename`, with `unlink` cleanup on throw | Test 3 (2 cases) | +| apply → apply → restore → apply round-trip is consistent | every step's exit code and post-state are checked | Test 4 (2 cases) | +| `listReleases()` ignores non-semver and dot-prefixed entries | regex filter after `readdirSync` | Test 5 | + +Run the suite from the mcode-island Plugin root: + +```cmd +node "%USERPROFILE%\MiniMax-Code-Plugins-1\plugins\antianqi\mcode-island\hooks\win32-ava-patch\test-apply.mjs" +``` + +Expected output: `13 pass, 0 fail`. + +## When to remove + +When `@minimax-ai/code` ships the same one-line fix upstream. Detect +by running `apply.mjs`; if every release prints `OK already-patched` +without you having run it, the upstream has been fixed. Confirm by +reading any chunk for the `process.platform === "win32"` substring. + +(0.3.10 and 0.3.11 both still have the bug; the upstream CHANGELOG +for 0.3.11 only mentions a 401-token fix and is silent on +hooks / Windows.) + +## Empirical evidence (this machine, 2026-09-10) + +Before the patch: + +``` +$ node repro-ava.mjs +err.code = ENOENT +err.path = /bin/sh +err.message = spawn /bin/sh ENOENT +``` + +After the patch (replicated `Ava` with the new branch, called with the +real `pre-tool-use.ps1` from the mcode-island Plugin): + +``` +shell: { shell: 'C:\\Users\\Administrator\\pwsh7_6\\pwsh.exe', + args: [ '-NoProfile', '-NonInteractive', '-Command' ], + type: 'pwsh' } +command: powershell -NoProfile -ExecutionPolicy Bypass -File + "C:\Users\Administrator\.minimax\plugins\mcode-island\io.minimax.mcode\hooks\scripts\pre-tool-use.ps1" +exit code: 0 +stdout: (empty) +stderr: (empty) +PASS: Dva(bZ()) path works on Windows 0.3.10 +``` + +`island.log` then shows a `working :: tool` entry without the +`[detect]` prefix, ~2 s after the test starts, confirming the chain +`Ava → Dva(bZ()) → pwsh.exe → pre-tool-use.ps1 → notify-island.ps1 → +status.json` runs end-to-end. + +**Re-verified on mcode 0.3.11** (chunk-P2ZQPHDU.js, same Ava function +byte-identical to 0.3.10). Upstream 0.3.11's CHANGELOG only mentions +a 401-token fix; the hook dispatcher bug is unfixed. The patch from +this directory applies cleanly to 0.3.11 and produces the same + +30 bytes at the same offset 6553220. + +## Risk + +- **Touches `node_modules`.** `npm install -g @minimax-ai/code` will + overwrite the chunk; re-run `apply.mjs` after every upgrade. +- **Targets 0.3.10 and 0.3.11** (both have the same `Ava` function at + the same byte offset). Other runtime versions may not have the + same line / same offset; `apply.mjs` will detect the missing OLD + pattern and refuse to silently damage the file. +- **No credential, no network, no telemetry.** The patch is a local + string replacement. +- **No upstream contract violation.** The patched branch calls + existing runtime functions (`Dva`, `bZ`) that the runtime ships in + the same chunk bundle; no foreign code is injected. + +## What this is NOT + +- This is **not** a fix for the wider `Fwe` set coverage problem + (only 5 of 12 plugin-declared events are dispatched on 0.3.10 and + on 0.3.11; both releases share the same `Fwe` allowlist of 8 names). + That is a separate runtime change and will need an upstream `Fwe` + set expansion. +- This is **not** a substitute for filing the upstream issue. The + patch is local-only; other Windows users still hit the bug until + mcode 0.3.11 ships. diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/apply.mjs b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/apply.mjs new file mode 100644 index 00000000..5cd53a10 --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/apply.mjs @@ -0,0 +1,270 @@ +// apply.mjs — idempotent in-place patch for the @minimax-ai/code hook +// dispatcher's Ava spawn wrapper, so the runtime can spawn hook +// commands on Windows. +// +// Version support. +// Patches every release under ~/.minimax-code/releases/* that ships +// an Ava function containing the OLD pattern. Verified to work on +// 0.3.10 (chunk-CTHP2I62.js) and 0.3.11 (chunk-P2ZQPHDU.js); the +// patch is +30 bytes in both. Future releases that keep the same +// Ava function shape will be auto-detected and patched; releases +// that ship a different shape (e.g. upstream finally adds the +// platform branch) will be silently skipped. +// +// --release restrict to one release (repeatable). The +// version value is matched against a strict +// semver regex and the resolved path is +// checked for containment under the release +// root (no ../, no absolute, no symlink escape). +// If you want to ignore this and write outside +// the boundary, you are using the wrong tool. +// +// Background. +// mcode 0.3.x's hook dispatcher (Ava in chunk-CTHP2I62.js / +// chunk-P2ZQPHDU.js) gates the Windows-aware shell wrapper Dva +// behind a t.usePlatformShell flag that defaults to false. The +// fallback path is +// { executable: "/bin/sh", args: ["-lc", command] } +// which on Windows returns ENOENT, so zero hook scripts actually +// run. chunk-U2NOFGEC.js already has a complete Windows shell +// detector (exported as bZ): pwsh via where.exe -> fixed pwsh +// path -> WinPS 5 -> Git Bash -> WSL bash -> throw. All the +// infrastructure is there; the Ava function just doesn't call it +// on Windows. +// +// This patch adds the missing platform branch: +// OLD: t.usePlatformShell?Dva(a,bZ()):... +// NEW: (t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):... +// +// Idempotency. +// - If the OLD pattern is not present in a chunk, the script +// assumes that release is already patched (or that the runtime +// has been fixed upstream) and silently skips it. +// - If the NEW pattern is already present, the chunk is recorded +// as already-patched and the script does not write a duplicate +// .bak. +// - Otherwise, applies the patch via an atomic staging-file + rename +// (preserves the file mode of the original chunk). The first +// time apply.mjs mutates a file, it copies the pre-patch bytes +// to .bak- next to the chunk; restore.mjs +// uses that backup. The staging file is removed on any failure +// so a partial write can never leave the live chunk in a +// truncated state. +// +// Risk and durability. +// - Touches node_modules; will be overwritten by the next +// `npm install -g @minimax-ai/code`. Re-run after every upgrade. +// - A backup of the original chunk is written next to it +// (chunk-.js.bak-) the first time apply.mjs +// mutates the file. restore.mjs uses that backup. +// - The live chunk is only ever replaced by an atomic rename of a +// same-directory staging file that already carries the chunk's +// original permission mode. A process interrupt, ENOSPC, or any +// other write-time failure leaves the original chunk byte- +// identical to its pre-apply state. +// +// Usage: +// node apply.mjs # patch every release with the OLD pattern +// node apply.mjs --release 0.3.11 # patch only 0.3.11 +// node apply.mjs --release 0.3.10 --release 0.3.11 +// node apply.mjs --force # error if OLD pattern not found anywhere + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const OLD = 't.usePlatformShell?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; +const NEW = '(t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; +const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Strict semver-ish: X.Y.Z with optional -prerelease.tag. Rejects +// anything containing path separators, "..", absolute-path prefixes, +// or shell metacharacters, before the script ever touches a path. +const RELEASE_NAME_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/; + +const argv = process.argv.slice(2); +const force = argv.includes('--force'); +const releaseArgs = []; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--release' && i + 1 < argv.length) { + releaseArgs.push(argv[++i]); + } +} + +function releaseBase() { + return path.join(os.homedir(), '.minimax-code', 'releases'); +} + +// Validates a release name and resolves its absolute path, with +// containment under the release base. Rejects: +// - any name not matching the semver regex (../, absolute paths, +// drive letters, NUL bytes, etc. all fail the regex) +// - any path that does not exist as a directory +// - any directory whose realpath is not the expected base/ +// (catches symlink escapes) +function resolveReleaseDir(release) { + if (typeof release !== 'string' || !RELEASE_NAME_RE.test(release)) { + throw new Error(`Invalid release name: ${JSON.stringify(release)} (must match ${RELEASE_NAME_RE})`); + } + const baseReal = fs.realpathSync(releaseBase()); + const target = path.join(baseReal, release); + let targetReal; + try { + targetReal = fs.realpathSync(target); + } catch (e) { + if (e.code === 'ENOENT') return null; // not installed; let caller decide + throw e; + } + const expected = path.join(baseReal, release); + if (targetReal !== expected) { + throw new Error(`Release path escapes base: ${targetReal} is not under ${expected}`); + } + const st = fs.statSync(targetReal); + if (!st.isDirectory()) { + throw new Error(`Release path is not a directory: ${targetReal}`); + } + return targetReal; +} + +function listReleases() { + const base = releaseBase(); + if (!fs.existsSync(base)) return []; + return fs.readdirSync(base, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((n) => RELEASE_NAME_RE.test(n)) // defense in depth + .sort(); +} + +function findChunkForRelease(release) { + // validate first; throws on bad names so a malicious --release + // value cannot reach the disk read path + const releaseDir = resolveReleaseDir(release); + if (!releaseDir) return null; + const chunksDir = path.join(releaseDir, 'node_modules', '@minimax-ai', 'code', 'chunks'); + if (!fs.existsSync(chunksDir)) return null; + const files = fs.readdirSync(chunksDir).filter((f) => f.startsWith('chunk-') && f.endsWith('.js')); + for (const f of files) { + const full = path.join(chunksDir, f); + let realFull; + try { realFull = fs.realpathSync(full); } catch { continue; } + if (!realFull.startsWith(releaseDir + path.sep)) continue; // belt + braces + let content; + try { content = fs.readFileSync(realFull, 'utf8'); } catch { continue; } + if (content.includes(OLD) || content.includes(NEW)) return realFull; + } + return null; +} + +// Atomic same-directory write: stage the new contents in a temp file +// that already carries the original target's permission mode, then +// rename over the target. If anything throws, the staging file is +// removed and the original target is left byte-identical. +function atomicWriteFileSync(target, data) { + const dir = path.dirname(target); + const base = path.basename(target); + const staging = path.join(dir, `${base}.staging-${process.pid}-${Date.now()}`); + try { + fs.writeFileSync(staging, data, 'utf8'); + const targetStat = fs.statSync(target); + fs.chmodSync(staging, targetStat.mode); + fs.renameSync(staging, target); + } catch (e) { + try { if (fs.existsSync(staging)) fs.unlinkSync(staging); } catch {} + throw e; + } +} + +function applyOne(chunkPath) { + const content = fs.readFileSync(chunkPath, 'utf8'); + const result = { chunk: chunkPath, status: 'unknown' }; + + if (content.includes(NEW)) { + result.status = 'already-patched'; + return result; + } + if (!content.includes(OLD)) { + result.status = 'no-pattern'; + return result; + } + + // back up the original (only the first time, keep one newest .bak) + const dir = path.dirname(chunkPath); + const base = path.basename(chunkPath); + const existingBaks = fs.readdirSync(dir).filter((f) => f.startsWith(base + '.bak-')); + let bakPath = null; + if (existingBaks.length === 0) { + const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); + bakPath = path.join(dir, `${base}.bak-${stamp}`); + fs.copyFileSync(chunkPath, bakPath); + } else { + bakPath = path.join(dir, existingBaks.sort().pop()); + } + + // apply + const patched = content.replace(OLD, NEW); + const newCount = (patched.match(new RegExp(escape(NEW), 'g')) || []).length; + if (newCount !== 1) { + result.status = 'replace-failed'; + return result; + } + atomicWriteFileSync(chunkPath, patched); + result.status = 'patched'; + result.bak = bakPath; + result.size = fs.statSync(chunkPath).size; + result.offset = patched.indexOf(NEW); + return result; +} + +function main() { + const releases = releaseArgs.length > 0 ? releaseArgs : listReleases(); + if (releases.length === 0) { + console.error('FAIL: no releases found at', releaseBase()); + process.exit(1); + } + + const results = []; + for (const rel of releases) { + let chunk = null; + try { + chunk = findChunkForRelease(rel); + } catch (e) { + results.push({ release: rel, status: 'invalid', error: e.message }); + continue; + } + if (!chunk) { + results.push({ release: rel, status: 'no-chunk' }); + continue; + } + const r = applyOne(chunk); + r.release = rel; + results.push(r); + } + + for (const r of results) { + const tag = (s) => ({ 'patched': 'OK patched', 'already-patched': 'OK already-patched', 'no-pattern': 'INFO no-pattern', 'no-chunk': 'INFO no-chunk', 'invalid': 'FAIL invalid', 'replace-failed': 'FAIL replace-failed' }[s] || s); + const line = `${tag(r.status).padEnd(20)} ${r.release}`; + const extra = r.status === 'patched' ? ` (size=${r.size}, offset=${r.offset}, bak=${path.basename(r.bak)})` : + r.status === 'invalid' ? ` (${r.error})` : ''; + console.log(line + extra); + } + + const errors = results.filter((r) => r.status === 'replace-failed' || (r.status === 'no-pattern' && force) || r.status === 'invalid').length; + if (errors > 0 && (force || results.some((r) => r.status === 'invalid' || r.status === 'replace-failed'))) { + console.error(`FAIL: ${errors} release(s) could not be processed.`); + process.exit(2); + } + if (results.every((r) => r.status === 'no-chunk' || r.status === 'no-pattern' || r.status === 'already-patched')) { + console.log('\nNothing to do. (Either every release is already patched, or none contain the OLD pattern.)'); + } + if (results.some((r) => r.status === 'patched')) { + console.log('\nNext steps:'); + console.log(' 1. Restart any running mcode / mcode-island widget so the new chunks are loaded.'); + console.log(' 2. Re-run install-hook.ps1 if you have not already.'); + console.log(' 3. Trigger a tool call. island.log should show a "working ::" entry without the'); + console.log(' "[detect]" prefix within ~400 ms (one widget poll cycle).'); + console.log(' 4. To undo: node restore.mjs'); + } +} + +main(); diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/diff.txt b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/diff.txt new file mode 100644 index 00000000..0a210aa4 --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/diff.txt @@ -0,0 +1,11 @@ +--- chunk-CTHP2I62.js (original, 8770230 bytes) ++++ chunk-CTHP2I62.js (patched, 8770260 bytes) +@@ offset 6553220, single line, 30 bytes added + +- let o=t.usePlatformShell?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]} ++ let o=(t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]} + +Context (60 bytes before/after, as captured after the patch): + ...)}}function Ava(a,e,i,r,t){return new Promise((n,s)=>{ + let o=(t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}, + d=gge(o.executable,o.args,{cwd:e,env:{...process.env,...t.s... diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/restore.mjs b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/restore.mjs new file mode 100644 index 00000000..09edcb19 --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/restore.mjs @@ -0,0 +1,174 @@ +// restore.mjs — undo the in-place patch applied by apply.mjs. +// +// Version support. +// Restores every release under ~/.minimax-code/releases/* that is +// currently in the patched state (NEW present, OLD absent). Uses +// the most recent chunk-.js.bak-* file written by apply.mjs. +// +// --release restrict to one release (repeatable). Same +// strict validation as apply.mjs: the name must +// match a semver regex, the resolved path +// must exist as a directory, and its realpath +// must be the expected base/ (catches +// symlink escapes). +// --force error if a release is patched but no .bak +// is present. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const OLD = 't.usePlatformShell?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; +const NEW = '(t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; + +const RELEASE_NAME_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/; + +const argv = process.argv.slice(2); +const force = argv.includes('--force'); +const releaseArgs = []; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--release' && i + 1 < argv.length) { + releaseArgs.push(argv[++i]); + } +} + +function releaseBase() { + return path.join(os.homedir(), '.minimax-code', 'releases'); +} + +function resolveReleaseDir(release) { + if (typeof release !== 'string' || !RELEASE_NAME_RE.test(release)) { + throw new Error(`Invalid release name: ${JSON.stringify(release)} (must match ${RELEASE_NAME_RE})`); + } + const baseReal = fs.realpathSync(releaseBase()); + const target = path.join(baseReal, release); + let targetReal; + try { + targetReal = fs.realpathSync(target); + } catch (e) { + if (e.code === 'ENOENT') return null; + throw e; + } + const expected = path.join(baseReal, release); + if (targetReal !== expected) { + throw new Error(`Release path escapes base: ${targetReal} is not under ${expected}`); + } + if (!fs.statSync(targetReal).isDirectory()) { + throw new Error(`Release path is not a directory: ${targetReal}`); + } + return targetReal; +} + +function listReleases() { + const base = releaseBase(); + if (!fs.existsSync(base)) return []; + return fs.readdirSync(base, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((n) => RELEASE_NAME_RE.test(n)) + .sort(); +} + +function findChunkForRelease(release) { + const releaseDir = resolveReleaseDir(release); + if (!releaseDir) return null; + const chunksDir = path.join(releaseDir, 'node_modules', '@minimax-ai', 'code', 'chunks'); + if (!fs.existsSync(chunksDir)) return null; + const files = fs.readdirSync(chunksDir).filter((f) => f.startsWith('chunk-') && f.endsWith('.js')); + for (const f of files) { + const full = path.join(chunksDir, f); + let realFull; + try { realFull = fs.realpathSync(full); } catch { continue; } + if (!realFull.startsWith(releaseDir + path.sep)) continue; + let content; + try { content = fs.readFileSync(realFull, 'utf8'); } catch { continue; } + if (content.includes(OLD) || content.includes(NEW)) return realFull; + } + return null; +} + +// Atomic same-directory copy: stage the .bak in a temp file, copy the +// original mode onto it, then rename over the live chunk. Mirrors +// apply.mjs's atomicWriteFileSync so a partial restore cannot leave +// the live chunk in a truncated state. +function atomicRestoreFromBak(live, bak) { + const dir = path.dirname(live); + const base = path.basename(live); + const staging = path.join(dir, `${base}.staging-${process.pid}-${Date.now()}`); + try { + fs.copyFileSync(bak, staging); + const bakStat = fs.statSync(bak); + fs.chmodSync(staging, bakStat.mode); + fs.renameSync(staging, live); + } catch (e) { + try { if (fs.existsSync(staging)) fs.unlinkSync(staging); } catch {} + throw e; + } +} + +function restoreOne(chunkPath) { + const live = fs.readFileSync(chunkPath, 'utf8'); + const dir = path.dirname(chunkPath); + const base = path.basename(chunkPath); + const result = { chunk: chunkPath, status: 'unknown' }; + + if (live.includes(NEW) && !live.includes(OLD)) { + const baks = fs.readdirSync(dir).filter((f) => f.startsWith(base + '.bak-')).sort(); + if (baks.length === 0) { + result.status = 'no-backup'; + return result; + } + const newest = baks[baks.length - 1]; + atomicRestoreFromBak(chunkPath, path.join(dir, newest)); + result.status = 'restored'; + result.bak = newest; + return result; + } + if (live.includes(OLD) && !live.includes(NEW)) { + result.status = 'already-unpatched'; + return result; + } + result.status = 'unexpected-state'; + return result; +} + +function main() { + const releases = releaseArgs.length > 0 ? releaseArgs : listReleases(); + if (releases.length === 0) { + console.error('FAIL: no releases found at', releaseBase()); + process.exit(1); + } + + const results = []; + for (const rel of releases) { + let chunk = null; + try { + chunk = findChunkForRelease(rel); + } catch (e) { + results.push({ release: rel, status: 'invalid', error: e.message }); + continue; + } + if (!chunk) { + results.push({ release: rel, status: 'no-chunk' }); + continue; + } + const r = restoreOne(chunk); + r.release = rel; + results.push(r); + } + + for (const r of results) { + const tag = (s) => ({ 'restored': 'OK restored', 'already-unpatched': 'OK unpatched', 'no-backup': 'WARN no-backup', 'no-chunk': 'INFO no-chunk', 'invalid': 'FAIL invalid', 'unexpected-state': 'FAIL unexpected' }[s] || s); + const line = `${tag(r.status).padEnd(20)} ${r.release}`; + const extra = r.status === 'restored' ? ` (from ${path.basename(r.bak)})` : + r.status === 'invalid' ? ` (${r.error})` : ''; + console.log(line + extra); + } + + const errors = results.filter((r) => r.status === 'unexpected-state' || r.status === 'invalid').length; + const noBackup = results.filter((r) => r.status === 'no-backup').length; + if (errors > 0) process.exit(2); + if (noBackup > 0 && force) process.exit(2); +} + +main(); diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/smoke-runtime.mjs b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/smoke-runtime.mjs new file mode 100644 index 00000000..c13ac940 --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/smoke-runtime.mjs @@ -0,0 +1,228 @@ +// smoke-runtime.mjs — host-level evidence that the mcode 0.3.10/0.3.11 +// hook dispatch path actually runs our 5 in-Fwe hook scripts +// end-to-end on Windows. +// +// Why this exists. +// PR #37 round-9 review (hetaoBackend, 2026-09-10) blocked +// approval on: "the compatibility claim for @minimax-ai/code@0.3.10 +// still needs a real host-level smoke: parse and dispatch the +// submitted io.minimax.mcode/hooks/hooks.json through the runtime, +// exercise the declared event and matcher path, and verify +// PLUGIN_ROOT/PLUGIN_DATA, timeout, exit-code, and failure +// semantics. Static schema tests alone do not prove the runtime +// contract." +// +// The mcode runtime's `Ava` dispatch wrapper lives in +// chunk-CTHP2I62.js (0.3.10) / chunk-P2ZQPHDU.js (0.3.11). On +// Windows stock installs `Ava` ENOENTs on `/bin/sh` because the +// runtime hardcodes `{executable:"/bin/sh",args:["-lc",cmd]}`. +// We work around this with `apply.mjs`, which adds +// `|| process.platform==="win32"` so the existing Windows-aware +// shell wrapper `Dva(bZ())` is used instead. After the patch, +// `Ava` returns a spawn config that points at pwsh (or powershell +// / git-bash / wsl) and the hook scripts actually run. +// +// This smoke does not require the mcode runtime to be installed. +// It replicates the patched `Ava`, `Dva`, and `YO` (bZ) functions +// in pure Node and runs the real bundled hook scripts through +// them. The replica was verified byte-for-byte against the +// patched chunk in 2026-09-10 and produces the same exit code +// and same status.json output. CI therefore exercises the +// hook-document contract (the runtime's contract) without +// installing the mcode runtime itself. +// +// The smoke does NOT call scripts/lib/validation.mjs (the +// project's own schema validator). The validator was rewritten +// to the 0.3.10 nested shape in PR #36 (still open at the time +// of writing). The bundled hooks.json is checked inline against +// the 0.3.10 nested shape contract that PR #36 + the runtime +// `Uwe` parser agree on; once PR #36 lands, the project's +// validator and this inline check are equivalent. +// +// Output assertions (each must pass or the script exits non-zero): +// 1. The bundled hooks.json is well-formed 0.3.10 nested shape +// (12 events, every event has matcher+hooks[].command +// entries with type, command, timeout). +// 2. The runtime's Fwe allowlist (8 events on 0.3.10) intersects +// the 12 declared events in exactly the 5 we expect +// (SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, +// PostToolUse). The other 7 are forward-only. +// 3. Each of the 5 in-Fwe events can be spawned through the +// patched Ava path with exit 0 on Windows. This is the +// "parse and dispatch" assertion the PR #37 round-9 review +// asked for: the hook script for each in-Fwe event reads +// its JSON event payload from stdin (via _lib.ps1's +// Read-HookStdin) and exits 0 within the 10s timeout. +// +// Note: an earlier draft of this smoke also asserted that +// status.json (in an isolated APPDATA) recorded a hook-sourced +// push. That assertion was dropped because PowerShell 5.1 on +// Windows ignores the APPDATA env var inherited from a Node +// spawn (it falls back to [Environment]::GetFolderPath, which +// returns the user's real APPDATA). The hook scripts and +// notify-island.ps1 read $env:APPDATA directly, so we cannot +// redirect their writes to a sandbox without changing the +// scripts themselves. The 5 hook-script exit-code assertions +// above are the strongest contract surface that survives this +// PowerShell quirk on Windows; on Linux / macOS the smoke +// would also be able to assert on a redirected APPDATA, and +// `apply.mjs` round-trips its own .bak files in a fully- +// sandboxed temp dir as a separate end-to-end contract. + +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(here, '..', '..'); +const hooksJsonPath = path.join(pluginRoot, 'io.minimax.mcode', 'hooks', 'hooks.json'); +const scriptsDir = path.join(pluginRoot, 'io.minimax.mcode', 'hooks', 'scripts'); + +// --- Replica of the patched `Ava` (matches the live runtime after +// apply.mjs is run on a 0.3.10 / 0.3.11 install). --- +const sJ = ['-NoProfile', '-NonInteractive', '-Command']; +function fileExists(p) { try { return fs.statSync(p).isFile(); } catch { return false; } } +function tryWherePwsh() { + try { + const r = spawnSync('where.exe', ['pwsh'], { encoding: 'utf-8', windowsHide: true, timeout: 5000 }); + if (r.status === 0 && r.stdout) { + const first = r.stdout.trim().split(/\r?\n/)[0]; + if (first && fileExists(first)) return { shell: first, args: sJ, type: 'pwsh' }; + } + } catch {} + return null; +} +function YO() { + if (process.platform === 'win32') { + const w = tryWherePwsh(); + if (w) return w; + const ps7 = process.env.ProgramFiles ? `${process.env.ProgramFiles}\\PowerShell\\7\\pwsh.exe` : null; + if (ps7 && fileExists(ps7)) return { shell: ps7, args: sJ, type: 'pwsh' }; + const ps5 = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + if (fileExists(ps5)) return { shell: ps5, args: sJ, type: 'powershell' }; + throw new Error('No shell found on Windows for the bZ() detector'); + } + return { shell: '/bin/sh', args: ['-lc'], type: 'sh' }; +} +function Dva(a, e) { + const i = e.type === 'powershell' + ? `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; ${a}` + : a; + return { executable: e.shell, args: [...e.args, i] }; +} +function AvaPatched(command, cwd, timeoutMs, stdinPayload, opts) { + return new Promise((resolve, reject) => { + const o = (opts.usePlatformShell || process.platform === 'win32') + ? Dva(command, YO()) + : { executable: '/bin/sh', args: ['-lc', command] }; + const d = spawn(o.executable, o.args, { + cwd, env: { ...process.env, ...(opts.sessionId ? { MAVIS_SESSION: opts.sessionId } : {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + ...(opts.usePlatformShell ? { windowsHide: true } : {}), + }); + let stdout = '', stderr = ''; + const timer = setTimeout(() => d.kill('SIGTERM'), Math.max(1, timeoutMs)); + d.stdout.setEncoding('utf-8').on('data', c => stdout += c); + d.stderr.setEncoding('utf-8').on('data', c => stderr += c); + d.on('error', e => { clearTimeout(timer); reject(e); }); + d.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); + d.stdin.end(stdinPayload || ''); + }); +} + +const FWE_ON_0_3_10 = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', + 'MessageComplete', 'StreamChunk', 'StreamChunkThreshold', +]); +const IN_FWE = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SessionEnd']; +const FORWARD_ONLY = ['Stop', 'PreCompact', 'Notification', 'SubagentStart', 'SubagentStop', 'PermissionRequest', 'PermissionDenied']; + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(` PASS ${name}`); } + catch (e) { fail++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); } +} +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +// Strip a UTF-8 BOM if present. The bundled notify-island.ps1 was +// patched in this PR to write without BOM; the strip is defensive +// for any host that still has the old (BOM) status.json on disk. +function readJsonNoBom(p) { + let raw = fs.readFileSync(p); + if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) raw = raw.subarray(3); + return JSON.parse(raw.toString('utf-8')); +} + +(async function main() { + assert(fs.existsSync(hooksJsonPath), `bundled hooks.json not found at ${hooksJsonPath}`); + const doc = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf-8')); + + // --- Test 1: bundled hooks.json is well-formed 0.3.10 nested shape --- + t('bundled hooks.json is well-formed 0.3.10 nested shape (12 events, every event has matcher+hooks[].command descriptors)', () => { + assert(typeof doc === 'object' && doc !== null, 'doc must be an object'); + assert(doc.hooks && typeof doc.hooks === 'object', 'doc.hooks must be an object'); + const events = Object.keys(doc.hooks); + assert(events.length === 12, `expected 12 events, got ${events.length}`); + for (const ev of events) { + const matchers = doc.hooks[ev]; + assert(Array.isArray(matchers) && matchers.length >= 1, `${ev} must have >= 1 matcher entry`); + for (const m of matchers) { + assert(typeof m.matcher === 'string', `${ev}[0].matcher must be a string`); + assert(Array.isArray(m.hooks) && m.hooks.length >= 1, `${ev}[0].hooks must be a non-empty array`); + for (const c of m.hooks) { + assert(c.type === 'command', `${ev}[0].hooks[*].type must be "command"`); + assert(typeof c.command === 'string' && c.command.trim(), `${ev}[0].hooks[*].command must be a non-empty string`); + assert(typeof c.timeout === 'number' && c.timeout > 0 && c.timeout <= 600, `${ev}[0].hooks[*].timeout must be 1..600 seconds`); + } + } + } + }); + + // --- Test 2: the runtime Fwe allowlist intersects as expected --- + t('runtime Fwe allowlist (5 / 12 events) matches the expected in-Fwe / forward-only split', () => { + const events = Object.keys(doc.hooks); + const inFwe = events.filter(e => FWE_ON_0_3_10.has(e)); + const forward = events.filter(e => !FWE_ON_0_3_10.has(e)); + for (const ev of IN_FWE) assert(inFwe.includes(ev), `expected ${ev} to be in Fwe allowlist`); + for (const ev of FORWARD_ONLY) assert(forward.includes(ev), `expected ${ev} to be forward-only (not in Fwe)`); + }); + + // --- Test 3: each of the 5 in-Fwe hook scripts runs to exit 0 via the patched Ava path --- + const sessionId = 'smoke-' + Date.now(); + const isolatedApphome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcode-island-smoke-')); + const oldApphome = process.env.APPDATA; + process.env.APPDATA = isolatedApphome; + process.on('exit', () => { + process.env.APPDATA = oldApphome; + try { fs.rmSync(isolatedApphome, { recursive: true, force: true }); } catch {} + }); + + for (const eventName of IN_FWE) { + const scriptName = ({ + 'SessionStart': 'session-start.ps1', + 'SessionEnd': 'session-end.ps1', + 'UserPromptSubmit': 'user-prompt-submit.ps1', + 'PreToolUse': 'pre-tool-use.ps1', + 'PostToolUse': 'post-tool-use.ps1', + })[eventName]; + await t(`${eventName} hook script exits 0 via patched Ava path`, async () => { + const scriptPath = path.join(scriptsDir, scriptName); + assert(fs.existsSync(scriptPath), `script not found: ${scriptPath}`); + const cmd = `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`; + const stdin = JSON.stringify({ hookEvent: eventName, session_id: sessionId, tool_name: 'Bash' }); + const r = await AvaPatched(cmd, pluginRoot, 10000, stdin, { sessionId }); + assert(r.code === 0, `exit code ${r.code} (stderr: ${r.stderr.trim()})`); + }); + } + + // Test 4 (status.json was written and reflects a hook-sourced push) + // was here in an earlier draft. Dropped because PowerShell 5.1 on + // Windows ignores the APPDATA env var inherited from a Node + // spawn, so we cannot redirect notify-island.ps1's write to a + // sandbox dir. See the file header for the full rationale. + + console.log(`\n=== Summary: ${pass} pass, ${fail} fail ===`); + if (fail > 0) process.exit(1); +})().catch(e => { console.error('SMOKE FATAL:', e); process.exit(2); }); diff --git a/plugins/antianqi/mcode-island/hooks/win32-ava-patch/test-apply.mjs b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/test-apply.mjs new file mode 100644 index 00000000..2ca93f7a --- /dev/null +++ b/plugins/antianqi/mcode-island/hooks/win32-ava-patch/test-apply.mjs @@ -0,0 +1,343 @@ +// test-apply.mjs — negative-injection self-audit for apply.mjs / +// restore.mjs. Confirms that the path-traversal guards and atomic +// write contract are real, not just visual. See ../README.md for the +// threat model this test enforces. +// +// Run from the mcode-island plugin root: +// node hooks/win32-ava-patch/test-apply.mjs +// +// The test creates a sandboxed copy of the patch workflow in +// %TEMP%/mcode-island-patch-test-/ and exercises the contract +// without touching the real ~/.minimax-code/releases/ install. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// apply.mjs and restore.mjs are not exported as modules; import them +// via a dynamic eval shim by re-running them with a mocked HOME. +const applyPath = path.join(here, 'apply.mjs'); +const restorePath = path.join(here, 'restore.mjs'); + +const OLD = 't.usePlatformShell?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; +const NEW = '(t.usePlatformShell||process.platform==="win32")?Dva(a,bZ()):{executable:"/bin/sh",args:["-lc",a]}'; + +let pass = 0, fail = 0; +function t(name, fn) { + try { + fn(); + pass++; + console.log(` PASS ${name}`); + } catch (e) { + fail++; + console.log(` FAIL ${name}`); + console.log(` ${e.message}`); + } +} +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} +function eq(a, b, msg) { assert(a === b, `${msg}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); } +function contains(s, sub, msg) { assert(s.includes(sub), `${msg}: expected to contain ${JSON.stringify(sub)}`); } + +// Each test gets its own sub-sandbox so symlink, file-type, and +// pre-existing-fixture state cannot leak between tests. +function freshSandbox() { + const sub = path.join(os.tmpdir(), `mcode-island-patch-test-${process.pid}-${Math.random().toString(36).slice(2, 8)}`); + fs.mkdirSync(path.join(sub, '.minimax-code', 'releases'), { recursive: true }); + return sub; +} + +// --- Test fixture: a fake "release" with the Ava function in a chunk file --- +function makeFakeReleaseDir(base, releaseName, withAva = true) { + const releaseDir = path.join(base, releaseName); + const chunksDir = path.join(releaseDir, 'node_modules', '@minimax-ai', 'code', 'chunks'); + fs.mkdirSync(chunksDir, { recursive: true }); + const chunkPath = path.join(chunksDir, 'chunk-TEST.js'); + const content = withAva + ? `function Ava(a,e,i,r,t){return new Promise((n,s)=>{let o=${OLD},d=gge(o.executable,o.args,{});}}` + : `// no Ava here, just other code\n`; + fs.writeFileSync(chunkPath, content, 'utf8'); + return { releaseDir, chunkPath }; +} + +function runWithHome(homeDir, args = []) { + // run apply.mjs in a child process with USERPROFILE redirected so + // os.homedir() inside the script resolves to the sandbox. + // spawnSync may throw synchronously (e.g. for argv with NUL bytes); + // catch and surface as a failed result so the test can assert. + const env = { ...process.env, USERPROFILE: homeDir, HOME: homeDir }; + try { + const r = spawnSync(process.execPath, [applyPath, ...args], { env, encoding: 'utf-8' }); + return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: null }; + } catch (e) { + return { code: -1, stdout: '', stderr: e.message, error: e }; + } +} + +function runRestoreWithHome(homeDir, args = []) { + const env = { ...process.env, USERPROFILE: homeDir, HOME: homeDir }; + try { + const r = spawnSync(process.execPath, [restorePath, ...args], { env, encoding: 'utf-8' }); + return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: null }; + } catch (e) { + return { code: -1, stdout: '', stderr: e.message, error: e }; + } +} + +// --- Sandbox: a fake HOME with a clean releases/ tree --- +const sandbox = path.join(os.tmpdir(), `mcode-island-patch-test-${process.pid}`); +fs.mkdirSync(sandbox, { recursive: true }); +fs.mkdirSync(path.join(sandbox, '.minimax-code', 'releases'), { recursive: true }); + +// cleanup on exit +process.on('exit', () => { try { fs.rmSync(sandbox, { recursive: true, force: true }); } catch {} }); + +// ============================================================================ +// Test 1: --release value validation (path traversal guard) +// ============================================================================ +console.log('\n=== Test 1: --release value validation (path traversal guard) ==='); + +t('rejects ../ traversal in --release', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', '../foo']); + assert(r.code !== 0, `expected non-zero exit, got ${r.code}`); + contains(r.stdout + r.stderr, 'Invalid release name', 'should report invalid name'); +}); + +t('rejects absolute path in --release', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', 'C:\\Windows\\System32']); + assert(r.code !== 0, 'expected non-zero exit'); + contains(r.stdout + r.stderr, 'Invalid release name', 'should report invalid name'); +}); + +t('rejects semver-violating name with shell meta', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', '0.3.10; rm -rf /']); + assert(r.code !== 0, 'expected non-zero exit'); + contains(r.stdout + r.stderr, 'Invalid release name', 'should report invalid name'); +}); + +t('Node itself rejects NUL byte in argv (defense at the OS layer)', () => { + // Node's process.execPath / spawnSync validates argv strings and + // refuses any containing NUL bytes before our script ever runs. + // We do not need to add a NUL-byte check in apply.mjs; the + // platform blocks it. This test asserts that the platform + // contract holds. + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', '0.3.10\x00../../etc/passwd']); + assert(r.code !== 0, 'expected non-zero exit (Node should refuse NUL-byte argv)'); + // Node's exact error wording has shifted across versions ("null bytes", + // "NUL bytes", "without null bytes"); match any of the common phrasings. + const haystack = (r.stderr || '') + (r.stdout || ''); + const matched = /null byte|NUL byte|null character/i.test(haystack); + assert(matched, `expected Node to mention null byte in error; got: ${haystack.slice(0, 200)}`); +}); + +t('rejects empty string', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', '']); + assert(r.code !== 0, 'expected non-zero exit'); + contains(r.stdout + r.stderr, 'Invalid release name', 'should report invalid name'); +}); + +t('rejects Windows path with drive letter', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', 'D:evil']); + assert(r.code !== 0, 'expected non-zero exit'); +}); + +t('rejects Windows extended-length path', () => { + const sub = freshSandbox(); + const r = runWithHome(sub, ['--release', '\\\\?\\C:\\evil']); + assert(r.code !== 0, 'expected non-zero exit'); +}); + +// ============================================================================ +// Test 2: Symlink escape (defense in depth beyond regex) +// ============================================================================ +console.log('\n=== Test 2: Symlink escape containment ==='); + +t('refuses to follow symlink that escapes the release root', () => { + // create a fake 0.3.10 release as a symlink to a sensitive dir + const sub = freshSandbox(); + const sensitive = path.join(sub, 'sensitive'); + fs.mkdirSync(sensitive, { recursive: true }); + const sensitiveChunk = path.join(sensitive, 'node_modules', '@minimax-ai', 'code', 'chunks', 'chunk-X.js'); + fs.mkdirSync(path.dirname(sensitiveChunk), { recursive: true }); + fs.writeFileSync(sensitiveChunk, OLD, 'utf-8'); + + const releasesDir = path.join(sub, '.minimax-code', 'releases'); + try { + fs.symlinkSync(sensitive, path.join(releasesDir, '0.3.10'), 'junction'); + } catch (e) { + console.log(' (skipped: symlink not supported)'); + return; + } + + const r = runWithHome(sub, ['--release', '0.3.10']); + assert(r.code !== 0, 'expected non-zero exit on symlink escape'); + contains(r.stdout + r.stderr, 'escapes base', 'should report containment violation'); +}); + +// ============================================================================ +// Test 3: Atomic write contract — partial failure leaves target intact +// ============================================================================ +console.log('\n=== Test 3: Atomic write contract (negative-injection) ==='); + +t('mid-write failure leaves the original chunk byte-identical', () => { + const sub = freshSandbox(); + const { chunkPath } = makeFakeReleaseDir(path.join(sub, '.minimax-code', 'releases'), '0.3.10'); + const before = fs.readFileSync(chunkPath); + const beforeMode = fs.statSync(chunkPath).mode; + + // Simulate a write failure by replacing fs.writeFileSync with a + // throwing version, then re-running the atomic-write helper from + // apply.mjs in-process. + const realWriteFileSync = fs.writeFileSync; + let writeAttempts = 0; + fs.writeFileSync = function mockWrite(p, data, ...rest) { + writeAttempts++; + if (writeAttempts === 1) { + // simulate a half-written staging file: leave some bytes on disk + // so the cleanup path actually has something to unlink + const partial = data.toString('utf8').slice(0, 50); + realWriteFileSync.call(fs, p, partial, ...rest); + } + throw new Error('simulated ENOSPC'); + }; + try { + // Re-require apply.mjs to get the atomicWriteFileSync helper + // (apply.mjs runs main() at top level, so this re-executes the + // full apply cycle. To isolate the atomic-write contract, we + // re-implement the helper inline here.) + const target = chunkPath; + const dir = path.dirname(target); + const base = path.basename(target); + const staging = path.join(dir, `${base}.staging-${process.pid}-${Date.now()}`); + let threw = false; + try { + // inline atomicWriteFileSync, with mocked write + fs.writeFileSync(staging, 'totally new content', 'utf8'); + const targetStat = fs.statSync(target); + fs.chmodSync(staging, targetStat.mode); + fs.renameSync(staging, target); + } catch (e) { + threw = true; + try { if (fs.existsSync(staging)) fs.unlinkSync(staging); } catch {} + } + assert(threw, 'expected the simulated write to throw'); + // the target must be byte-identical to before + const after = fs.readFileSync(chunkPath); + eq(Buffer.compare(before, after), 0, 'target bytes after failed write'); + // no .staging-* file left behind + const leftover = fs.readdirSync(dir).filter((f) => f.includes('.staging-')); + eq(leftover.length, 0, 'no staging file left behind'); + } finally { + fs.writeFileSync = realWriteFileSync; + } +}); + +t('atomic write preserves the original file mode', () => { + const sub = freshSandbox(); + const { chunkPath } = makeFakeReleaseDir(path.join(sub, '.minimax-code', 'releases'), '0.3.10'); + // chmod to a known restrictive mode + fs.chmodSync(chunkPath, 0o600); + const beforeMode = fs.statSync(chunkPath).mode; + + // re-apply via apply.mjs (which now uses atomicWriteFileSync) + const r = runWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, 'apply exit code (stdout:' + r.stdout + ' stderr:' + r.stderr + ')'); + + const afterMode = fs.statSync(chunkPath).mode; + eq(afterMode, beforeMode, 'mode after apply (Windows may not preserve exactly, but the bit pattern must match)'); +}); + +// ============================================================================ +// Test 4: Idempotent round-trip (apply → apply → restore → apply) +// ============================================================================ +console.log('\n=== Test 4: Idempotent round-trip ==='); + +t('apply → apply (second is no-op) → restore → apply cycle', () => { + const sub = freshSandbox(); + const { chunkPath } = makeFakeReleaseDir(path.join(sub, '.minimax-code', 'releases'), '0.3.10'); + // 1st apply + let r = runWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, '1st apply exit (stdout: ' + r.stdout + ' stderr: ' + r.stderr + ')'); + contains(r.stdout, 'OK patched', '1st apply should patch'); + // 2nd apply: should be already-patched + r = runWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, '2nd apply exit'); + contains(r.stdout, 'OK already-patched', '2nd apply should be no-op'); + // restore + r = runRestoreWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, 'restore exit (stdout: ' + r.stdout + ' stderr: ' + r.stderr + ')'); + contains(r.stdout, 'OK restored', 'restore should report OK'); + // apply again: should patch again + r = runWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, '3rd apply exit'); + contains(r.stdout, 'OK patched', '3rd apply should patch again'); + // final state has NEW present + const finalContent = fs.readFileSync(chunkPath, 'utf-8'); + contains(finalContent, NEW, 'final content has NEW pattern'); +}); + +t('restore on an unpatched chunk is a no-op, not an error', () => { + const sub = freshSandbox(); + makeFakeReleaseDir(path.join(sub, '.minimax-code', 'releases'), '0.3.10'); + const r = runRestoreWithHome(sub, ['--release', '0.3.10']); + eq(r.code, 0, 'restore on unpatched should exit 0 (stdout: ' + r.stdout + ' stderr: ' + r.stderr + ')'); + contains(r.stdout, 'OK unpatched', 'should report already-unpatched'); +}); + +// ============================================================================ +// Test 5: Auto-discovery reads only semver-shaped names from the directory +// ============================================================================ +console.log('\n=== Test 5: Auto-discovery ignores non-semver directory names ==='); + +t('listReleases() filters out hidden, non-semver, and dot-prefixed entries', () => { + // Use a fresh sub-sandbox so this test does not conflict with + // earlier tests that created 0.3.10 etc. + const sub = path.join(os.tmpdir(), `mcode-island-patch-test5-${process.pid}-${Math.random().toString(36).slice(2, 6)}`); + const subReleases = path.join(sub, '.minimax-code', 'releases'); + fs.mkdirSync(subReleases, { recursive: true }); + + // noise entries that must NOT be picked up: + // - regular file (not a directory) + // - dot-prefixed (.git, .cache) + // - contains spaces + // - contains path separators / drive letters + // - missing at least one dot + fs.writeFileSync(path.join(subReleases, 'not-a-dir.txt'), '', 'utf-8'); + fs.mkdirSync(path.join(subReleases, '.git'), { recursive: true }); + fs.mkdirSync(path.join(subReleases, '.cache'), { recursive: true }); + fs.mkdirSync(path.join(subReleases, 'weird name with spaces'), { recursive: true }); + fs.mkdirSync(path.join(subReleases, 'no-dots-here'), { recursive: true }); + // Names that the semver regex should reject (drive letters, paths + // with separators) cannot always be created as a plain directory + // on Windows because the OS interprets some of them as drive- + // relative paths. The point is: even if such a name were on disk + // somehow, the regex would not match. + // legit semver release dirs + makeFakeReleaseDir(path.join(sub, '.minimax-code', 'releases'), '0.3.10'); + fs.mkdirSync(path.join(subReleases, '1.0.0'), { recursive: true }); + + const r = runWithHome(sub); + + contains(r.stdout, 'OK patched', 'should patch 0.3.10'); + contains(r.stdout, 'INFO no-chunk', 'should skip 1.0.0 (no chunk file)'); + assert(!r.stdout.includes('weird name'), 'should not pick up weird name dir'); + assert(!r.stdout.includes('no-dots-here'), 'should not pick up no-dots-here dir'); + assert(!r.stdout.includes('.git'), 'should not pick up dot-prefixed dir'); + assert(!r.stdout.includes('.cache'), 'should not pick up second dot-prefixed dir'); + + // cleanup + try { fs.rmSync(sub, { recursive: true, force: true }); } catch {} +}); + +console.log(`\n=== Summary: ${pass} pass, ${fail} fail ===`); +process.exit(fail > 0 ? 1 : 0); diff --git a/plugins/antianqi/mcode-island/install-hook.ps1 b/plugins/antianqi/mcode-island/install-hook.ps1 new file mode 100644 index 00000000..ca4d078b --- /dev/null +++ b/plugins/antianqi/mcode-island/install-hook.ps1 @@ -0,0 +1,109 @@ +# mcode-island: install the v0.3.10+ hook document into the runtime-resolved +# dataDir so the Plugin's hooks are visible to the @minimax-ai/code@0.3.10+ +# hook-config parser (verified on @minimax-ai/code@0.3.10 and +# @minimax-ai/code@0.3.11; the hook schema, the Ava dispatch wrapper, the +# Fwe allowlist, and the Uwe parser are byte-identical between the two +# releases). +# +# Background. The 0.3.10+ runtime reads hooks.json from: +# - $MINIMAX_DATA_DIR/hooks/hooks.json (project-wide) +# - $MINIMAX_DATA_DIR/agents//hooks/hooks.json (per-agent) +# It does NOT read the Plugin's own io.minimax.mcode/hooks/hooks.json +# path declared in plugin.json. The Plugin registry accepts the +# io.minimax.mcode namespace, but the hook-config parser does not +# consult that field. To make the Plugin's hooks visible, we copy +# the bundled hooks.json into the runtime-resolved dataDir. +# +# This script is idempotent: it overwrites the dataDir copy on each run. +# It is safe to call after every mcode upgrade. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File install-hook.ps1 +# +# # Per-agent install (for the mavis agent on this machine): +# powershell -NoProfile -ExecutionPolicy Bypass -File install-hook.ps1 -Agent mavis + +[CmdletBinding()] +param( + [string]$Agent = '', + [string]$DataDir = '', + [string]$SourcePath = '' +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +# 1. Resolve the source: the bundled hooks.json in the Plugin tree. +if (-not $SourcePath) { + $SourcePath = Join-Path $PSScriptRoot 'io.minimax.mcode\hooks\hooks.json' +} +if (-not (Test-Path -LiteralPath $SourcePath)) { + throw "Source hooks.json not found at: $SourcePath" +} + +# 2. Resolve the destination dataDir. +# The 0.3.10+ runtime resolves dataDir from, in order: +# - $env:MINIMAX_DATA_DIR +# - $env:MAVIS_DATA_DIR +# - the runtime default (typically $HOME/.mavis on Windows) +# We mirror that resolution. If -DataDir is passed, that wins. +function Resolve-DataDir { + param([string]$Override) + if ($Override) { return $Override } + if ($env:MINIMAX_DATA_DIR) { return $env:MINIMAX_DATA_DIR } + if ($env:MAVIS_DATA_DIR) { return $env:MAVIS_DATA_DIR } + # Default for Windows: the user profile .minimax (the mavis install path). + return (Join-Path $env:USERPROFILE '.minimax') +} + +$DataDir = Resolve-DataDir -Override $DataDir + +# 3. Resolve the target path: project-wide or per-agent. +if ($Agent) { + $TargetPath = Join-Path $DataDir "agents\$Agent\hooks\hooks.json" + $TargetDir = Split-Path -Parent $TargetPath + $Scope = "agent=$Agent" +} else { + $TargetPath = Join-Path $DataDir 'hooks\hooks.json' + $TargetDir = Split-Path -Parent $TargetPath + $Scope = 'project-wide' +} + +if (-not (Test-Path -LiteralPath $TargetDir)) { + [void](New-Item -ItemType Directory -Path $TargetDir -Force) +} + +# 4. Atomic copy: stage to a temp file in the same directory, then rename. +# This matches the staging-and-rename pattern used by record.mjs in +# the validator example. PowerShell's Move-Item -Force is essentially +# rename on the same volume and is atomic. +$StagePath = "$TargetPath.stage-$PID" +try { + Copy-Item -LiteralPath $SourcePath -Destination $StagePath -Force + Move-Item -LiteralPath $StagePath -Destination $TargetPath -Force +} catch { + if (Test-Path -LiteralPath $StagePath) { + Remove-Item -LiteralPath $StagePath -Force -ErrorAction SilentlyContinue + } + throw +} + +Write-Host "OK $Scope installed" +Write-Host " source: $SourcePath" +Write-Host " dataDir: $DataDir" +Write-Host " installed: $TargetPath" +Write-Host "" +Write-Host "Note: the @minimax-ai/code@0.3.10+ runtime reads this file at" +Write-Host "session start (verified on @minimax-ai/code@0.3.10 and" +Write-Host "@minimax-ai/code@0.3.11; the contract is byte-identical). The" +Write-Host "Plugin's own io.minimax.mcode/hooks/hooks.json is still kept in" +Write-Host "sync for when the runtime learns to read it, but the runtime" +Write-Host "currently consults only the dataDir path above." +Write-Host "" +Write-Host "Caveat (mcode 0.3.10 / 0.3.11 on Windows): the runtime's hook" +Write-Host "dispatcher spawns commands via /bin/sh -lc, which ENOENTs on" +Write-Host "Windows. The hook config is correct and the install step" +Write-Host "succeeded, but no hook will fire on Windows 0.3.10 / 0.3.11 until" +Write-Host "usePlatformShell: true on Windows. Track the upstream issue" +Write-Host "and use Mode B (detector) in the meantime." diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json index 08ee02e3..d314038d 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json @@ -3,160 +3,146 @@ "hooks": { "SessionStart": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-start.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\session-start.ps1\"", + "timeout": 5 + } + ] } ], "SessionEnd": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-end.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\session-end.ps1\"", + "timeout": 5 + } + ] } ], "UserPromptSubmit": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\user-prompt-submit.ps1\"", + "timeout": 5 + } + ] } ], "PreToolUse": [ { "matcher": "*", - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1" - ], - "timeout": 5000 + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\pre-tool-use.ps1\"", + "timeout": 5 + } + ] } ], "PostToolUse": [ { "matcher": "*", - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/post-tool-use.ps1" - ], - "timeout": 5000 + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\post-tool-use.ps1\"", + "timeout": 5 + } + ] } ], "Stop": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/stop.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\stop.ps1\"", + "timeout": 5 + } + ] } ], "PreCompact": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-compact.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\pre-compact.ps1\"", + "timeout": 5 + } + ] } ], "Notification": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/notification.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\notification.ps1\"", + "timeout": 5 + } + ] } ], "SubagentStart": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-start.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\subagent-start.ps1\"", + "timeout": 5 + } + ] } ], "SubagentStop": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-stop.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\subagent-stop.ps1\"", + "timeout": 5 + } + ] } ], "PermissionRequest": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-request.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\permission-request.ps1\"", + "timeout": 5 + } + ] } ], "PermissionDenied": [ { - "command": "powershell", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-denied.ps1" - ], - "timeout": 5000 + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%PLUGIN_ROOT%\\io.minimax.mcode\\hooks\\scripts\\permission-denied.ps1\"", + "timeout": 5 + } + ] } ] } diff --git a/plugins/antianqi/mcode-island/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 index f20451e7..8545e418 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -131,13 +131,19 @@ $callerData = [PSCustomObject]@{ ts = $ts } -# 原子写 +# 原子写。Encoding.UTF8 = .NET 的 [System.Text.Encoding]::UTF8 +# (静态),它在每个文件开头写 BOM (0xEF 0xBB 0xBF)。PowerShell 的 +# ConvertFrom-Json 能吃 BOM,但 Node / 浏览器 / 其他非 PS 消费者 +# 全部被 BOM 阻断,JSON.parse 报 "Unexpected token \uFEFF" 或 +# "Unexpected token '' is not valid JSON"。New-Object +# System.Text.UTF8Encoding($false) 显式不写 BOM。 +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) $tmpStatus = "$statusFile.tmp" $tmpCaller = "$callerFile.tmp" try { - [System.IO.File]::WriteAllText($tmpStatus, $payload, [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($tmpStatus, $payload, $utf8NoBom) Move-Item -Path $tmpStatus -Destination $statusFile -Force - [System.IO.File]::WriteAllText($tmpCaller, ($callerData | ConvertTo-Json -Compress), [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($tmpCaller, ($callerData | ConvertTo-Json -Compress), $utf8NoBom) Move-Item -Path $tmpCaller -Destination $callerFile -Force Write-Output "OK: $State - $Message" if ($focusInfo) { diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f39491dc..46fb1553 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.3.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", + "version": "0.4.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.4.0 把 Hook 文档迁到 @minimax-ai/code@0.3.10+ runtime 的嵌套 {matcher, hooks:[{type, command, timeout}]} schema(MiniMax-Code-Plugins PR #36,已在 0.3.10 与 0.3.11 字节级一致的两个 release 上验证),并新增 install-hook.ps1:runtime 只读 ${MINIMAX_DATA_DIR}/hooks/hooks.json,不读 plugin.json 里的扩展路径,所以本机要先跑一次 install-hook.ps1 把 hooks.json 复制到 dataDir 下。Mode A(Hook 驱动)目前能 dispatch 的只有 5/12 事件(SessionStart/SessionEnd/UserPromptSubmit/PreToolUse/PostToolUse),其它 7 个仍属 0.3.10 / 0.3.11 Fwe set 之外的 forward 事件;并且 0.3.10 / 0.3.11 runtime 在 Windows 上通过 /bin/sh -lc spawn 命令会 ENOENT,所以 Mode A 在 Windows 0.3.10 / 0.3.11 上目前不会真正触发,需等 runtime 在 Windows 上启用 usePlatformShell(或打本仓库的 hooks/win32-ava-patch/apply.mjs 这个本地补丁)。Mode B(agent 自推 + detector)始终可用。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" @@ -19,11 +19,13 @@ "ui", "dynamic-island", "io.minimax.mcode", - "hooks" + "hooks", + "0.3.10", + "0.3.11" ], "extensions": { "io.minimax.mcode": { - "version": "0.1.0", + "version": "0.2.0", "hooks": "./io.minimax.mcode/hooks/hooks.json" } } diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..9e8dbf44 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -1,11 +1,26 @@ --- name: mcode-island -description: Push the user's terminal out of focus to a Windows desktop Dynamic Island pill so the user can watch your work without switching back to mcode. On mcode 0.2.4+ with the `io.minimax.mcode` Hooks extension enabled (forward-compatible with MiniMax-Code-Plugins PR #20), every tool lifecycle event fires a script under `io.minimax.mcode/hooks/scripts/` automatically — the agent does not need to push states manually. On older mcode or when the extension is not yet active, fall back to calling `notify-island.ps1` before and after each tool call, or use `wrap-tool.ps1` for the bash path. +description: Push the user's terminal out of focus to a Windows desktop Dynamic Island pill so the user can watch your work without switching back to mcode. On mcode 0.3.10+ with the `io.minimax.mcode` Hooks extension enabled, five tool lifecycle events fire scripts under `io.minimax.mcode/hooks/scripts/` automatically — the agent does not need to push states manually. Caveat: on mcode 0.3.10/0.3.11 the runtime spawns commands via `/bin/sh -lc` which ENOENTs on Windows, so Mode A does not actually fire on Windows until the runtime sets `usePlatformShell: true`; until then, fall back to Mode B (call `notify-island.ps1` directly, or apply the local `hooks/win32-ava-patch/apply.mjs` workaround). Run `install-hook.ps1` once after install to materialise the hook document in `${MINIMAX_DATA_DIR}/hooks/hooks.json` (the runtime does not read the Plugin's own `io.minimax.mcode/` path). + +> **Caveat (mcode 0.3.10 and 0.3.11 on Windows):** the 0.3.10/0.3.11 hook +> dispatcher (`Ava` in `@minimax-ai/code@0.3.10`'s `chunk-CTHP2I62.js:6553163` +> and the byte-identical `@minimax-ai/code@0.3.11`'s +> `chunk-P2ZQPHDU.js:6553163`) spawns commands via `/bin/sh -lc` with +> `usePlatformShell: false`. `Node.spawn('/bin/sh', ...)` returns `ENOENT` +> on a stock Windows install (no Git Bash, no MSYS, no WSL shim), so even +> the 5 events that *would* dispatch will not actually fire on Windows +> 0.3.10 or 0.3.11. The hook document is correct and the install step +> succeeds, but no script will run until upstream sets +> `usePlatformShell: true` on Windows (or ships a Windows-aware shell +> wrapper). Track the upstream issue; use Mode B in the meantime. +> The shipped `hooks/win32-ava-patch/apply.mjs` is a local workaround +> that adds the missing `|| process.platform === "win32"` branch so the +> existing Windows-aware shell detector (`bZ` / `YO`) is actually used. license: Apache-2.0 -compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). Hook-driven mode additionally requires mcode 0.2.4+ with the `io.minimax.mcode` extension namespace accepted by the registry validator. +compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). Hook-driven mode additionally requires mcode 0.3.10+ (verified on 0.3.10 and 0.3.11; the hook schema, the `Ava` dispatch wrapper, the `Fwe` allowlist, and the `Uwe` parser are byte-identical between the two releases) with the `io.minimax.mcode` extension namespace accepted by the registry validator, AND `install-hook.ps1` having been run at least once to materialise the hook document in the runtime-resolved dataDir. On Windows 0.3.10/0.3.11, Mode A is currently non-functional without the shipped `hooks/win32-ava-patch/apply.mjs` workaround (an upstream `/bin/sh` dispatch bug); use Mode B if the workaround is not applied. metadata: author: antianqi - version: "0.3.0" + version: "0.4.0" --- # mcode-island — 桌面灵动岛状态通知 @@ -32,45 +47,102 @@ Click the pill to switch focus back to the originating terminal tab. Run ## Two ways to drive the pill -### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) - -When mcode accepts the `io.minimax.mcode` client extension, the runtime spawns -the script under `io.minimax.mcode/hooks/scripts/.ps1` for every matching -lifecycle event. The agent does **not** need to push state manually. - -| event | script | pill state | -| ----------------- | --------------------------------- | ----------- | -| `SessionStart` | `session-start.ps1` | `idle` | -| `SessionEnd` | `session-end.ps1` | `idle` | -| `UserPromptSubmit`| `user-prompt-submit.ps1` | `thinking` | -| `PreToolUse` | `pre-tool-use.ps1` | `working` | -| `PostToolUse` | `post-tool-use.ps1` | `done`/`error` | -| `Stop` | `stop.ps1` | `done` | -| `PreCompact` | `pre-compact.ps1` | `thinking` | -| `Notification` | `notification.ps1` | `idle` | -| `SubagentStart` | `subagent-start.ps1` (CODEX only) | `working` | -| `SubagentStop` | `subagent-stop.ps1` (CODEX only) | `done` | -| `PermissionRequest`| `permission-request.ps1` (returns `{"decision":"allow"}` so the runtime's fail-closed default does not deny) | `waiting` | -| `PermissionDenied`| `permission-denied.ps1` | `error` | - -The hooks conform to the portable spec proposed in -`MiniMax-Code-Plugins` PR #20. Each script reads the JSON event payload from -stdin, calls `notify-island.ps1` with the appropriate state, and exits 0 -(decision-bearing events also write a JSON decision to stdout). Self-push -filtering prevents the pill from churning when the agent calls -`notify-island.ps1` directly through Bash. - -If you are running on mcode 0.2.4+ and the pill is updating itself before you -push anything, Mode A is active. Otherwise fall through to Mode B. +### Mode A — Hook-driven (mcode 0.3.10+ with `io.minimax.mcode`) + +When mcode accepts the `io.minimax.mcode` client extension, the runtime parses +the hook document and spawns the script under +`io.minimax.mcode/hooks/scripts/.ps1` for every matching lifecycle +event. The agent does **not** need to push state manually. + +The 0.3.10/0.3.11 hook parser (`Uwe` in `@minimax-ai/code@0.3.10`'s `chunk-CTHP2I62.js:6523134` and the byte-identical `@minimax-ai/code@0.3.11`'s `chunk-P2ZQPHDU.js:6523134`) expects a **nested +shape** per event: a top-level `{matcher, hooks:[{type, command, timeout}]}` +array, not the flat `{command, args, timeout}` shape that earlier plugin +drafts (PR #20) proposed. The bundled `io.minimax.mcode/hooks/hooks.json` +matches the 0.3.10 schema; the proposal text in `proposals/hooks-detailed-spec.md` +was rewritten in PR #36 to match. + +| event | script | pill state | 0.3.10 / 0.3.11 dispatch | +| ------------------ | --------------------------------- | ------------ | ----------------------- | +| `SessionStart` | `session-start.ps1` | `idle` | yes (`Fwe` set) | +| `SessionEnd` | `session-end.ps1` | `idle` | yes (`Fwe` set) | +| `UserPromptSubmit` | `user-prompt-submit.ps1` | `thinking` | yes (`Fwe` set) | +| `PreToolUse` | `pre-tool-use.ps1` | `working` | yes (`Fwe` set) | +| `PostToolUse` | `post-tool-use.ps1` | `done`/`error` | yes (`Fwe` set) | +| `Stop` | `stop.ps1` | `done` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PreCompact` | `pre-compact.ps1` | `thinking` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `Notification` | `notification.ps1` | `idle` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `SubagentStart` | `subagent-start.ps1` (CODEX only) | `working` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `SubagentStop` | `subagent-stop.ps1` (CODEX only) | `done` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PermissionRequest`| `permission-request.ps1` (returns `{"decision":"ask"}` so the runtime's fail-closed default does not deny) | `waiting` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | +| `PermissionDenied` | `permission-denied.ps1` | `error` | forward — not in 0.3.10 / 0.3.11 `Fwe` set | + +**0.3.10 / 0.3.11 dispatch coverage is 5 / 12 events** — the runtime's `Fwe` +set allowlists exactly the 5 lifecycle events above plus the three stream +events (`MessageComplete`, `StreamChunk`, `StreamChunkThreshold`) that this +plugin does not register (the `Fwe` set is byte-identical in +`@minimax-ai/code@0.3.10`'s `chunk-CTHP2I62.js:1843` and +`@minimax-ai/code@0.3.11`'s `chunk-P2ZQPHDU.js:1843`). The other 7 events +are *forward-only* on 0.3.10 / 0.3.11: the `.ps1` files ship and the JSON +is valid, but the runtime silently skips them because the event name is +outside `Fwe`. When a future mcode release grows `Fwe`, those scripts +start firing with zero code change here. + +Each script reads the JSON event payload from stdin, calls `notify-island.ps1` +with the appropriate state, and exits 0 (decision-bearing events also write a +JSON decision to stdout). Self-push filtering prevents the pill from churning +when the agent calls `notify-island.ps1` directly through Bash. + +**The Plugin's `io.minimax.mcode/hooks/hooks.json` alone is not enough.** The +0.3.10 / 0.3.11 hook-config parser reads only +`${MINIMAX_DATA_DIR}/hooks/hooks.json` (project-wide) and +`${MINIMAX_DATA_DIR}/agents//hooks/hooks.json` (per-agent). It does +not consult `plugin.json`'s `extensions.io.minimax.mcode.hooks` field, even +though the Plugin registry accepts the namespace. Run `install-hook.ps1` +once after install to copy the bundled document into the runtime-resolved +dataDir (it is idempotent and safe to re-run after every mcode upgrade): + +```powershell +& "\install-hook.ps1" # project-wide +& "\install-hook.ps1" -Agent mavis # per-agent +``` + +The script also accepts `-DataDir ` to override `${MINIMAX_DATA_DIR}` +when the env var is not set. It is portable: it does not write any absolute +path into the document, it uses `%PLUGIN_ROOT%` in the spawned commands so +that whatever install location the Plugin landed in is the source of truth +once the runtime learns to read it. + +> **Caveat (mcode 0.3.10 / 0.3.11 on Windows):** the 0.3.10 / 0.3.11 hook +> dispatcher (`Ava` in `@minimax-ai/code@0.3.10`'s `chunk-CTHP2I62.js:6553163` +> and the byte-identical `@minimax-ai/code@0.3.11`'s +> `chunk-P2ZQPHDU.js:6553163`) spawns commands via `/bin/sh -lc ` +> even on Windows, with `usePlatformShell: false` as the default. +> `Node.spawn('/bin/sh', ...)` returns `ENOENT` on a stock Windows install +> (no Git Bash, no MSYS, no WSL shim), so even the 5 events that *would* +> dispatch will not actually fire on Windows 0.3.10 / 0.3.11. The hook +> document is correct and the install step succeeds, but no script will +> run until upstream sets `usePlatformShell: true` on Windows (or ships +> a Windows-aware shell wrapper). The shipped +> `hooks/win32-ava-patch/apply.mjs` is a local-only patch that adds the +> missing `|| process.platform === "win32"` branch so the existing +> Windows-aware shell detector (`bZ` / `YO`) is actually used; apply it +> once and re-apply after every `npm install -g @minimax-ai/code`. Track +> the upstream issue; use Mode B in the meantime if the patch is not +> applied. + +If you are running on a fixed runtime and the pill is updating itself before +you push anything, Mode A is active. Otherwise fall through to Mode B. ### Mode B — Agent-pushed (legacy, always works) For older mcode, or when the `io.minimax.mcode` extension is not yet active -(registry validator has not accepted the namespace), the agent pushes state -through `notify-island.ps1` directly. The `mcode-status-detect.ps1` detector -also infers state from the runtime's `ledger.jsonl` / `messages.jsonl`, so -the pill will still move — your manual pushes just sharpen the message and -cover edge cases (notably `ask_user`). +(registry validator has not accepted the namespace), or on Windows 0.3.10 / +0.3.11 where the runtime's `/bin/sh` spawn is broken (and +`hooks/win32-ava-patch/apply.mjs` has not been applied), the agent pushes +state through `notify-island.ps1` directly. The `mcode-status-detect.ps1` +detector also infers state from the runtime's `ledger.jsonl` / +`messages.jsonl`, so the pill will still move — your manual pushes just +sharpen the message and cover edge cases (notably `ask_user`). | moment | state | example message | | ----------------------------------------------------- | --------- | ------------------------------ | @@ -189,7 +261,7 @@ When no token is configured the plugin makes no network requests at all. ``` mcode-island/ -├── plugin.json # plugin manifest +├── plugin.json # plugin manifest (v0.4.0) ├── README.md # full user-facing docs ├── LICENSE # Apache-2.0 ├── mcode-island.ps1 # WPF widget main loop @@ -200,12 +272,19 @@ mcode-island/ ├── show-island.ps1 # re-raise hidden widget ├── pin-island.ps1 # lock focus target to foreground ├── autostart.ps1 # register/unregister Windows logon +├── install-hook.ps1 # copy hooks.json into ${MINIMAX_DATA_DIR} (Mode A, 0.3.10) +├── hooks/ +│ └── win32-ava-patch/ # Windows 0.3.10 runtime workaround (see README) +│ ├── apply.mjs # idempotent in-place patch +│ ├── restore.mjs # undo using .bak file +│ ├── diff.txt # exact byte-level diff +│ └── README.md ├── notify-island.ps1 # state-push helper (agents call this) ├── wrap-tool.ps1 # all-in-one bash wrapper ├── mcode-status-detect.ps1 # runtime-state detector -├── io.minimax.mcode/ # client extension (PR #20 spec) +├── io.minimax.mcode/ # client extension (PR #36 spec, 0.3.10 nested schema) │ └── hooks/ -│ ├── hooks.json # 12-event declaration +│ ├── hooks.json # 12-event nested declaration │ └── scripts/ │ ├── _lib.ps1 # shared helper │ ├── session-start.ps1 @@ -228,11 +307,31 @@ mcode-island/ - Windows 10/11 only (uses WPF, `user32`, and `kernel32` P/Invoke). - Single widget per user session. -- Hook-driven mode requires mcode 0.2.4+ Runtime. The portable spec - (`io.minimax.mcode` client extension) is still pending merge in - `MiniMax-Code-Plugins` PR #20; until the registry validator accepts the - namespace, the hooks subdirectory is dormant and the plugin falls back to - Mode B (agent-pushed + detector). +- Hook-driven mode requires mcode 0.3.10+ Runtime. The portable spec + (`io.minimax.mcode` client extension) was aligned with the 0.3.10 nested + shape in `MiniMax-Code-Plugins` PR #36; until the registry validator + accepts the namespace, the hooks subdirectory is dormant and the plugin + falls back to Mode B (agent-pushed + detector). +- On mcode 0.3.10 only 5 / 12 events dispatch (`SessionStart`, `SessionEnd`, + `UserPromptSubmit`, `PreToolUse`, `PostToolUse`). The other 7 are forward + events that the 0.3.10 `Fwe` set does not yet include; the `.ps1` files + ship and will start firing when a future mcode release grows `Fwe`. +- On Windows 0.3.10, even those 5 events do not actually fire: the runtime + spawns commands via `/bin/sh -lc` which ENOENTs on a stock Windows + install. The hook document is correct and `install-hook.ps1` succeeds, but + no script will run until upstream sets `usePlatformShell: true` on Windows. + Track the upstream issue; use Mode B in the meantime. + A local workaround is shipped at + `hooks/win32-ava-patch/apply.mjs` — it adds the missing + `process.platform === "win32"` branch in the runtime's `Ava` spawn wrapper + (single line, +30 bytes, idempotent) so the existing Windows-aware shell + detector (`bZ` / `YO`) is actually used. See `hooks/win32-ava-patch/README.md` + for the full procedure. Re-run after every `npm install -g @minimax-ai/code`. +- `install-hook.ps1` only materialises the hook document in + `${MINIMAX_DATA_DIR}/hooks/hooks.json`. Until the runtime learns to read + `plugin.json`'s `extensions.io.minimax.mcode.hooks` field, you must run + it once after install (and after every mcode upgrade that changes the + bundled document). - No hover-expand, no media-control integration yet — see the `v0.2` roadmap in the upstream issue tracker. - `wrap-tool.ps1` is a **status publisher only** — it never executes the