openclaw-windows-node/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
Chris Anderson 3b8793db37
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>
2026-05-01 09:27:50 -07:00

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