openclaw-windows-node/tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs
Chris Anderson 3b8793db37
feat: winnode CLI for invoking node commands over local MCP (#250)
* feat: winnode CLI for invoking node commands over local MCP

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>

* fix(test): gate MCP readiness on token-bearing client

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>

* feat(winnode): auto-load MCP bearer token

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>

* feat(winnode): apply 19 review findings (F-01..F-21)

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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:27:50 -07:00

111 lines
3.7 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Text;
namespace OpenClaw.WinNode.Cli.Tests;
/// <summary>
/// Tiny loopback HTTP server that captures the request body and returns a
/// canned response. Lets the RunAsync tests exercise the real HttpClient code
/// path (timeouts, connection failures, JSON-RPC envelopes) without any
/// reliance on the running tray.
/// </summary>
internal sealed class FakeMcpServer : IDisposable
{
private readonly HttpListener _listener = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _loop;
public int Port { get; }
public string Url => $"http://127.0.0.1:{Port}/";
public string? LastRequestBody { get; private set; }
public string? LastRequestMethod { get; private set; }
public string? LastRequestContentType { get; private set; }
public string? LastRequestAuthorization { get; private set; }
/// <summary>Set by the test before issuing the call.</summary>
public Func<string, (HttpStatusCode Status, string Body, string ContentType)>? Responder { get; set; }
/// <summary>If true, the server holds the request to force a client timeout.</summary>
public bool HoldForever { get; set; }
public FakeMcpServer()
{
Port = FindFreePort();
_listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
_listener.Start();
_loop = Task.Run(AcceptLoopAsync);
}
private async Task AcceptLoopAsync()
{
while (!_cts.IsCancellationRequested && _listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { return; }
_ = Task.Run(() => HandleAsync(ctx));
}
}
private async Task HandleAsync(HttpListenerContext ctx)
{
try
{
LastRequestMethod = ctx.Request.HttpMethod;
LastRequestContentType = ctx.Request.ContentType;
LastRequestAuthorization = ctx.Request.Headers["Authorization"];
using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding ?? Encoding.UTF8))
{
LastRequestBody = await reader.ReadToEndAsync();
}
if (HoldForever)
{
try { await Task.Delay(Timeout.Infinite, _cts.Token); }
catch { /* server shutting down */ }
return;
}
var responder = Responder ?? DefaultResponder;
var (status, body, contentType) = responder(LastRequestBody ?? "");
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = (int)status;
ctx.Response.ContentType = contentType;
ctx.Response.ContentLength64 = bytes.Length;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch
{
try { ctx.Response.Abort(); } catch { }
}
}
private static (HttpStatusCode, string, string) DefaultResponder(string _)
=> (HttpStatusCode.OK,
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{}\"}],\"isError\":false}}",
"application/json");
private static int FindFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
try { _loop.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
}