feat: winnode CLI for invoking node commands over local MCP - #250
Conversation
Mirrors `openclaw nodes invoke`'s flag surface but routes to the local tray's MCP HTTP server (default http://127.0.0.1:8765/) instead of the gateway. `--node` and `--idempotency-key` are accepted for paste-from- gateway parity and ignored. Ships skill.md alongside winnode.exe documenting every supported command, argument schema, and the A2UI v0.8 JSONL grammar for agent use. Tests: 62 cases, 100% line/branch on CliRunner via in-process unit tests plus a loopback HttpListener fake that exercises the full HTTP path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InitializeAsync would return ready as soon as `GET /` returned 200, even if `mcp-token.txt` had not been read yet. Against a tray binary built before the auth-before-dispatch hardening (where `GET /` answers 200 without auth), this raced ahead and handed back a tokenless `Client` — every subsequent POST then 401'd. Restructure the loop to require both the token-on-disk and a 200 from a token-bearing GET before declaring ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CLI now sends `Authorization: Bearer <token>` on every MCP request,
without the user having to plumb the token themselves. Resolution chain
mirrors the per-tool secret convention (gh, az, anthropic):
1. `--mcp-token <literal>` flag
2. `OPENCLAW_MCP_TOKEN` env var (literal)
3. `mcp-token.txt` under `$OPENCLAW_TRAY_DATA_DIR` if set, else
`%APPDATA%\OpenClawTray\` — the same location SettingsManager
points the tray at, so a sandboxed tray is found automatically.
When the token comes from disk, run `McpAuthToken.VerifyAcl` (the same
hygiene check `NodeService.StartMcpServer` runs at startup) and route
any owner/DACL warning to stderr so the user knows to rotate. `--verbose`
reports the resolved auth source without echoing the secret value.
Tests redirect via `OPENCLAW_TRAY_DATA_DIR` to a temp sandbox dir so they
don't pick up the developer machine's real tray token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hardens the winnode CLI against the threat model in
C:/temp/winnode-cli-review-2026-04-30/01-findings.md. F-15 (port-0 nit)
was approved as no-action; F-17 was a positive observation.
- F-01/F-09: validate --mcp-url; refuse auto-loaded token off-loopback
- F-02: explicit SocketsHttpHandler with AllowAutoRedirect=false
- F-03: cap response body at 16 MiB with explicit overflow message
- F-04: warn unconditionally when --mcp-token is used (process-listing leak)
- F-05: warn unconditionally when --idempotency-key is supplied
- F-06: TokenLooksValid ASCII-printable check; ignore corrupt tokens
- F-07: don't echo full token-file path in --verbose
- F-08: canonicalize OPENCLAW_TRAY_DATA_DIR; reject symlink redirect
- F-10: RunAsyncTests is now IDisposable (cleans up sandbox dir)
- F-11: SkillMdDriftTests + REGENERATE-ME header in skill.md;
McpToolBridge.KnownCommands exposes the canonical command set;
skill.md re-synced with live capability surface
- F-12: --params @<path> loads JSON object from disk
- F-13: Token_file_with_wide_acl_emits_warn (Windows-only, gracefully
skips when SetAccessControl is denied by hardened CI)
- F-14: BuildToolsCallBody returns (byte[], int) consumed by
ByteArrayContent without a string round-trip
- F-16+F-21: SanitizeForStderr strips control chars, redacts ≥32-char
base64url runs, caps at 4 KiB, default-quiet first-line-only,
full sanitized body under --verbose
- F-18: --invoke-timeout capped at 600000 ms; long arithmetic on the
+5000 buffer; out-of-range exits 2
- F-19: --mcp-port and OPENCLAW_MCP_PORT bounded [1, 65535]; env-var
out-of-range falls back to default with a verbose warning
- F-20: distinguish missing/empty/unreadable/loaded token-file states;
unreadable exits 1 with a diagnostic before any HTTP traffic
Tests: 23 added (115/115 pass). All other suites stay green
(Shared 1046/1066, Tray 245/245, Integration 18/18, UI 62/62).
WinNode CLI line coverage: 91.6% (434/474 in Program.cs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed Headline gates added:
Test results (all green, run locally on Windows with the tray running):
WinNode CLI line coverage: 91.6% (434 / 474 in Follow-up worth flagging: |

Summary
Adds
winnode, a small CLI for invoking OpenClaw node commands against the local Windows tray over MCP, plus the auth/security plumbing the CLI needs to talk to a hardened tray. Branch contains three commits:cdbd9e9feat: winnode CLI for invoking node commands over local MCPOpenClaw.WinNode.Cliproject, ships aswinnode.exenext to its agent skill reference. Mirrors theopenclaw nodes invokeflag surface.26babc2fix(test): gate MCP readiness on token-bearing clientTrayAppFixture.InitializeAsyncagainst theGET /200-without-auth race that was masking integration-test breakage on stale tray binaries.146a042feat(winnode): auto-load MCP bearer tokenMcpAuthToken.VerifyAclon the on-disk file, and routes warnings to stderr.What
winnodedoesopenclaw nodes invokeso existing skills and call sites keep working.--nodeand--idempotency-keyare accepted for parity but ignored — calls always target the local tray on127.0.0.1:8765(override via--mcp-url,--mcp-port, orOPENCLAW_MCP_PORT).tools/callenvelope, POSTs it to the MCP HTTP server, surfaces tool errors on stderr (exit 1) and pretty-prints the capability payload on stdout.skill.mdnext to the exe so an agent drivingwinnodehas the catalog of supported commands and the A2UI v0.8 grammar inline.Works end-to-end against a running tray:
Bearer-token auto-loading
The MCP server requires a bearer token on every request (per
424f690 fix(security): require MCP auth before method dispatch). Rather than make every caller plumb the token by hand, the CLI resolves it automatically — same per-tool secret patterngh,az, andanthropicuse:--mcp-token <literal>flag.OPENCLAW_MCP_TOKENenv var (literal token, not a path).mcp-token.txtunder$OPENCLAW_TRAY_DATA_DIRif set, else%APPDATA%\OpenClawTray\— the same pathSettingsManager.SettingsDirectoryPathresolves for the tray, so a sandboxed tray instance is found automatically and the integration-test fixture can sandbox both sides with one env var.When the token comes from disk, the CLI runs
McpAuthToken.VerifyAcl(path)— the same hygiene checkNodeService.StartMcpServerruns at startup — and routes any owner-mismatch / DACL-grants-outside-{user,SYSTEM,Administrators} warning to stderr.--verbosereports the resolved auth source (bearer (--mcp-token),bearer (OPENCLAW_MCP_TOKEN),bearer (file:<path>), ornone) without ever echoing the secret value itself.Integration-test fixture fix
TrayAppFixture.InitializeAsyncpollsGET /to confirm the tray is up, then re-issues the JSON-RPCClientwith the bearer token frommcp-token.txt. The previous loop returned ready as soon asGET /answered 200 — even if the token file hadn't been read yet. Against a tray binary built before the auth-before-dispatch fix (whereGET /returns 200 without auth), this raced ahead with a tokenlessClient, and every subsequent POST 401'd. New shape requires both:mcp-token.txtis on disk and readable.GET /returns 200 with that token in the header.Either condition alone is no longer sufficient.
Test results
All five suites pass on the dev machine:
WinNode CLI code coverage
Collected with
dotnet-coverage(cross-process collector, captures spawned subprocesses) over the full test run, then filtered to thewinnodeassembly withreportgenerator.Per-class:
OpenClaw.WinNode.Cli.CliRunner— 98.8% (every public/internal method covered; the missed lines are minor edge cases in verbose logging and the unusedhttpHandlerinjection seam).OpenClaw.WinNode.Cli.WinNodeOptions— 100%.OpenClaw.WinNode.Cli.Program— 0% (5-lineMainshim that delegates straight toCliRunner.RunAsync; tests bypass it for hermeticity).Test plan
dotnet test openclaw-windows-node.slnx -c Debugpasses locally (Windows; UI tests need-r win-x64).dotnet build src/OpenClaw.WinNode.Cliproduceswinnode.exewithskill.mdalongside.winnode --command system.which --params '{"bins":["git"]}'returns the resolved path.winnode --command screen.listreturns the screen list.winnode --command canvas.a2ui.push --params '{"jsonl":"…"}'renders a surface in the A2UI pane.winnode --verbose --command system.which --params '{"bins":["git"]}'reportsauth: bearer (file:…).OPENCLAW_MCP_TOKENset in the shell, the CLI uses the env value (override beats file).--mcp-token, the CLI returnsMCP HTTP 401and exits 1.winnode --command screen.listexits 1 with the "enable Local MCP Server" hint.