openclaw-windows-node/build.ps1
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

242 lines
7.6 KiB
PowerShell

<#
.SYNOPSIS
Build script for OpenClaw Windows Hub
.DESCRIPTION
Builds all projects, checks prerequisites, and provides clear guidance.
.PARAMETER Project
Which project to build: All, Tray, WinUI, Shared, CommandPalette, Cli
Default: All
.PARAMETER Configuration
Build configuration: Debug, Release
Default: Debug
.PARAMETER CheckOnly
Only check prerequisites, don't build
.EXAMPLE
.\build.ps1
.\build.ps1 -Project WinUI -Configuration Release
.\build.ps1 -CheckOnly
#>
param(
[ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli")]
[string]$Project = "All",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[switch]$CheckOnly
)
$ErrorActionPreference = "Stop"
# Colors for output
function Write-Header($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
function Write-Success($text) { Write-Host "$text" -ForegroundColor Green }
function Write-Warning($text) { Write-Host "⚠️ $text" -ForegroundColor Yellow }
function Write-Error($text) { Write-Host "$text" -ForegroundColor Red }
function Write-Info($text) { Write-Host " $text" -ForegroundColor Gray }
# Track issues
$issues = @()
Write-Host @"
🦞 OpenClaw Windows Hub - Build Script
=======================================
"@ -ForegroundColor Magenta
# =============================================================================
# PREREQUISITE CHECKS
# =============================================================================
Write-Header "Checking Prerequisites"
# Check OS
if ($env:OS -ne "Windows_NT") {
Write-Error "This project requires Windows"
exit 1
}
Write-Success "Windows detected"
# Check .NET SDK
$dotnetVersion = $null
try {
$dotnetVersion = & dotnet --version 2>$null
} catch {}
if (-not $dotnetVersion) {
Write-Error ".NET SDK not found"
Write-Info "Download from: https://dotnet.microsoft.com/download"
$issues += "Missing .NET SDK"
} else {
Write-Success ".NET SDK: $dotnetVersion"
# Check for .NET 10 (needed for all projects)
$sdks = & dotnet --list-sdks 2>$null
$hasNet10 = $sdks | Where-Object { $_ -match "^10\." }
if (-not $hasNet10) {
Write-Error ".NET 10 SDK not found (required for all projects)"
Write-Info "Download preview from: https://dotnet.microsoft.com/download/dotnet/10.0"
$issues += "Missing .NET 10 SDK"
} else {
Write-Success ".NET 10 SDK available"
}
}
# Check Windows SDK (for WinUI)
$windowsSdkPath = "${env:ProgramFiles(x86)}\Windows Kits\10\Include"
if (Test-Path $windowsSdkPath) {
$sdkVersions = Get-ChildItem $windowsSdkPath -Directory | Select-Object -ExpandProperty Name | Sort-Object -Descending
Write-Success "Windows SDK: $($sdkVersions[0])"
} else {
Write-Warning "Windows 10 SDK not found (needed for WinUI build)"
Write-Info "Install via Visual Studio Installer or standalone SDK"
$issues += "Windows 10 SDK not detected"
}
# Check WebView2 Runtime (for WinUI chat window)
$webView2Key = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
$webView2KeyAlt = "HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
$webView2Version = $null
if (Test-Path $webView2Key) {
$webView2Version = (Get-ItemProperty $webView2Key -ErrorAction SilentlyContinue).pv
} elseif (Test-Path $webView2KeyAlt) {
$webView2Version = (Get-ItemProperty $webView2KeyAlt -ErrorAction SilentlyContinue).pv
}
if ($webView2Version) {
Write-Success "WebView2 Runtime: $webView2Version"
} else {
Write-Warning "WebView2 Runtime not detected (needed for WinUI chat window)"
Write-Info "Usually pre-installed on Windows 10/11. Get from: https://developer.microsoft.com/microsoft-edge/webview2"
# Not a hard failure - app will fall back to browser
}
# Check architecture
$arch = $env:PROCESSOR_ARCHITECTURE
Write-Success "Architecture: $arch"
if ($arch -eq "ARM64") {
Write-Info "ARM64 detected - builds will target ARM64 by default"
}
# Summary
Write-Header "Prerequisite Summary"
if ($issues.Count -eq 0) {
Write-Success "All prerequisites met!"
} else {
Write-Warning "$($issues.Count) issue(s) found:"
foreach ($issue in $issues) {
Write-Info "- $issue"
}
}
if ($CheckOnly) {
Write-Host "`nRun without -CheckOnly to build.`n"
exit 0
}
# =============================================================================
# BUILD
# =============================================================================
Write-Header "Building Projects ($Configuration)"
# Detect runtime identifier based on architecture
$rid = if ($arch -eq "ARM64") { "win-arm64" } else { "win-x64" }
Write-Info "Runtime identifier: $rid"
$buildResults = @{}
function Build-Project($name, $path, $useRid = $false) {
Write-Host "`nBuilding $name..." -ForegroundColor White
if (-not (Test-Path $path)) {
Write-Error "Project not found: $path"
return $false
}
# WinUI requires runtime identifier for self-contained WebView2 support
if ($useRid) {
$result = & dotnet build $path -c $Configuration -r $rid 2>&1
} else {
$result = & dotnet build $path -c $Configuration 2>&1
}
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
Write-Success "$name built successfully"
return $true
} else {
Write-Error "$name build failed"
# Show relevant error lines
$result | Select-String "error" | Select-Object -First 5 | ForEach-Object {
Write-Info $_.Line
}
return $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", "WinNodeCli", "WinUI") } else { @($Project) }
# Always build Shared first if building other projects
if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Shared") {
$toBuild = @("Shared") + $toBuild
}
foreach ($proj in $toBuild) {
if ($projects.ContainsKey($proj)) {
$projInfo = $projects[$proj]
$buildResults[$proj] = Build-Project $proj $projInfo.Path $projInfo.UseRid
}
}
# =============================================================================
# SUMMARY
# =============================================================================
Write-Header "Build Summary"
$successCount = ($buildResults.Values | Where-Object { $_ -eq $true }).Count
$failCount = ($buildResults.Values | Where-Object { $_ -eq $false }).Count
foreach ($proj in $buildResults.Keys) {
if ($buildResults[$proj]) {
Write-Success "$proj"
} else {
Write-Error "$proj"
}
}
Write-Host ""
if ($failCount -eq 0) {
Write-Host "🦞 All builds succeeded!" -ForegroundColor Green
Write-Host "`nTo run:" -ForegroundColor Cyan
if ($buildResults.ContainsKey("WinUI") -or $buildResults.ContainsKey("All")) {
Write-Host " WinUI: .\src\OpenClaw.Tray.WinUI\bin\$Configuration\net10.0-windows10.0.19041.0\$rid\OpenClaw.Tray.WinUI.exe" -ForegroundColor White
}
} else {
Write-Host "$failCount build(s) failed" -ForegroundColor Red
exit 1
}
Write-Host ""