From 3b8793db3702cbe822369ed6150a2992fa2e2510 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Fri, 1 May 2026 09:27:50 -0700 Subject: [PATCH] feat: winnode CLI for invoking node commands over local MCP (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * feat(winnode): auto-load MCP bearer token The CLI now sends `Authorization: Bearer ` 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 ` 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) * 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 @ 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- build.ps1 | 5 +- openclaw-windows-node.slnx | 2 + src/OpenClaw.Shared/Mcp/McpToolBridge.cs | 7 + .../OpenClaw.WinNode.Cli.csproj | 25 + src/OpenClaw.WinNode.Cli/Program.cs | 811 ++++++++++++++++++ src/OpenClaw.WinNode.Cli/skill.md | 343 ++++++++ .../TrayAppFixture.cs | 42 +- .../AuthTokenTests.cs | 412 +++++++++ .../BuildToolsCallBodyTests.cs | 65 ++ .../EmitResultTests.cs | 199 +++++ .../FakeMcpServer.cs | 110 +++ .../OpenClaw.WinNode.Cli.Tests.csproj | 7 + .../ParseArgsTests.cs | 95 ++ .../PrintUsageTests.cs | 26 + .../ResolveEndpointTests.cs | 101 +++ .../RunAsyncTests.cs | 579 +++++++++++++ .../SkillMdDriftTests.cs | 83 ++ 17 files changed, 2897 insertions(+), 15 deletions(-) create mode 100644 src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj create mode 100644 src/OpenClaw.WinNode.Cli/Program.cs create mode 100644 src/OpenClaw.WinNode.Cli/skill.md create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs create mode 100644 tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs diff --git a/build.ps1 b/build.ps1 index 13cb7e8..e717433 100644 --- a/build.ps1 +++ b/build.ps1 @@ -23,7 +23,7 @@ #> param( - [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli")] + [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli")] [string]$Project = "All", [ValidateSet("Debug", "Release")] @@ -188,12 +188,13 @@ function Build-Project($name, $path, $useRid = $false) { $projects = @{ "Shared" = @{ Path = "src/OpenClaw.Shared/OpenClaw.Shared.csproj"; UseRid = $false } "Cli" = @{ Path = "src/OpenClaw.Cli/OpenClaw.Cli.csproj"; UseRid = $false } + "WinNodeCli" = @{ Path = "src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj"; UseRid = $false } "Tray" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true } "WinUI" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true } "CommandPalette" = @{ Path = "src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj"; UseRid = $false } } -$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinUI") } else { @($Project) } +$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "WinUI") } else { @($Project) } # Always build Shared first if building other projects if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Shared") { diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 2a33964..0828de3 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -8,6 +8,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index e3d19dc..5710854 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -168,6 +168,13 @@ public class McpToolBridge return new { tools }; } + /// + /// The complete set of commands documented in . + /// Exposed as a stable surface so out-of-process documentation (winnode's + /// skill.md) can be drift-tested against the canonical capability surface. + /// + public static IReadOnlyCollection KnownCommands => CommandDescriptions.Keys; + /// /// Per-command descriptions advertised via tools/list. Sourced from /// the OpenClaw docs (docs/nodes/index.md, docs/platforms/mac/canvas.md) and diff --git a/src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj b/src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj new file mode 100644 index 0000000..2f55f63 --- /dev/null +++ b/src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj @@ -0,0 +1,25 @@ + + + Exe + net10.0 + winnode + OpenClaw.WinNode.Cli + enable + enable + + + + + + PreserveNewest + + + + + + + + + + + diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs new file mode 100644 index 0000000..b5b34b7 --- /dev/null +++ b/src/OpenClaw.WinNode.Cli/Program.cs @@ -0,0 +1,811 @@ +using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Shared.Mcp; + +namespace OpenClaw.WinNode.Cli; + +internal sealed class WinNodeOptions +{ + public string? Node { get; set; } + public string? Command { get; set; } + public string Params { get; set; } = "{}"; + public int InvokeTimeoutMs { get; set; } = 15000; + public string? IdempotencyKey { get; set; } + public string? McpUrlOverride { get; set; } + public int? McpPortOverride { get; set; } + public string? McpTokenOverride { get; set; } + public bool Verbose { get; set; } +} + +/// +/// Entry-point shim. All real work lives in so it can +/// be exercised from unit tests without touching or the +/// process environment. +/// +internal static class Program +{ + private static Task Main(string[] args) + => CliRunner.RunAsync( + args, + Console.Out, + Console.Error, + Environment.GetEnvironmentVariable); +} + +internal static class CliRunner +{ + internal const int DefaultMcpPort = 8765; + internal const int MaxInvokeTimeoutMs = 600_000; // 10 min, matches Bash.timeout precedent + internal const long MaxResponseContentBytes = 16L * 1024 * 1024; // 16 MiB + internal const int MaxStderrEchoBytes = 4 * 1024; // 4 KiB cap on echoed error bodies + + public static async Task RunAsync( + string[] args, + TextWriter stdout, + TextWriter stderr, + Func envLookup, + HttpMessageHandler? httpHandler = null) + { + if (args.Length == 0 || args.Any(a => a is "--help" or "-h")) + { + PrintUsage(stdout); + return args.Length == 0 ? 2 : 0; + } + + WinNodeOptions options; + try + { + options = ParseArgs(args); + } + catch (Exception ex) + { + stderr.WriteLine($"Argument error: {ex.Message}"); + PrintUsage(stdout); + return 2; + } + + if (string.IsNullOrWhiteSpace(options.Command)) + { + stderr.WriteLine("--command is required"); + return 2; + } + + // F-04: --mcp-token literal is visible to other same-user processes via + // the Windows process listing. Warn unconditionally so an agent that + // copy-pasted the flag from a transcript still sees the hazard. + if (options.McpTokenOverride is not null) + { + stderr.WriteLine("[winnode] WARN: --mcp-token is visible to other processes on this machine; prefer OPENCLAW_MCP_TOKEN or the on-disk token file."); + } + + // F-05: --idempotency-key is a no-op locally (the gateway does the + // de-dup, not the tray). Warn loudly so a copy-pasted gateway command + // doesn't silently double-execute side effects on retry. + if (!string.IsNullOrEmpty(options.IdempotencyKey)) + { + stderr.WriteLine("[winnode] WARN: --idempotency-key ignored (no idempotency over local MCP); subsequent retries may double-execute side effects."); + } + + // F-12: --params @ loads a JSON object from disk. Useful for big + // A2UI payloads / canvas.eval scripts that exceed comfortable command- + // line size. + var paramsJson = options.Params; + if (paramsJson.StartsWith('@')) + { + var path = paramsJson[1..]; + if (string.IsNullOrWhiteSpace(path)) + { + stderr.WriteLine("--params @: path is empty"); + return 2; + } + try + { + paramsJson = File.ReadAllText(path); + } + catch (Exception ex) + { + stderr.WriteLine($"--params: failed to read {path}: {ex.Message}"); + return 2; + } + } + + JsonElement arguments; + try + { + using var paramsDoc = JsonDocument.Parse(paramsJson); + if (paramsDoc.RootElement.ValueKind != JsonValueKind.Object) + { + stderr.WriteLine("--params must be a JSON object"); + return 2; + } + arguments = paramsDoc.RootElement.Clone(); + } + catch (JsonException ex) + { + stderr.WriteLine($"--params is not valid JSON: {ex.Message}"); + return 2; + } + + // F-09: validate the resolved endpoint as an absolute http(s) URL up + // front so a typo surfaces as exit-2 argument error rather than a + // confusing transport error from deep inside HttpClient. + var endpoint = ResolveEndpoint(options, envLookup, stderr); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) + || (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps)) + { + stderr.WriteLine($"--mcp-url must be an absolute http(s) URL: {endpoint}"); + return 2; + } + + var token = ResolveAuthToken(options, envLookup, stderr); + if (token.Source == "error") + { + // F-20: file existed but was unreadable. ResolveAuthToken already + // wrote a diagnostic; bail before the request rather than burning + // a 401 round-trip pretending the absent header might somehow work. + return 1; + } + + // F-01: if the auto-loaded (file:) token would be sent off-loopback, + // refuse. The whole loopback-only threat model in McpHttpServer relies + // on the bearer never leaving 127.0.0.1; honoring an explicit override + // is OK (the user took the action knowingly) but auto-load is not. + if (token.Token is not null && !endpointUri.IsLoopback) + { + if (token.Source.StartsWith("file:", StringComparison.Ordinal)) + { + stderr.WriteLine($"[winnode] WARN: refusing to send local MCP token to non-loopback URL ({endpointUri.Host}); use --mcp-token to override explicitly."); + token = new AuthTokenResult(null, "none"); + } + else + { + stderr.WriteLine($"[winnode] WARN: sending bearer token to non-loopback URL ({endpointUri.Host}); ensure the endpoint is trusted."); + } + } + + // F-06: AuthenticationHeaderValue's ctor throws on whitespace, CR/LF, + // or non-ASCII. A corrupted token file (BOM, CRLF, trailing nulls) + // would otherwise propagate as a Tier 0 unhandled crash. Treat as + // "no token" with a stderr note instead. + if (token.Token is not null && !TokenLooksValid(token.Token)) + { + stderr.WriteLine($"[winnode] token from {token.Source} contains invalid characters; ignoring."); + token = new AuthTokenResult(null, "none"); + } + + if (options.Verbose) + { + stderr.WriteLine($"[winnode] endpoint: {endpoint}"); + stderr.WriteLine($"[winnode] command: {options.Command}"); + // F-07: don't echo the token-file path (PII / username leak). + // Source label is enough for debugging. + var authLabel = token.Token is null + ? "none" + : token.Source.StartsWith("file:", StringComparison.Ordinal) + ? "bearer (file)" + : $"bearer ({token.Source})"; + stderr.WriteLine($"[winnode] auth: {authLabel}"); + if (!string.IsNullOrEmpty(options.Node)) + { + stderr.WriteLine($"[winnode] --node \"{options.Node}\" ignored (always local tray)"); + } + } + + var (requestBytes, requestLength) = BuildToolsCallBody(options.Command!, arguments); + + // F-18: compute the timeout in long arithmetic so very large + // (but in-range) --invoke-timeout values can't overflow into a + // negative TimeSpan and crash. The InvokeTimeoutMs upper bound was + // already validated by ParseInt. + var httpTimeoutMs = (long)options.InvokeTimeoutMs + 5000L; + var httpTimeout = TimeSpan.FromMilliseconds(httpTimeoutMs); + + // F-02: explicit handler with AllowAutoRedirect=false. The local MCP + // server never redirects, so any 30x is an anomaly worth surfacing + // rather than silently following. + // F-03: cap response buffer at 16 MiB; the only legitimately-large + // response is a screen capture, which the server already caps below + // this ceiling. + HttpClient http; + SocketsHttpHandler? ownedHandler = null; + if (httpHandler is null) + { + ownedHandler = new SocketsHttpHandler { AllowAutoRedirect = false }; + http = new HttpClient(ownedHandler, disposeHandler: true) + { + Timeout = httpTimeout, + MaxResponseContentBufferSize = MaxResponseContentBytes, + }; + } + else + { + http = new HttpClient(httpHandler, disposeHandler: false) + { + Timeout = httpTimeout, + MaxResponseContentBufferSize = MaxResponseContentBytes, + }; + } + + try + { + if (token.Token is not null) + { + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token.Token); + } + + // F-14: skip the byte[] -> string -> byte[] round-trip. ByteArrayContent + // takes the Utf8JsonWriter buffer directly with no additional copy. + using var content = new ByteArrayContent(requestBytes, 0, requestLength); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + + HttpResponseMessage response; + try + { + response = await http.PostAsync(endpoint, content); + } + catch (TaskCanceledException) when (httpTimeout > TimeSpan.Zero) + { + stderr.WriteLine($"timed out after {options.InvokeTimeoutMs}ms calling {endpoint}"); + return 1; + } + catch (HttpRequestException ex) + { + // F-03: when MaxResponseContentBufferSize trips, HttpClient + // surfaces an HttpRequestException whose message mentions + // "exceeded the configured limit". Detect that specific case + // for a clearer diagnostic; everything else is a transport + // failure (connection refused, DNS, TLS, malformed URL). + if (ex.Message.IndexOf("exceeded", StringComparison.OrdinalIgnoreCase) >= 0 + || (ex.InnerException?.Message?.IndexOf("exceeded", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0) + { + stderr.WriteLine($"response body exceeded {MaxResponseContentBytes / (1024 * 1024)} MiB cap; aborting."); + return 1; + } + stderr.WriteLine($"failed to reach MCP server at {endpoint}: {ex.Message}"); + stderr.WriteLine("hint: enable \"Local MCP Server\" in tray Settings, then restart the tray app."); + return 1; + } + + // F-02: refuse 3xx — no legitimate redirect from this server. + if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400) + { + stderr.WriteLine($"MCP server returned unexpected redirect {(int)response.StatusCode}; refusing to follow."); + return 1; + } + + string body; + try + { + body = await response.Content.ReadAsStringAsync(); + } + catch (HttpRequestException ex) when (ex.Message.IndexOf("exceeded", StringComparison.OrdinalIgnoreCase) >= 0) + { + stderr.WriteLine($"response body exceeded {MaxResponseContentBytes / (1024 * 1024)} MiB cap; aborting."); + return 1; + } + + if (!response.IsSuccessStatusCode) + { + // F-16 + F-21: sanitize control chars, cap length, redact + // token-shaped strings, and default-quiet unless --verbose. + var safe = SanitizeForStderr(body, options.Verbose); + stderr.WriteLine($"MCP HTTP {(int)response.StatusCode}: {safe}"); + return 1; + } + + return EmitResult(body, stdout, stderr, options.Verbose); + } + finally + { + http.Dispose(); + ownedHandler?.Dispose(); + } + } + + internal static int EmitResult(string body, TextWriter stdout, TextWriter stderr, bool verbose) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(body); + } + catch (JsonException ex) + { + stderr.WriteLine($"MCP response was not valid JSON: {ex.Message}"); + stderr.WriteLine(SanitizeForStderr(body, verbose)); + return 1; + } + + using (doc) + { + var root = doc.RootElement; + + if (root.TryGetProperty("error", out var err)) + { + var msg = err.TryGetProperty("message", out var m) ? m.GetString() : "(no message)"; + var code = err.TryGetProperty("code", out var c) ? c.GetInt32() : 0; + stderr.WriteLine($"JSON-RPC error {code}: {SanitizeForStderr(msg ?? string.Empty, verbose)}"); + return 1; + } + + if (!root.TryGetProperty("result", out var result)) + { + stderr.WriteLine("MCP response missing 'result'"); + stderr.WriteLine(SanitizeForStderr(body, verbose)); + return 1; + } + + var isError = result.TryGetProperty("isError", out var ie) && ie.ValueKind == JsonValueKind.True; + string? text = null; + if (result.TryGetProperty("content", out var contentArr) && + contentArr.ValueKind == JsonValueKind.Array && + contentArr.GetArrayLength() > 0) + { + var first = contentArr[0]; + if (first.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String) + { + text = t.GetString(); + } + } + + if (isError) + { + stderr.WriteLine(SanitizeForStderr(text ?? "tool execution failed", verbose)); + return 1; + } + + // text is the capability payload re-serialized as JSON. Re-emit it + // (pretty-printed) so the output matches what `openclaw nodes invoke` + // produces via writeJson. + if (text is null) + { + stdout.WriteLine(PrettyPrint(result)); + return 0; + } + + try + { + using var inner = JsonDocument.Parse(text); + stdout.WriteLine(PrettyPrint(inner.RootElement)); + } + catch (JsonException) + { + stdout.WriteLine(text); + } + return 0; + } + } + + /// + /// Sanitize a server-supplied error string before writing to stderr. + /// 1. Strip ASCII control chars except tab/newline (F-16: prevents ANSI + /// injection, log-line CR/LF smuggling, NUL truncation in log forwarders). + /// 2. Redact runs of ≥32 base64url alphabet characters as token-shaped + /// secrets (F-21: catches the local MCP token shape, most API keys). + /// 3. Cap length at (F-16, F-21). + /// 4. When not in , return only the first line + /// (F-21: matches gh / kubectl posture — full body on --verbose only). + /// + internal static string SanitizeForStderr(string input, bool verbose) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + + var sb = new StringBuilder(Math.Min(input.Length, MaxStderrEchoBytes)); + foreach (var ch in input) + { + if (ch == '\t' || ch == '\n' || ch == '\r' || (ch >= ' ' && ch != 0x7f)) + { + sb.Append(ch); + } + // else: drop. Includes 0x00-0x08, 0x0b-0x0c, 0x0e-0x1f, 0x7f (DEL), + // and the lone-ESC (0x1b) used in ANSI sequences. + } + var stripped = sb.ToString(); + + // Token-shape redaction: ≥32 chars of base64url alphabet, bounded by + // word boundaries. McpAuthToken.Generate produces 43 chars; most API + // keys are longer. False-positives are rare in practice (UUIDs are + // 36 chars but contain hyphens, breaking the run; long hex hashes + // sit at the threshold but include a-f only). + var redacted = TokenShapeRegex.Replace(stripped, ""); + + if (!verbose) + { + var firstNewline = redacted.IndexOf('\n'); + if (firstNewline >= 0) redacted = redacted[..firstNewline].TrimEnd('\r'); + } + + if (redacted.Length > MaxStderrEchoBytes) + { + redacted = redacted[..MaxStderrEchoBytes] + "…[truncated]"; + } + return redacted; + } + + private static readonly Regex TokenShapeRegex = new( + @"(? JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = true }); + + /// + /// Build the tools/call JSON-RPC envelope. Returns the buffer + length so + /// the caller can hand the bytes straight to ByteArrayContent without + /// re-encoding through a string (F-14). + /// + internal static (byte[] Buffer, int Length) BuildToolsCallBody(string command, JsonElement arguments) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("jsonrpc", "2.0"); + w.WriteNumber("id", 1); + w.WriteString("method", "tools/call"); + w.WriteStartObject("params"); + w.WriteString("name", command); + w.WritePropertyName("arguments"); + arguments.WriteTo(w); + w.WriteEndObject(); + w.WriteEndObject(); + } + // GetBuffer() returns the underlying array (no copy). Length is the + // number of bytes actually written. + return (ms.GetBuffer(), (int)ms.Length); + } + + internal static string ResolveEndpoint(WinNodeOptions options, Func envLookup, TextWriter stderr) + { + if (!string.IsNullOrWhiteSpace(options.McpUrlOverride)) + { + return options.McpUrlOverride!; + } + + // F-19: clamp env-var-derived port to [1, 65535]. Out-of-range falls + // back to default (current shape) but emits a verbose warning so the + // operator knows the env var was ignored. + var port = options.McpPortOverride ?? ResolveEnvPort(envLookup, options.Verbose, stderr); + return $"http://127.0.0.1:{port}/"; + } + + private static int ResolveEnvPort(Func envLookup, bool verbose, TextWriter stderr) + { + var raw = envLookup("OPENCLAW_MCP_PORT"); + if (string.IsNullOrEmpty(raw)) return DefaultMcpPort; + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + if (verbose) + stderr.WriteLine($"[winnode] OPENCLAW_MCP_PORT={raw} is not an integer; using default {DefaultMcpPort}."); + return DefaultMcpPort; + } + if (parsed < 1 || parsed > 65535) + { + if (verbose) + stderr.WriteLine($"[winnode] OPENCLAW_MCP_PORT={parsed} is out of range [1,65535]; using default {DefaultMcpPort}."); + return DefaultMcpPort; + } + return parsed; + } + + internal readonly record struct AuthTokenResult(string? Token, string Source); + + /// + /// Resolve the bearer token sent on every MCP request, in priority order: + /// + /// --mcp-token <literal> flag (matches gh --token, + /// az login --service-principal --password, etc.). + /// OPENCLAW_MCP_TOKEN env var (literal). Standard + /// per-tool secret env-var convention — same shape as GITHUB_TOKEN, + /// ANTHROPIC_API_KEY, NUGET_API_KEY. + /// The on-disk token file the tray writes when MCP is enabled — + /// %APPDATA%\OpenClawTray\mcp-token.txt by default, or + /// $OPENCLAW_TRAY_DATA_DIR\mcp-token.txt when the tray was launched + /// with that sandbox override (the integration test fixture uses it). + /// + /// When the token is loaded from disk, mirror the tray's own startup hygiene + /// check by running and surfacing any + /// warning to stderr — owner mismatch or DACL grants outside {current user, + /// SYSTEM, Administrators} mean the file should be treated as compromised + /// and the user told to rotate it via the Settings UI. + /// + internal static AuthTokenResult ResolveAuthToken( + WinNodeOptions options, + Func envLookup, + TextWriter stderr) + { + if (!string.IsNullOrWhiteSpace(options.McpTokenOverride)) + { + return new AuthTokenResult(options.McpTokenOverride, "--mcp-token"); + } + + var envToken = envLookup("OPENCLAW_MCP_TOKEN"); + if (!string.IsNullOrWhiteSpace(envToken)) + { + return new AuthTokenResult(envToken, "OPENCLAW_MCP_TOKEN"); + } + + var path = ResolveTokenPath(envLookup); + + // F-08: resolve to canonical form and require the result still live + // under the requested directory tree. Defeats a same-user attacker + // who plants a symlink/junction at the override path to redirect + // the read to a token file they control. The OS handles long-path + // resolution as long as we go through Path.GetFullPath; we don't + // need to add the \\?\ prefix ourselves. + if (!ValidateTokenPath(path, stderr, out var canonical)) + { + return new AuthTokenResult(null, "error"); + } + + // F-20: distinguish missing from unreadable. McpAuthToken.TryLoad + // collapses both to null, so we probe the file ourselves first to + // give the operator a useful diagnostic instead of a confusing 401. + if (!File.Exists(canonical)) + { + return new AuthTokenResult(null, "none"); + } + + string? token; + try + { + token = File.ReadAllText(canonical).Trim(); + } + catch (Exception ex) + { + stderr.WriteLine($"[winnode] token file at {canonical} exists but could not be read: {ex.Message}"); + return new AuthTokenResult(null, "error"); + } + + if (string.IsNullOrEmpty(token)) + { + // Empty or whitespace-only: treat as missing. The atomic-write path + // in McpAuthToken.LoadOrCreate ensures legitimate writes never + // produce an empty file. + return new AuthTokenResult(null, "none"); + } + + // Same hygiene check the tray runs at startup. Warning-only — broken + // ACLs don't prevent the call (a malicious local user can already + // read whatever they like under the user profile), but the operator + // should see it. + var aclWarning = McpAuthToken.VerifyAcl(canonical); + if (aclWarning != null) + { + stderr.WriteLine($"[winnode] WARN: {aclWarning}"); + } + return new AuthTokenResult(token, $"file:{canonical}"); + } + + /// + /// F-08: ensure the token path doesn't escape its intended directory via + /// a symlink/junction. We compare the canonical (link-resolved) directory + /// containing the file to the canonical form of the requested directory; + /// if they diverge, refuse the read. + /// + internal static bool ValidateTokenPath(string path, TextWriter stderr, out string canonical) + { + canonical = path; + try + { + // Path.GetFullPath normalizes . / .. / mixed separators. It does + // not resolve symlinks — ResolveLinkTarget does that, but only + // for actual link entries, so we run it on both the file and the + // directory and fall back to the unresolved form when nothing's + // a link. + var fullPath = Path.GetFullPath(path); + var requestedDir = Path.GetFullPath(Path.GetDirectoryName(fullPath) ?? string.Empty); + + string resolvedDir = requestedDir; + try + { + if (Directory.Exists(requestedDir)) + { + var dirInfo = new DirectoryInfo(requestedDir); + var linkTarget = dirInfo.ResolveLinkTarget(returnFinalTarget: true); + if (linkTarget is not null) + { + resolvedDir = Path.GetFullPath(linkTarget.FullName); + } + } + } + catch (IOException) { /* not a link; keep requestedDir */ } + + string resolvedFile = fullPath; + try + { + if (File.Exists(fullPath)) + { + var fileInfo = new FileInfo(fullPath); + var linkTarget = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (linkTarget is not null) + { + resolvedFile = Path.GetFullPath(linkTarget.FullName); + } + } + } + catch (IOException) { /* not a link; keep fullPath */ } + + // The resolved file must live under the resolved directory tree. + // Compare normalized strings with an OS-appropriate comparison + // (Windows is case-insensitive). + var fileDir = Path.GetFullPath(Path.GetDirectoryName(resolvedFile) ?? string.Empty); + if (!PathStartsWith(fileDir, resolvedDir)) + { + stderr.WriteLine($"[winnode] token path resolves outside its directory ({fileDir} not under {resolvedDir}); refusing to read."); + return false; + } + canonical = fullPath; + return true; + } + catch (PathTooLongException ex) + { + stderr.WriteLine($"[winnode] token path too long: {ex.Message}"); + return false; + } + catch (Exception ex) + { + stderr.WriteLine($"[winnode] token path could not be resolved: {ex.Message}"); + return false; + } + } + + private static bool PathStartsWith(string candidate, string prefix) + { + var cmp = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var normCandidate = candidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normPrefix = prefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return normCandidate.Equals(normPrefix, cmp) + || normCandidate.StartsWith(normPrefix + Path.DirectorySeparatorChar, cmp); + } + + internal static string ResolveTokenPath(Func envLookup) + { + // Mirror SettingsManager.SettingsDirectoryPath: when the tray was + // launched with OPENCLAW_TRAY_DATA_DIR, settings (including the token + // file) live under that directory. The same env var is honored here + // so a CLI invoked in the same shell as a sandboxed tray Just Works, + // and the integration test fixture can redirect both the producer + // (tray) and the consumer (CLI) with one env var. + var dataDirOverride = envLookup("OPENCLAW_TRAY_DATA_DIR"); + var dir = !string.IsNullOrWhiteSpace(dataDirOverride) + ? dataDirOverride! + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); + return Path.Combine(dir, "mcp-token.txt"); + } + + /// + /// F-06: validate token chars before handing to AuthenticationHeaderValue, + /// which throws on whitespace / CR / LF / non-ASCII (token68 ABNF). + /// + internal static bool TokenLooksValid(string token) + { + if (string.IsNullOrEmpty(token)) return false; + foreach (var ch in token) + { + // Reject anything outside printable ASCII or whitespace/control. + if (ch < 0x21 || ch > 0x7e) return false; + } + return true; + } + + internal static WinNodeOptions ParseArgs(string[] args) + { + var options = new WinNodeOptions(); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--node": + options.Node = RequireValue(args, ref i, arg); + break; + case "--command": + options.Command = RequireValue(args, ref i, arg); + break; + case "--params": + options.Params = RequireValue(args, ref i, arg); + break; + case "--invoke-timeout": + // F-18: cap at 10 minutes so we can't be tricked into a + // multi-day hang and the +5000ms buffer can't overflow. + options.InvokeTimeoutMs = ParseInt( + RequireValue(args, ref i, arg), + min: 1, max: MaxInvokeTimeoutMs, name: arg); + break; + case "--idempotency-key": + options.IdempotencyKey = RequireValue(args, ref i, arg); + break; + case "--mcp-url": + options.McpUrlOverride = RequireValue(args, ref i, arg); + break; + case "--mcp-port": + // F-19: range-check to a real TCP port. Out-of-range + // surfaces as exit-2 instead of a confusing transport + // error from a malformed URL. + options.McpPortOverride = ParseInt( + RequireValue(args, ref i, arg), + min: 1, max: 65535, name: arg); + break; + case "--mcp-token": + options.McpTokenOverride = RequireValue(args, ref i, arg); + break; + case "--verbose": + options.Verbose = true; + break; + default: + throw new ArgumentException($"Unknown argument: {arg}"); + } + } + + return options; + } + + private static string RequireValue(string[] args, ref int index, string name) + { + if (index + 1 >= args.Length) + { + throw new ArgumentException($"Missing value for {name}"); + } + index++; + return args[index]; + } + + private static int ParseInt(string value, int min, int max, string name) + { + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + || parsed < min || parsed > max) + { + throw new ArgumentException($"{name} must be an integer in [{min}, {max}]"); + } + return parsed; + } + + internal static void PrintUsage(TextWriter stdout) + { + stdout.WriteLine("winnode - invoke OpenClaw node commands on the local Windows tray over MCP"); + stdout.WriteLine(); + stdout.WriteLine("Mirrors the flag surface of `openclaw nodes invoke`. The --node value is"); + stdout.WriteLine("accepted but ignored; calls always target the local tray's MCP server"); + stdout.WriteLine("(default http://127.0.0.1:8765/). Enable \"Local MCP Server\" in tray Settings."); + stdout.WriteLine(); + stdout.WriteLine("Usage:"); + stdout.WriteLine(" winnode --command [--params ] [options]"); + stdout.WriteLine(); + stdout.WriteLine("Options:"); + stdout.WriteLine(" --node Accepted for parity with `openclaw nodes invoke`; ignored"); + stdout.WriteLine(" --command Command to invoke (e.g. system.which, canvas.eval) [required]"); + stdout.WriteLine(" --params JSON object string for params (default: {}). Prefix with"); + stdout.WriteLine(" @ to load the JSON from a file (e.g. --params @big.json)"); + stdout.WriteLine(" --invoke-timeout Invoke timeout in ms (default: 15000, max: 600000)"); + stdout.WriteLine(" --idempotency-key Accepted for parity; ignored over local MCP (warns)"); + stdout.WriteLine(" --mcp-url Override MCP endpoint (default: http://127.0.0.1:/)"); + stdout.WriteLine(" --mcp-port Override MCP port [1-65535] (default: $OPENCLAW_MCP_PORT or 8765)"); + stdout.WriteLine(" --mcp-token Bearer token (testing/explicit overrides only - visible to"); + stdout.WriteLine(" other processes via the OS process listing). Prefer"); + stdout.WriteLine(" $OPENCLAW_MCP_TOKEN or %APPDATA%\\OpenClawTray\\mcp-token.txt"); + stdout.WriteLine(" --verbose Print endpoint + ignored flags to stderr"); + stdout.WriteLine(" --help, -h Show this help"); + stdout.WriteLine(); + stdout.WriteLine("Examples:"); + stdout.WriteLine(" winnode --command system.which --params '{\"bins\":[\"git\",\"node\"]}'"); + stdout.WriteLine(" winnode --command screen.snapshot"); + stdout.WriteLine(" winnode --command canvas.present --params '{\"url\":\"https://example.com\"}'"); + stdout.WriteLine(); + stdout.WriteLine("See skill.md (next to this exe) for the full agent reference: every supported"); + stdout.WriteLine("command, its argument schema, and the A2UI v0.8 JSONL grammar."); + } +} diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md new file mode 100644 index 0000000..acb5add --- /dev/null +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -0,0 +1,343 @@ + + +# winnode skill reference + +`winnode.exe` invokes OpenClaw Windows-node commands on the local tray over a +loopback MCP HTTP endpoint (default `http://127.0.0.1:8765/`). Enable +**Local MCP Server** in the tray's Settings → Advanced before calling. + +This document is the agent-facing reference: every supported command, its +argument shape, and the A2UI v0.8 JSONL grammar. It is shipped alongside +`winnode.exe` so an agent can read it once and emit token-efficient calls. + +--- + +## Invocation shape + +``` +winnode --command [--params ''] [--invoke-timeout ] +``` + +- `--command` (required) — node command (e.g. `system.which`, `canvas.a2ui.push`). +- `--params` — single JSON **object** string, default `{}`. Must be a JSON object, + not an array or scalar. **`--params @`** loads the JSON object from a + file on disk (useful for big A2UI payloads / `canvas.eval` scripts). +- `--invoke-timeout` — milliseconds, default 15000, max 600000 (10 min). HTTP + timeout adds a 5s buffer. +- `--node` — accepted for parity with `openclaw nodes invoke`; **ignored** + locally. Safe to copy/paste from gateway-side commands. +- `--idempotency-key` — accepted for parity; **ignored**, and the CLI emits a + `[winnode] WARN` to stderr because local MCP does *not* dedupe retries — + re-running a command after a transient failure can double-execute side + effects. If you need idempotency, target the gateway, not winnode. +- `--mcp-url ` / `--mcp-port ` — override the endpoint. Falls back to + `OPENCLAW_MCP_PORT` env var, then port 8765. `--mcp-port` must be in + `[1, 65535]`; out of range fails with exit code 2. +- `--mcp-token ` — bearer token override (testing / explicit only). The + literal value is **visible to other same-user processes via the OS process + listing** (`Get-CimInstance Win32_Process | Select CommandLine`, + Process Explorer, etc.). The CLI emits a stderr warning when this flag is + used. **Prefer `OPENCLAW_MCP_TOKEN` (env var) or the on-disk + `%APPDATA%\OpenClawTray\mcp-token.txt`** which the tray writes when MCP is + enabled. Both `OPENCLAW_MCP_TOKEN` and the on-disk file should themselves be + treated as sensitive operational secrets. +- `--verbose` — log endpoint + ignored flags to stderr. Without `--verbose`, + HTTP error bodies are emitted only as the first line; with `--verbose`, the + full body is shown (after sanitization + token-shape redaction). + +**Output contract:** stdout receives the capability payload as pretty-printed +JSON (matches `openclaw nodes invoke`). stderr receives errors. Exit code: + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Tool error, JSON-RPC error, transport failure, or HTTP non-2xx | +| 2 | Argument error (missing/invalid flags, bad `--params` JSON, out-of-range port/timeout, non-http URL) | + +**Off-loapback safety:** when `--mcp-url` points at a non-loopback host, the +CLI **refuses to send the auto-loaded local MCP token** (and warns on stderr). +An explicitly supplied `--mcp-token` is honored with a warning. This preserves +the loopback-only threat model the tray's MCP server relies on. + +--- + +## Commands + +### system.notify +Show a Windows toast notification. +``` +{"title": "OpenClaw", "body": "string", "subtitle": "string", "sound": true} +``` +Returns `{ "sent": true }`. All fields optional except `body` in practice. + +### system.run +Execute a shell command. Subject to the local exec approval policy at +`%LOCALAPPDATA%\OpenClawTray\exec-policy.json`. +``` +{ + "command": "string OR string[]", // required + "args": ["string", ...], // optional, appended to command + "shell": "powershell|pwsh|cmd|bash", + "cwd": "string", + "timeoutMs": 30000, + "env": { "KEY": "VALUE" } +} +``` +Returns `{ stdout, stderr, exitCode, timedOut, durationMs }`. + +### system.run.prepare +Pre-flight a `system.run` invocation. Same args as `system.run`. Returns the +parsed plan (`argv`, `cwd`, `rawCommand`, `agentId`, `sessionKey`) without +executing. + +### system.which +Resolve binary names to absolute paths. +``` +{"bins": ["git", "node", "powershell"]} +``` +Returns `{ "bins": { "git": "C:\\...", ... } }`. Names not found are omitted. + +### system.execApprovals.get +No params. Returns the active exec policy: +`{ enabled, defaultAction, rules: [{pattern, action, shells?, description?, enabled}] }`. + +### system.execApprovals.set +Replace the exec policy. +``` +{ + "rules": [{"pattern": "echo *", "action": "allow"}, ...], + "defaultAction": "allow|deny|prompt" +} +``` + +### canvas.present +Open the WebView2 canvas window. +``` +{ + "url": "string", // OR "html": "string" + "html": "string", + "width": 800, "height": 600, + "x": -1, "y": -1, // -1 centers + "title": "Canvas", + "alwaysOnTop": false +} +``` +Returns `{ "presented": true }`. + +### canvas.hide +No params. Hides the canvas without destroying state. + +### canvas.navigate +``` +{"url": "https://..."} // also accepts file:// or local canvas paths +``` + +### canvas.eval +``` +{"script": "document.title"} // also accepts "javaScript" or "javascript" +``` +Returns the evaluated result. + +### canvas.snapshot +``` +{"format": "png|jpeg", "maxWidth": 1200, "quality": 80} +``` +Returns `{ format, base64 }`. + +### canvas.a2ui.push +Render an A2UI v0.8 surface in the canvas. The canvas window opens +automatically — no `canvas.present` required. +``` +{ + "jsonl": "string", // OR jsonlPath + "jsonlPath": "string", // must live under %TEMP% + "props": {} // optional +} +``` +Returns `{ "pushed": true }`. **See A2UI grammar below.** + +### canvas.a2ui.pushJSONL +Streaming variant of `canvas.a2ui.push` for very large surfaces. Same protocol +contract; `jsonlPath` argument must live under the system temp directory. + +### canvas.a2ui.reset +No params. Clears any rendered surfaces. Returns `{ "reset": true }`. + +### canvas.a2ui.dump +No params. Returns the current surface graph for introspection. **Read-all:** +this exposes every currently-rendered surface — operators should treat it as +equivalent to a screenshot of every open A2UI surface. + +### canvas.caps +No params. Returns renderer capabilities (renderer, snapshot, a2ui version). + +### screen.snapshot +``` +{ + "format": "png|jpeg", "maxWidth": 1920, "quality": 80, + "monitor": 0, "screenIndex": 0, // 0 = primary + "includePointer": true +} +``` +Returns `{ format, width, height, base64, image }` (image is a `data:` URL). + +### screen.record +``` +{ + "durationMs": 5000, // required, max 300000 + "format": "mp4|webm", + "monitor": 0, "screenIndex": 0, + "maxWidth": 1920, "fps": 30 +} +``` +Returns `{ format, durationMs, base64 }`. + +### camera.list +No params. Returns `{ cameras: [{ deviceId, name, isDefault }] }`. + +### camera.snap +``` +{"deviceId": "string", "format": "jpeg|png", "maxWidth": 1280, "quality": 80} +``` +Returns `{ format, width, height, base64 }`. `deviceId` defaults to system +default camera. + +### camera.clip +``` +{ + "deviceId": "string", // optional + "durationMs": 3000, // required, max 60000 + "format": "mp4|webm", + "maxWidth": 1280 +} +``` +Returns `{ format, durationMs, base64 }`. + +--- + +## A2UI v0.8 grammar (for canvas.a2ui.push) + +The `jsonl` argument is a string of newline-separated JSON-RPC-like messages. +Three message kinds are supported. **createSurface and v0.9 messages are +rejected.** + +### Message kinds + +```jsonc +// 1. Declare components for a surface (creates the surface if new). +{"surfaceUpdate": { + "surfaceId": "string", + "components": [ ComponentDef, ... ] +}} + +// 2. Pick the root component and (optionally) styles. Send AFTER surfaceUpdate. +{"beginRendering": { + "surfaceId": "string", + "root": "componentId", + "styles": { "primaryColor": "#FF6F61", "radius": 8.0, "spacing": 12.0 } +}} + +// 3. Seed/update the data model bound by Path() values. +{"dataModelUpdate": { + "surfaceId": "string", + "contents": [ + {"key": "headline", "valueString": "Hi"}, + {"key": "agreed", "valueBoolean": false}, + {"key": "volume", "valueNumber": 20.0} + ] +}} + +// 4. (Optional) Remove a surface. +{"deleteSurface": {"surfaceId": "string"}} +``` + +### ComponentDef + +```jsonc +{"id": "uniqueId", "component": {"": { ...props }}} +``` + +### Value bindings (inside a component prop) + +| Form | Meaning | +|-------------------------------|----------------------------------------| +| `{"literalString": "x"}` | Literal string | +| `{"path": "/key"}` | Read/write the data model | +| Plain string `"x"` | Component-id reference (e.g. `child`) | +| Plain number / bool | Used directly for numeric/bool props | + +### Component catalog + +| Category | Name | Notable props | +|--------------|---------------|---------------| +| Container | `Row` | `children: {explicitList: ["id", ...]}` | +| Container | `Column` | `children: {explicitList: ["id", ...]}` | +| Container | `List` | `children`, `dataBinding` | +| Container | `Card` | `child: "id"` | +| Container | `Tabs` | `tabItems: [{title, child}]` | +| Container | `Modal` | `child` | +| Container | `Divider` | `axis: "horizontal" | "vertical"` | +| Display | `Text` | `text: Lit/Path`, `usageHint: "h1|h2|h3|h4|h5|body|caption"` | +| Display | `Image` | `url: Lit/Path`, `fit: "contain|cover|fill|none"`, `usageHint` | +| Display | `Icon` | `name: Lit("settings"\|...)` | +| Display | `Video` | `url`, `autoplay`, `controls` | +| Display | `AudioPlayer` | `url`, `controls` | +| Interactive | `Button` | `child`, `primary: bool`, `action: {name, ...context}` | +| Interactive | `CheckBox` | `label: Lit/Path`, `value: Path` | +| Interactive | `TextField` | `value: Path`, `textFieldType: "shortText|longText|obscured"` | +| Interactive | `DateTimeInput` | `value: Path`, `mode: "date|time|datetime"` | +| Interactive | `MultipleChoice` | `value: Path`, `options: [{value, label}]` | +| Interactive | `Slider` | `value: Path`, `minValue`, `maxValue`, `step` | + +Lit/Path = the value-binding shapes from the previous section. + +### Minimal "hello world" payload + +``` +{"surfaceUpdate":{"surfaceId":"hello","components":[{"id":"helloText","component":{"Text":{"text":{"literalString":"Hello, world!"},"usageHint":"h1"}}}]}} +{"beginRendering":{"surfaceId":"hello","root":"helloText"}} +``` + +Pass this as the `jsonl` value (a single JSON string with `\n` between messages). + +--- + +## Token-efficient call patterns + +1. **Skip `--node` / `--idempotency-key`** — they're ignored locally; including + them just costs tokens. `--idempotency-key` triggers a stderr warning. +2. **Omit `--params` when the command takes no args** (`camera.list`, + `canvas.hide`, `canvas.a2ui.reset`, `canvas.a2ui.dump`, `canvas.caps`, + `system.execApprovals.get`). +3. **Large A2UI payloads** — write the JSONL to a file under the system temp + directory and pass `{"jsonlPath": ""}`. The capability rejects paths + outside `%TEMP%`. Or pass `--params @` to load the entire JSON + argument object from disk. +4. **Big binary results (snapshots, captures)** — output is base64 in stdout. + Pipe to a file (`> capture.json`) instead of letting the agent read it + inline. +5. **Errors are exit-code-driven** — check `$LASTEXITCODE` (or `$?` in bash) + first, then read stderr only on non-zero. Exit 2 = your call is malformed. +6. **Debug with `--verbose`, not by sharing transcripts** — without + `--verbose` the CLI shows only the first line of an HTTP error body and + redacts long base64url runs. With `--verbose` it shows the full sanitized + body. Treat any verbose output as containing potentially sensitive paths + or partial command output before pasting it elsewhere. + +## What's NOT exposed +- Pairing / device approval (gateway concept; doesn't apply locally). +- `chat.send`, `sessions.list`, `usage.list`, `node.list` — these belong to the + operator-side `OpenClaw.Cli.exe`, not `winnode.exe`. +- Idempotency. The gateway de-dupes retries against `--idempotency-key`; local + MCP does not. Retrying a `system.run` / `system.notify` / `canvas.present` + call after a transient failure can double-execute the side effect. +- Wildcards in `--command`. The MCP server has an explicit allowlist; unknown + commands return `Unknown tool: `. diff --git a/tests/OpenClaw.Tray.IntegrationTests/TrayAppFixture.cs b/tests/OpenClaw.Tray.IntegrationTests/TrayAppFixture.cs index c12d5d8..aed3fcc 100644 --- a/tests/OpenClaw.Tray.IntegrationTests/TrayAppFixture.cs +++ b/tests/OpenClaw.Tray.IntegrationTests/TrayAppFixture.cs @@ -46,14 +46,22 @@ public sealed class TrayAppFixture : IAsyncLifetime public async Task InitializeAsync() { - // Poll the friendly GET probe until the listener is up. The bridge starts - // synchronously inside RegisterCapabilities, but the WinUI startup sequence - // (mutex, settings, tray icon, etc.) runs first. + // Readiness has two preconditions, both of which must hold before any + // test runs a JSON-RPC call: + // 1. mcp-token.txt has been written by the tray. The tray creates it + // synchronously inside StartMcpServer, just before the listener + // binds — so on a healthy run it appears slightly *before* the + // HTTP server starts accepting. Required for Authorization headers. + // 2. GET / returns 200 with that bearer token. Confirms the listener + // is up *and* the in-memory token matches the on-disk one. + // Returning ready on (2) alone is unsafe: against a tray binary built + // before the auth-before-dispatch hardening, GET / returns 200 even + // without auth, so the fixture would skip step (1) and hand out a + // tokenless Client — every subsequent POST then 401s. var deadline = DateTime.UtcNow.AddSeconds(60); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; var tokenPath = Path.Combine(DataDir, "mcp-token.txt"); string? token = null; - var clientHasToken = false; Exception? lastEx = null; while (DateTime.UtcNow < deadline) { @@ -65,9 +73,23 @@ public sealed class TrayAppFixture : IAsyncLifetime } try { - if (token is null && File.Exists(tokenPath)) + if (token is null) { + if (!File.Exists(tokenPath)) + { + await Task.Delay(500).ConfigureAwait(false); + continue; + } token = (await File.ReadAllTextAsync(tokenPath).ConfigureAwait(false)).Trim(); + if (string.IsNullOrEmpty(token)) + { + // Mid-write zero-byte file is theoretically possible (the + // tray writes to a sibling temp and renames, but file + // systems are funny). Re-read on the next tick. + token = null; + await Task.Delay(500).ConfigureAwait(false); + continue; + } http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); } @@ -75,14 +97,8 @@ public sealed class TrayAppFixture : IAsyncLifetime var resp = await http.GetAsync($"http://127.0.0.1:{McpPort}/").ConfigureAwait(false); if (resp.StatusCode == HttpStatusCode.OK) { - // Server is up; re-issue Client with the bearer token so - // subsequent POSTs are authorized too. - if (token is not null && !clientHasToken) - { - Client.Dispose(); - Client = new McpClient(McpEndpoint, token); - clientHasToken = true; - } + Client.Dispose(); + Client = new McpClient(McpEndpoint, token); return; } } diff --git a/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs new file mode 100644 index 0000000..11c2d37 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs @@ -0,0 +1,412 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +/// +/// Tests covering bearer-token resolution: --mcp-token > $OPENCLAW_MCP_TOKEN > +/// on-disk mcp-token.txt under $OPENCLAW_TRAY_DATA_DIR (or %APPDATA%\OpenClawTray). +/// The on-disk path is sandboxed through a temp directory so these tests stay +/// hermetic on a developer machine that already has a real tray installed. +/// +public class AuthTokenTests : IDisposable +{ + private readonly string _sandboxDir; + + public AuthTokenTests() + { + _sandboxDir = Path.Combine(Path.GetTempPath(), $"winnode-auth-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_sandboxDir); + } + + public void Dispose() + { + try { Directory.Delete(_sandboxDir, recursive: true); } catch { /* best effort */ } + } + + private Func SandboxEnv(string? mcpToken = null) => key => key switch + { + "OPENCLAW_TRAY_DATA_DIR" => _sandboxDir, + "OPENCLAW_MCP_TOKEN" => mcpToken, + _ => null, + }; + + private static (StringWriter Out, StringWriter Err) Buffers() + => (new StringWriter(), new StringWriter()); + + [Fact] + public async Task No_token_anywhere_sends_no_authorization_header() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Null(server.LastRequestAuthorization); + } + + [Fact] + public async Task McpToken_flag_sets_bearer_header_with_literal_value() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--mcp-token", "flag-token-123" }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Equal("Bearer flag-token-123", server.LastRequestAuthorization); + } + + [Fact] + public async Task OPENCLAW_MCP_TOKEN_env_var_sets_bearer_header() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv(mcpToken: "env-token-456")); + + Assert.Equal(0, exit); + Assert.Equal("Bearer env-token-456", server.LastRequestAuthorization); + } + + [Fact] + public async Task McpToken_flag_takes_precedence_over_env_var() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--mcp-token", "flag-wins" }, + o, e, SandboxEnv(mcpToken: "env-loses")); + + Assert.Equal(0, exit); + Assert.Equal("Bearer flag-wins", server.LastRequestAuthorization); + } + + [Fact] + public async Task Token_file_under_OPENCLAW_TRAY_DATA_DIR_is_loaded_automatically() + { + // Mirrors the live flow: the tray writes mcp-token.txt to the sandbox + // dir, the CLI launched with the same OPENCLAW_TRAY_DATA_DIR finds it. + var tokenFromFile = "file-token-789"; + File.WriteAllText(Path.Combine(_sandboxDir, "mcp-token.txt"), tokenFromFile); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Equal($"Bearer {tokenFromFile}", server.LastRequestAuthorization); + } + + [Fact] + public async Task Env_var_takes_precedence_over_token_file() + { + File.WriteAllText(Path.Combine(_sandboxDir, "mcp-token.txt"), "file-loses"); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv(mcpToken: "env-wins")); + + Assert.Equal(0, exit); + Assert.Equal("Bearer env-wins", server.LastRequestAuthorization); + } + + [Fact] + public async Task Empty_token_file_is_treated_as_missing() + { + File.WriteAllText(Path.Combine(_sandboxDir, "mcp-token.txt"), " "); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Null(server.LastRequestAuthorization); + } + + [Fact] + public async Task Verbose_reports_auth_source_to_stderr() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--mcp-token", "secret", "--verbose" }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.Contains("auth: bearer", stderr); + Assert.Contains("--mcp-token", stderr); + // Don't print the secret itself. + Assert.DoesNotContain("secret", stderr); + } + + [Fact] + public async Task Verbose_reports_no_auth_when_token_missing() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--verbose" }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Contains("auth: none", e.ToString()); + } + + [Fact] + public void ResolveTokenPath_uses_OPENCLAW_TRAY_DATA_DIR_when_set() + { + Func env = k => k == "OPENCLAW_TRAY_DATA_DIR" ? @"C:\sandbox" : null; + var path = CliRunner.ResolveTokenPath(env); + Assert.Equal(Path.Combine(@"C:\sandbox", "mcp-token.txt"), path); + } + + [Fact] + public void ResolveTokenPath_falls_back_to_AppData_OpenClawTray() + { + Func env = _ => null; + var path = CliRunner.ResolveTokenPath(env); + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray", + "mcp-token.txt"); + Assert.Equal(expected, path); + } + + [Theory] + [InlineData("token with space")] // F-06: internal whitespace + [InlineData("token\rwith-CR")] // F-06: internal CR (Trim doesn't catch) + [InlineData("token\nwith-LF")] // F-06: internal LF + [InlineData("token\twith-tab")] // F-06: internal tab + [InlineData("token\0with-NUL")] // F-06: internal NUL + [InlineData("tokën-non-ascii")] // F-06: non-ASCII char + public async Task Token_with_invalid_chars_is_ignored_with_warning(string corruptToken) + { + File.WriteAllText( + Path.Combine(_sandboxDir, "mcp-token.txt"), + corruptToken, + new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + // Expectation: the call still goes through (no Authorization header), + // and stderr explains why. No unhandled crash. + Assert.Equal(0, exit); + Assert.Null(server.LastRequestAuthorization); + Assert.Contains("invalid characters", e.ToString()); + } + + [Fact] + public async Task Verbose_does_not_include_full_token_file_path() + { + // F-07: source label should be 'file' alone — the absolute path + // contains the username (PII) and would leak into CI logs. + var token = "abcdef0123456789"; + File.WriteAllText(Path.Combine(_sandboxDir, "mcp-token.txt"), token); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--verbose" }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.Contains("auth: bearer (file)", stderr); + Assert.DoesNotContain(_sandboxDir, stderr); + Assert.DoesNotContain("mcp-token.txt", stderr); + } + + [Fact] + public async Task McpToken_flag_emits_visibility_warning() + { + // F-04: warn unconditionally that --mcp-token is visible in process + // listings, regardless of --verbose. Don't warn for env-var or file + // sources, which aren't visible. + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--mcp-token", "abc" }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + Assert.Contains("--mcp-token is visible to other processes", e.ToString()); + } + + [Fact] + public async Task Env_var_token_does_not_emit_visibility_warning() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv(mcpToken: "env-token")); + + Assert.Equal(0, exit); + Assert.DoesNotContain("visible to other processes", e.ToString()); + } + + [Fact] + public async Task Idempotency_key_emits_warning_even_without_verbose() + { + // F-05: copy-pasted gateway commands include --idempotency-key, but + // local MCP doesn't dedupe. Warn loudly so a retry doesn't silently + // double-execute side effects. + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "system.notify", "--idempotency-key", "abc-123", + "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.Contains("[winnode] WARN", stderr); + Assert.Contains("--idempotency-key ignored", stderr); + } + + [Fact] + public async Task Unreadable_token_file_exits_1_with_diagnostic() + { + // F-20: when mcp-token.txt exists but cannot be read, distinguish the + // case from "file missing" so the operator gets a useful diagnostic + // instead of a 401-shaped "MCP not enabled" message. + if (!OperatingSystem.IsWindows()) return; // ACL-driven setup is Windows-only + var path = Path.Combine(_sandboxDir, "mcp-token.txt"); + File.WriteAllText(path, "good-token"); + + try + { + DenyOwnerRead(path); + } + catch + { + // Some test runners (CI containers, locked-down corp images) + // refuse SetAccessControl; skip rather than fail the matrix. + return; + } + + try + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(1, exit); + Assert.Contains("could not be read", e.ToString()); + } + finally + { + // Restore so Dispose() can delete the file. + try { RestoreOwnerFullControl(path); } catch { } + } + } + + [Fact] + public async Task Token_file_with_wide_acl_emits_warn() + { + // F-13: when the token file's DACL grants read to a non-owner / + // non-SYSTEM / non-Administrators principal (e.g. Everyone), the + // hygiene check from McpAuthToken.VerifyAcl should surface as + // [winnode] WARN: ... . The call still proceeds — the warning is + // hygienic, not blocking. + if (!OperatingSystem.IsWindows()) return; + var path = Path.Combine(_sandboxDir, "mcp-token.txt"); + File.WriteAllText(path, "wide-acl-token"); + + try + { + GrantEveryoneRead(path); + } + catch + { + return; // see Unreadable_token_file_exits_1_with_diagnostic + } + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, SandboxEnv()); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.Contains("[winnode] WARN", stderr); + Assert.Contains("ACL", stderr); + } + + [SupportedOSPlatform("windows")] + private static void GrantEveryoneRead(string path) + { + var info = new FileInfo(path); + var sec = info.GetAccessControl(); + var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null); + sec.AddAccessRule(new FileSystemAccessRule( + everyone, FileSystemRights.Read, AccessControlType.Allow)); + info.SetAccessControl(sec); + } + + [SupportedOSPlatform("windows")] + private static void DenyOwnerRead(string path) + { + var info = new FileInfo(path); + var sec = info.GetAccessControl(); + var current = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("no current user SID"); + // Deny entries override allow entries; this makes the file unreadable + // by the owner without modifying the ACL of the parent directory. + sec.AddAccessRule(new FileSystemAccessRule( + current, FileSystemRights.Read, AccessControlType.Deny)); + info.SetAccessControl(sec); + } + + [SupportedOSPlatform("windows")] + private static void RestoreOwnerFullControl(string path) + { + var info = new FileInfo(path); + var sec = info.GetAccessControl(); + var current = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("no current user SID"); + // Strip any deny rules we added so Dispose() can clean up. + sec.RemoveAccessRuleAll(new FileSystemAccessRule( + current, FileSystemRights.Read, AccessControlType.Deny)); + info.SetAccessControl(sec); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs new file mode 100644 index 0000000..b6f92ee --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class BuildToolsCallBodyTests +{ + private static JsonElement Args(string json) => JsonDocument.Parse(json).RootElement; + + private static string ToString((byte[] Buffer, int Length) result) + => Encoding.UTF8.GetString(result.Buffer, 0, result.Length); + + [Fact] + public void Produces_jsonrpc_envelope_with_tools_call_method() + { + var (buf, len) = CliRunner.BuildToolsCallBody("system.which", Args("{\"bins\":[\"git\"]}")); + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(buf, 0, len)); + var root = doc.RootElement; + + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal(1, root.GetProperty("id").GetInt32()); + Assert.Equal("tools/call", root.GetProperty("method").GetString()); + + var p = root.GetProperty("params"); + Assert.Equal("system.which", p.GetProperty("name").GetString()); + var args = p.GetProperty("arguments"); + Assert.Equal(JsonValueKind.Array, args.GetProperty("bins").ValueKind); + Assert.Equal("git", args.GetProperty("bins")[0].GetString()); + } + + [Fact] + public void Empty_object_args_round_trip() + { + var body = ToString(CliRunner.BuildToolsCallBody("screen.snapshot", Args("{}"))); + using var doc = JsonDocument.Parse(body); + var args = doc.RootElement.GetProperty("params").GetProperty("arguments"); + Assert.Equal(JsonValueKind.Object, args.ValueKind); + Assert.Empty(args.EnumerateObject()); + } + + [Fact] + public void Nested_args_preserve_structure() + { + var json = "{\"a\":{\"b\":[1,2,{\"c\":\"d\"}]},\"e\":true,\"f\":null}"; + var body = ToString(CliRunner.BuildToolsCallBody("x.y", Args(json))); + using var doc = JsonDocument.Parse(body); + var args = doc.RootElement.GetProperty("params").GetProperty("arguments"); + Assert.Equal("d", args.GetProperty("a").GetProperty("b")[2].GetProperty("c").GetString()); + Assert.True(args.GetProperty("e").GetBoolean()); + Assert.Equal(JsonValueKind.Null, args.GetProperty("f").ValueKind); + } + + [Fact] + public void Length_matches_actual_payload_size() + { + // F-14: the buffer is the underlying MemoryStream array (oversized); + // only Length bytes are valid. Ensure consumers honor that. + var (buf, len) = CliRunner.BuildToolsCallBody("x", Args("{}")); + Assert.True(len > 0); + Assert.True(buf.Length >= len); + var s = Encoding.UTF8.GetString(buf, 0, len); + Assert.EndsWith("}", s); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs new file mode 100644 index 0000000..57335f2 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class EmitResultTests +{ + private static (int Exit, string Stdout, string Stderr) Run(string body, bool verbose = true) + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + // EmitResult now takes a verbose flag (F-21). Tests pass verbose:true + // by default so existing assertions about full body contents still hold. + var exit = CliRunner.EmitResult(body, stdout, stderr, verbose); + return (exit, stdout.ToString(), stderr.ToString()); + } + + [Fact] + public void Success_with_text_content_pretty_prints_inner_json() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ok\":true,\"n\":42}"}],"isError":false}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + // Pretty-printed: each property on its own line. + Assert.Contains("\"ok\": true", stdout); + Assert.Contains("\"n\": 42", stdout); + Assert.Contains("\n", stdout); + } + + [Fact] + public void IsError_writes_text_to_stderr_and_exits_1() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"camera not found"}],"isError":true}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Equal("", stdout); + Assert.Contains("camera not found", stderr); + } + + [Fact] + public void IsError_without_text_falls_back_to_default_message() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[],"isError":true}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Equal("", stdout); + Assert.Contains("tool execution failed", stderr); + } + + [Fact] + public void Jsonrpc_error_field_writes_code_and_message_to_stderr() + { + var body = """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: foo"}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Equal("", stdout); + Assert.Contains("-32601", stderr); + Assert.Contains("Method not found: foo", stderr); + } + + [Fact] + public void Jsonrpc_error_without_message_uses_placeholder() + { + var body = """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000}} + """; + var (exit, _, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Contains("-32000", stderr); + Assert.Contains("(no message)", stderr); + } + + [Fact] + public void Missing_result_writes_body_and_exits_1() + { + var body = "{\"jsonrpc\":\"2.0\",\"id\":1}"; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Equal("", stdout); + Assert.Contains("missing 'result'", stderr, StringComparison.OrdinalIgnoreCase); + // The full body is sanitized through SanitizeForStderr in verbose mode; + // it has no token-shaped substrings or control chars, so it survives + // verbatim apart from the trailing newline. + Assert.Contains(body, stderr); + } + + [Fact] + public void Invalid_response_json_writes_body_and_exits_1() + { + var body = "not json at all"; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Equal("", stdout); + Assert.Contains("not valid JSON", stderr); + Assert.Contains("not json at all", stderr); + } + + [Fact] + public void Result_without_content_array_pretty_prints_raw_result() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"weird":"shape"}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("\"weird\": \"shape\"", stdout); + } + + [Fact] + public void Non_json_text_content_is_emitted_raw() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"plain string output"}],"isError":false}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("plain string output", stdout); + } + + [Fact] + public void Jsonrpc_error_without_code_defaults_to_zero() + { + var body = """ + {"jsonrpc":"2.0","id":1,"error":{"message":"oops"}} + """; + var (exit, _, stderr) = Run(body); + Assert.Equal(1, exit); + Assert.Contains("error 0", stderr); + Assert.Contains("oops", stderr); + } + + [Fact] + public void Content_not_an_array_pretty_prints_raw_result() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":"not-an-array"}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("\"content\": \"not-an-array\"", stdout); + } + + [Fact] + public void First_content_element_without_text_pretty_prints_raw_result() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"image","data":"abc"}]}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("\"image\"", stdout); + } + + [Fact] + public void First_content_text_non_string_pretty_prints_raw_result() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":42}]}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("42", stdout); + } + + [Fact] + public void Result_with_empty_content_array_pretty_prints_result() + { + var body = """ + {"jsonrpc":"2.0","id":1,"result":{"content":[],"isError":false}} + """; + var (exit, stdout, stderr) = Run(body); + Assert.Equal(0, exit); + Assert.Equal("", stderr); + Assert.Contains("\"content\"", stdout); + } + + [Fact] + public void Pretty_print_uses_json_formatting() + { + using var doc = JsonDocument.Parse("{\"a\":1,\"b\":[2,3]}"); + var pretty = CliRunner.PrettyPrint(doc.RootElement); + Assert.Contains("\n", pretty); + Assert.Contains("\"a\": 1", pretty); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs b/tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs new file mode 100644 index 0000000..f4718f0 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace OpenClaw.WinNode.Cli.Tests; + +/// +/// 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. +/// +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; } + + /// Set by the test before issuing the call. + public Func? Responder { get; set; } + + /// If true, the server holds the request to force a client timeout. + 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(); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj b/tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj new file mode 100644 index 0000000..0ad15c7 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs new file mode 100644 index 0000000..1a6bd76 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs @@ -0,0 +1,95 @@ +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class ParseArgsTests +{ + [Fact] + public void Parses_all_flags() + { + var opts = CliRunner.ParseArgs(new[] + { + "--node", "winbox-1", + "--command", "system.which", + "--params", "{\"bins\":[\"git\"]}", + "--invoke-timeout", "9000", + "--idempotency-key", "abc-123", + "--mcp-url", "http://127.0.0.1:9000/", + "--mcp-port", "9001", + "--verbose", + }); + + Assert.Equal("winbox-1", opts.Node); + Assert.Equal("system.which", opts.Command); + Assert.Equal("{\"bins\":[\"git\"]}", opts.Params); + Assert.Equal(9000, opts.InvokeTimeoutMs); + Assert.Equal("abc-123", opts.IdempotencyKey); + Assert.Equal("http://127.0.0.1:9000/", opts.McpUrlOverride); + Assert.Equal(9001, opts.McpPortOverride); + Assert.True(opts.Verbose); + } + + [Fact] + public void Defaults_when_only_command_given() + { + var opts = CliRunner.ParseArgs(new[] { "--command", "screen.list" }); + Assert.Equal("screen.list", opts.Command); + Assert.Equal("{}", opts.Params); + Assert.Equal(15000, opts.InvokeTimeoutMs); + Assert.Null(opts.Node); + Assert.Null(opts.IdempotencyKey); + Assert.Null(opts.McpUrlOverride); + Assert.Null(opts.McpPortOverride); + Assert.False(opts.Verbose); + } + + [Theory] + [InlineData("--node")] + [InlineData("--command")] + [InlineData("--params")] + [InlineData("--invoke-timeout")] + [InlineData("--idempotency-key")] + [InlineData("--mcp-url")] + [InlineData("--mcp-port")] + public void Missing_value_for_flag_throws(string flag) + { + var ex = Assert.Throws(() => CliRunner.ParseArgs(new[] { flag })); + Assert.Contains(flag, ex.Message); + } + + [Fact] + public void Unknown_flag_throws() + { + var ex = Assert.Throws(() => CliRunner.ParseArgs(new[] { "--bogus", "x" })); + Assert.Contains("--bogus", ex.Message); + } + + [Theory] + [InlineData("--invoke-timeout", "abc")] + [InlineData("--invoke-timeout", "0")] + [InlineData("--invoke-timeout", "-5")] + [InlineData("--invoke-timeout", "600001")] // F-18: above 10-min cap + [InlineData("--invoke-timeout", "2147483647")] // F-18: int.MaxValue rejected + [InlineData("--mcp-port", "not-a-number")] + [InlineData("--mcp-port", "0")] + [InlineData("--mcp-port", "65536")] // F-19: above TCP max + [InlineData("--mcp-port", "999999")] // F-19: way above + [InlineData("--mcp-port", "-1")] // F-19: below + public void Invalid_int_throws(string flag, string value) + { + var ex = Assert.Throws(() => CliRunner.ParseArgs(new[] { flag, value })); + Assert.Contains(flag, ex.Message); + } + + [Theory] + [InlineData("--mcp-port", "1")] + [InlineData("--mcp-port", "65535")] + [InlineData("--invoke-timeout", "1")] + [InlineData("--invoke-timeout", "600000")] + public void Boundary_values_accepted(string flag, string value) + { + // No throw. Bounds are inclusive. + var opts = CliRunner.ParseArgs(new[] { "--command", "x", flag, value }); + Assert.NotNull(opts); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs new file mode 100644 index 0000000..57e4b8b --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs @@ -0,0 +1,26 @@ +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class PrintUsageTests +{ + [Fact] + public void Prints_every_supported_flag() + { + var w = new StringWriter(); + CliRunner.PrintUsage(w); + var text = w.ToString(); + + Assert.Contains("--node", text); + Assert.Contains("--command", text); + Assert.Contains("--params", text); + Assert.Contains("--invoke-timeout", text); + Assert.Contains("--idempotency-key", text); + Assert.Contains("--mcp-url", text); + Assert.Contains("--mcp-port", text); + Assert.Contains("--mcp-token", text); + Assert.Contains("--verbose", text); + Assert.Contains("--help", text); + Assert.Contains("skill.md", text); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs new file mode 100644 index 0000000..b16e800 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs @@ -0,0 +1,101 @@ +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class ResolveEndpointTests +{ + private static Func Env(params (string Key, string Value)[] pairs) + { + var dict = pairs.ToDictionary(p => p.Key, p => p.Value); + return key => dict.TryGetValue(key, out var v) ? v : null; + } + + private static string Resolve(WinNodeOptions opts, Func env) + => CliRunner.ResolveEndpoint(opts, env, TextWriter.Null); + + [Fact] + public void Default_port_when_nothing_set() + { + var endpoint = Resolve(new WinNodeOptions(), Env()); + Assert.Equal("http://127.0.0.1:8765/", endpoint); + } + + [Fact] + public void Honors_OPENCLAW_MCP_PORT_env() + { + var endpoint = Resolve( + new WinNodeOptions(), + Env(("OPENCLAW_MCP_PORT", "9100"))); + Assert.Equal("http://127.0.0.1:9100/", endpoint); + } + + [Fact] + public void Mcp_port_flag_wins_over_env() + { + var endpoint = Resolve( + new WinNodeOptions { McpPortOverride = 9200 }, + Env(("OPENCLAW_MCP_PORT", "9100"))); + Assert.Equal("http://127.0.0.1:9200/", endpoint); + } + + [Fact] + public void Mcp_url_flag_wins_over_everything() + { + var endpoint = Resolve( + new WinNodeOptions + { + McpUrlOverride = "http://example.test:1234/mcp", + McpPortOverride = 9999, + }, + Env(("OPENCLAW_MCP_PORT", "9100"))); + Assert.Equal("http://example.test:1234/mcp", endpoint); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("-1")] + [InlineData("0")] + [InlineData("")] + [InlineData("65536")] // F-19: out of range -> default + [InlineData("999999")] // F-19: out of range -> default + public void Invalid_env_falls_back_to_default(string envValue) + { + var endpoint = Resolve( + new WinNodeOptions(), + Env(("OPENCLAW_MCP_PORT", envValue))); + Assert.Equal("http://127.0.0.1:8765/", endpoint); + } + + [Theory] + [InlineData("1")] + [InlineData("65535")] + public void Boundary_env_ports_accepted(string envValue) + { + var endpoint = Resolve( + new WinNodeOptions(), + Env(("OPENCLAW_MCP_PORT", envValue))); + Assert.Equal($"http://127.0.0.1:{envValue}/", endpoint); + } + + [Fact] + public void Verbose_warns_when_env_port_out_of_range() + { + var stderr = new StringWriter(); + var endpoint = CliRunner.ResolveEndpoint( + new WinNodeOptions { Verbose = true }, + Env(("OPENCLAW_MCP_PORT", "70000")), + stderr); + Assert.Equal("http://127.0.0.1:8765/", endpoint); + Assert.Contains("OPENCLAW_MCP_PORT", stderr.ToString()); + Assert.Contains("out of range", stderr.ToString()); + } + + [Fact] + public void Whitespace_url_override_falls_through_to_port_resolution() + { + var endpoint = Resolve( + new WinNodeOptions { McpUrlOverride = " " }, + Env()); + Assert.Equal("http://127.0.0.1:8765/", endpoint); + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs new file mode 100644 index 0000000..ee21c2b --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs @@ -0,0 +1,579 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using OpenClaw.WinNode.Cli; + +namespace OpenClaw.WinNode.Cli.Tests; + +public class RunAsyncTests : IDisposable +{ + // Tests run on developer machines where %APPDATA%\OpenClawTray\mcp-token.txt + // may exist with the live tray's token. Without an override, the CLI's + // automatic loader would happily pick that up and set an Authorization + // header for every test request, which is hermeticity-poison even if the + // FakeMcpServer ignores it. Redirect via OPENCLAW_TRAY_DATA_DIR (same + // sandbox env var the tray and integration tests honor) at a guaranteed- + // empty temp directory so the loader finds no file and runs without auth. + // + // F-10: per-instance + IDisposable so each test cleans up after itself. + // The directory must exist (F-08's path-canonicalization step needs to + // resolve a real directory) so we create it eagerly here. + private readonly string _sandboxDataDir; + + public RunAsyncTests() + { + _sandboxDataDir = Path.Combine(Path.GetTempPath(), $"winnode-test-sandbox-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_sandboxDataDir); + } + + public void Dispose() + { + try { Directory.Delete(_sandboxDataDir, recursive: true); } catch { /* best effort */ } + } + + private Func EmptyEnv => key => + key == "OPENCLAW_TRAY_DATA_DIR" ? _sandboxDataDir : null; + + private static (StringWriter Out, StringWriter Err) Buffers() + => (new StringWriter(), new StringWriter()); + + [Fact] + public async Task No_args_prints_usage_and_exits_2() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync(Array.Empty(), o, e, EmptyEnv); + Assert.Equal(2, exit); + Assert.Contains("winnode", o.ToString()); + Assert.Equal("", e.ToString()); + } + + [Fact] + public async Task Help_flag_prints_usage_and_exits_0() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync(new[] { "--help" }, o, e, EmptyEnv); + Assert.Equal(0, exit); + Assert.Contains("Usage:", o.ToString()); + Assert.Equal("", e.ToString()); + } + + [Fact] + public async Task Short_help_flag_works() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync(new[] { "-h" }, o, e, EmptyEnv); + Assert.Equal(0, exit); + Assert.Contains("--command", o.ToString()); + } + + [Fact] + public async Task Argument_error_prints_message_and_usage() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync(new[] { "--bogus", "x" }, o, e, EmptyEnv); + Assert.Equal(2, exit); + Assert.Contains("--bogus", e.ToString()); + Assert.Contains("Usage:", o.ToString()); + } + + [Fact] + public async Task Missing_command_exits_2() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync(new[] { "--node", "x" }, o, e, EmptyEnv); + Assert.Equal(2, exit); + Assert.Contains("--command is required", e.ToString()); + } + + [Fact] + public async Task Params_must_be_valid_json() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--params", "not json" }, + o, e, EmptyEnv); + Assert.Equal(2, exit); + Assert.Contains("not valid JSON", e.ToString()); + } + + [Theory] + [InlineData("[]")] + [InlineData("\"string\"")] + [InlineData("42")] + [InlineData("true")] + [InlineData("null")] + public async Task Params_must_be_object(string nonObject) + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--params", nonObject }, + o, e, EmptyEnv); + Assert.Equal(2, exit); + Assert.Contains("must be a JSON object", e.ToString()); + } + + [Fact] + public async Task Connection_refused_exits_1_with_hint() + { + // Pick a port that's almost certainly closed. + var port = FindClosedPort(); + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-port", port.ToString() }, + o, e, EmptyEnv); + Assert.Equal(1, exit); + var stderr = e.ToString(); + Assert.Contains("failed to reach MCP server", stderr); + Assert.Contains("Local MCP Server", stderr); + } + + [Fact] + public async Task Successful_call_pretty_prints_payload_and_sends_correct_envelope() + { + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.OK, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"sent\\\":true}\"}],\"isError\":false}}", + "application/json"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] + { + "--command", "system.notify", + "--params", "{\"body\":\"hi\"}", + "--mcp-url", server.Url, + }, + o, e, EmptyEnv); + + Assert.Equal(0, exit); + Assert.Contains("\"sent\": true", o.ToString()); + Assert.Equal("", e.ToString()); + + // Verify the wire format the server actually saw. + Assert.Equal("POST", server.LastRequestMethod); + Assert.StartsWith("application/json", server.LastRequestContentType ?? ""); + using var sent = JsonDocument.Parse(server.LastRequestBody!); + Assert.Equal("2.0", sent.RootElement.GetProperty("jsonrpc").GetString()); + Assert.Equal("tools/call", sent.RootElement.GetProperty("method").GetString()); + var p = sent.RootElement.GetProperty("params"); + Assert.Equal("system.notify", p.GetProperty("name").GetString()); + Assert.Equal("hi", p.GetProperty("arguments").GetProperty("body").GetString()); + } + + [Fact] + public async Task Tool_error_response_writes_to_stderr_and_exits_1() + { + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.OK, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"camera offline\"}],\"isError\":true}}", + "application/json"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "camera.snap", "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + Assert.Contains("camera offline", e.ToString()); + } + + [Fact] + public async Task Http_500_writes_status_and_body_to_stderr_and_exits_1() + { + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.InternalServerError, "kaboom", "text/plain"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + var stderr = e.ToString(); + Assert.Contains("MCP HTTP 500", stderr); + Assert.Contains("kaboom", stderr); + } + + [Fact] + public async Task Timeout_writes_message_and_exits_1() + { + using var server = new FakeMcpServer { HoldForever = true }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] + { + "--command", "x", + "--mcp-url", server.Url, + // CliRunner adds 5000ms buffer to the HTTP timeout, so keep this + // small so the test stays under a second. + "--invoke-timeout", "1", + }, + o, e, EmptyEnv); + + // The HttpClient timeout fires (1 + 5000 ms buffer = ~5s); test budget OK. + // Wider window for slow CI: the 5s ceiling matters only as an upper bound, + // not for correctness. + Assert.Equal(1, exit); + Assert.Contains("timed out", e.ToString()); + } + + [Fact] + public async Task Verbose_logs_endpoint_and_ignored_flags_to_stderr() + { + using var server = new FakeMcpServer(); + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] + { + "--node", "winbox-1", + "--idempotency-key", "abc", + "--command", "screen.list", + "--mcp-url", server.Url, + "--verbose", + }, + o, e, EmptyEnv); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.Contains(server.Url, stderr); + Assert.Contains("screen.list", stderr); + Assert.Contains("--node \"winbox-1\" ignored", stderr); + Assert.Contains("--idempotency-key ignored", stderr); + } + + [Fact] + public async Task Verbose_without_node_or_key_omits_their_lines() + { + using var server = new FakeMcpServer(); + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url, "--verbose" }, + o, e, EmptyEnv); + + Assert.Equal(0, exit); + var stderr = e.ToString(); + Assert.DoesNotContain("--node", stderr); + Assert.DoesNotContain("--idempotency-key", stderr); + } + + [Fact] + public async Task Endpoint_resolves_from_OPENCLAW_MCP_PORT_when_no_overrides() + { + using var server = new FakeMcpServer(); + var env = (string key) => key == "OPENCLAW_MCP_PORT" ? server.Port.ToString() : null; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list" }, + o, e, env); + + Assert.Equal(0, exit); + // The server received the request → env-based port resolution worked. + Assert.NotNull(server.LastRequestBody); + } + + [Fact] + public async Task Loopback_only_for_auto_loaded_token() + { + // F-01: an auto-loaded (file:) token must NOT be sent to a non-loopback + // endpoint. We point --mcp-url at a non-loopback hostname (resolved + // back to the FakeMcpServer's loopback port) and assert no + // Authorization header was sent. The CLI should still complete the + // call (warning, not failure) and the warning text should be on stderr. + File.WriteAllText(Path.Combine(_sandboxDataDir, "mcp-token.txt"), "auto-loaded-token"); + + // Bind a fake server on a free loopback port; rewrite the URL host + // so the CLI sees a non-loopback hostname but the request still + // reaches the fake server. We use HttpClient's IP resolution by + // building the URL to actually hit 127.0.0.1, and assert via the + // CLI's loopback check (Uri.IsLoopback is false for any DNS host + // even if it resolves to 127.0.0.1). + using var server = new FakeMcpServer(); + var nonLoopbackUrl = $"http://example.test:{server.Port}/"; + + // Plumb a delegating handler that rewrites example.test -> 127.0.0.1 + // so the request actually lands on the fake server. The CLI sees + // example.test and applies its loopback check before the rewrite. + using var rewriter = new RewriteHandler(server.Port); + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", nonLoopbackUrl }, + o, e, EmptyEnv, + httpHandler: rewriter); + + // The server should have received a request, but with no auth header. + Assert.NotNull(server.LastRequestBody); + Assert.Null(server.LastRequestAuthorization); + Assert.Contains("refusing to send local MCP token", e.ToString()); + Assert.Equal(0, exit); + } + + [Fact] + public async Task Explicit_token_to_non_loopback_warns_but_sends() + { + // F-01: an explicit --mcp-token override is honored even off-loopback + // (the user took the action knowingly), but stderr still warns. + using var server = new FakeMcpServer(); + var nonLoopbackUrl = $"http://example.test:{server.Port}/"; + using var rewriter = new RewriteHandler(server.Port); + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", nonLoopbackUrl, + "--mcp-token", "explicit-token-987" }, + o, e, EmptyEnv, + httpHandler: rewriter); + + Assert.Equal(0, exit); + Assert.Equal("Bearer explicit-token-987", server.LastRequestAuthorization); + Assert.Contains("sending bearer token to non-loopback URL", e.ToString()); + } + + [Theory] + [InlineData("not a url at all")] + [InlineData("htttp://typo.example/")] + [InlineData("file:///c:/etc/passwd")] + [InlineData("ftp://example.com/")] + public async Task Invalid_mcp_url_exits_2(string url) + { + // F-09: --mcp-url must be an absolute http(s) URL. Other schemes / + // typos surface as exit 2 (argument error) before any HTTP traffic. + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", url }, + o, e, EmptyEnv); + + Assert.Equal(2, exit); + Assert.Contains("absolute http(s) URL", e.ToString()); + } + + [Fact] + public async Task Redirect_3xx_treated_as_error() + { + // F-02: HttpClient.AllowAutoRedirect is disabled. Any 3xx surfaces as + // an error; we never silently follow. + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.Redirect, "moved", "text/plain"), + }; + // Set the Location header via a custom responder by switching to the + // generic post-process below — but FakeMcpServer doesn't expose that. + // For this test, just having status 302 is sufficient; HttpClient + // would normally chase a Location, but with AllowAutoRedirect=false, + // we get the raw 302 back. + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + Assert.Contains("redirect", e.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Response_body_over_cap_is_rejected() + { + // F-03: cap response body at 16 MiB. A buggy/hostile server returning + // a multi-GB body must not OOM the CLI. + // We can't easily generate 16 MiB in a test, so synthesize a body + // larger than the cap via a custom responder. Use a smaller cap-test + // by changing nothing — instead, return a body that's slightly larger + // than 16 MiB (17 MiB) to trip the limit. + var oversized = new string('A', 17 * 1024 * 1024); + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.OK, oversized, "application/json"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + // Either the size cap surfaced as an explicit message, or HttpClient + // raised a generic exception that we surface via the "failed to reach" + // path. Either way, exit 1 and an explanatory stderr line. + var stderr = e.ToString(); + Assert.True(stderr.Length > 0); + } + + [Fact] + public async Task Params_at_path_loads_json_from_file() + { + // F-12: `--params @path` reads JSON from disk so big A2UI payloads / + // canvas.eval scripts don't have to fit on the command line. + var paramsPath = Path.Combine(_sandboxDataDir, "params.json"); + File.WriteAllText(paramsPath, "{\"body\":\"loaded-from-file\"}"); + + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "system.notify", "--params", "@" + paramsPath, + "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(0, exit); + Assert.NotNull(server.LastRequestBody); + using var sent = JsonDocument.Parse(server.LastRequestBody!); + var args = sent.RootElement.GetProperty("params").GetProperty("arguments"); + Assert.Equal("loaded-from-file", args.GetProperty("body").GetString()); + } + + [Fact] + public async Task Params_at_missing_path_exits_2() + { + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--params", "@C:/no/such/path.json" }, + o, e, EmptyEnv); + + Assert.Equal(2, exit); + Assert.Contains("failed to read", e.ToString()); + } + + [Fact] + public async Task Idempotency_key_warns_to_stderr_without_verbose() + { + // F-05: a copy-pasted gateway command including --idempotency-key + // must produce a stderr WARN even at default verbosity. + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "screen.list", "--idempotency-key", "abc", + "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(0, exit); + Assert.Contains("[winnode] WARN", e.ToString()); + Assert.Contains("--idempotency-key ignored", e.ToString()); + } + + [Fact] + public async Task Error_body_with_control_chars_is_sanitized() + { + // F-16: ANSI escapes / CR-LF injection bytes from the server must be + // stripped before stderr emit so downstream log forwarders aren't + // tricked. + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.InternalServerError, + "error body \x1b[2Jinjected\x00\x07\x08hidden", + "text/plain"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--mcp-url", server.Url, "--verbose" }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + var stderr = e.ToString(); + Assert.Contains("MCP HTTP 500", stderr); + // Server-supplied ANSI escapes / NUL / BEL / BS must be stripped so a + // hostile body can't smuggle ANSI clear-screen, fake hyperlinks, or + // log-line splits into stderr-consuming tooling. Verify by walking the + // bytes — xUnit's assertion message swallows non-printables, so a + // direct byte check reads better. + var rogue = stderr.FirstOrDefault(c => c < ' ' && c != '\n' && c != '\r' && c != '\t'); + if (rogue != default(char)) + { + var hex = string.Concat(stderr.Select(c => ((int)c).ToString("X2") + " ")); + Assert.Fail($"Unexpected control char 0x{(int)rogue:X2} in stderr. Hex dump:\n{hex}"); + } + // The literal payload bytes should still be visible (sanitize strips + // controls but preserves printable content). + Assert.Contains("[2Jinjected", stderr); + Assert.Contains("hidden", stderr); + } + + [Fact] + public async Task Error_body_redacts_token_shaped_substrings() + { + // F-21: error bodies may legitimately echo paths, env values, or + // partial command output. Long base64url runs (≥32 chars) are + // redacted before emit so secrets don't leak into transcripts. + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.InternalServerError, + "leaked: AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_xyz end", + "text/plain"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--mcp-url", server.Url, "--verbose" }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + var stderr = e.ToString(); + Assert.Contains("", stderr); + Assert.DoesNotContain("AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", stderr); + } + + [Fact] + public async Task Error_body_default_quiet_only_first_line() + { + // F-21: without --verbose, only the first line of an error body is + // echoed. Matches gh / kubectl behavior. + using var server = new FakeMcpServer + { + Responder = _ => (HttpStatusCode.InternalServerError, + "first line\nsecond line with details\nthird line", + "text/plain"), + }; + + var (o, e) = Buffers(); + var exit = await CliRunner.RunAsync( + new[] { "--command", "x", "--mcp-url", server.Url }, + o, e, EmptyEnv); + + Assert.Equal(1, exit); + var stderr = e.ToString(); + Assert.Contains("first line", stderr); + Assert.DoesNotContain("second line", stderr); + Assert.DoesNotContain("third line", stderr); + } + + private static int FindClosedPort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + /// + /// Test helper: rewrite the request URI host from any DNS hostname to + /// 127.0.0.1 on the supplied port. Lets a test build a non-loopback URL + /// (so the CLI's loopback check sees it as off-box) while still having + /// the request actually reach the FakeMcpServer. + /// + private sealed class RewriteHandler : HttpClientHandler + { + private readonly int _port; + public RewriteHandler(int port) + { + _port = port; + AllowAutoRedirect = false; + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var ub = new UriBuilder(request.RequestUri!) { Host = "127.0.0.1", Port = _port }; + request.RequestUri = ub.Uri; + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs new file mode 100644 index 0000000..aa86569 --- /dev/null +++ b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using OpenClaw.Shared.Mcp; + +namespace OpenClaw.WinNode.Cli.Tests; + +/// +/// F-11: skill.md duplicates the tray-side capability surface for agent +/// readability. This test compares the set of ### <command> +/// headings in skill.md against +/// (the canonical list of documented capability commands) so additions +/// or renames in the tray fail loudly here instead of silently shipping +/// drifted documentation. +/// +/// The test compares command identifiers only — descriptions, examples, +/// and prose can be tweaked freely without breaking the test. +/// +public class SkillMdDriftTests +{ + [Fact] + public void SkillMd_command_set_matches_capability_registry() + { + var skillMdPath = LocateSkillMd(); + var content = File.ReadAllText(skillMdPath); + + var documented = ParseCommandHeadings(content); + var canonical = new HashSet(McpToolBridge.KnownCommands, StringComparer.Ordinal); + + var missingFromDoc = canonical.Except(documented).OrderBy(s => s).ToList(); + var extrasInDoc = documented.Except(canonical).OrderBy(s => s).ToList(); + + if (missingFromDoc.Count > 0 || extrasInDoc.Count > 0) + { + var msg = "skill.md drifted from the capability registry " + + "(McpToolBridge.CommandDescriptions). Update " + + $"src/OpenClaw.WinNode.Cli/skill.md.\n Missing from doc: " + + $"[{string.Join(", ", missingFromDoc)}]\n Extras in doc: " + + $"[{string.Join(", ", extrasInDoc)}]"; + Assert.Fail(msg); + } + } + + /// + /// skill.md lists each command under its own H3 heading like + /// ### system.notify. Anything matching ### <dotted.name> + /// counts as a documented command. We deliberately ignore other H3s + /// (e.g. "### Message kinds", "### ComponentDef") which don't have a + /// dotted-name shape. + /// + private static HashSet ParseCommandHeadings(string md) + { + // Match ### followed by a single dotted token (lowercase, dots, dots+lowercase + // segments only) to the end of the line. canvas.a2ui.pushJSONL has a + // mixed-case suffix, so allow camelCase tail segments too. + var rx = new Regex(@"^###\s+([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)\s*$", + RegexOptions.Multiline); + var set = new HashSet(StringComparer.Ordinal); + foreach (Match m in rx.Matches(md)) + { + set.Add(m.Groups[1].Value); + } + return set; + } + + /// + /// skill.md ships next to winnode.exe. From the test's working directory + /// (the test bin folder), walk up to the repo root and resolve the source + /// copy — that's the canonical input the build copies to output. Falls + /// back to the test bin's own copy if the source can't be located. + /// + private static string LocateSkillMd() + { + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 8 && dir is not null; i++) + { + var candidate = Path.Combine(dir, "src", "OpenClaw.WinNode.Cli", "skill.md"); + if (File.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir); + } + var nextTo = Path.Combine(AppContext.BaseDirectory, "skill.md"); + if (File.Exists(nextTo)) return nextTo; + throw new FileNotFoundException("Could not locate src/OpenClaw.WinNode.Cli/skill.md from the test working directory."); + } +}