Repository navigation
413 lines (387 loc) · 21.8 KB
/
Copy pathmcode-island-windows.yml
File metadata and controls
413 lines (387 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
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"