* 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>
241 lines
8.7 KiB
C#
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;
|
|
}
|
|
}
|