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:
parent
1433349d10
commit
3b8793db37
@ -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") {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
25
src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj
Normal file
25
src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj
Normal 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>
|
||||
811
src/OpenClaw.WinNode.Cli/Program.cs
Normal file
811
src/OpenClaw.WinNode.Cli/Program.cs
Normal 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 <literal></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.");
|
||||
}
|
||||
}
|
||||
343
src/OpenClaw.WinNode.Cli/skill.md
Normal file
343
src/OpenClaw.WinNode.Cli/skill.md
Normal 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>`.
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
412
tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
Normal file
412
tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
Normal file
65
tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
199
tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs
Normal file
199
tests/OpenClaw.WinNode.Cli.Tests/EmitResultTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
110
tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs
Normal file
110
tests/OpenClaw.WinNode.Cli.Tests/FakeMcpServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\OpenClaw.WinNode.Cli\OpenClaw.WinNode.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
95
tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs
Normal file
95
tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs
Normal file
26
tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
101
tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs
Normal file
101
tests/OpenClaw.WinNode.Cli.Tests/ResolveEndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
579
tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
Normal file
579
tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
Normal file
83
tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
Normal 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>### <command></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>### <dotted.name></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.");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user