* 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>
111 lines
3.7 KiB
C#
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();
|
|
}
|
|
}
|