Skip to content

mcode-island v0.3.0: add io.minimax.mcode Hooks extension (forward-compat with PR #20) #3

mcode-island v0.3.0: add io.minimax.mcode Hooks extension (forward-compat with PR #20)

mcode-island v0.3.0: add io.minimax.mcode Hooks extension (forward-compat with PR #20) #3

name: mcode-island (windows-latest)
on:
pull_request:
paths:
- 'plugins/antianqi/mcode-island/**'
- '.github/workflows/mcode-island-windows.yml'
push:
branches: [main]
paths:
- 'plugins/antianqi/mcode-island/**'
- '.github/workflows/mcode-island-windows.yml'
# Manual dispatch: lets a maintainer / the PR author trigger the
# same windows-latest job outside a PR. Used to capture a
# github-hosted green check on the fork (the fork-to-upstream PR
# itself cannot trigger Actions without explicit maintainer
# approval, and first-time-contributor protection is on). Mirrors
# what `add-tool-map`'s `tool-map-windows.yml` did in commit
# `e777e3c` for PR #5.
workflow_dispatch:
# Round-5 review (hetaoBackend, 2026-08-28T08:22:25Z) on commit 38413d9:
# "The remaining blocker is executable platform evidence. This is a
# Windows/PowerShell/WPF/Win32 plugin ... but the PR adds no workflow
# and this head has no Actions run. The Node smoke is static and does
# not execute the PowerShell scripts. Please add a windows-latest job
# that at minimum parses all `.ps1` files and exercises token set/show/clear
# in an isolated data directory, mocked usage-API behavior, and hook
# stdin/stdout paths without opening the real UI."
#
# This workflow exercises those four contract surfaces on windows-latest
# without requiring a desktop session, a real OAuth token, or a real
# network round-trip to api.minimaxi.com. It does not open the WPF UI
# (no explorer.exe, no logon session) and does not run the
# mcode-status-detect.ps1 main loop (which would block for 60s+ in
# CI and require a real mcode install). The detector's 5h usage path
# is exercised in step 4 by re-using the same token + URL the detector
# uses, pointed at a localhost HttpListener (in a Start-Job, sync
# wait) that records the Authorization header and returns a synthetic
# model_remains JSON.
#
# `[code]smith` is SKIPPED on this repository, so this windows-latest
# job is the CI evidence for the round-5 review.
permissions:
contents: read
jobs:
mcode-island-windows:
name: mcode-island on windows-latest (parse + token + hook + mock-API)
runs-on: windows-latest
timeout-minutes: 10
defaults:
run:
shell: pwsh
steps:
- name: Checkout
uses: actions/checkout@v4
# 1) Parse all .ps1 files. Static syntax check; if any .ps1
# fails to parse, CI fails. A future change that introduces
# a PowerShell syntax error anywhere in the plugin (main
# script, hooks/scripts/*.ps1, set-token, notify-island,
# detector, ...) will fail this step. Negative-injection:
# try adding a stray `}` to any .ps1 and this step fails.
- name: Parse all .ps1 files (round-5 requirement #1)
run: |
$root = Resolve-Path 'plugins/antianqi/mcode-island'
$files = @(Get-ChildItem -Path $root -Recurse -Filter *.ps1)
if ($files.Count -eq 0) { throw "No .ps1 files found under $root" }
Write-Host "Parsing $($files.Count) .ps1 files under $root..."
$bad = 0
foreach ($f in $files) {
$errs = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs)
if ($errs -and $errs.Count -gt 0) {
$rel = $f.FullName.Substring($root.Path.Length + 1) -replace '\\', '/'
Write-Host "PARSE FAIL: $rel"
$errs | ForEach-Object {
Write-Host " line $($_.Extent.StartLineNumber):col $($_.Extent.StartColumnNumber) $($_.Message)"
}
$bad++
}
}
if ($bad -gt 0) { throw "$bad / $($files.Count) .ps1 files failed to parse" }
Write-Host "OK: $($files.Count) .ps1 files parsed without syntax errors"
# 2) Token set/show/clear in an isolated data directory. We
# redirect $env:APPDATA in **this step's process** (not
# just to GITHUB_ENV for future steps), so the
# set-token.ps1 invocations below write into
# $RUNNER_TEMP\mcode-island-apphome\ instead of the
# runner's real APPDATA. The detector's
# $APPDATA\mcode-island\config.json path is followed
# exactly; only the root is swapped.
- name: Token set / show / clear roundtrip in isolated APPDATA (round-5 requirement #2)
env:
FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef'
run: |
# Force UTF-8 in the parent so the round-trip
# parent.write -> child.stdout -> child.Console ->
# parent.capture chain survives the
# PowerShell-5.1-on-non-UTF-8-system codepage trap
# (children inherit the parent's [Console]::OutputEncoding
# at process start; if parent is cp1252/GBK and child
# sets UTF-8 internally, captured strings can be
# truncated on the way back).
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
try { chcp 65001 | Out-Null } catch {}
$apphome = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP 'mcode-island-apphome') -Force
$env:APPDATA = $apphome.FullName
# Also export to GITHUB_ENV so step 3 (hook) and step 4
# (mock usage-API) inherit the same isolated APPDATA.
"APPDATA=$($apphome.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "Isolated APPDATA: $($apphome.FullName)"
# Defensive: unset any pre-existing MINIMAX_OAUTH_TOKEN
# / MINIMAX_API_KEY so the set-token show step below
# must report the config.json source (its fallback
# contract). GitHub Actions does not export these by
# default, but a future PR could add a workflow-level
# env: that pollutes this test. Step 4 sets
# MINIMAX_OAUTH_TOKEN explicitly for its own test.
foreach ($name in 'MINIMAX_OAUTH_TOKEN', 'MINIMAX_API_KEY') {
if (Test-Path "env:$name") { Remove-Item "env:$name" -ErrorAction SilentlyContinue }
}
$set = 'plugins/antianqi/mcode-island/set-token.ps1'
# 2a) set: write token
$r1 = (& $set $env:FAKE_TOKEN | Out-String).Trim()
if ($r1 -notmatch '^已写入') { throw "set: expected '已写入' header, got: $r1" }
$cfgFile = Join-Path $apphome.FullName 'mcode-island\config.json'
if (-not (Test-Path $cfgFile)) { throw "set: $cfgFile not written" }
$cfg = Get-Content $cfgFile -Raw | ConvertFrom-Json
if ($cfg.planApiToken -ne $env:FAKE_TOKEN) {
throw "set: config.json planApiToken mismatch (got: $($cfg.planApiToken))"
}
# 2b) show: verify it reports the config.json source + masked prefix
$r2 = (& $set -Show | Out-String).Trim()
if ($r2 -notmatch 'config\.json planApiToken') {
throw "show after set: expected 'config.json planApiToken', got: $r2"
}
$expectedMask = ($env:FAKE_TOKEN).Substring(0, [Math]::Min(10, $env:FAKE_TOKEN.Length)) + '\.\.\.'
if ($r2 -notmatch $expectedMask) {
throw "show after set: expected masked prefix matching '$expectedMask', got: $r2"
}
# 2c) clear: remove token
$r3 = (& $set -Clear | Out-String).Trim()
if ($r3 -notmatch '已从 config\.json 删除') { throw "clear: expected '已从 config.json 删除', got: $r3" }
$cfgAfter = Get-Content $cfgFile -Raw | ConvertFrom-Json
if ($cfgAfter.PSObject.Properties['planApiToken']) {
throw "clear: planApiToken still present in config.json"
}
# 2d) show: verify it reports the unconfigured state
$r4 = (& $set -Show | Out-String).Trim()
if ($r4 -ne 'token 未配置') { throw "show after clear: expected 'token 未配置', got: $r4" }
Write-Host "Token set/show/clear roundtrip OK (4 / 4 checks)"
# 3) Hook stdin/stdout: pipe a synthetic PreToolUse event into
# the bundled io.minimax.mcode/hooks/scripts/pre-tool-use.ps1
# hook entry. The hook reads the JSON event from stdin
# (_lib.ps1 Read-HookStdin), formats the tool summary, and
# pushes a `working` state to status.json via notify-island.
# The push is asserted by reading back
# $APPDATA\mcode-island\status.json (the same path the
# WPF widget polls at runtime). Negative-injection: change
# pre-tool-use.ps1 to push a wrong state, this step fails.
#
# PowerShell 5.1 caveat: `$string | & script.ps1` does
# NOT rewire the child process's stdin; only stdout/stderr
# cross the pipeline. We must launch the hook as a real
# child process with an explicit -RedirectStandardInput
# so [Console]::In.ReadToEnd() inside the hook sees the
# JSON. The CI's isolated APPDATA (set in step 2) is
# inherited via $env:APPDATA below.
- name: Hook stdin / stdout (PreToolUse) writes status.json (round-5 requirement #4)
run: |
$hook = 'plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1'
$stdinFile = Join-Path $env:RUNNER_TEMP 'hook-stdin-pretooluse.json'
# Build the JSON as a single-line PowerShell single-quoted
# string instead of using a here-doc. A here-doc (`@'...'@`)
# in this `run: |` YAML block triggered a YAML parse error
# in the v1 commit: the leading `@'` after a `run: |` block
# scalar confused js-yaml (it tried to treat `@'` as a
# block-scalar start, then ran into a `}` / `,` and a `\`
# backslash on the same line and gave up at line 187).
# The single-line string is a 1:1 content match for the
# previous here-doc body and is portable across the YAML
# parser GitHub Actions uses.
$stdinJson = '{"session_id":"ci-fake-session","transcript_path":"C:\\fake\\transcript","cwd":"C:\\fake\\cwd","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo ci-pretooluse-test"}}'
Set-Content -Path $stdinFile -Value $stdinJson -Encoding utf8 -NoNewline
$p = Start-Process -FilePath 'powershell' `
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $hook) `
-NoNewWindow -RedirectStandardInput $stdinFile `
-PassThru
$p.WaitForExit()
if ($p.ExitCode -ne 0) { throw "pre-tool-use.ps1 exited with code $($p.ExitCode)" }
$statusFile = Join-Path $env:APPDATA 'mcode-island\status.json'
if (-not (Test-Path $statusFile)) { throw "hook did not write $statusFile" }
$status = Get-Content $statusFile -Raw | ConvertFrom-Json
if ($status.state -ne 'working') { throw "status.state: got '$($status.state)' (want 'working')" }
if ($status.source -ne 'agent') { throw "status.source: got '$($status.source)' (want 'agent' -- hook push is agent-sourced)" }
if ($status.message -notmatch '^Bash\s*:') { throw "status.message: got '$($status.message)' (want to start with 'Bash :')" }
if ($status.message -notmatch 'ci-pretooluse-test') {
throw "status.message: got '$($status.message)' (want to contain 'ci-pretooluse-test')"
}
Write-Host "Hook PreToolUse OK: state=$($status.state) source=$($status.source) message='$($status.message)'"
# 4) Get-5hUsage via dot-source + matching fixture + token
# source precedence (amszuidas round-8 P2 fix on
# 2026-09-07T03:17:02Z, head 812dd29).
#
# Round-5 step 4 (this step's predecessor) reconstructed
# the URL and headers by hand and called Invoke-RestMethod
# directly, with a fixture that used field names the
# implementation does NOT read (model/remainingPct/resetMs
# vs model_name/current_interval_remaining_percent/remains_time).
# That meant: a future change to the implementation's
# field-name contract would NOT fail this CI step, because
# the CI step never went through the implementation. This
# round-9 step fixes that by dot-sourcing the file with
# -Once (so the main loop runs exactly once and breaks,
# per the `$Once` switch in the file) and then calling
# `Get-5hUsage` directly. The mock server returns a
# payload with the SAME field names the implementation
# reads, so a future field-name regression will produce
# `null` here and fail the assertion. Token-source
# precedence is covered by three sub-tests: env-var wins
# over config.json, config.json alone works, no token
# returns $null.
#
# Dot-sourcing -Once: the file's main loop
# (line 519-641) is wrapped in `if (-not $Once) { ... }`
# for the pidFile write, and the inner `while ($true)`
# breaks on the first iteration when `$Once` is true
# (line 639). On that one iteration `Get-5hUsage` is
# called from `Refresh-5hUsage` (line 609), but
# `$script:plan5hToken` is still null at that point
# (this step sets it just before calling
# `Get-5hUsage` below), so the call returns $null at
# line 419 without touching the network. The single
# loop iteration also writes `$APPDATA/mcode-island/status.json`
# via `Write-Status`, which is fine: the isolated
# APPDATA from step 2 is in $env:APPDATA, so the
# write goes to the runner temp, not the real
# machine's APPDATA.
- name: Get-5hUsage via dot-source + matching fixture + token-source precedence (round-8 P2 fix)
env:
FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef'
run: |
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
try { chcp 65001 | Out-Null } catch {}
$psPath = 'plugins/antianqi/mcode-island/mcode-status-detect.ps1'
if (-not (Test-Path $psPath)) { throw "$psPath not found" }
# Dot-source with -Once: imports all functions, the
# main loop runs once and breaks (line 639 in
# mcode-status-detect.ps1).
. $psPath -Once
# Pick a free port for the mock listener.
$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$probe.Start()
$freePort = [int]$probe.LocalEndpoint.Port
$probe.Stop()
Write-Host "Picked free port: $freePort"
# Redirect the module-scope $PLAN_API_HOST to our
# mock. The file's byte-array `_s` helper has already
# resolved the real https URL into this var; we
# reassign to the localhost mock listener. $PLAN_API_PATH
# stays as /v1/coding_plan/remains, which is the
# same path the implementation requests.
$script:PLAN_API_HOST = "http://127.0.0.1:$freePort"
# Fixture body. Field names MATCH what Get-5hUsage
# actually reads (model_name / current_interval_remaining_percent
# / remains_time), NOT the round-5 fixture's
# (model / remainingPct / resetMs). A future change
# that breaks the field-name contract will cause the
# function to return $null at line 432 and fail the
# assertion below.
$fixtureBody = '{"model_remains":[{"model_name":"general","current_interval_remaining_percent":84,"remains_time":16200000}]}'
# Mock-server helper: one Start-Job per test, each
# job handles exactly one request and returns the
# captured Authorization header + path. Using one
# job per test (rather than one job for all three
# tests) keeps each test independent: a failure in
# test a cannot corrupt the mock for tests b or c.
function Run-MockOneRequest {
param($port, $body)
$j = Start-Job -ScriptBlock {
param($p, $b)
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://127.0.0.1:$p/")
$listener.Start()
try {
$ctx = $listener.GetContext()
$auth = $ctx.Request.Headers['Authorization']
$path = $ctx.Request.Url.AbsolutePath
$bytes = [System.Text.Encoding]::UTF8.GetBytes($b)
$ctx.Response.StatusCode = 200
$ctx.Response.ContentType = 'application/json'
$ctx.Response.ContentLength64 = $bytes.Length
$ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$ctx.Response.Close()
[PSCustomObject]@{ auth = $auth; path = $path }
} finally {
$listener.Stop()
$listener.Close()
}
} -ArgumentList $port, $body
return ,$j
}
# Test a: env-var token wins; implementation must
# read the new field names; return value must be
# the documented `{ remainingPct, resetMs }` shape.
$jobA = Run-MockOneRequest $freePort $fixtureBody
try {
$env:MINIMAX_OAUTH_TOKEN = $env:FAKE_TOKEN
$script:plan5hToken = $env:FAKE_TOKEN
$data = Get-5hUsage
$mock = $jobA | Wait-Job -Timeout 15 | Receive-Job
if (-not $mock) { throw "test a: listener did not complete (state $($jobA.State))" }
if ($mock.auth -ne "Bearer $env:FAKE_TOKEN") {
throw "test a: Authorization was '$($mock.auth)' (want 'Bearer $env:FAKE_TOKEN')"
}
if ($mock.path -ne '/v1/coding_plan/remains') {
throw "test a: path was '$($mock.path)' (want '/v1/coding_plan/remains')"
}
if ($null -eq $data) {
throw "test a: Get-5hUsage returned null with the env-var token set (fixture field-name contract is broken)"
}
if ($data.remainingPct -ne 84) {
throw "test a: remainingPct=$($data.remainingPct) (want 84)"
}
if ($data.resetMs -ne 16200000) {
throw "test a: resetMs=$($data.resetMs) (want 16200000)"
}
Write-Host "test a (env token, matching fixture): OK remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)"
} finally {
if ($jobA.State -ne 'Completed') { Stop-Job $jobA }
Remove-Job $jobA -Force
}
# Test b: clear env vars, only config.json has a
# token. Re-deriving $script:plan5hToken from
# config.json the same way the file's top-level
# init does (line 124-125). Get-5hUsage must pick
# up the config.json token.
$cfgDir = Join-Path $env:APPDATA 'mcode-island'
if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null }
$cfgToken = 'ci-cfg-only-token-abcdef0123456789'
@{ planApiToken = $cfgToken } | ConvertTo-Json |
Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8
Remove-Item 'env:MINIMAX_OAUTH_TOKEN' -ErrorAction SilentlyContinue
Remove-Item 'env:MINIMAX_API_KEY' -ErrorAction SilentlyContinue
$cfg = Get-Content (Join-Path $cfgDir 'config.json') -Raw | ConvertFrom-Json
$script:plan5hToken = $null
if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) {
$script:plan5hToken = [string]$cfg.planApiToken
}
if ($script:plan5hToken -ne $cfgToken) {
throw "test b: config.json planApiToken was not picked up (got '$($script:plan5hToken)')"
}
$jobB = Run-MockOneRequest $freePort $fixtureBody
try {
$data = Get-5hUsage
$mock = $jobB | Wait-Job -Timeout 15 | Receive-Job
if (-not $mock) { throw "test b: listener did not complete (state $($jobB.State))" }
if ($mock.auth -ne "Bearer $cfgToken") {
throw "test b: Authorization was '$($mock.auth)' (want 'Bearer $cfgToken')"
}
if ($null -eq $data) { throw "test b: Get-5hUsage returned null with config.json token" }
if ($data.remainingPct -ne 84) { throw "test b: remainingPct=$($data.remainingPct) (want 84)" }
if ($data.resetMs -ne 16200000) { throw "test b: resetMs=$($data.resetMs) (want 16200000)" }
Write-Host "test b (config-only token): OK remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)"
} finally {
if ($jobB.State -ne 'Completed') { Stop-Job $jobB }
Remove-Job $jobB -Force
}
# Test c: no token anywhere (env vars cleared,
# config.json removed) -> Get-5hUsage returns null
# at line 419 without hitting the network.
Remove-Item (Join-Path $cfgDir 'config.json') -Force -ErrorAction SilentlyContinue
$script:plan5hToken = $null
$data = Get-5hUsage
if ($null -ne $data) {
throw "test c: Get-5hUsage should return null with no token, got: $data"
}
Write-Host "test c (no token): OK returned null"
Write-Host "Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK"