* 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>
413 lines
14 KiB
C#
413 lines
14 KiB
C#
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);
|
|
}
|
|
}
|