Skip to content

feat: winnode CLI for invoking node commands over local MCP - #250

Merged
shanselman merged 4 commits into
openclaw:masterfrom
codemonkeychris:feat/node-cli
May 1, 2026
Merged

feat: winnode CLI for invoking node commands over local MCP#250
shanselman merged 4 commits into
openclaw:masterfrom
codemonkeychris:feat/node-cli

Conversation

@codemonkeychris

Copy link
Copy Markdown
Contributor

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:

Commit Scope
cdbd9e9 feat: winnode CLI for invoking node commands over local MCP New OpenClaw.WinNode.Cli project, ships as winnode.exe next to its agent skill reference. Mirrors the openclaw nodes invoke flag surface.
26babc2 fix(test): gate MCP readiness on token-bearing client Hardens TrayAppFixture.InitializeAsync against the GET / 200-without-auth race that was masking integration-test breakage on stale tray binaries.
146a042 feat(winnode): auto-load MCP bearer token The CLI now resolves and sends the bearer token automatically, runs McpAuthToken.VerifyAcl on the on-disk file, and routes warnings to stderr.

What winnode does

winnode --command <command> [--params <json>] [options]
  • Takes the same flags as openclaw nodes invoke so existing skills and call sites keep working. --node and --idempotency-key are accepted for parity but ignored — calls always target the local tray on 127.0.0.1:8765 (override via --mcp-url, --mcp-port, or OPENCLAW_MCP_PORT).
  • Builds a JSON-RPC tools/call envelope, POSTs it to the MCP HTTP server, surfaces tool errors on stderr (exit 1) and pretty-prints the capability payload on stdout.
  • Ships skill.md next to the exe so an agent driving winnode has the catalog of supported commands and the A2UI v0.8 grammar inline.

Works end-to-end against a running tray:

> winnode --command canvas.a2ui.push --params '{"jsonl":"…"}' --verbose
[winnode] endpoint: http://127.0.0.1:8765/
[winnode] command: canvas.a2ui.push
[winnode] auth: bearer (file:C:\Users\<you>\AppData\Roaming\OpenClawTray\mcp-token.txt)
{
  "pushed": true
}

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 pattern gh, az, and anthropic use:

  1. --mcp-token <literal> flag.
  2. OPENCLAW_MCP_TOKEN env var (literal token, not a path).
  3. mcp-token.txt under $OPENCLAW_TRAY_DATA_DIR if set, else %APPDATA%\OpenClawTray\ — the same path SettingsManager.SettingsDirectoryPath resolves 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 check NodeService.StartMcpServer runs at startup — and routes any owner-mismatch / DACL-grants-outside-{user,SYSTEM,Administrators} warning to stderr. --verbose reports the resolved auth source (bearer (--mcp-token), bearer (OPENCLAW_MCP_TOKEN), bearer (file:<path>), or none) without ever echoing the secret value itself.

Integration-test fixture fix

TrayAppFixture.InitializeAsync polls GET / to confirm the tray is up, then re-issues the JSON-RPC Client with the bearer token from mcp-token.txt. The previous loop returned ready as soon as GET / answered 200 — even if the token file hadn't been read yet. Against a tray binary built before the auth-before-dispatch fix (where GET / returns 200 without auth), this raced ahead with a tokenless Client, and every subsequent POST 401'd. New shape requires both:

  1. mcp-token.txt is on disk and readable.
  2. 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:

Suite Pass Fail
OpenClaw.WinNode.Cli.Tests 73 0
OpenClaw.Shared.Tests 1046 0 (20 platform-skipped)
OpenClaw.Tray.Tests 245 0
OpenClaw.Tray.IntegrationTests 18 0
OpenClaw.Tray.UITests 62 0

WinNode CLI code coverage

Collected with dotnet-coverage (cross-process collector, captures spawned subprocesses) over the full test run, then filtered to the winnode assembly with reportgenerator.

Metric Value
Line 96.9% (258/266)
Branch 95% (76/80)
Method 92.3% (12/13)

Per-class:

  • OpenClaw.WinNode.Cli.CliRunner98.8% (every public/internal method covered; the missed lines are minor edge cases in verbose logging and the unused httpHandler injection seam).
  • OpenClaw.WinNode.Cli.WinNodeOptions100%.
  • OpenClaw.WinNode.Cli.Program — 0% (5-line Main shim that delegates straight to CliRunner.RunAsync; tests bypass it for hermeticity).

Test plan

  • dotnet test openclaw-windows-node.slnx -c Debug passes locally (Windows; UI tests need -r win-x64).
  • dotnet build src/OpenClaw.WinNode.Cli produces winnode.exe with skill.md alongside.
  • With the tray running and Local MCP Server enabled in Settings:
    • winnode --command system.which --params '{"bins":["git"]}' returns the resolved path.
    • winnode --command screen.list returns 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"]}' reports auth: bearer (file:…).
  • With OPENCLAW_MCP_TOKEN set in the shell, the CLI uses the env value (override beats file).
  • With a bogus token via --mcp-token, the CLI returns MCP HTTP 401 and exits 1.
  • Without the tray running, winnode --command screen.list exits 1 with the "enable Local MCP Server" hint.

codemonkeychris and others added 3 commits April 30, 2026 07:05
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>
@codemonkeychris

Copy link
Copy Markdown
Contributor Author

Test command:

.\winnode --command canvas.a2ui.push --params '{"jsonl":"{\"surfaceUpdate\":{\"surfaceId\":\"hello\",\"components\":[{\"id\":\"root\",\"component\":{\"Text\":{\"text\":{\"literalString\":\"Hello from winnode!\"},\"usageHint\":\"h1\"}}}]}}\n{\"beginRendering\":{\"surfaceId\":\"hello\",\"root\":\"root\",\"catalogId\":\"a2ui-v0.8\"}}"}'
Screenshot 2026-04-30 110753

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>
@codemonkeychris

Copy link
Copy Markdown
Contributor Author

Pushed bc1b260 — applies 19 of the 21 review findings (F-01..F-21) from C:/temp/winnode-cli-review-2026-04-30/01-findings.md. F-15 was approved as no-action; F-17 was a positive note.

Headline gates added:

  • F-01/F-09: validate --mcp-url; refuse auto-loaded token off-loopback
  • F-02: explicit SocketsHttpHandler { AllowAutoRedirect = false }
  • F-03: cap response body at 16 MiB
  • F-16+F-21: SanitizeForStderr strips control chars, redacts ≥32-char base64url runs, caps at 4 KiB, default-quiet first-line-only
  • F-18: --invoke-timeout capped at 600 000 ms with long arithmetic
  • F-19: --mcp-port and OPENCLAW_MCP_PORT bounded [1, 65535]
  • F-20: distinguish missing/empty/unreadable token-file states
  • Plus F-04/05/06/07/08/10/11/12/13/14 — see commit message for the full list.

skill.md was re-synced with the live capability surface (added screen.snapshot, screen.record, camera.clip, canvas.a2ui.pushJSONL; removed screen.list, screen.capture). New SkillMdDriftTests now compares the documented command set against McpToolBridge.KnownCommands so future drift breaks the build.

Test results (all green, run locally on Windows with the tray running):

Project Pass Total
OpenClaw.WinNode.Cli.Tests 115 115
OpenClaw.Shared.Tests 1046 1066 (20 pre-existing skips)
OpenClaw.Tray.Tests 245 245
OpenClaw.Tray.IntegrationTests (with OPENCLAW_RUN_INTEGRATION=1) 18 18
OpenClaw.Tray.UITests 62 62

WinNode CLI line coverage: 91.6% (434 / 474 in Program.cs) via dotnet-coverage collect. Uncovered lines are all defensive paths: the static Main shim (32-36), --params @ empty-path branch (101-103), F-08 link-resolution / PathTooLong / generic-catch branches (615-657), and the SanitizeForStderr 4 KiB truncate path (427-429) — all hard to trigger from xUnit without real symlinks / oversized fixtures.

Follow-up worth flagging: OpenClaw.WinNode.Cli.Tests isn't wired into .github/workflows/ci.yml yet — the existing CI runs Shared, Tray, Integration, and UI but not the WinNode CLI tests. Should mirror the existing dotnet-coverage collect pattern for parity.

@github-actions github-actions Bot mentioned this pull request May 1, 2026
23 tasks
@shanselman
shanselman merged commit 3b8793d into openclaw:master May 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants