feat(mcode-island)!: rewrite plugin manifest for mcode 0.4.0+ runtime (v1.0.0) #10
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-11 | |
| # refactor: dot-source the lib, not the full detector). | |
| # | |
| # 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. The | |
| # round-9 fix dot-sourced the full detector with `-Once` | |
| # so `Get-5hUsage` ran for real. That regressed in CI | |
| # (run 34139430883): the detector's top-level init | |
| # requires mcode to be installed (`Find-McodeRoot` exits | |
| # 2 if no `.minimax-code` is found), and a github-hosted | |
| # windows-latest runner has no mcode. Round-11 extracts | |
| # `Get-5hUsage` + its URL constants into | |
| # `plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1`, | |
| # a self-contained module with no mcode-install-root | |
| # dependency. This step dot-sources the lib directly. | |
| # | |
| # Negative-injection: any of the following will fail this | |
| # step and surface in CI before the PR can be merged. | |
| # - lib file deleted -> `Get-5hUsage` is | |
| # not recognized | |
| # - URL byte-array corrupted -> mock receives wrong | |
| # path (test a asserts | |
| # path = '/v1/coding_plan/remains') | |
| # - field-name regression -> fixture returns wrong | |
| # field names, function | |
| # returns $null (test a | |
| # asserts remainingPct=84) | |
| # - $PLAN_API_HOST redirect | |
| # removed in this step -> mock never gets a | |
| # request, listener | |
| # times out (test a | |
| # asserts listener | |
| # completed) | |
| - name: Get-5hUsage via lib dot-source + matching fixture + token-source precedence (round-11 refactor) | |
| 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 {} | |
| # Dot-source the lib (NOT the full detector). The lib is | |
| # self-contained: Get-5hUsage + URL/host constants, no | |
| # mcode-install-root check, no main loop. CI runners do | |
| # not have mcode installed; the full detector would fail | |
| # at the install-root check. See the file's header comment | |
| # for the design rationale. | |
| $libPath = 'plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1' | |
| if (-not (Test-Path $libPath)) { throw "$libPath not found (round-11 lib must exist)" } | |
| . $libPath | |
| # 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 script-scope $PLAN_API_HOST to our | |
| # mock. The lib'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 | |
| # in scripts/lib/Get-5hUsage.ps1 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 (the foreach exits without | |
| # finding `model_name == 'general'`) 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" |