openclaw-windows-node/tests/OpenClaw.Tray.IntegrationTests/TrayAppFixture.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

241 lines
8.7 KiB
C#

using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Shared;
namespace OpenClaw.Tray.IntegrationTests;
/// <summary>
/// xUnit class fixture that spawns the WinUI tray app in MCP-only mode against
/// an isolated data directory and a free localhost port, and waits for the MCP
/// HTTP server to come up. One process is shared across the test class.
/// </summary>
public sealed class TrayAppFixture : IAsyncLifetime
{
public string DataDir { get; }
public int McpPort { get; }
public string McpEndpoint => $"http://127.0.0.1:{McpPort}/mcp";
public McpClient Client { get; private set; }
private readonly string _exePath;
private readonly Process _process;
private bool _disposed;
public TrayAppFixture()
{
DataDir = Path.Combine(Path.GetTempPath(), "openclaw-tray-it-" + Guid.NewGuid().ToString("N").Substring(0, 8));
Directory.CreateDirectory(DataDir);
McpPort = FindFreePort();
WriteSettings();
_exePath = LocateTrayExe();
_process = SpawnTray();
// Note: token doesn't exist until the tray starts the MCP server.
// We reset Client.Authorization once the file appears in InitializeAsync.
Client = new McpClient(McpEndpoint);
}
public async Task InitializeAsync()
{
// 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;
Exception? lastEx = null;
while (DateTime.UtcNow < deadline)
{
if (_process.HasExited)
{
throw new InvalidOperationException(
$"Tray process exited before MCP server became ready (exit code {_process.ExitCode}). " +
$"Logs: {Path.Combine(DataDir, "openclaw-tray.log")}");
}
try
{
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);
}
var resp = await http.GetAsync($"http://127.0.0.1:{McpPort}/").ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.OK)
{
Client.Dispose();
Client = new McpClient(McpEndpoint, token);
return;
}
}
catch (Exception ex)
{
lastEx = ex;
}
await Task.Delay(500).ConfigureAwait(false);
}
throw new TimeoutException(
$"MCP server never came up on {McpEndpoint}. Last error: {lastEx?.Message}. " +
$"Logs: {Path.Combine(DataDir, "openclaw-tray.log")}");
}
public Task DisposeAsync()
{
if (_disposed) return Task.CompletedTask;
_disposed = true;
Client.Dispose();
try
{
if (!_process.HasExited)
{
// Tray app has no clean IPC to ask it to quit, so just kill it.
// Mutex and HTTP listener are released on process exit.
_process.Kill(entireProcessTree: true);
_process.WaitForExit(5_000);
}
}
catch { /* best effort */ }
finally
{
_process.Dispose();
}
try { Directory.Delete(DataDir, recursive: true); } catch { /* best effort */ }
return Task.CompletedTask;
}
private void WriteSettings()
{
// Token must be non-empty to skip the setup wizard. HasSeenActivityStreamTip
// suppresses the first-run UI tip. EnableMcpServer + !EnableNodeMode routes
// through StartLocalOnlyAsync (no gateway WebSocket).
var settings = new SettingsData
{
Token = "integration-test-token",
EnableMcpServer = true,
EnableNodeMode = false,
AutoStart = false,
GlobalHotkeyEnabled = false,
ShowNotifications = false,
HasSeenActivityStreamTip = true,
};
File.WriteAllText(Path.Combine(DataDir, "settings.json"), settings.ToJson());
}
private static string LocateTrayExe()
{
var rid = RuntimeInformation.ProcessArchitecture switch
{
Architecture.Arm64 => "win-arm64",
Architecture.X64 => "win-x64",
var other => throw new PlatformNotSupportedException($"Unsupported process architecture: {other}"),
};
var configuration =
#if DEBUG
"Debug";
#else
"Release";
#endif
var repoRoot = FindRepoRoot();
var exe = Path.Combine(
repoRoot,
"src", "OpenClaw.Tray.WinUI", "bin", configuration,
"net10.0-windows10.0.19041.0", rid, "OpenClaw.Tray.WinUI.exe");
if (!File.Exists(exe))
{
throw new FileNotFoundException(
$"Tray exe not found at {exe}. Build it first: " +
$"`dotnet build src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj -c {configuration} -r {rid}`");
}
return exe;
}
private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "openclaw-windows-node.slnx")))
return dir;
var parent = Directory.GetParent(dir)?.FullName;
if (parent == dir || parent == null) break;
dir = parent;
}
throw new DirectoryNotFoundException("Could not locate repo root (openclaw-windows-node.slnx) from " + AppContext.BaseDirectory);
}
private static int FindFreePort()
{
// Bind to port 0 to let the OS pick a free port, then release it.
// There's a small race window where another process could grab the port
// before the tray app rebinds, but it's vanishingly small in practice.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
private Process SpawnTray()
{
var psi = new ProcessStartInfo
{
FileName = _exePath,
WorkingDirectory = Path.GetDirectoryName(_exePath)!,
UseShellExecute = false,
CreateNoWindow = false, // tray app is windowed; flag doesn't really apply
};
psi.Environment["OPENCLAW_TRAY_DATA_DIR"] = DataDir;
psi.Environment["OPENCLAW_MCP_PORT"] = McpPort.ToString();
psi.Environment["OPENCLAW_SUPPRESS_EXTERNAL_BROWSER"] = "1";
var p = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start tray app process");
return p;
}
}