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.");
+ }
+}