mcode-island v0.3.0: add io.minimax.mcode Hooks extension (forward-compat with PR #20) #2
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ConvertTo-Json -Compress)" | |
| } | |
| Write-Host "test c (no token): OK returned `$null`" | |
| Write-Host "Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK" |