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

* feat: winnode CLI for invoking node commands over local MCP

Mirrors `openclaw nodes invoke`'s flag surface but routes to the local
tray's MCP HTTP server (default http://127.0.0.1:8765/) instead of the
gateway. `--node` and `--idempotency-key` are accepted for paste-from-
gateway parity and ignored.

Ships skill.md alongside winnode.exe documenting every supported
command, argument schema, and the A2UI v0.8 JSONL grammar for agent use.

Tests: 62 cases, 100% line/branch on CliRunner via in-process unit tests
plus a loopback HttpListener fake that exercises the full HTTP path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

InitializeAsync would return ready as soon as `GET /` returned 200, even
if `mcp-token.txt` had not been read yet. Against a tray binary built
before the auth-before-dispatch hardening (where `GET /` answers 200
without auth), this raced ahead and handed back a tokenless `Client` —
every subsequent POST then 401'd. Restructure the loop to require both
the token-on-disk and a 200 from a token-bearing GET before declaring
ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

The CLI now sends `Authorization: Bearer <token>` on every MCP request,
without the user having to plumb the token themselves. Resolution chain
mirrors the per-tool secret convention (gh, az, anthropic):

  1. `--mcp-token <literal>` flag
  2. `OPENCLAW_MCP_TOKEN` env var (literal)
  3. `mcp-token.txt` under `$OPENCLAW_TRAY_DATA_DIR` if set, else
     `%APPDATA%\OpenClawTray\` — the same location SettingsManager
     points the tray at, so a sandboxed tray is found automatically.

When the token comes from disk, run `McpAuthToken.VerifyAcl` (the same
hygiene check `NodeService.StartMcpServer` runs at startup) and route
any owner/DACL warning to stderr so the user knows to rotate. `--verbose`
reports the resolved auth source without echoing the secret value.

Tests redirect via `OPENCLAW_TRAY_DATA_DIR` to a temp sandbox dir so they
don't pick up the developer machine's real tray token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Hardens the winnode CLI against the threat model in
C:/temp/winnode-cli-review-2026-04-30/01-findings.md. F-15 (port-0 nit)
was approved as no-action; F-17 was a positive observation.

- F-01/F-09: validate --mcp-url; refuse auto-loaded token off-loopback
- F-02: explicit SocketsHttpHandler with AllowAutoRedirect=false
- F-03: cap response body at 16 MiB with explicit overflow message
- F-04: warn unconditionally when --mcp-token is used (process-listing leak)
- F-05: warn unconditionally when --idempotency-key is supplied
- F-06: TokenLooksValid ASCII-printable check; ignore corrupt tokens
- F-07: don't echo full token-file path in --verbose
- F-08: canonicalize OPENCLAW_TRAY_DATA_DIR; reject symlink redirect
- F-10: RunAsyncTests is now IDisposable (cleans up sandbox dir)
- F-11: SkillMdDriftTests + REGENERATE-ME header in skill.md;
        McpToolBridge.KnownCommands exposes the canonical command set;
        skill.md re-synced with live capability surface
- F-12: --params @<path> loads JSON object from disk
- F-13: Token_file_with_wide_acl_emits_warn (Windows-only, gracefully
        skips when SetAccessControl is denied by hardened CI)
- F-14: BuildToolsCallBody returns (byte[], int) consumed by
        ByteArrayContent without a string round-trip
- F-16+F-21: SanitizeForStderr strips control chars, redacts ≥32-char
        base64url runs, caps at 4 KiB, default-quiet first-line-only,
        full sanitized body under --verbose
- F-18: --invoke-timeout capped at 600000 ms; long arithmetic on the
        +5000 buffer; out-of-range exits 2
- F-19: --mcp-port and OPENCLAW_MCP_PORT bounded [1, 65535]; env-var
        out-of-range falls back to default with a verbose warning
- F-20: distinguish missing/empty/unreadable/loaded token-file states;
        unreadable exits 1 with a diagnostic before any HTTP traffic

Tests: 23 added (115/115 pass). All other suites stay green
(Shared 1046/1066, Tray 245/245, Integration 18/18, UI 62/62).
WinNode CLI line coverage: 91.6% (434/474 in Program.cs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Anderson 2026-05-01 09:27:50 -07:00 committed by GitHub
parent 1433349d10
commit 3b8793db37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2897 additions and 15 deletions

View File

@ -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") {

View File

@ -8,6 +8,7 @@
</Configurations>
<Folder Name="/src/">
<Project Path="src/OpenClaw.Cli/OpenClaw.Cli.csproj" />
<Project Path="src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj" />
<Project Path="src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj">
<Platform Solution="*|Any CPU" Project="x64" />
<Platform Solution="*|x64" Project="x64" />
@ -24,6 +25,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj" />
<Project Path="tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj" />
<Project Path="tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj" />
<Project Path="tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj" />
<Project Path="tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj">

View File

@ -168,6 +168,13 @@ public class McpToolBridge
return new { tools };
}
/// <summary>
/// The complete set of commands documented in <see cref="CommandDescriptions"/>.
/// Exposed as a stable surface so out-of-process documentation (winnode's
/// skill.md) can be drift-tested against the canonical capability surface.
/// </summary>
public static IReadOnlyCollection<string> KnownCommands => CommandDescriptions.Keys;
/// <summary>
/// Per-command descriptions advertised via <c>tools/list</c>. Sourced from
/// the OpenClaw docs (docs/nodes/index.md, docs/platforms/mac/canvas.md) and

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>winnode</AssemblyName>
<RootNamespace>OpenClaw.WinNode.Cli</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Ship the agent skill reference next to winnode.exe. -->
<Content Include="skill.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenClaw.Shared\OpenClaw.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenClaw.WinNode.Cli.Tests" />
</ItemGroup>
</Project>

View File

@ -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; }
}
/// <summary>
/// Entry-point shim. All real work lives in <see cref="CliRunner"/> so it can
/// be exercised from unit tests without touching <see cref="Console"/> or the
/// process environment.
/// </summary>
internal static class Program
{
private static Task<int> 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<int> RunAsync(
string[] args,
TextWriter stdout,
TextWriter stderr,
Func<string, string?> 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 @<path> 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>: 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;
}
}
/// <summary>
/// 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 <see cref="MaxStderrEchoBytes"/> (F-16, F-21).
/// 4. When not in <paramref name="verbose"/>, return only the first line
/// (F-21: matches gh / kubectl posture — full body on --verbose only).
/// </summary>
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, "<redacted>");
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(
@"(?<![A-Za-z0-9_\-])[A-Za-z0-9_\-]{32,}(?![A-Za-z0-9_\-])",
RegexOptions.Compiled);
internal static string PrettyPrint(JsonElement element)
=> JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = true });
/// <summary>
/// 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).
/// </summary>
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<string, string?> 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<string, string?> 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);
/// <summary>
/// Resolve the bearer token sent on every MCP request, in priority order:
/// <list type="number">
/// <item><c>--mcp-token &lt;literal&gt;</c> flag (matches <c>gh --token</c>,
/// <c>az login --service-principal --password</c>, etc.).</item>
/// <item><c>OPENCLAW_MCP_TOKEN</c> env var (literal). Standard
/// per-tool secret env-var convention — same shape as <c>GITHUB_TOKEN</c>,
/// <c>ANTHROPIC_API_KEY</c>, <c>NUGET_API_KEY</c>.</item>
/// <item>The on-disk token file the tray writes when MCP is enabled —
/// <c>%APPDATA%\OpenClawTray\mcp-token.txt</c> by default, or
/// <c>$OPENCLAW_TRAY_DATA_DIR\mcp-token.txt</c> when the tray was launched
/// with that sandbox override (the integration test fixture uses it).</item>
/// </list>
/// When the token is loaded from disk, mirror the tray's own startup hygiene
/// check by running <see cref="McpAuthToken.VerifyAcl"/> 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.
/// </summary>
internal static AuthTokenResult ResolveAuthToken(
WinNodeOptions options,
Func<string, string?> 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}");
}
/// <summary>
/// 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.
/// </summary>
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<string, string?> 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");
}
/// <summary>
/// F-06: validate token chars before handing to AuthenticationHeaderValue,
/// which throws on whitespace / CR / LF / non-ASCII (token68 ABNF).
/// </summary>
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 <command> [--params <json>] [options]");
stdout.WriteLine();
stdout.WriteLine("Options:");
stdout.WriteLine(" --node <idOrNameOrIp> Accepted for parity with `openclaw nodes invoke`; ignored");
stdout.WriteLine(" --command <command> Command to invoke (e.g. system.which, canvas.eval) [required]");
stdout.WriteLine(" --params <json|@path> 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 <ms> Invoke timeout in ms (default: 15000, max: 600000)");
stdout.WriteLine(" --idempotency-key <key> Accepted for parity; ignored over local MCP (warns)");
stdout.WriteLine(" --mcp-url <url> Override MCP endpoint (default: http://127.0.0.1:<port>/)");
stdout.WriteLine(" --mcp-port <port> Override MCP port [1-65535] (default: $OPENCLAW_MCP_PORT or 8765)");
stdout.WriteLine(" --mcp-token <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.");
}
}

View File

@ -0,0 +1,343 @@
<!--
REGENERATE-ME-WHEN-CAPABILITIES-CHANGE
The list of supported commands below is checked at CI time against the live
capability surface (see SkillMdDriftTests). When a capability is added,
removed, or renamed in src/OpenClaw.Shared/Mcp/McpToolBridge.cs
(CommandDescriptions), update this document so the drift test stays green —
the test compares command identifiers, so prose can still be tweaked by hand.
-->
# 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 <name> [--params '<json-object>'] [--invoke-timeout <ms>]
```
- `--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 @<path>`** 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 <url>` / `--mcp-port <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 <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": {"<ComponentName>": { ...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": "<path>"}`. The capability rejects paths
outside `%TEMP%`. Or pass `--params @<path>` 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: <name>`.

View File

@ -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;
}
}

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
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<string, string?> 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<string, string?> 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<string, string?> 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

View File

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\OpenClaw.WinNode.Cli\OpenClaw.WinNode.Cli.csproj" />
</ItemGroup>
</Project>

View File

@ -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<ArgumentException>(() => CliRunner.ParseArgs(new[] { flag }));
Assert.Contains(flag, ex.Message);
}
[Fact]
public void Unknown_flag_throws()
{
var ex = Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,101 @@
using OpenClaw.WinNode.Cli;
namespace OpenClaw.WinNode.Cli.Tests;
public class ResolveEndpointTests
{
private static Func<string, string?> 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<string, string?> 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);
}
}

View File

@ -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<string, string?> 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<string>(), 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("<redacted>", 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;
}
/// <summary>
/// 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.
/// </summary>
private sealed class RewriteHandler : HttpClientHandler
{
private readonly int _port;
public RewriteHandler(int port)
{
_port = port;
AllowAutoRedirect = false;
}
protected override Task<HttpResponseMessage> 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);
}
}
}

View File

@ -0,0 +1,83 @@
using System.Text.RegularExpressions;
using OpenClaw.Shared.Mcp;
namespace OpenClaw.WinNode.Cli.Tests;
/// <summary>
/// F-11: skill.md duplicates the tray-side capability surface for agent
/// readability. This test compares the set of <c>### &lt;command&gt;</c>
/// headings in skill.md against <see cref="McpToolBridge.KnownCommands"/>
/// (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.
/// </summary>
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<string>(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);
}
}
/// <summary>
/// skill.md lists each command under its own H3 heading like
/// <c>### system.notify</c>. Anything matching <c>### &lt;dotted.name&gt;</c>
/// counts as a documented command. We deliberately ignore other H3s
/// (e.g. "### Message kinds", "### ComponentDef") which don't have a
/// dotted-name shape.
/// </summary>
private static HashSet<string> 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<string>(StringComparer.Ordinal);
foreach (Match m in rx.Matches(md))
{
set.Add(m.Groups[1].Value);
}
return set;
}
/// <summary>
/// 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.
/// </summary>
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.");
}
}