From 2178da67cd49a94b439e8b4c6c7ec2e677cbf5fe Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:44:51 +0000 Subject: [PATCH 01/77] feat(browser): pair Chrome with the managed Windows gateway Add a bounded native-messaging executable, current-user tray IPC, managed-local WSL pairing admission, ownership-preserving registration, packaging, and architecture-specific proof hooks. Store installation and Windows runtime proof remain coordinated follow-up gates. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- .github/workflows/ci.yml | 14 ++ docs/ARCHITECTURE.md | 1 + docs/BROWSER_EXTENSION_BOOTSTRAP.md | 125 ++++++++++++++++ installer.iss | 35 +++++ openclaw-windows-node.slnx | 1 + scripts/Test-BrowserNativeHost.ps1 | 137 ++++++++++++++++++ scripts/Test-ReleaseExecutableSignatures.ps1 | 5 +- .../OpenClaw.BrowserNativeHost.csproj | 14 ++ src/OpenClaw.BrowserNativeHost/Program.cs | 48 ++++++ .../BrowserBootstrapService.cs | 79 ++++++++++ .../BrowserBootstrapWslCommand.cs | 90 ++++++++++++ .../Browser/BrowserBootstrapPipeServer.cs | 66 +++++++++ .../Browser/BrowserNativeProtocol.cs | 106 ++++++++++++++ .../Browser/BrowserNativeRegistration.cs | 121 ++++++++++++++++ .../App.AppShutdownCoordinator.cs | 10 ++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 7 + .../BrowserNativeHost.targets | 30 ++++ .../OpenClaw.Tray.WinUI.csproj | 1 + .../Services/BrowserBootstrapHost.cs | 66 +++++++++ .../BrowserBootstrapServiceTests.cs | 95 ++++++++++++ .../BrowserBootstrapPipeTests.cs | 42 ++++++ .../BrowserNativeProtocolTests.cs | 107 ++++++++++++++ ...rowserBootstrapIntegrationContractTests.cs | 52 +++++++ .../OpenClaw.Tray.Tests.csproj | 3 + 24 files changed, 1254 insertions(+), 1 deletion(-) create mode 100644 docs/BROWSER_EXTENSION_BOOTSTRAP.md create mode 100644 scripts/Test-BrowserNativeHost.ps1 create mode 100644 src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj create mode 100644 src/OpenClaw.BrowserNativeHost/Program.cs create mode 100644 src/OpenClaw.Connection/BrowserBootstrapService.cs create mode 100644 src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserNativeProtocol.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs create mode 100644 src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets create mode 100644 src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs create mode 100644 tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/BrowserNativeProtocolTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5915ab931..5faab284b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -736,6 +736,10 @@ jobs: - name: Publish WinUI Tray App run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r win-x64 --self-contained --no-restore -o publish -p:Version=$env:OPENCLAW_BUILD_VERSION -p:InformationalVersion=$env:OPENCLAW_BUILD_VERSION + - name: Prove packaged native browser host + shell: pwsh + run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe + - name: Verify x64 Native Runtime Payload shell: pwsh run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime @@ -792,6 +796,10 @@ jobs: - name: Publish WinUI Tray App run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r win-arm64 --self-contained --no-restore -o publish -p:Version=$env:OPENCLAW_BUILD_VERSION -p:InformationalVersion=$env:OPENCLAW_BUILD_VERSION + - name: Prove packaged native browser host + shell: pwsh + run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe + - name: Verify ARM64 Native Runtime Payload shell: pwsh # -SkipNativeLoadProbe keeps parity with the prior release matrix. Presence @@ -1023,6 +1031,9 @@ jobs: foreach ($binary in $openClawBinaries) { New-Item -ItemType HardLink -Path "signing-input-x64\$binary" -Target "artifacts\tray-win-x64\$binary" | Out-Null } + foreach ($binary in @("OpenClaw.BrowserNativeHost.exe", "OpenClaw.BrowserNativeHost.dll")) { + New-Item -ItemType HardLink -Path "signing-input-x64\$binary" -Target "artifacts\tray-win-x64\tools\browser-bootstrap\$binary" | Out-Null + } - name: Stage ARM64 OpenClaw Binaries for Signing shell: pwsh @@ -1041,6 +1052,9 @@ jobs: foreach ($binary in $openClawBinaries) { New-Item -ItemType HardLink -Path "signing-input-arm64\$binary" -Target "artifacts\tray-win-arm64\$binary" | Out-Null } + foreach ($binary in @("OpenClaw.BrowserNativeHost.exe", "OpenClaw.BrowserNativeHost.dll")) { + New-Item -ItemType HardLink -Path "signing-input-arm64\$binary" -Target "artifacts\tray-win-arm64\tools\browser-bootstrap\$binary" | Out-Null + } - name: Sign x64 OpenClaw Binaries uses: azure/artifact-signing-action@v2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eceb743ed..2a1010230 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -252,6 +252,7 @@ leading and trailing pipe. Columns, in order: | chat-composer-host-lifetime | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs | ad hoc per-render HostCallbacks assignment and no explicit composer session lifetime | IChatComposerFactory + ChatComposerSession, owned/disposed exactly once by MountedReactorChat | ChatPage and ChatWindow each receive a separate session over the same provider; the factory is a stateless singleton with no constructor-started work | disposing a MountedReactorChat disposes its ChatComposerSession (controller then view model) exactly once, and repeated Dispose calls are a no-op | ChatComposerSessionTests.Dispose_DisposesViewModelAndControllerExactlyOnce | behavioral | - | | reactor-chat-root-composer-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs | composer draft/attachment/slash/voice/send mutable state and direct composer send/model/thinking/catalog/queue-cancel provider calls | ChatComposerViewModel + ChatComposerController | provider subscription, initial load, immutable snapshot, selected/materialized/compose-only thread selection, timeline/generation/metadata projection, permission-card forwarding, checkpoint routing, #1089 scroll/follow tokens, root composition, and construction of one immutable ChatComposerInputs projection per render | the root holds no composer UseState/refs and calls no composer provider API directly; it builds ChatComposerInputs, forwards them to ReactorChatComposer for post-commit application, and binds the SelectThread handoff | ChatRootComposerClosureTests.Root_DoesNotReintroduceComposerMutableState | source-shape | when OpenClawReactorChatRoot is replaced by a different root/composer boundary | | sensitive-capture-guard | authoritative | src/OpenClaw.Tray.WinUI/Services/NodeService.cs | ordering of consent, visible per-use indication, and sensor access for instantaneous screen, camera, and location captures | SensitiveCaptureExecutor + SensitiveCapturePlans | NodeService supplies the consent prompt, localized notification, and platform sensor delegates | every sensitive instant capture completes consent and visible indication before its sensor delegate can run; denied consent never reaches the sensor | SensitiveCaptureExecutorTests.ExecuteAsync_RequiresConsentAndIndicatorBeforeSensorAccess | behavioral | - | +| browser-native-bootstrap | authoritative | new native messaging entry point | local browser extension pairing admission, generation fencing and CLI delegation | BrowserBootstrapService + BrowserBootstrapHost + BrowserBootstrapWslCommand | App composes and disposes the host only; GatewayConnectionManager retains all connection intent and lifecycle | disabled browser control, explicit disconnect, active gateway changes and unverified endpoints cannot deliver pairing material; no parallel relay or credential store | BrowserBootstrapServiceTests.DisconnectDuringCli_DiscardsPairing | behavioral | - | ## Deferred test builders diff --git a/docs/BROWSER_EXTENSION_BOOTSTRAP.md b/docs/BROWSER_EXTENSION_BOOTSTRAP.md new file mode 100644 index 000000000..a35478af0 --- /dev/null +++ b/docs/BROWSER_EXTENSION_BOOTSTRAP.md @@ -0,0 +1,125 @@ +# Windows Chrome extension bootstrap + +The Windows native host is `ai.openclaw.browser_bootstrap`. It admits only the +Foundation extension `kcdjddhmeafeomebliikmbpblkmkfoig`. Chrome still owns installation +and permission approval. This implementation does not force enterprise policy, +change Chrome profile preferences, or replace an extension's saved pairing. + +## Current scope + +Automatic pairing is supported for a running release Companion connected to its +explicitly setup-managed local WSL gateway. Browser control must be enabled and +the connection manager must still permit automatic connection intent. An explicit +Disconnect/Stop, gateway switch, disabled Browser control, unverified listener, +or stopped Companion prevents credential delivery. Dev and isolated instances +do not claim the production Chrome host. + +Remote/SSH gateways, legacy records identified only by a friendly-name convention, +and custom WSL users are not automatically adopted. The native host returns +`manual_required` for unsupported topology and `pairing_unavailable` for unavailable +local runtime. No relay process is started by the Windows helper. The existing +`ensure_relay` operation is recognized but returns `manual_required` in this lane. + +The per-user installer registers the native executable before starting the tray. +Store-request installation is a separate pending integration; native registration +alone does not install the extension. A registry entry or manifest belonging to +another installation is preserved. Moving the executable, including a versioned +MSIX path change, currently requires explicit removal of its old owned native +registration before registering the new installation. This is not silently +resolved by overwriting a foreign path. Packaged MSIX registry visibility and +upgrade behavior require real Windows proof before claiming MSIX parity. + +## Ownership and call graph + +1. Chrome launches `tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe`. + `BrowserNativeProtocol` validates the exact origin, strict v1 request schema, + canonical 16-32-byte nonce, UTF-8, and four-byte little-endian framing. + Requests are capped at 4 KiB. Raw standard streams are binary, not text writers. +2. `BrowserNativeRegistration` verifies the per-user manifest, exact executable + and origin. It inspects both registry views and machine/user locations before + creating the HKCU 32-bit native-host registration, preserving foreign entries. +3. The executable sends one bounded request through the Windows current-user-only + named pipe `OpenClawTray.BrowserBootstrap.v1`. Both pipe endpoints use + `PipeOptions.CurrentUserOnly`. The process does not read saved gateway tokens, + settings credentials, or a copied bearer secret. Same-user arbitrary code is + outside this boundary, as with Chrome's native messaging launch itself. +4. `BrowserBootstrapHost` composes `GatewayRegistry`, + `IGatewayConnectionManager`, `SettingsManager` and + `ManagedLocalGatewayPortProvenanceService`. `BrowserBootstrapService` pins the + active record, verifies ownership before and after CLI execution, and discards + stale results after state/registry/settings generations change. +5. `BrowserBootstrapWslCommand` invokes the system `wsl.exe` in that exact distro, + with a script over stdin to `/bin/bash --noprofile --norc -s`. It verifies the + default WSL user is `openclaw` and selects only the installer's known CLI + locations: `/home/openclaw/.openclaw/bin/openclaw`, `/opt/openclaw/bin/openclaw`, + then `/usr/local/bin/openclaw`. It clears inherited CLI profile/state/config and + Node overrides. It checks the running default systemd service's profile/state/config + environment against the default installed user profile and refuses custom paths. + stdout/stderr are bounded, secret-bearing stdout remains only + in memory, and neither is passed through SetupEngine's command-output logger. +6. The canonical TypeScript CLI owns relay-token generation and pairing encoding. + Windows validates local topology, gateway port, loopback route and gateway hint + before returning the nonce-bound native response. + +Existing runtime facts behind that boundary: + +- `InstallCliStep.ExecuteAsync` installs and verifies the managed Linux CLI and + its Node runtime; `EnsureCliOnDefaultPathAsync` supplies a compatibility symlink. +- `WslGatewayControlCommandBuilder` uses the same known CLI locations and stdin + scripts for start/stop/restart. Bootstrap does not call those lifecycle actions. +- `BrowserProxyActivation` and `NodeService` register `BrowserProxyCapability`, + which forwards HTTP to a browser-control endpoint. `BrowserProxyTunnelState` + can forward the remote control endpoint over SSH. That is not a local extension + relay or bundled Windows Node worker, so it cannot supply a Windows-local relay + credential. This patch leaves that separate HTTP/shared-token contract unchanged. + +## Cross-repository CLI contract + +Required command in the managed WSL install: + +```text +openclaw browser extension pair --json --local-gateway +``` + +Successful stdout must be exactly one JSON object, with no progress prose: + +```json +{ + "topology": "local", + "relayPort": 18792, + "pairingString": "ws://127.0.0.1:18789/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#" +} +``` + +The flag must use the canonical `buildBrowserExtensionPairing` with +`localTransport: "gateway"`, reject remote-mode/TLS configurations, preserve browser +opt-out, and never reinterpret a gateway authentication token as a relay token. +The gateway port must come from that same CLI profile. The Windows active record +port must match. Old CLI versions fail closed; Windows does not scrape human +output or fall back to reading another profile's secrets. + +## Validation and proof gates + +Baseline remains `./build.ps1`, full Shared tests and full Tray tests. Add: + +```powershell +dotnet test tests/OpenClaw.Shared.Tests --filter "FullyQualifiedName~BrowserNativeProtocolTests|FullyQualifiedName~BrowserBootstrapPipeTests" +dotnet test tests/OpenClaw.Connection.Tests --filter FullyQualifiedName~BrowserBootstrapServiceTests +dotnet test tests/OpenClaw.Tray.Tests --filter FullyQualifiedName~BrowserBootstrapIntegrationContractTests +./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe +``` + +The executable proof runs in the x64 and ARM64 publish jobs. It refuses existing +host registrations, uses synthetic pairing material, validates binary framing, +exact-origin rejection, oversize rejection, authenticated pipe handoff, owned +cleanup and preservation of a foreign registry entry. It is not a Chrome/WSL E2E +proof and must not be represented as one. + +Required real behavior proof still needs a disposable Windows installation with +the matching TS CLI, official Chrome extension, and explicit Chrome permission +approval. Exercise first pairing, existing extension pairing, extension disconnect, +Browser-control disabled, gateway Disconnect/Stop, gateway switch during bootstrap, +foreign native registration, normal upgrade, uninstall and ARM64 launch. Never +publish pairing strings, raw native responses, gateway records or token files. +Relevant proof pools are `windows-clean-installer-upgrade`, +`windows-wsl-gateway-e2e`, `windows-11-arm64` and `windows-winui-interactive`. diff --git a/installer.iss b/installer.iss index 506720732..d1e5cae99 100644 --- a/installer.iss +++ b/installer.iss @@ -129,6 +129,7 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [Code] var VCRuntimeInstallSucceeded: Boolean; + BrowserNativeHostRegistered: Boolean; LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; @@ -338,10 +339,44 @@ begin Log('{#MyStartupTaskName} startup task already absent or unavailable.'); end; +procedure RegisterBrowserNativeHost; +var + ResultCode: Integer; +begin + BrowserNativeHostRegistered := False; +#ifndef DevBuild + if Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), + '--register', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + BrowserNativeHostRegistered := ResultCode = 0; + if not BrowserNativeHostRegistered then + Log('Native browser registration was not ready. No extension installation may be requested.'); +#endif +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + // ssPostInstall runs after payload installation and before the [Run] tray launch. + // Any Store-request integration must additionally require BrowserNativeHostRegistered. + if CurStep = ssPostInstall then + RegisterBrowserNativeHost; +end; + +procedure UnregisterBrowserNativeHost; +var + ResultCode: Integer; +begin +#ifndef DevBuild + if not Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), + '--unregister', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Log('Native browser host unregister was unavailable. Foreign registrations are preserved.'); +#endif +end; + procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); begin if CurUninstallStep = usUninstall then begin + UnregisterBrowserNativeHost; RemoveAppAutoStart; EnsureLocalGatewayCleanupChoice; RunLocalGatewayCleanup; diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index f828a1420..d80b05308 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -5,6 +5,7 @@ + diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 new file mode 100644 index 000000000..7ef9a4223 --- /dev/null +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -0,0 +1,137 @@ +<# +.SYNOPSIS + Exercises the packaged native .exe with binary frames and a synthetic current-user tray pipe. +.DESCRIPTION + Run only on a disposable Windows proof host. Refuses any pre-existing host registration. + This proves native framing/registry/IPC, not Chrome permission approval or real WSL pairing. +#> +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ExecutablePath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$exe = (Resolve-Path -LiteralPath $ExecutablePath).Path +$key = 'Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap' +$origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/' +$root = [Microsoft.Win32.RegistryKey]::OpenBaseKey('CurrentUser', 'Registry32') +foreach ($hive in @('CurrentUser', 'LocalMachine')) { + foreach ($view in @('Registry32', 'Registry64')) { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + try { + $existing = $base.OpenSubKey($key) + if ($null -ne $existing) { $existing.Dispose(); throw 'Proof refuses an existing native host registration.' } + } finally { $base.Dispose() } + } +} + +function Start-Native([string]$CallerOrigin) { + $start = [Diagnostics.ProcessStartInfo]::new($exe) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.ArgumentList.Add($CallerOrigin) + $start.ArgumentList.Add('--parent-window=0') + [Diagnostics.Process]::Start($start) +} +function Write-Frame([IO.Stream]$Stream, [byte[]]$Bytes) { + $header = [BitConverter]::GetBytes([uint32]$Bytes.Length) + $Stream.Write($header, 0, 4) + $Stream.Write($Bytes, 0, $Bytes.Length) + $Stream.Flush() +} +function Read-Frame([IO.Stream]$Stream) { + $ct = [Threading.CancellationTokenSource]::new(10000) + try { + $header = [byte[]]::new(4) + $Stream.ReadExactlyAsync([Memory[byte]]$header, $ct.Token).AsTask().GetAwaiter().GetResult() + $length = [BitConverter]::ToUInt32($header, 0) + if ($length -eq 0 -or $length -gt 4096) { throw 'Invalid response frame length.' } + $bytes = [byte[]]::new($length) + $Stream.ReadExactlyAsync([Memory[byte]]$bytes, $ct.Token).AsTask().GetAwaiter().GetResult() + [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json + } finally { $ct.Dispose() } +} +function Assert-Exit([Diagnostics.Process]$Process) { + if (-not $Process.WaitForExit(10000)) { $Process.Kill($true); throw 'Native host did not exit.' } + if ($Process.ExitCode -ne 0) { throw 'Native host returned nonzero exit.' } + if ($Process.StandardError.ReadToEnd().Length -ne 0) { throw 'Native host unexpectedly wrote diagnostics.' } + if ($Process.StandardOutput.BaseStream.ReadByte() -ne -1) { throw 'Native host wrote trailing unframed output.' } +} + +$registered = $false +try { + & $exe --register + if ($LASTEXITCODE -ne 0) { throw 'Native registration failed.' } + $registered = $true + $process = Start-Native 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/' + try { + $reply = Read-Frame $process.StandardOutput.BaseStream + if ($reply.ok -or $reply.code -ne 'origin_forbidden') { throw 'Foreign origin was not rejected.' } + Assert-Exit $process + } finally { $process.Dispose() } + + $process = Start-Native $origin + try { + $header = [BitConverter]::GetBytes([uint32]4097) + $process.StandardInput.BaseStream.Write($header, 0, 4) + $process.StandardInput.Close() + $reply = Read-Frame $process.StandardOutput.BaseStream + if ($reply.ok -or $reply.code -ne 'invalid_frame') { throw 'Oversized frame was not rejected.' } + Assert-Exit $process + } finally { $process.Dispose() } + + $pipe = [IO.Pipes.NamedPipeServerStream]::new('OpenClawTray.BrowserBootstrap.v1', + [IO.Pipes.PipeDirection]::InOut, 1, [IO.Pipes.PipeTransmissionMode]::Byte, + [IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::CurrentUserOnly) + try { + $waiting = $pipe.WaitForConnectionAsync() + $process = Start-Native $origin + try { + $json = '{"v":1,"op":"bootstrap","nonce":"AAAAAAAAAAAAAAAAAAAAAA"}' + Write-Frame $process.StandardInput.BaseStream ([Text.Encoding]::UTF8.GetBytes($json)) + if (-not $waiting.Wait(10000)) { throw 'Native host did not connect to tray pipe.' } + $request = Read-Frame $pipe + if ($request.op -ne 'bootstrap' -or $request.nonce -ne 'AAAAAAAAAAAAAAAAAAAAAA') { throw 'Native request changed.' } + $response = '{"v":1,"ok":true,"nonce":"AAAAAAAAAAAAAAAAAAAAAA","pairingString":"synthetic-native-proof"}' + Write-Frame $pipe ([Text.Encoding]::UTF8.GetBytes($response)) + $reply = Read-Frame $process.StandardOutput.BaseStream + if (-not $reply.ok -or $reply.pairingString -ne 'synthetic-native-proof' -or $reply.nonce -ne $request.nonce) { throw 'Native response changed.' } + Assert-Exit $process + } finally { + if (-not $process.HasExited) { $process.Kill($true) } + $process.Dispose() + } + } finally { $pipe.Dispose() } + & $exe --unregister + if ($LASTEXITCODE -ne 0) { throw 'Native unregister failed.' } + $registered = $false + $remaining = $root.OpenSubKey($key) + if ($null -ne $remaining) { $remaining.Dispose(); throw 'Owned registration remained after uninstall.' } + # Verify a foreign entry is neither overwritten nor removed. The sentinel is this proof's own key. + $sentinel = 'openclaw-proof-foreign-' + [Guid]::NewGuid().ToString('N') + $foreign = $root.CreateSubKey($key) + $foreign.SetValue('', $sentinel) + $foreign.Dispose() + try { + & $exe --register + if ($LASTEXITCODE -eq 0) { throw 'Native registration adopted a foreign entry.' } + & $exe --unregister + if ($LASTEXITCODE -eq 0) { throw 'Native unregister adopted a foreign entry.' } + $foreign = $root.OpenSubKey($key) + try { + if ($foreign.GetValue('') -ne $sentinel) { throw 'Foreign registration was changed.' } + } finally { $foreign.Dispose() } + } finally { + $foreign = $root.OpenSubKey($key) + if ($null -ne $foreign) { + $owned = $foreign.GetValue('') -eq $sentinel + $foreign.Dispose() + if ($owned) { $root.DeleteSubKey($key, $false) } + } + } + Write-Host 'NATIVE_BOOTSTRAP_PROOF_OK: binary framing, exact origin, bounded request, current-user IPC, owned cleanup, foreign-entry preservation.' +} finally { + if ($registered) { & $exe --unregister } + $root.Dispose() +} diff --git a/scripts/Test-ReleaseExecutableSignatures.ps1 b/scripts/Test-ReleaseExecutableSignatures.ps1 index 357e2c3b1..fd9c4cbbd 100644 --- a/scripts/Test-ReleaseExecutableSignatures.ps1 +++ b/scripts/Test-ReleaseExecutableSignatures.ps1 @@ -53,6 +53,7 @@ function Get-BinaryClassification { '^OpenClaw\.SetupEngine\.dll$' { return "OpenClawOwned" } '^OpenClaw\.Shared\.dll$' { return "OpenClawOwned" } '^OpenClawTray\.FunctionalUI\.dll$' { return "OpenClawOwned" } + '^tools\\browser-bootstrap\\OpenClaw\.BrowserNativeHost\.(exe|dll)$' { return "OpenClawOwned" } '(^|\\)createdump\.exe$' { return "ThirdPartyExcluded" } '(^|\\)RestartAgent\.exe$' { return "ThirdPartyExcluded" } '^tools\\mxc\\[^\\]+\\wxc-exec\.exe$' { return "ThirdPartyExcluded" } @@ -127,7 +128,9 @@ foreach ($binary in $binaries) { "OpenClaw.SetupEngine.UI.dll", "OpenClaw.SetupEngine.dll", "OpenClaw.Shared.dll", - "OpenClawTray.FunctionalUI.dll" + "OpenClawTray.FunctionalUI.dll", + "tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe", + "tools\browser-bootstrap\OpenClaw.BrowserNativeHost.dll" ) | ForEach-Object { $requiredBinary = $_ if (-not ($binaries | Where-Object RelativePath -eq $requiredBinary)) { diff --git a/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj b/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj new file mode 100644 index 000000000..668f1b4d1 --- /dev/null +++ b/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + OpenClaw.BrowserNativeHost + enable + enable + + + + + + + diff --git a/src/OpenClaw.BrowserNativeHost/Program.cs b/src/OpenClaw.BrowserNativeHost/Program.cs new file mode 100644 index 000000000..22e6e9047 --- /dev/null +++ b/src/OpenClaw.BrowserNativeHost/Program.cs @@ -0,0 +1,48 @@ +using System.IO.Pipes; +using OpenClaw.Shared.Browser; + +if (!OperatingSystem.IsWindows()) return 1; +if (args.Length == 1 && args[0] is "--register" or "--unregister") +{ + try + { + return BrowserNativeRegistration.Apply(Environment.ProcessPath!, args[0] == "--unregister") ? 0 : 1; + } + catch { return 1; } // Never emit manifest paths or user data to Chrome's stdout. +} + +byte[] response; +using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25)); +try +{ + if (!BrowserNativeProtocol.IsAllowedCaller(args)) + response = BrowserNativeProtocol.Failure("origin_forbidden"); + else if (!BrowserNativeRegistration.IsRegistered(Environment.ProcessPath!)) + response = BrowserNativeProtocol.Failure("manifest_invalid"); + else + { + var payload = await BrowserNativeProtocol.ReadAsync(Console.OpenStandardInput(), BrowserNativeProtocol.RequestLimit, deadline.Token); + _ = BrowserNativeProtocol.ParseRequest(payload); + // Windows authenticates both ends to the current user. No HTTP endpoint or copied bearer token. + using var pipe = new NamedPipeClientStream(".", BrowserNativeProtocol.PipeName, + PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(2000, deadline.Token); + await BrowserNativeProtocol.WriteAsync(pipe, payload, deadline.Token); + response = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.ResponseLimit, deadline.Token); + } +} +catch (InvalidDataException ex) when (ex.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") +{ + response = BrowserNativeProtocol.Failure(ex.Message); +} +catch +{ + response = BrowserNativeProtocol.Failure("pairing_unavailable"); +} +try +{ + using var writeDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await BrowserNativeProtocol.WriteAsync(Console.OpenStandardOutput(), response, writeDeadline.Token); + return 0; +} +catch { return 1; } diff --git a/src/OpenClaw.Connection/BrowserBootstrapService.cs b/src/OpenClaw.Connection/BrowserBootstrapService.cs new file mode 100644 index 000000000..25a6a7934 --- /dev/null +++ b/src/OpenClaw.Connection/BrowserBootstrapService.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Connection; + +/// Pairs only the active, connected app-managed local gateway. Never owns a relay or gateway lifecycle. +public sealed class BrowserBootstrapService( + Func getActive, + Func isAllowed, + Func> verifyEndpoint, + Func> runPairing) +{ + private long _generation; + + public void Invalidate() => Interlocked.Increment(ref _generation); + + public async Task HandleAsync(byte[] payload, CancellationToken ct) + { + var generation = Interlocked.Read(ref _generation); + var request = BrowserNativeProtocol.ParseRequest(payload); + // Local-gateway transport wakes Browser control itself. Arbitrary relay ports must never start a process. + if (request.Op != "bootstrap") return BrowserNativeProtocol.Failure("manual_required"); + var record = getActive(); + if (!IsManagedLocal(record)) return BrowserNativeProtocol.Failure("manual_required"); + var pinned = record!; + if (generation != Interlocked.Read(ref _generation) || !Current(pinned) || + !await verifyEndpoint(pinned, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) + return BrowserNativeProtocol.Failure("pairing_unavailable"); + var json = await runPairing(pinned.SetupManagedDistroName!, ct); + var pairing = ParsePairing(json, new Uri(pinned.Url).Port); + // A disconnect, switch, or preference change while the CLI ran wins over returning credentials. + if (generation != Interlocked.Read(ref _generation) || !Current(pinned) || + !await verifyEndpoint(pinned, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) + return BrowserNativeProtocol.Failure("pairing_unavailable"); + return BrowserNativeProtocol.Pairing(request.Nonce, pairing); + } + + private bool Current(GatewayRecord pinned) + { + var current = getActive(); + return current is not null && current.Id == pinned.Id && current.Url == pinned.Url && + current.SetupManagedDistroName == pinned.SetupManagedDistroName && IsManagedLocal(current) && isAllowed(current); + } + + public static bool IsManagedLocal(GatewayRecord? record) => + record is { IsLocal: true, SshTunnel: null } && + !string.IsNullOrWhiteSpace(record.SetupManagedDistroName) && + record.SetupManagedDistroName.Length <= 64 && + record.SetupManagedDistroName.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.') && + Uri.TryCreate(record.Url, UriKind.Absolute, out var uri) && + uri.Scheme == "ws" && uri.Host is "127.0.0.1" or "localhost" or "[::1]" && + uri.Port is >= 1 and <= 65535 && uri.AbsolutePath == "/" && + uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0; + + public static string ParsePairing(string json, int gatewayPort) + { + if (json.Length > 16384) throw new InvalidDataException("pairing_unavailable"); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + root.EnumerateObject().GroupBy(p => p.Name).Any(g => g.Count() != 1) || + !root.TryGetProperty("topology", out var topology) || topology.GetString() != "local" || + !root.TryGetProperty("pairingString", out var value) || value.ValueKind != JsonValueKind.String) + throw new InvalidDataException("pairing_unavailable"); + var pairing = value.GetString()!; + if (!Uri.TryCreate(pairing, UriKind.Absolute, out var uri) || + uri.Scheme != "ws" || uri.Host != "127.0.0.1" || uri.Port != gatewayPort || + uri.AbsolutePath != "/browser/extension" || uri.UserInfo.Length != 0 || + uri.Fragment.Length is < 33 or > 257 || + uri.Fragment[1..].Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_')) + throw new InvalidDataException("pairing_unavailable"); + var expectedHint = $"ws://127.0.0.1:{gatewayPort}"; + var query = uri.Query; + if (!query.StartsWith("?gateway=", StringComparison.Ordinal) || query.Contains('&') || + Uri.UnescapeDataString(query[9..]) != expectedHint) + throw new InvalidDataException("pairing_unavailable"); + return pairing; + } +} diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs new file mode 100644 index 000000000..2b0d7872a --- /dev/null +++ b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.Connection; + +/// Secret-bearing CLI output is bounded in memory and never sent through setup diagnostics. +public static class BrowserBootstrapWslCommand +{ + public const string Script = """ + set -euo pipefail + test "$(id -un)" = openclaw + unset OPENCLAW_PROFILE OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_HOME NODE_OPTIONS NODE_PATH + export PATH=/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin + export HOME=/home/openclaw + pid=$(systemctl --user show openclaw-gateway.service -p MainPID --value) + [[ "$pid" =~ ^[1-9][0-9]*$ ]] + test -r "/proc/$pid/environ" + while IFS= read -r -d '' entry; do + case "$entry" in + HOME=*|OPENCLAW_HOME=*) test "${entry#*=}" = /home/openclaw ;; + OPENCLAW_PROFILE=*) test "${entry#*=}" = default ;; + OPENCLAW_STATE_DIR=*) test "${entry#*=}" = /home/openclaw/.openclaw ;; + OPENCLAW_CONFIG_PATH=*) test "${entry#*=}" = /home/openclaw/.openclaw/openclaw.json ;; + esac + done < "/proc/$pid/environ" + export OPENCLAW_STATE_DIR=/home/openclaw/.openclaw + export OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw/openclaw.json + for cli in /home/openclaw/.openclaw/bin/openclaw /opt/openclaw/bin/openclaw /usr/local/bin/openclaw; do + if test -x "$cli"; then + exec "$cli" browser extension pair --json --local-gateway + fi + done + exit 127 + """; + + public static async Task RunAsync(string distro, CancellationToken ct) + { + var start = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe"), + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = new UTF8Encoding(false, true), + StandardErrorEncoding = Encoding.UTF8 + }; + foreach (var arg in new[] { "--distribution", distro, "--exec", "/bin/bash", "--noprofile", "--norc", "-s" }) + start.ArgumentList.Add(arg); + // Do not propagate Windows CLI profile/runtime overrides through WSLENV. + start.Environment.Remove("WSLENV"); + using var process = Process.Start(start) ?? throw new IOException("pairing_unavailable"); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(TimeSpan.FromSeconds(15)); + var output = ReadBoundedAsync(process.StandardOutput, deadline.Token); + var error = ReadBoundedAsync(process.StandardError, deadline.Token); + try + { + await process.StandardInput.WriteAsync((Script + "\n").AsMemory(), deadline.Token); + process.StandardInput.Close(); + // Await drains alongside exit so oversized output faults immediately rather than waiting for exit. + await Task.WhenAll(output, error, process.WaitForExitAsync(deadline.Token)); + if (process.ExitCode != 0) throw new IOException("pairing_unavailable"); + return await output; + } + finally + { + if (!process.HasExited) + { + try { process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + } + } + } + + private static async Task ReadBoundedAsync(StreamReader reader, CancellationToken ct) + { + var buffer = new char[1024]; + var result = new StringBuilder(); + for (;;) + { + var count = await reader.ReadAsync(buffer.AsMemory(), ct); + if (count == 0) return result.ToString(); + if (result.Length + count > 16384) throw new IOException("pairing_unavailable"); + result.Append(buffer, 0, count); + } + } +} diff --git a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs new file mode 100644 index 000000000..5fbdc3b71 --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs @@ -0,0 +1,66 @@ +using System.IO.Pipes; + +namespace OpenClaw.Shared.Browser; + +/// Current-user authenticated, single-flight bootstrap IPC. No port, token file, or gateway transport. +public sealed class BrowserBootstrapPipeServer( + Func> handle, + string pipeName = BrowserNativeProtocol.PipeName) : IAsyncDisposable +{ + private readonly CancellationTokenSource _stop = new(); + private Task? _loop; + + public void Start() + { + if (_loop is not null) throw new InvalidOperationException("Already started."); + _loop = RunAsync(); + } + + private async Task RunAsync() + { + while (!_stop.IsCancellationRequested) + { + try + { + using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, + 4096, 16384); + await pipe.WaitForConnectionAsync(_stop.Token); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); + deadline.CancelAfter(TimeSpan.FromSeconds(20)); + byte[] response; + try + { + var request = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.RequestLimit, deadline.Token); + response = await handle(request, deadline.Token); + } + catch (InvalidDataException ex) when (ex.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") + { + response = BrowserNativeProtocol.Failure(ex.Message); + } + catch (Exception) when (!_stop.IsCancellationRequested) + { + response = BrowserNativeProtocol.Failure("pairing_unavailable"); + } + await BrowserNativeProtocol.WriteAsync(pipe, response, deadline.Token); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { return; } + catch (Exception) when (!_stop.IsCancellationRequested) + { + // Do not log requests, responses, or exception messages containing pairing material. + await Task.Delay(250, _stop.Token); + } + } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { } + } + _stop.Dispose(); + } +} diff --git a/src/OpenClaw.Shared/Browser/BrowserNativeProtocol.cs b/src/OpenClaw.Shared/Browser/BrowserNativeProtocol.cs new file mode 100644 index 000000000..576fd247b --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserNativeProtocol.cs @@ -0,0 +1,106 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Shared.Browser; + +/// The v1 Chrome bootstrap contract shared with the browser plugin. +public static class BrowserNativeProtocol +{ + public const string HostName = "ai.openclaw.browser_bootstrap"; + public const string ExtensionId = "kcdjddhmeafeomebliikmbpblkmkfoig"; + public const string Origin = "chrome-extension://" + ExtensionId + "/"; + public const string PipeName = "OpenClawTray.BrowserBootstrap.v1"; + public const int RequestLimit = 4096; + public const int ResponseLimit = 1024 * 1024 - 1; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public sealed record Request(string Op, string Nonce, int? RelayPort = null); + + public static bool IsAllowedCaller(string[] args) => + args.Length is 1 or 2 && args[0] == Origin && + (args.Length == 1 || (args[1].StartsWith("--parent-window=", StringComparison.Ordinal) && + ulong.TryParse(args[1]["--parent-window=".Length..], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out _))); + + public static Request ParseRequest(byte[] payload) + { + if (payload.Length is 0 or > RequestLimit) + throw new InvalidDataException("invalid_frame"); + string json; + try { json = StrictUtf8.GetString(payload); } + catch (DecoderFallbackException) { throw new InvalidDataException("invalid_utf8"); } + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("invalid_request"); + var properties = root.EnumerateObject().ToArray(); + if (properties.Select(p => p.Name).Distinct(StringComparer.Ordinal).Count() != properties.Length || + !root.TryGetProperty("v", out var version) || !version.TryGetInt32(out var v) || v != 1 || + !root.TryGetProperty("op", out var op) || op.ValueKind != JsonValueKind.String || + !root.TryGetProperty("nonce", out var nonce) || nonce.ValueKind != JsonValueKind.String || + !IsNonce(nonce.GetString()!)) + throw new InvalidDataException("invalid_request"); + var operation = op.GetString(); + string[] expected = operation == "ensure_relay" ? ["v", "op", "nonce", "relayPort"] : ["v", "op", "nonce"]; + if (properties.Length != expected.Length || properties.Any(p => !expected.Contains(p.Name))) + throw new InvalidDataException("invalid_request"); + if (operation == "bootstrap") return new(operation, nonce.GetString()!); + if (operation == "ensure_relay" && root.GetProperty("relayPort").TryGetInt32(out var port) && port is >= 1 and <= 65535) + return new(operation, nonce.GetString()!, port); + throw new InvalidDataException("invalid_request"); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + throw new InvalidDataException("invalid_request"); + } + } + + private static bool IsNonce(string nonce) + { + if (nonce.Length is < 22 or > 43 || nonce.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_')) + return false; + try + { + var padded = nonce.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight((padded.Length + 3) / 4 * 4, '='); + var bytes = Convert.FromBase64String(padded); + return bytes.Length is >= 16 and <= 32 && + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') == nonce; + } + catch (FormatException) { return false; } + } + + // Windows x64/ARM64 both use little-endian lengths. Raw streams never perform CR/LF conversion. + public static async Task ReadAsync(Stream input, int limit, CancellationToken ct) + { + var header = new byte[4]; + try + { + await input.ReadExactlyAsync(header, ct); + var length = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (length == 0 || length > limit) throw new InvalidDataException("invalid_frame"); + var payload = new byte[(int)length]; + await input.ReadExactlyAsync(payload, ct); + return payload; + } + catch (EndOfStreamException) { throw new InvalidDataException("invalid_frame"); } + } + + public static async Task WriteAsync(Stream output, byte[] payload, CancellationToken ct) + { + if (payload.Length is 0 or > ResponseLimit) throw new InvalidDataException("invalid_frame"); + var header = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)payload.Length); + await output.WriteAsync(header, ct); + await output.WriteAsync(payload, ct); + await output.FlushAsync(ct); + } + + public static byte[] Failure(string code) => JsonSerializer.SerializeToUtf8Bytes(new { v = 1, ok = false, code }); + public static byte[] Pairing(string nonce, string pairingString) => + JsonSerializer.SerializeToUtf8Bytes(new { v = 1, ok = true, nonce, pairingString }); +} diff --git a/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs b/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs new file mode 100644 index 000000000..344017b00 --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs @@ -0,0 +1,121 @@ +using System.Runtime.Versioning; +using System.Text.Json; +using Microsoft.Win32; + +namespace OpenClaw.Shared.Browser; + +/// Owns only the per-user native host. This never requests an extension installation. +public static class BrowserNativeRegistration +{ + private const string Key = @"Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap"; + private static string ManifestPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", "browser-native", "ai.openclaw.browser_bootstrap.json"); + + public static string BuildManifest(string executable) => JsonSerializer.Serialize(new + { + name = BrowserNativeProtocol.HostName, + description = "OpenClaw Companion browser pairing", + path = Path.GetFullPath(executable), + type = "stdio", + allowed_origins = new[] { BrowserNativeProtocol.Origin } + }); + + public static bool ManifestMatches(string json, string executable) + { + try + { + using var doc = JsonDocument.Parse(json); + using var expected = JsonDocument.Parse(BuildManifest(executable)); + return doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.EnumerateObject().Select(p => p.Name).Distinct(StringComparer.Ordinal).Count() == 5 && + doc.RootElement.EnumerateObject().Count() == 5 && + JsonElement.DeepEquals(doc.RootElement, expected.RootElement); + } + catch (JsonException) { return false; } + } + + private static bool StoredManifestMatches(string executable) + { + var info = new FileInfo(ManifestPath); + return info.Exists && info.Length is > 0 and <= 4096 && + (info.Attributes & FileAttributes.ReparsePoint) == 0 && + ManifestMatches(File.ReadAllText(ManifestPath), executable); + } + + [SupportedOSPlatform("windows")] + public static bool IsRegistered(string executable) + { + using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); + using var key = root.OpenSubKey(Key); + return key?.GetValue("") is string registered && + string.Equals(registered, ManifestPath, StringComparison.OrdinalIgnoreCase) && + File.Exists(ManifestPath) && + (File.GetAttributes(ManifestPath) & FileAttributes.ReparsePoint) == 0 && + StoredManifestMatches(executable); + } + + [SupportedOSPlatform("windows")] + private static bool HasForeignRegistration(string executable) + { + foreach (var hive in new[] { RegistryHive.CurrentUser, RegistryHive.LocalMachine }) + foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) + { + using var root = RegistryKey.OpenBaseKey(hive, view); + using var key = root.OpenSubKey(Key); + if (key is null) continue; + if (key.GetValue("") is not string path || + !string.Equals(path, ManifestPath, StringComparison.OrdinalIgnoreCase) || + !StoredManifestMatches(executable)) return true; + } + return false; + } + + [SupportedOSPlatform("windows")] + public static bool Apply(string executable, bool remove = false) + { + using var mutex = new Mutex(false, @"Local\OpenClawTray.BrowserNativeRegistration"); + if (!mutex.WaitOne(TimeSpan.FromSeconds(5))) return false; + try + { + using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); + using var existing = root.OpenSubKey(Key); + // Do not shadow a host installed by another product, user install, or registry view. + if (!remove && HasForeignRegistration(executable)) return false; + if (existing is not null && !IsRegistered(executable)) return false; + if (remove) + { + if (existing is null) return true; + // Never delete a key with someone else's additional values or children. + if (existing.SubKeyCount != 0 || existing.GetValueNames().Any(name => name != "")) return false; + existing.Dispose(); + root.DeleteSubKey(Key, false); + File.Delete(ManifestPath); + return true; + } + if (!File.Exists(executable)) return false; + var directory = Path.GetDirectoryName(ManifestPath)!; + Directory.CreateDirectory(directory); + for (var parent = new DirectoryInfo(directory); parent is not null; parent = parent.Parent) + if ((parent.Attributes & FileAttributes.ReparsePoint) != 0) return false; + if (File.Exists(ManifestPath)) + { + if (!StoredManifestMatches(executable)) return false; + } + else + { + using var file = new FileStream(ManifestPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var writer = new StreamWriter(file); + writer.Write(BuildManifest(executable)); + } + using var key = root.CreateSubKey(Key); + if (key.GetValue("") is { } current && + (current is not string currentPath || + !string.Equals(currentPath, ManifestPath, StringComparison.OrdinalIgnoreCase))) return false; + if (!StoredManifestMatches(executable)) return false; + key.SetValue("", ManifestPath, RegistryValueKind.String); + return IsRegistered(executable); + } + finally { mutex.ReleaseMutex(); } + } +} diff --git a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs index c5ad8bb28..6522fb7aa 100644 --- a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs @@ -80,6 +80,16 @@ private AppShutdownPlan BuildShutdownPlan() })); } + var browserBootstrapHost = _browserBootstrapHost; + if (browserBootstrapHost is not null) + { + steps.Add(new AppShutdownStep("browser bootstrap", async () => + { + _browserBootstrapHost = null; + await browserBootstrapHost.DisposeAsync(); + })); + } + var connectionManager = _connectionManager; if (connectionManager is not null) { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 04c7311b3..e14accfe1 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -54,6 +54,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer private GatewayConnectionManager? _connectionManager; private GatewayDirectConnectService? _gatewayDirectConnectService; private GatewayRegistry? _gatewayRegistry; + private BrowserBootstrapHost? _browserBootstrapHost; private OpenClawTray.Services.ManagedLocalGatewayAutoRepairMonitor? _managedLocalAutoRepairMonitor; private ManagedLocalGatewayPortProvenanceService? _managedLocalPortProvenance; private OpenClawTray.Chat.OpenClawChatCoordinator? _chatCoordinator; @@ -818,6 +819,12 @@ _dispatcherQueue is null validationTunnelFactory: () => new SshTunnelService(appLogger)); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; + // Release identity only: isolated/dev instances must never claim Chrome's production host. + if (!AppIdentity.IsDev && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR"))) + { + _browserBootstrapHost = new BrowserBootstrapHost(_gatewayRegistry, _connectionManager, _settings, managedLocalPortProvenance); + _browserBootstrapHost.Start(); + } _gatewayDirectConnectService = new GatewayDirectConnectService( _connectionManager, _gatewayRegistry, diff --git a/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets b/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets new file mode 100644 index 000000000..c8c24183c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets @@ -0,0 +1,30 @@ + + + $(MSBuildThisFileDirectory)..\OpenClaw.BrowserNativeHost\OpenClaw.BrowserNativeHost.csproj + $(RuntimeIdentifier) + win-arm64 + win-x64 + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj\browser-native\$(Configuration)\$(BrowserNativeHostRid)\')) + + + + + + <_BrowserNativeHostFile Include="$(BrowserNativeHostPayload)**\*" /> + + tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + + diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 8b22399b7..f70091cf0 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -439,4 +439,5 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs b/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs new file mode 100644 index 000000000..90ff6428c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs @@ -0,0 +1,66 @@ +using System.Runtime.Versioning; +using OpenClaw.Connection; +using OpenClaw.Shared.Browser; + +namespace OpenClawTray.Services; + +/// Composes native pairing with the existing registry, intent, settings and endpoint-provenance owners. +[SupportedOSPlatform("windows")] +internal sealed class BrowserBootstrapHost : IAsyncDisposable +{ + private readonly GatewayRegistry _registry; + private readonly IGatewayConnectionManager _manager; + private readonly SettingsManager _settings; + private readonly BrowserBootstrapService _service; + private readonly BrowserBootstrapPipeServer _pipe; + + public BrowserBootstrapHost(GatewayRegistry registry, IGatewayConnectionManager manager, + SettingsManager settings, ManagedLocalGatewayPortProvenanceService provenance) + { + _registry = registry; + _manager = manager; + _settings = settings; + _service = new BrowserBootstrapService( + registry.GetActive, + record => settings.NodeBrowserProxyEnabled && + manager.CurrentSnapshot.GatewayId == record.Id && + manager.CurrentSnapshot.OperatorState == RoleConnectionState.Connected && + manager.IsAutomaticReconnectAllowed(record.Id) && !manager.IsManualGatewayLifecycleInProgress, + async (record, ct) => (await provenance.InspectAsync(record, ct)).Kind == + GatewayEndpointProvenanceKind.ExpectedManagedGateway, + BrowserBootstrapWslCommand.RunAsync); + _pipe = new BrowserBootstrapPipeServer(_service.HandleAsync); + } + + public void Start() + { + // Installer registers first; release portable/MSIX starts can register the same packaged helper. + // A conflicting existing manifest is preserved, never adopted or overwritten. + if (_settings.NodeBrowserProxyEnabled) + { + try + { + BrowserNativeRegistration.Apply(Path.Combine(AppContext.BaseDirectory, + "tools", "browser-bootstrap", "OpenClaw.BrowserNativeHost.exe")); + } + catch (Exception) { /* Optional integration must not prevent tray startup. */ } + } + _registry.Changed += OnRegistryChanged; + _manager.StateChanged += OnStateChanged; + _settings.Saved += OnSettingsSaved; + _pipe.Start(); + } + + private void OnRegistryChanged(object? sender, GatewayRegistryChangedEventArgs e) => _service.Invalidate(); + private void OnStateChanged(object? sender, GatewayConnectionSnapshot e) => _service.Invalidate(); + private void OnSettingsSaved(object? sender, EventArgs e) => _service.Invalidate(); + + public async ValueTask DisposeAsync() + { + _service.Invalidate(); + _registry.Changed -= OnRegistryChanged; + _manager.StateChanged -= OnStateChanged; + _settings.Saved -= OnSettingsSaved; + await _pipe.DisposeAsync(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs new file mode 100644 index 000000000..f2dd99f89 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Connection.Tests; + +public class BrowserBootstrapServiceTests +{ + private static readonly byte[] Request = Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}"); + private static GatewayRecord Local => new() { Id = "local", Url = "ws://127.0.0.1:18789", IsLocal = true, SetupManagedDistroName = "OpenClawGateway" }; + private static string PairingJson(int port = 18789, string topology = "local") => JsonSerializer.Serialize(new + { + topology, + pairingString = $"ws://127.0.0.1:{port}/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A{port}#" + new string('a', 64), + relayPort = 18792 + }); + private static bool IsOk(byte[] bytes) { using var doc = JsonDocument.Parse(bytes); return doc.RootElement.GetProperty("ok").GetBoolean(); } + + [Fact] + public async Task Local_DelegatesToPinnedDistroWithoutReadingGatewayCredentials() + { + var record = Local with { SharedGatewayToken = "never-use", BootstrapToken = "never-use" }; + var calls = 0; + var service = new BrowserBootstrapService(() => record, _ => true, (_, _) => Task.FromResult(true), + (distro, _) => { Assert.Equal("OpenClawGateway", distro); calls++; return Task.FromResult(PairingJson()); }); + Assert.True(IsOk(await service.HandleAsync(Request, default))); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task DisabledOrUnverified_DoesNotRunCli(bool allowed, bool verified) + { + var service = new BrowserBootstrapService(() => Local, _ => allowed, (_, _) => Task.FromResult(verified), + (_, _) => throw new InvalidOperationException("Must not run")); + Assert.False(IsOk(await service.HandleAsync(Request, default))); + } + + [Fact] + public async Task DisconnectDuringCli_DiscardsPairing() + { + var allowed = true; + var service = new BrowserBootstrapService(() => Local, _ => allowed, (_, _) => Task.FromResult(true), + (_, _) => { allowed = false; return Task.FromResult(PairingJson()); }); + Assert.False(IsOk(await service.HandleAsync(Request, default))); + } + + [Fact] + public async Task SwitchAwayAndBackDuringCli_DiscardsOldGeneration() + { + BrowserBootstrapService? service = null; + service = new BrowserBootstrapService(() => Local, _ => true, (_, _) => Task.FromResult(true), + (_, _) => { service!.Invalidate(); return Task.FromResult(PairingJson()); }); + Assert.False(IsOk(await service.HandleAsync(Request, default))); + } + + [Fact] + public async Task EndpointChangedAfterCli_DiscardsPairing() + { + var probes = 0; + var service = new BrowserBootstrapService(() => Local, _ => true, (_, _) => Task.FromResult(++probes == 1), + (_, _) => Task.FromResult(PairingJson())); + Assert.False(IsOk(await service.HandleAsync(Request, default))); + } + + [Fact] + public void LocalAdmission_RejectsRemoteManualSshAndLegacyNameInference() + { + Assert.True(BrowserBootstrapService.IsManagedLocal(Local)); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "wss://example.com" })); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { IsLocal = false })); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { SetupManagedDistroName = null, FriendlyName = "Local (OpenClawGateway)" })); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "ws://127.0.0.1:18789/path" })); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "ws://127.0.0.1:18789?token=x" })); + } + + [Fact] + public void PairingOutput_MustMatchLocalGatewayTopologyAndPort() + { + Assert.NotEmpty(BrowserBootstrapService.ParsePairing(PairingJson(), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(18790), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(topology: "direct-remote"), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson().Replace("/browser/extension", "/extension"), 18789)); + } + + [Fact] + public void Command_UsesCanonicalPairingNotGatewaySecretsOrLifecycle() + { + Assert.Contains("browser extension pair --json --local-gateway", BrowserBootstrapWslCommand.Script); + Assert.Contains("unset OPENCLAW_PROFILE OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH", BrowserBootstrapWslCommand.Script); + Assert.DoesNotContain("gateway start", BrowserBootstrapWslCommand.Script); + Assert.DoesNotContain("auth.token", BrowserBootstrapWslCommand.Script); + } +} diff --git a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs new file mode 100644 index 000000000..35b026e48 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs @@ -0,0 +1,42 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Shared.Tests; + +public class BrowserBootstrapPipeTests +{ + [Fact] + public async Task CurrentUserPipe_RoundTripsOneBoundedRequest() + { + var name = "OpenClaw.BrowserBootstrap.Test." + Guid.NewGuid().ToString("N"); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = new BrowserBootstrapPipeServer((payload, _) => + { + var request = BrowserNativeProtocol.ParseRequest(payload); + return Task.FromResult(BrowserNativeProtocol.Pairing(request.Nonce, "synthetic-test-pairing")); + }, name); + server.Start(); + using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(timeout.Token); + await BrowserNativeProtocol.WriteAsync(pipe, + Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}"), timeout.Token); + using var result = JsonDocument.Parse(await BrowserNativeProtocol.ReadAsync(pipe, 4096, timeout.Token)); + Assert.True(result.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal("synthetic-test-pairing", result.RootElement.GetProperty("pairingString").GetString()); + } + + [Fact] + public void Manifest_RequiresExactExecutableOriginAndSchema() + { + var path = Path.GetFullPath("OpenClaw.BrowserNativeHost.exe"); + var manifest = BrowserNativeRegistration.BuildManifest(path); + Assert.True(BrowserNativeRegistration.ManifestMatches(manifest, path)); + Assert.False(BrowserNativeRegistration.ManifestMatches(manifest, path + "other")); + Assert.False(BrowserNativeRegistration.ManifestMatches(manifest.Replace(BrowserNativeProtocol.ExtensionId, new string('a', 32)), path)); + Assert.False(BrowserNativeRegistration.ManifestMatches(manifest.Replace("\"stdio\"", "\"http\""), path)); + Assert.False(BrowserNativeRegistration.ManifestMatches(manifest[..^1] + ",\"type\":\"stdio\"}", path)); + } +} diff --git a/tests/OpenClaw.Shared.Tests/BrowserNativeProtocolTests.cs b/tests/OpenClaw.Shared.Tests/BrowserNativeProtocolTests.cs new file mode 100644 index 000000000..63fb07f02 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/BrowserNativeProtocolTests.cs @@ -0,0 +1,107 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Shared.Tests; + +public class BrowserNativeProtocolTests +{ + private const string Nonce = "AAAAAAAAAAAAAAAAAAAAAA"; + private static byte[] Request(string extra = "") => Encoding.UTF8.GetBytes( + "{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"" + Nonce + "\"" + extra + "}"); + + [Fact] + public void Bootstrap_UsesCanonicalV1Contract() + { + var request = BrowserNativeProtocol.ParseRequest(Request()); + Assert.Equal("bootstrap", request.Op); + Assert.Equal(Nonce, request.Nonce); + Assert.Null(request.RelayPort); + } + + [Theory] + [InlineData(",\"v\":1")] + [InlineData(",\"\\u0076\":1")] + [InlineData(",\"extra\":true")] + [InlineData(",\"relayPort\":18792")] + public void RejectsDuplicateEscapedOrUnknownKeys(string extra) => + Assert.Equal("invalid_request", Assert.Throws(() => BrowserNativeProtocol.ParseRequest(Request(extra))).Message); + + [Theory] + [InlineData("")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAB")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] + [InlineData("AAAAAAAAAAAAAAAAAAAAA+")] + public void RejectsNoncanonicalNonce(string nonce) => + Assert.Throws(() => BrowserNativeProtocol.ParseRequest( + Encoding.UTF8.GetBytes($"{{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"{nonce}\"}}"))); + + [Theory] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{\"v\":\"1\",\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}")] + [InlineData("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":1}")] + [InlineData("{\"v\":1,\"op\":\"unknown\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}")] + public void RejectsInvalidSchema(string json) => + Assert.Throws(() => BrowserNativeProtocol.ParseRequest(Encoding.UTF8.GetBytes(json))); + + [Fact] + public void RejectsInvalidUtf8() => Assert.Equal("invalid_utf8", + Assert.Throws(() => BrowserNativeProtocol.ParseRequest([0xff])).Message); + + [Fact] + public void ExactOriginAndChromeParentWindowOnly() + { + Assert.True(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin])); + Assert.True(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin, "--parent-window=0"])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin.TrimEnd('/')])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin + "evil"])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin.ToUpperInvariant()])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin, "--register"])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller([BrowserNativeProtocol.Origin, "--parent-window=-1"])); + Assert.False(BrowserNativeProtocol.IsAllowedCaller(["chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/"])); + } + + [Theory] + [InlineData(0)] + [InlineData(4097)] + [InlineData(uint.MaxValue)] + public async Task RejectsFrameLengthBeforeAllocation(uint length) + { + var header = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(header, length); + await Assert.ThrowsAsync(() => BrowserNativeProtocol.ReadAsync(new MemoryStream(header), 4096, default)); + } + + [Fact] + public async Task Frame_RoundTripsBinaryWithoutTextConversion() + { + var payload = Encoding.UTF8.GetBytes("{\"line\":\"é\\r\\n\"}"); + using var stream = new MemoryStream(); + await BrowserNativeProtocol.WriteAsync(stream, payload, default); + Assert.Equal(payload.Length, BinaryPrimitives.ReadInt32LittleEndian(stream.ToArray())); + stream.Position = 0; + Assert.Equal(payload, await BrowserNativeProtocol.ReadAsync(stream, 4096, default)); + } + + [Fact] + public async Task RejectsTruncatedFrame() => + await Assert.ThrowsAsync(() => BrowserNativeProtocol.ReadAsync(new MemoryStream([3, 0, 0, 0, 1]), 4096, default)); + + [Theory] + [InlineData(1)] + [InlineData(65535)] + public void EnsureRelay_ParsesOnlyBoundedPorts(int port) => Assert.Equal(port, + BrowserNativeProtocol.ParseRequest(Encoding.UTF8.GetBytes( + $"{{\"v\":1,\"op\":\"ensure_relay\",\"nonce\":\"{Nonce}\",\"relayPort\":{port}}}")).RelayPort); + + [Fact] + public void Response_ContainsNonceButNoAdditionalCredentialFields() + { + using var result = JsonDocument.Parse(BrowserNativeProtocol.Pairing(Nonce, "test-pairing")); + Assert.Equal(4, result.RootElement.EnumerateObject().Count()); + Assert.Equal(Nonce, result.RootElement.GetProperty("nonce").GetString()); + } +} diff --git a/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs new file mode 100644 index 000000000..af1c33a7c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs @@ -0,0 +1,52 @@ +namespace OpenClaw.Tray.Tests; + +public class BrowserBootstrapIntegrationContractTests +{ + private static string Read(params string[] parts) => File.ReadAllText(Path.Combine( + new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray())); + + [Fact] + public void Installer_RegistersNativeHelperBeforeTrayWithoutRequestingElevation() + { + var installer = Read("installer.iss"); + Assert.Contains("if CurStep = ssPostInstall then", installer); + Assert.Contains("RegisterBrowserNativeHost;", installer); + Assert.Contains("BrowserNativeHostRegistered := ResultCode = 0", installer); + Assert.Contains("No extension installation may be requested", installer); + Assert.Contains("PrivilegesRequired=lowest", installer); + Assert.Contains("UnregisterBrowserNativeHost;", installer); + Assert.DoesNotContain("ExtensionInstallForcelist", installer); + Assert.DoesNotContain("Preferences", installer); + } + + [Fact] + public void Bootstrap_UsesExistingOwnersAndExcludesIsolatedProfiles() + { + var app = Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + var host = Read("src", "OpenClaw.Tray.WinUI", "Services", "BrowserBootstrapHost.cs"); + Assert.Contains("!AppIdentity.IsDev", app); + Assert.Contains("OPENCLAW_TRAY_DATA_DIR", app); + Assert.Contains("manager.IsAutomaticReconnectAllowed(record.Id)", host); + Assert.Contains("settings.NodeBrowserProxyEnabled", host); + Assert.Contains("RoleConnectionState.Connected", host); + Assert.Contains("provenance.InspectAsync", host); + Assert.DoesNotContain("SharedGatewayToken", host); + Assert.DoesNotContain("BootstrapToken", host); + Assert.Contains("await browserBootstrapHost.DisposeAsync()", Read("src", "OpenClaw.Tray.WinUI", "App.AppShutdownCoordinator.cs")); + } + + [Fact] + public void Packaging_RequiresRealExeAndBothArchitecturesProveIt() + { + var targets = Read("src", "OpenClaw.Tray.WinUI", "BrowserNativeHost.targets"); + Assert.Contains("SelfContained=true", targets); + Assert.Contains("win-x64", targets); + Assert.Contains("win-arm64", targets); + Assert.Contains("OpenClaw.BrowserNativeHost.exe", targets); + var workflow = Read(".github", "workflows", "ci.yml"); + Assert.Equal(2, workflow.Split("- name: Prove packaged native browser host").Length - 1); + Assert.Contains("OpenClaw.BrowserNativeHost.dll", workflow); + Assert.Contains("Test-BrowserNativeHost.ps1", workflow); + Assert.Contains("tools\\browser-bootstrap\\OpenClaw.BrowserNativeHost.exe", Read("scripts", "Test-ReleaseExecutableSignatures.ps1")); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 63a59eb10..1eb1df18f 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -221,4 +221,7 @@ + + + From be2bfa2b402f7d3ecd087e57630c4685a83f8f82 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:50:43 +0000 Subject: [PATCH 02/77] feat(browser): request the Chrome Store extension during Windows setup Register the native host before creating the owned HKCU external Store request, preserve foreign entries and persisted opt-outs, and remove only owned registrations. Match the coordinated remote:false CLI response and keep pre-setup bootstrap retryable. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- docs/BROWSER_EXTENSION_BOOTSTRAP.md | 20 ++-- installer.iss | 30 ++++- scripts/Test-BrowserNativeHost.ps1 | 56 +++++++++- .../OpenClaw.BrowserNativeHost.csproj | 1 + src/OpenClaw.BrowserNativeHost/Program.cs | 7 +- .../BrowserBootstrapService.cs | 6 +- .../Browser/ChromeExtensionInstallRequest.cs | 104 ++++++++++++++++++ .../BrowserBootstrapServiceTests.cs | 16 ++- .../ChromeExtensionInstallRequestTests.cs | 64 +++++++++++ ...rowserBootstrapIntegrationContractTests.cs | 5 + 10 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs create mode 100644 tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs diff --git a/docs/BROWSER_EXTENSION_BOOTSTRAP.md b/docs/BROWSER_EXTENSION_BOOTSTRAP.md index a35478af0..7a22fa58e 100644 --- a/docs/BROWSER_EXTENSION_BOOTSTRAP.md +++ b/docs/BROWSER_EXTENSION_BOOTSTRAP.md @@ -21,8 +21,13 @@ local runtime. No relay process is started by the Windows helper. The existing `ensure_relay` operation is recognized but returns `manual_required` in this lane. The per-user installer registers the native executable before starting the tray. -Store-request installation is a separate pending integration; native registration -alone does not install the extension. A registry entry or manifest belonging to +After successful native registration, the selected installer task creates an owned +HKCU external-extension request with the official Chrome Store update URL. Chrome +still owns download and permission approval. A saved Browser control opt-out blocks +a new request. The installer also remembers a declined task across upgrades; it does +not rewrite Chrome removal or disconnect state. +The tray startup path registers only the helper, so it cannot undo the installer +opt-out by re-requesting the extension. A registry entry or manifest belonging to another installation is preserved. Moving the executable, including a versioned MSIX path change, currently requires explicit removal of its old owned native registration before registering the new installation. This is not silently @@ -85,15 +90,15 @@ Successful stdout must be exactly one JSON object, with no progress prose: ```json { - "topology": "local", + "remote": false, "relayPort": 18792, "pairingString": "ws://127.0.0.1:18789/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#" } ``` -The flag must use the canonical `buildBrowserExtensionPairing` with -`localTransport: "gateway"`, reject remote-mode/TLS configurations, preserve browser -opt-out, and never reinterpret a gateway authentication token as a relay token. +The flag uses the canonical `buildBrowserExtensionPairing` with +`localTransport: "gateway"`. It must reject remote-mode/TLS configurations, preserve +browser opt-out, and never reinterpret a gateway authentication token as a relay token. The gateway port must come from that same CLI profile. The Windows active record port must match. Old CLI versions fail closed; Windows does not scrape human output or fall back to reading another profile's secrets. @@ -112,7 +117,8 @@ dotnet test tests/OpenClaw.Tray.Tests --filter FullyQualifiedName~BrowserBootstr The executable proof runs in the x64 and ARM64 publish jobs. It refuses existing host registrations, uses synthetic pairing material, validates binary framing, exact-origin rejection, oversize rejection, authenticated pipe handoff, owned -cleanup and preservation of a foreign registry entry. It is not a Chrome/WSL E2E +cleanup, native-first HKCU Store requests and preservation of foreign native and +external-extension registry entries. It is not a Chrome/WSL E2E proof and must not be represented as one. Required real behavior proof still needs a disposable Windows installation with diff --git a/installer.iss b/installer.iss index d1e5cae99..7b60399dc 100644 --- a/installer.iss +++ b/installer.iss @@ -58,6 +58,7 @@ Compression={#MyCompression} SolidCompression={#MySolidCompression} WizardStyle=modern PrivilegesRequired=lowest +UsePreviousTasks=yes SetupIconFile=src\OpenClaw.Tray.WinUI\Assets\openclaw.ico UninstallDisplayIcon={app}\{#MyAppExeName} ; Round 2 (Scott #5): block install/uninstall while the tray is running. @@ -95,6 +96,9 @@ Name: "english"; MessagesFile: "compiler:Default.isl" #endif [Tasks] +#ifndef DevBuild +Name: "chromeextension"; Description: "Add the OpenClaw Chrome extension (Chrome approval required)"; GroupDescription: "Browser integration:" +#endif Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "startupicon"; Description: "Start {#MyAppName} when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked @@ -353,12 +357,32 @@ begin #endif end; +procedure RequestChromeExtension; +var + ResultCode: Integer; +begin +#ifndef DevBuild + if not BrowserNativeHostRegistered or not WizardIsTaskSelected('chromeextension') then + Exit; + if Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), + '--request-extension', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if ResultCode = 0 then + Log('Requested the official Chrome Store extension. Chrome permission approval is still required.') + else + Log('Chrome extension request was not written. Existing foreign registrations are preserved.'); + end; +#endif +end; + procedure CurStepChanged(CurStep: TSetupStep); begin - // ssPostInstall runs after payload installation and before the [Run] tray launch. - // Any Store-request integration must additionally require BrowserNativeHostRegistered. + // Files are installed before native registration, which must succeed before the Store request. if CurStep = ssPostInstall then + begin RegisterBrowserNativeHost; + RequestChromeExtension; + end; end; procedure UnregisterBrowserNativeHost; @@ -366,6 +390,8 @@ var ResultCode: Integer; begin #ifndef DevBuild + Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), + '--remove-extension-request', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); if not Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), '--unregister', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then Log('Native browser host unregister was unavailable. Foreign registrations are preserved.'); diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index 7ef9a4223..f28e250d6 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -12,13 +12,16 @@ Set-StrictMode -Version Latest $exe = (Resolve-Path -LiteralPath $ExecutablePath).Path $key = 'Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap' $origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/' +$extensionKey = 'Software\Google\Chrome\Extensions\kcdjddhmeafeomebliikmbpblkmkfoig' $root = [Microsoft.Win32.RegistryKey]::OpenBaseKey('CurrentUser', 'Registry32') foreach ($hive in @('CurrentUser', 'LocalMachine')) { foreach ($view in @('Registry32', 'Registry64')) { $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) try { - $existing = $base.OpenSubKey($key) - if ($null -ne $existing) { $existing.Dispose(); throw 'Proof refuses an existing native host registration.' } + foreach ($probeKey in @($key, $extensionKey)) { + $existing = $base.OpenSubKey($probeKey) + if ($null -ne $existing) { $existing.Dispose(); throw 'Proof refuses an existing native host or extension registration.' } + } } finally { $base.Dispose() } } } @@ -60,10 +63,27 @@ function Assert-Exit([Diagnostics.Process]$Process) { } $registered = $false +$requestRegistered = $false try { + & $exe --request-extension + if ($LASTEXITCODE -eq 0) { throw 'Store request succeeded before native registration.' } + $early = $root.OpenSubKey($extensionKey) + if ($null -ne $early) { $early.Dispose(); throw 'Store registry key appeared before native registration.' } & $exe --register if ($LASTEXITCODE -ne 0) { throw 'Native registration failed.' } $registered = $true + & $exe --request-extension + if ($LASTEXITCODE -ne 0) { throw 'Owned HKCU Store request failed.' } + $requestRegistered = $true + $requestKey = $root.OpenSubKey($extensionKey) + try { + if ($requestKey.GetValue('update_url') -ne 'https://clients2.google.com/service/update2/crx' -or + $requestKey.GetValue('openclaw_native_host') -ne $exe -or $requestKey.ValueCount -ne 2) { + throw 'Store request registry payload was not exact.' + } + } finally { $requestKey.Dispose() } + & $exe --request-extension + if ($LASTEXITCODE -ne 0) { throw 'Owned Store request was not idempotent.' } $process = Start-Native 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/' try { $reply = Read-Frame $process.StandardOutput.BaseStream @@ -103,6 +123,35 @@ try { $process.Dispose() } } finally { $pipe.Dispose() } + & $exe --remove-extension-request + if ($LASTEXITCODE -ne 0) { throw 'Owned Store request cleanup failed.' } + $requestRegistered = $false + $remainingRequest = $root.OpenSubKey($extensionKey) + if ($null -ne $remainingRequest) { $remainingRequest.Dispose(); throw 'Owned Store request remained after cleanup.' } + $storeSentinel = 'openclaw-proof-foreign-' + [Guid]::NewGuid().ToString('N') + $foreignStore = $root.CreateSubKey($extensionKey) + $foreignStore.SetValue('update_url', 'https://clients2.google.com/service/update2/crx') + $foreignStore.SetValue('proof_owner', $storeSentinel) + $foreignStore.Dispose() + try { + & $exe --request-extension + if ($LASTEXITCODE -eq 0) { throw 'Store request adopted a foreign registry entry.' } + & $exe --remove-extension-request + if ($LASTEXITCODE -eq 0) { throw 'Store cleanup adopted a foreign registry entry.' } + $foreignStore = $root.OpenSubKey($extensionKey) + try { + if ($foreignStore.ValueCount -ne 2 -or $foreignStore.GetValue('proof_owner') -ne $storeSentinel) { + throw 'Foreign Store registry entry changed.' + } + } finally { $foreignStore.Dispose() } + } finally { + $foreignStore = $root.OpenSubKey($extensionKey) + if ($null -ne $foreignStore) { + $owned = $foreignStore.GetValue('proof_owner') -eq $storeSentinel + $foreignStore.Dispose() + if ($owned) { $root.DeleteSubKey($extensionKey, $false) } + } + } & $exe --unregister if ($LASTEXITCODE -ne 0) { throw 'Native unregister failed.' } $registered = $false @@ -130,8 +179,9 @@ try { if ($owned) { $root.DeleteSubKey($key, $false) } } } - Write-Host 'NATIVE_BOOTSTRAP_PROOF_OK: binary framing, exact origin, bounded request, current-user IPC, owned cleanup, foreign-entry preservation.' + Write-Host 'NATIVE_BOOTSTRAP_PROOF_OK: binary framing, exact origin, bounded request, current-user IPC, native-first HKCU Store request, owned cleanup, foreign-entry preservation.' } finally { + if ($requestRegistered) { & $exe --remove-extension-request } if ($registered) { & $exe --unregister } $root.Dispose() } diff --git a/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj b/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj index 668f1b4d1..b0870536b 100644 --- a/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj +++ b/src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj @@ -10,5 +10,6 @@ + diff --git a/src/OpenClaw.BrowserNativeHost/Program.cs b/src/OpenClaw.BrowserNativeHost/Program.cs index 22e6e9047..6892a4e22 100644 --- a/src/OpenClaw.BrowserNativeHost/Program.cs +++ b/src/OpenClaw.BrowserNativeHost/Program.cs @@ -2,11 +2,14 @@ using OpenClaw.Shared.Browser; if (!OperatingSystem.IsWindows()) return 1; -if (args.Length == 1 && args[0] is "--register" or "--unregister") +if (args.Length == 1 && args[0] is "--register" or "--unregister" or "--request-extension" or "--remove-extension-request") { try { - return BrowserNativeRegistration.Apply(Environment.ProcessPath!, args[0] == "--unregister") ? 0 : 1; + var success = args[0] is "--request-extension" or "--remove-extension-request" + ? ChromeExtensionInstallRequest.Apply(Environment.ProcessPath!, args[0] == "--remove-extension-request") + : BrowserNativeRegistration.Apply(Environment.ProcessPath!, args[0] == "--unregister"); + return success ? 0 : 1; } catch { return 1; } // Never emit manifest paths or user data to Chrome's stdout. } diff --git a/src/OpenClaw.Connection/BrowserBootstrapService.cs b/src/OpenClaw.Connection/BrowserBootstrapService.cs index 25a6a7934..f6d88f78e 100644 --- a/src/OpenClaw.Connection/BrowserBootstrapService.cs +++ b/src/OpenClaw.Connection/BrowserBootstrapService.cs @@ -21,6 +21,9 @@ public async Task HandleAsync(byte[] payload, CancellationToken ct) // Local-gateway transport wakes Browser control itself. Arbitrary relay ports must never start a process. if (request.Op != "bootstrap") return BrowserNativeProtocol.Failure("manual_required"); var record = getActive(); + // First-install Chrome approval can precede completion of the Companion setup wizard. + // Keep that state retryable; manual_required is a durable extension state. + if (record is null) return BrowserNativeProtocol.Failure("pairing_unavailable"); if (!IsManagedLocal(record)) return BrowserNativeProtocol.Failure("manual_required"); var pinned = record!; if (generation != Interlocked.Read(ref _generation) || !Current(pinned) || @@ -59,7 +62,8 @@ public static string ParsePairing(string json, int gatewayPort) var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || root.EnumerateObject().GroupBy(p => p.Name).Any(g => g.Count() != 1) || - !root.TryGetProperty("topology", out var topology) || topology.GetString() != "local" || + !root.TryGetProperty("remote", out var remote) || remote.ValueKind != JsonValueKind.False || + !root.TryGetProperty("relayPort", out var relayPort) || !relayPort.TryGetInt32(out var port) || port is < 1 or > 65535 || !root.TryGetProperty("pairingString", out var value) || value.ValueKind != JsonValueKind.String) throw new InvalidDataException("pairing_unavailable"); var pairing = value.GetString()!; diff --git a/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs b/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs new file mode 100644 index 000000000..4e170ee95 --- /dev/null +++ b/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs @@ -0,0 +1,104 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.Json; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace OpenClaw.Shared.Browser; + +/// Owns a normal per-user Chrome Store request, never an enterprise policy or Chrome profile preference. +public static class ChromeExtensionInstallRequest +{ + public const string UpdateUrl = "https://clients2.google.com/service/update2/crx"; + public const string RegistryPath = @"Software\Google\Chrome\Extensions\" + BrowserNativeProtocol.ExtensionId; + public const string OwnerValue = "openclaw_native_host"; + + public static bool IsOwned(IReadOnlyDictionary values, string executable, bool allowIncomplete = false) => + values.Count is >= 1 and <= 2 && values.Keys.All(k => k is OwnerValue or "update_url") && + values.TryGetValue(OwnerValue, out var owner) && owner is string path && + string.Equals(path, Path.GetFullPath(executable), StringComparison.OrdinalIgnoreCase) && + ((values.TryGetValue("update_url", out var url) && url is string update && update == UpdateUrl) || + (allowIncomplete && !values.ContainsKey("update_url"))); + + public static bool SettingsAllowRequest(string? json) + { + if (json is null) return true; // Fresh install uses SettingsData's Browser control default. + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) return false; + var fields = document.RootElement.EnumerateObject() + .Where(p => p.Name == "NodeBrowserProxyEnabled").ToArray(); + return fields.Length == 0 || (fields.Length == 1 && fields[0].Value.ValueKind == JsonValueKind.True); + } + catch (JsonException) { return false; } + } + + private static bool SavedBrowserControlAllowsRequest() + { + var settingsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray", "settings.json"); + var file = new FileInfo(settingsPath); + if (!file.Exists) return true; + // Inspect only the persisted preference. Never deserialize/decrypt or log credential fields. + return file.Length <= 1024 * 1024 && SettingsAllowRequest(File.ReadAllText(settingsPath)); + } + + [SupportedOSPlatform("windows")] + public static bool Apply(string executable, bool remove = false) + { + using var mutex = new Mutex(false, @"Local\OpenClawTray.ChromeExtensionInstallRequest"); + if (!mutex.WaitOne(TimeSpan.FromSeconds(5))) return false; + try + { + using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Default); + using var existing = root.OpenSubKey(RegistryPath); + if (remove) + { + if (existing is null) return true; + if (existing.SubKeyCount != 0 || !IsOwned(ReadValues(existing), executable, allowIncomplete: true)) return false; + existing.Dispose(); + root.DeleteSubKey(RegistryPath, false); + return true; + } + // Native host must already be usable before Chrome can see update_url. + if (!BrowserNativeRegistration.IsRegistered(executable) || !SavedBrowserControlAllowsRequest()) return false; + // Chromium prefers HKLM32 over HKCU. Never shadow or modify another registration. + foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser }) + foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) + { + using var otherRoot = RegistryKey.OpenBaseKey(hive, view); + using var other = otherRoot.OpenSubKey(RegistryPath); + if (other is null) continue; + if (hive == RegistryHive.LocalMachine || other.SubKeyCount != 0 || + !IsOwned(ReadValues(other), executable)) return false; + } + if (existing is not null) return IsOwned(ReadValues(existing), executable); + + // RegistryKey.CreateSubKey cannot distinguish "created" from "opened". The + // disposition protects a foreign entry created between our inspection and write. + var status = RegCreateKeyEx(root.Handle, RegistryPath, 0, null, 0, 0x2001f, + IntPtr.Zero, out var handle, out var disposition); + using (handle) + { + if (status != 0) return false; + using var key = RegistryKey.FromHandle(handle); + if (disposition != 1) return IsOwned(ReadValues(key), executable); + key.SetValue(OwnerValue, Path.GetFullPath(executable), RegistryValueKind.String); + // This is the actual Store request. Chrome still requires the user's approval. + key.SetValue("update_url", UpdateUrl, RegistryValueKind.String); + return IsOwned(ReadValues(key), executable); + } + } + finally { mutex.ReleaseMutex(); } + } + + [SupportedOSPlatform("windows")] + private static Dictionary ReadValues(RegistryKey key) => + key.GetValueNames().ToDictionary(name => name, name => key.GetValue(name), StringComparer.Ordinal); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = false)] + private static extern int RegCreateKeyEx(SafeRegistryHandle key, string subKey, int reserved, + string? keyClass, int options, int desiredAccess, IntPtr securityAttributes, + out SafeRegistryHandle result, out int disposition); +} diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs index f2dd99f89..6bf83f217 100644 --- a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -8,14 +8,24 @@ public class BrowserBootstrapServiceTests { private static readonly byte[] Request = Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}"); private static GatewayRecord Local => new() { Id = "local", Url = "ws://127.0.0.1:18789", IsLocal = true, SetupManagedDistroName = "OpenClawGateway" }; - private static string PairingJson(int port = 18789, string topology = "local") => JsonSerializer.Serialize(new + private static string PairingJson(int port = 18789, bool remote = false) => JsonSerializer.Serialize(new { - topology, + remote, pairingString = $"ws://127.0.0.1:{port}/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A{port}#" + new string('a', 64), relayPort = 18792 }); private static bool IsOk(byte[] bytes) { using var doc = JsonDocument.Parse(bytes); return doc.RootElement.GetProperty("ok").GetBoolean(); } + [Fact] + public async Task FirstInstallBeforeSetup_RemainsRetryable() + { + var service = new BrowserBootstrapService(() => null, _ => false, + (_, _) => throw new InvalidOperationException("Must not probe"), + (_, _) => throw new InvalidOperationException("Must not run")); + using var result = JsonDocument.Parse(await service.HandleAsync(Request, default)); + Assert.Equal("pairing_unavailable", result.RootElement.GetProperty("code").GetString()); + } + [Fact] public async Task Local_DelegatesToPinnedDistroWithoutReadingGatewayCredentials() { @@ -80,7 +90,7 @@ public void PairingOutput_MustMatchLocalGatewayTopologyAndPort() { Assert.NotEmpty(BrowserBootstrapService.ParsePairing(PairingJson(), 18789)); Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(18790), 18789)); - Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(topology: "direct-remote"), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(remote: true), 18789)); Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson().Replace("/browser/extension", "/extension"), 18789)); } diff --git a/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs b/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs new file mode 100644 index 000000000..8e3cd8ecf --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs @@ -0,0 +1,64 @@ +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Shared.Tests; + +public class ChromeExtensionInstallRequestTests +{ + private static readonly string Executable = Path.GetFullPath("OpenClaw.BrowserNativeHost.exe"); + private static Dictionary Owned => new() + { + [ChromeExtensionInstallRequest.OwnerValue] = Executable, + ["update_url"] = ChromeExtensionInstallRequest.UpdateUrl + }; + + [Theory] + [InlineData(null, true)] + [InlineData("{}", true)] + [InlineData("{\"NodeBrowserProxyEnabled\":true}", true)] + [InlineData("{\"NodeBrowserProxyEnabled\":false}", false)] + [InlineData("{\"NodeBrowserProxyEnabled\":false,\"NodeBrowserProxyEnabled\":true}", false)] + [InlineData("{\"NodeBrowserProxyEnabled\":\"true\"}", false)] + [InlineData("invalid", false)] + public void SavedBrowserControlOptOut_IsPreserved(string? json, bool expected) => + Assert.Equal(expected, ChromeExtensionInstallRequest.SettingsAllowRequest(json)); + + [Fact] + public void ExactOwnedStoreRequest_IsRecognized() => + Assert.True(ChromeExtensionInstallRequest.IsOwned(Owned, Executable)); + + [Fact] + public void MatchingStoreUrlWithoutOwner_IsNotAdopted() + { + var values = Owned; + values.Remove(ChromeExtensionInstallRequest.OwnerValue); + Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); + } + + [Fact] + public void ForeignOwnerOrUrl_IsPreserved() + { + Assert.False(ChromeExtensionInstallRequest.IsOwned(Owned, Executable + ".other")); + var values = Owned; + values["update_url"] = "https://example.invalid/crx"; + Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); + Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable, allowIncomplete: true)); + } + + [Fact] + public void AdditionalValues_AreNotOwned() + { + var values = Owned; + values["path"] = "foreign.crx"; + Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); + } + + [Fact] + public void PartialOwnCreation_CanBeCleanedButIsNotAnInstalledRequest() + { + var values = Owned; + values.Remove("update_url"); + Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); + Assert.True(ChromeExtensionInstallRequest.IsOwned(values, Executable, allowIncomplete: true)); + Assert.False(ChromeExtensionInstallRequest.IsOwned(new Dictionary(), Executable, allowIncomplete: true)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs index af1c33a7c..1b5ccff6f 100644 --- a/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs @@ -12,6 +12,11 @@ public void Installer_RegistersNativeHelperBeforeTrayWithoutRequestingElevation( Assert.Contains("if CurStep = ssPostInstall then", installer); Assert.Contains("RegisterBrowserNativeHost;", installer); Assert.Contains("BrowserNativeHostRegistered := ResultCode = 0", installer); + Assert.Contains("UsePreviousTasks=yes", installer); + Assert.Contains("not WizardIsTaskSelected('chromeextension')", installer); + Assert.Contains("--request-extension", installer); + Assert.Contains("--remove-extension-request", installer); + Assert.Matches(@"RegisterBrowserNativeHost;\r?\n RequestChromeExtension;", installer); Assert.Contains("No extension installation may be requested", installer); Assert.Contains("PrivilegesRequired=lowest", installer); Assert.Contains("UnregisterBrowserNativeHost;", installer); From 8d52bde23bf3b230c6a74fbadfd0ee5ecadf6017 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:06:29 +0000 Subject: [PATCH 03/77] test(browser): label synthetic gateway credentials explicitly Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs index 6bf83f217..a82dda44e 100644 --- a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -29,7 +29,7 @@ public async Task FirstInstallBeforeSetup_RemainsRetryable() [Fact] public async Task Local_DelegatesToPinnedDistroWithoutReadingGatewayCredentials() { - var record = Local with { SharedGatewayToken = "never-use", BootstrapToken = "never-use" }; + var record = Local with { SharedGatewayToken = "test-token-placeholder", BootstrapToken = "test-token-placeholder" }; var calls = 0; var service = new BrowserBootstrapService(() => record, _ => true, (_, _) => Task.FromResult(true), (distro, _) => { Assert.Equal("OpenClawGateway", distro); calls++; return Task.FromResult(PairingJson()); }); From 1449549b21f834129b10d1fc1235be7f934b33af Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:08:48 +0000 Subject: [PATCH 04/77] chore: ignore local agent validation scratch Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 97d6a0cbc..301423f18 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,6 @@ visual-test-output/ msix-validation-evidence/ packaging-test-output/ uninstall-validation-output/ + +# Local agent validation SDKs, logs and proof scratch +.openclaw/tmp/ From f156a25b324394380296e61ae8cdf64d20d3a16c Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:10:09 +0000 Subject: [PATCH 05/77] test(browser): use a noncredential query rejection fixture Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs index a82dda44e..048398ed2 100644 --- a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -82,7 +82,7 @@ public void LocalAdmission_RejectsRemoteManualSshAndLegacyNameInference() Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { IsLocal = false })); Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { SetupManagedDistroName = null, FriendlyName = "Local (OpenClawGateway)" })); Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "ws://127.0.0.1:18789/path" })); - Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "ws://127.0.0.1:18789?token=x" })); + Assert.False(BrowserBootstrapService.IsManagedLocal(Local with { Url = "ws://127.0.0.1:18789?unexpected=1" })); } [Fact] From 136a95559a02b69c0b4d4b98589e34e8e5680490 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:15:22 +0000 Subject: [PATCH 06/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: ebec6cb8-5bb0-429e-9c98-7584d9f52b77 Co-authored-by: steipete <58493+steipete@users.noreply.github.com> From ccba8231c427d27c241de8cd0b2e60279da8ed8f Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:27:57 +0000 Subject: [PATCH 07/77] fix(browser): preserve the native helper runtime in Windows packages Insert the helper payload after SDK publish conflict resolution so parent runtime assets do not remove its nested hostpolicy and framework files. Add executable MSBuild regression tests and a completeness gate. Independent autoreview passed with no actionable findings. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- .../BrowserNativeHost.targets | 21 +++++- .../BrowserNativeHostPublishTests.cs | 70 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs diff --git a/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets b/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets index c8c24183c..8b9f5e62f 100644 --- a/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets +++ b/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets @@ -17,14 +17,29 @@ tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest - PreserveNewest + Never + + + + + + tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest - + + + + <_RequiredBrowserNativeFile Include="OpenClaw.BrowserNativeHost.exe;OpenClaw.BrowserNativeHost.dll;OpenClaw.BrowserNativeHost.runtimeconfig.json;OpenClaw.BrowserNativeHost.deps.json;hostpolicy.dll;hostfxr.dll;System.Private.CoreLib.dll" /> + + diff --git a/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs b/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs new file mode 100644 index 000000000..4f9dafc01 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Xml.Linq; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public class BrowserNativeHostPublishTests +{ + [Theory] + [InlineData(null)] + [InlineData("hostpolicy.dll")] + [InlineData("hostfxr.dll")] + [InlineData("System.Private.CoreLib.dll")] + public async Task Publish_KeepsIndependentRuntimeAndRejectsMissingFiles(string? missing) + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var project = XDocument.Load(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "BrowserNativeHost.targets")); + var add = new XElement(project.Root!.Elements("Target").Single(x => (string?)x.Attribute("Name") == "AddBrowserNativeHostToPublishItems")); + var verify = new XElement(project.Root.Elements("Target").Single(x => (string?)x.Attribute("Name") == "VerifyBrowserNativeHostPublished")); + Assert.Equal("ComputeResolvedFilesToPublishList", (string?)add.Attribute("AfterTargets")); + var build = project.Root.Elements("Target").Single(x => (string?)x.Attribute("Name") == "BuildBrowserNativeHostPayload"); + Assert.Empty(build.Descendants("ResolvedFileToPublish")); + Assert.Equal("Never", build.Descendants("ContentWithTargetPath").Single().Element("CopyToPublishDirectory")!.Value); + + using var temp = new TempDirectory(); + var payload = temp.Combine("payload"); + var published = temp.Combine("publish") + Path.DirectorySeparatorChar; + Directory.CreateDirectory(payload); + var required = verify.Descendants("_RequiredBrowserNativeFile").Single().Attribute("Include")!.Value.Split(';'); + foreach (var file in required.Where(file => file != missing)) + File.WriteAllText(Path.Combine(payload, file), "synthetic artifact: " + file); + var parentRuntime = temp.Combine("hostpolicy.dll"); + File.WriteAllText(parentRuntime, "parent runtime"); + var harness = temp.Combine("publish-native.proj"); + // Exercise the production late item insertion, copy paths and completeness gate. + // The stand-in resolution target retains a same-named runtime at the parent root. + new XDocument(new XElement("Project", + new XElement("PropertyGroup", new XElement("PublishDir", published)), + new XElement("Target", new XAttribute("Name", "BuildBrowserNativeHostPayload"), + new XElement("ItemGroup", new XElement("_BrowserNativeHostFile", new XAttribute("Include", Path.Combine(payload, "**", "*"))))), + new XElement("Target", new XAttribute("Name", "ComputeResolvedFilesToPublishList"), + new XElement("ItemGroup", new XElement("ResolvedFileToPublish", new XAttribute("Include", parentRuntime), + new XElement("RelativePath", "hostpolicy.dll")))), + add, + new XElement("Target", new XAttribute("Name", "Publish"), new XAttribute("DependsOnTargets", "ComputeResolvedFilesToPublishList"), + new XElement("Copy", new XAttribute("SourceFiles", "@(ResolvedFileToPublish)"), + new XAttribute("DestinationFiles", "@(ResolvedFileToPublish->'$(PublishDir)%(RelativePath)')"))), + verify)).Save(harness); + + var start = new ProcessStartInfo("dotnet") { WorkingDirectory = root, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in new[] { "msbuild", harness, "-t:Publish", "-nologo", "-v:minimal" }) start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try { await process.WaitForExitAsync(timeout.Token); } + catch (OperationCanceledException) { process.Kill(true); throw; } + var output = await stdout + await stderr; + if (missing is not null) + { + Assert.NotEqual(0, process.ExitCode); + Assert.Contains("missing " + missing, output); + return; + } + Assert.True(process.ExitCode == 0, output); + Assert.Equal("parent runtime", File.ReadAllText(Path.Combine(published, "hostpolicy.dll"))); + foreach (var file in required) + Assert.Equal("synthetic artifact: " + file, File.ReadAllText(Path.Combine(published, "tools", "browser-bootstrap", file))); + } +} From d2624dae3e020c5f65d6a2ebd48c3cff70aa4a40 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:30:59 +0000 Subject: [PATCH 08/77] test(browser): keep native frame proof responses free of async completion objects Execute the production framing functions in a MemoryStream regression before touching the registry. Suppress VoidTaskResult pipeline output from ReadExactlyAsync. Independent autoreview passed with no actionable findings. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- scripts/Test-BrowserNativeHost.ps1 | 5 +++-- scripts/test-browser-native-framing.ps1 | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 scripts/test-browser-native-framing.ps1 diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index f28e250d6..a1690b135 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -9,6 +9,7 @@ param([Parameter(Mandatory)][string]$ExecutablePath) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +& (Join-Path $PSScriptRoot 'test-browser-native-framing.ps1') $exe = (Resolve-Path -LiteralPath $ExecutablePath).Path $key = 'Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap' $origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/' @@ -47,11 +48,11 @@ function Read-Frame([IO.Stream]$Stream) { $ct = [Threading.CancellationTokenSource]::new(10000) try { $header = [byte[]]::new(4) - $Stream.ReadExactlyAsync([Memory[byte]]$header, $ct.Token).AsTask().GetAwaiter().GetResult() + [void]$Stream.ReadExactlyAsync([Memory[byte]]$header, $ct.Token).AsTask().GetAwaiter().GetResult() $length = [BitConverter]::ToUInt32($header, 0) if ($length -eq 0 -or $length -gt 4096) { throw 'Invalid response frame length.' } $bytes = [byte[]]::new($length) - $Stream.ReadExactlyAsync([Memory[byte]]$bytes, $ct.Token).AsTask().GetAwaiter().GetResult() + [void]$Stream.ReadExactlyAsync([Memory[byte]]$bytes, $ct.Token).AsTask().GetAwaiter().GetResult() [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json } finally { $ct.Dispose() } } diff --git a/scripts/test-browser-native-framing.ps1 b/scripts/test-browser-native-framing.ps1 new file mode 100644 index 000000000..fd57dcc3a --- /dev/null +++ b/scripts/test-browser-native-framing.ps1 @@ -0,0 +1,25 @@ +# Exercise the production framing functions without touching registry or launching the host. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$source = Join-Path $PSScriptRoot 'Test-BrowserNativeHost.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile($source, [ref]$tokens, [ref]$errors) +if ($errors.Count -ne 0) { throw 'Native proof script did not parse.' } +foreach ($name in @('Write-Frame', 'Read-Frame')) { + $definitions = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true)) + if ($definitions.Count -ne 1) { throw "Expected one $name function." } + . ([ScriptBlock]::Create($definitions[0].Extent.Text)) +} +$stream = [IO.MemoryStream]::new() +try { + Write-Frame $stream ([Text.Encoding]::UTF8.GetBytes('{"ok":true,"marker":"native-framing-proof"}')) + $stream.Position = 0 + $result = @(Read-Frame $stream) + if ($result.Count -ne 1 -or -not $result[0].ok -or $result[0].marker -ne 'native-framing-proof') { + throw 'Read-Frame must return exactly one decoded message, not async completion objects.' + } +} finally { $stream.Dispose() } +Write-Host 'NATIVE_FRAMING_HELPER_PROOF_OK' From f90b195174a9d58281c58bbc1b78d3a4e71fe18b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:33:52 +0000 Subject: [PATCH 09/77] fix(browser): verify the exact pairing destination and normalize WSL input Address verified ClawSweeper findings: validate the canonical IPv4 destination with the same pinned gateway and distro before and after CLI execution, while retaining original-record lifecycle checks. Normalize CRLF/CR script input to LF before Bash stdin. Six regression cases added; 16 focused connection tests and independent autoreview pass. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- .../BrowserBootstrapService.cs | 6 ++- .../BrowserBootstrapWslCommand.cs | 5 ++- .../BrowserBootstrapServiceTests.cs | 37 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Connection/BrowserBootstrapService.cs b/src/OpenClaw.Connection/BrowserBootstrapService.cs index f6d88f78e..e61429cfc 100644 --- a/src/OpenClaw.Connection/BrowserBootstrapService.cs +++ b/src/OpenClaw.Connection/BrowserBootstrapService.cs @@ -26,14 +26,16 @@ public async Task HandleAsync(byte[] payload, CancellationToken ct) if (record is null) return BrowserNativeProtocol.Failure("pairing_unavailable"); if (!IsManagedLocal(record)) return BrowserNativeProtocol.Failure("manual_required"); var pinned = record!; + // The CLI always returns IPv4. Verify that exact destination, not an IPv6/localhost alias. + var destination = pinned with { Url = $"ws://127.0.0.1:{new Uri(pinned.Url).Port}" }; if (generation != Interlocked.Read(ref _generation) || !Current(pinned) || - !await verifyEndpoint(pinned, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) + !await verifyEndpoint(destination, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) return BrowserNativeProtocol.Failure("pairing_unavailable"); var json = await runPairing(pinned.SetupManagedDistroName!, ct); var pairing = ParsePairing(json, new Uri(pinned.Url).Port); // A disconnect, switch, or preference change while the CLI ran wins over returning credentials. if (generation != Interlocked.Read(ref _generation) || !Current(pinned) || - !await verifyEndpoint(pinned, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) + !await verifyEndpoint(destination, ct) || !Current(pinned) || generation != Interlocked.Read(ref _generation)) return BrowserNativeProtocol.Failure("pairing_unavailable"); return BrowserNativeProtocol.Pairing(request.Nonce, pairing); } diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs index 2b0d7872a..c50afa794 100644 --- a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs +++ b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs @@ -33,6 +33,9 @@ unset OPENCLAW_PROFILE OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_HOME NOD exit 127 """; + internal static string NormalizeStandardInput(string script) => + script.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').TrimEnd('\n') + "\n"; + public static async Task RunAsync(string distro, CancellationToken ct) { var start = new ProcessStartInfo @@ -58,7 +61,7 @@ public static async Task RunAsync(string distro, CancellationToken ct) var error = ReadBoundedAsync(process.StandardError, deadline.Token); try { - await process.StandardInput.WriteAsync((Script + "\n").AsMemory(), deadline.Token); + await process.StandardInput.WriteAsync(NormalizeStandardInput(Script).AsMemory(), deadline.Token); process.StandardInput.Close(); // Await drains alongside exit so oversized output faults immediately rather than waiting for exit. await Task.WhenAll(output, error, process.WaitForExitAsync(deadline.Token)); diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs index 048398ed2..7db1a3c3b 100644 --- a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -94,6 +94,43 @@ public void PairingOutput_MustMatchLocalGatewayTopologyAndPort() Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson().Replace("/browser/extension", "/extension"), 18789)); } + [Theory] + [InlineData("ws://[::1]:18789")] + [InlineData("ws://localhost:18789")] + public async Task AliasRecord_CannotAuthorizeAnUnownedIpv4Destination(string activeUrl) + { + var active = Local with { Url = activeUrl }; + var service = new BrowserBootstrapService(() => active, _ => true, + (destination, _) => Task.FromResult(destination.Url == activeUrl), + (_, _) => throw new InvalidOperationException("Must not run against unowned IPv4")); + Assert.False(IsOk(await service.HandleAsync(Request, default))); + } + + [Fact] + public async Task BothProvenanceChecks_PinActualDestinationAndDistro() + { + var active = Local with { Url = "ws://[::1]:18789" }; + var calls = 0; + var service = new BrowserBootstrapService(() => active, _ => true, (destination, _) => + { + Assert.Equal("ws://127.0.0.1:18789", destination.Url); + Assert.Equal(active.Id, destination.Id); + Assert.Equal(active.SetupManagedDistroName, destination.SetupManagedDistroName); + calls++; + return Task.FromResult(true); + }, (_, _) => Task.FromResult(PairingJson())); + Assert.True(IsOk(await service.HandleAsync(Request, default))); + Assert.Equal(2, calls); + Assert.Equal("ws://[::1]:18789", active.Url); + } + + [Theory] + [InlineData("set -e\necho proof", "set -e\necho proof\n")] + [InlineData("set -e\r\necho proof\r\n", "set -e\necho proof\n")] + [InlineData("set -e\recho proof", "set -e\necho proof\n")] + public void WslInput_NormalizesWindowsCheckoutLineEndings(string input, string expected) => + Assert.Equal(expected, BrowserBootstrapWslCommand.NormalizeStandardInput(input)); + [Fact] public void Command_UsesCanonicalPairingNotGatewaySecretsOrLifecycle() { From 0468aae2d2c6b04afdac9244fad16c4be74c2074 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:42:40 +0000 Subject: [PATCH 10/77] test(browser): report successful native proof after expected rejections Clear the stale native exit code only after all proof assertions and cleanup finish successfully. Real Windows x64 and ARM64 runs reached both success markers but GitHub inherited the last intentional rejection exit status. Independent autoreview passed without actionable findings. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- scripts/Test-BrowserNativeHost.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index a1690b135..73aae9e4d 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -186,3 +186,5 @@ try { if ($registered) { & $exe --unregister } $root.Dispose() } +# Expected rejection probes leave LASTEXITCODE=1. Only a fully completed proof reaches here. +$global:LASTEXITCODE = 0 From c1d151dff7ca6a8f28b48043d608849dd44e9d4b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:43:51 +0000 Subject: [PATCH 11/77] feat(browser): consolidate Windows Chrome setup ownership Use the agreed bounded management ABI and sole generation-backed registry owner for explicit managed WSL and native Windows transports. Preserve private ACL, provenance, lifecycle, cancellation and foreign-entry protections; exercise packaged helpers and installer pipe management in Windows CI. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/ci.yml | 37 ++- docs/ARCHITECTURE.md | 3 +- installer.iss | 58 ++-- openclaw-windows-node.slnx | 4 +- scripts/BrowserBootstrapManagement.iss | 269 +++++++++++++++ scripts/Test-BrowserBootstrapInstaller.ps1 | 76 +++++ scripts/Test-BrowserNativeHost.ps1 | 305 ++++++++---------- scripts/Test-ReleaseExecutableSignatures.ps1 | 9 +- .../Directory.Build.targets | 1 + .../ManagementContract.cs | 253 +++++++++++++++ ...OpenClaw.BrowserBootstrap.Contracts.csproj | 1 + .../BrowserControlPreference.cs | 22 ++ .../Directory.Build.targets | 1 + .../GenerationStore.cs | 146 +++++++++ .../NativeTransport.cs | 101 ++++++ .../OpenClaw.BrowserBootstrap.csproj | 4 + src/OpenClaw.BrowserBootstrap/Program.cs | 69 ++++ src/OpenClaw.BrowserBootstrap/README.md | 17 + .../RegistrationService.cs | 121 +++++++ .../WindowsAuthority.cs | 165 ++++++++++ .../WindowsRegistrationPlatform.cs | 224 +++++++++++++ .../OpenClaw.BrowserNativeHost.csproj | 15 - src/OpenClaw.BrowserNativeHost/Program.cs | 51 --- .../Browser/BrowserBootstrapPipeServer.cs | 74 +++-- .../Browser/BrowserManagementClient.cs | 44 +++ .../Browser/BrowserNativeRegistration.cs | 121 ------- .../Browser/ChromeExtensionInstallRequest.cs | 104 ------ src/OpenClaw.Shared/OpenClaw.Shared.csproj | 1 + .../BrowserBootstrap.targets | 30 ++ .../BrowserNativeHost.targets | 45 --- .../OpenClaw.Tray.WinUI.csproj | 2 +- .../Services/BrowserBootstrapHost.cs | 10 +- .../GenerationMetadataTests.cs | 67 ++++ .../ManagementContractTests.cs | 108 +++++++ .../NativeCancellationTests.cs | 60 ++++ .../OpenClaw.BrowserBootstrap.Tests.csproj | 1 + .../RegistrationServiceTests.cs | 106 ++++++ .../RegistryTransactionTests.cs | 69 ++++ .../BrowserBootstrapPipeTests.cs | 27 +- .../ChromeExtensionInstallRequestTests.cs | 64 ---- ...rowserBootstrapIntegrationContractTests.cs | 30 +- .../BrowserBootstrapPublishTests.cs | 38 +++ .../BrowserNativeHostPublishTests.cs | 70 ---- 43 files changed, 2277 insertions(+), 746 deletions(-) create mode 100644 scripts/BrowserBootstrapManagement.iss create mode 100644 scripts/Test-BrowserBootstrapInstaller.ps1 create mode 100644 src/OpenClaw.BrowserBootstrap.Contracts/Directory.Build.targets create mode 100644 src/OpenClaw.BrowserBootstrap.Contracts/ManagementContract.cs create mode 100644 src/OpenClaw.BrowserBootstrap.Contracts/OpenClaw.BrowserBootstrap.Contracts.csproj create mode 100644 src/OpenClaw.BrowserBootstrap/BrowserControlPreference.cs create mode 100644 src/OpenClaw.BrowserBootstrap/Directory.Build.targets create mode 100644 src/OpenClaw.BrowserBootstrap/GenerationStore.cs create mode 100644 src/OpenClaw.BrowserBootstrap/NativeTransport.cs create mode 100644 src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj create mode 100644 src/OpenClaw.BrowserBootstrap/Program.cs create mode 100644 src/OpenClaw.BrowserBootstrap/README.md create mode 100644 src/OpenClaw.BrowserBootstrap/RegistrationService.cs create mode 100644 src/OpenClaw.BrowserBootstrap/WindowsAuthority.cs create mode 100644 src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs delete mode 100644 src/OpenClaw.BrowserNativeHost/OpenClaw.BrowserNativeHost.csproj delete mode 100644 src/OpenClaw.BrowserNativeHost/Program.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserManagementClient.cs delete mode 100644 src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs delete mode 100644 src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs create mode 100644 src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets delete mode 100644 src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/GenerationMetadataTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/RegistrationServiceTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs delete mode 100644 tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs delete mode 100644 tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5faab284b..a42d1c2f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,6 +266,11 @@ jobs: 'tests/OpenClaw.TestSupport/Directory.Build.props', 'tests/OpenClaw.Shared.TestHost/Directory.Build.props', 'src/OpenClaw.Shared/OpenClaw.Shared.csproj', + 'src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj', + 'src/OpenClaw.BrowserBootstrap.Contracts/OpenClaw.BrowserBootstrap.Contracts.csproj', + 'src/OpenClaw.BrowserBootstrap/Directory.Build.targets', + 'src/OpenClaw.BrowserBootstrap.Contracts/Directory.Build.targets', + 'tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj', 'src/OpenClaw.Connection/OpenClaw.Connection.csproj', 'src/OpenClaw.Cli/OpenClaw.Cli.csproj', 'src/OpenClaw.WinNode.Cli/OpenClaw.WinNode.Cli.csproj', @@ -281,6 +286,7 @@ jobs: dotnet restore tests/OpenClaw.Shared.Tests dotnet restore tests/OpenClaw.Connection.Tests dotnet restore tests/OpenClaw.WinNode.Cli.Tests + dotnet restore tests/OpenClaw.BrowserBootstrap.Tests - name: Install dotnet-coverage if: ${{ github.event_name != 'pull_request' }} @@ -291,6 +297,7 @@ jobs: dotnet build tests/OpenClaw.Shared.Tests -c Debug --no-restore dotnet build tests/OpenClaw.Connection.Tests -c Debug --no-restore dotnet build tests/OpenClaw.WinNode.Cli.Tests -c Debug --no-restore + dotnet build tests/OpenClaw.BrowserBootstrap.Tests -c Debug --no-restore - name: Run Shared Tests env: @@ -308,6 +315,13 @@ jobs: -ResultsDirectory TestResults\Connection -TrxFileName OpenClaw.Connection.Tests.trx + - name: Run Browser Bootstrap Contract Tests + run: > + ./scripts/Invoke-CiTest.ps1 + -Project tests/OpenClaw.BrowserBootstrap.Tests + -ResultsDirectory TestResults\BrowserBootstrap + -TrxFileName OpenClaw.BrowserBootstrap.Tests.trx + - name: Run WinNode CLI Tests run: > ./scripts/Invoke-CiTest.ps1 @@ -738,7 +752,18 @@ jobs: - name: Prove packaged native browser host shell: pwsh - run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe + run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe + + - name: Compile bounded installer management transport + shell: pwsh + run: | + $compiler = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + if (-not (Test-Path -LiteralPath $compiler)) { choco install innosetup -y --no-progress; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + & $compiler /DMyAppVersion=0.0.0 /DMyAppArch=x64 /Dpublish=publish /DMyCompression=none /DMySolidCompression=no "/O$env:RUNNER_TEMP\browser-installer-compile" installer.iss + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Only a dedicated fixture installation on the disposable hosted runner is executed. + ./scripts/Test-BrowserBootstrapInstaller.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe -CompilerPath $compiler + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify x64 Native Runtime Payload shell: pwsh @@ -798,7 +823,7 @@ jobs: - name: Prove packaged native browser host shell: pwsh - run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe + run: ./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe - name: Verify ARM64 Native Runtime Payload shell: pwsh @@ -1025,13 +1050,13 @@ jobs: "OpenClaw.Connection.dll", "OpenClaw.SetupEngine.UI.dll", "OpenClaw.SetupEngine.dll", - "OpenClaw.Shared.dll", + "OpenClaw.Shared.dll", "OpenClaw.BrowserBootstrap.Contracts.dll", "OpenClawTray.FunctionalUI.dll" ) foreach ($binary in $openClawBinaries) { New-Item -ItemType HardLink -Path "signing-input-x64\$binary" -Target "artifacts\tray-win-x64\$binary" | Out-Null } - foreach ($binary in @("OpenClaw.BrowserNativeHost.exe", "OpenClaw.BrowserNativeHost.dll")) { + foreach ($binary in @("OpenClaw.BrowserBootstrap.exe")) { New-Item -ItemType HardLink -Path "signing-input-x64\$binary" -Target "artifacts\tray-win-x64\tools\browser-bootstrap\$binary" | Out-Null } @@ -1046,13 +1071,13 @@ jobs: "OpenClaw.Connection.dll", "OpenClaw.SetupEngine.UI.dll", "OpenClaw.SetupEngine.dll", - "OpenClaw.Shared.dll", + "OpenClaw.Shared.dll", "OpenClaw.BrowserBootstrap.Contracts.dll", "OpenClawTray.FunctionalUI.dll" ) foreach ($binary in $openClawBinaries) { New-Item -ItemType HardLink -Path "signing-input-arm64\$binary" -Target "artifacts\tray-win-arm64\$binary" | Out-Null } - foreach ($binary in @("OpenClaw.BrowserNativeHost.exe", "OpenClaw.BrowserNativeHost.dll")) { + foreach ($binary in @("OpenClaw.BrowserBootstrap.exe")) { New-Item -ItemType HardLink -Path "signing-input-arm64\$binary" -Target "artifacts\tray-win-arm64\tools\browser-bootstrap\$binary" | Out-Null } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2a1010230..3033ffb07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -253,6 +253,7 @@ leading and trailing pipe. Columns, in order: | reactor-chat-root-composer-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs | composer draft/attachment/slash/voice/send mutable state and direct composer send/model/thinking/catalog/queue-cancel provider calls | ChatComposerViewModel + ChatComposerController | provider subscription, initial load, immutable snapshot, selected/materialized/compose-only thread selection, timeline/generation/metadata projection, permission-card forwarding, checkpoint routing, #1089 scroll/follow tokens, root composition, and construction of one immutable ChatComposerInputs projection per render | the root holds no composer UseState/refs and calls no composer provider API directly; it builds ChatComposerInputs, forwards them to ReactorChatComposer for post-commit application, and binds the SelectThread handoff | ChatRootComposerClosureTests.Root_DoesNotReintroduceComposerMutableState | source-shape | when OpenClawReactorChatRoot is replaced by a different root/composer boundary | | sensitive-capture-guard | authoritative | src/OpenClaw.Tray.WinUI/Services/NodeService.cs | ordering of consent, visible per-use indication, and sensor access for instantaneous screen, camera, and location captures | SensitiveCaptureExecutor + SensitiveCapturePlans | NodeService supplies the consent prompt, localized notification, and platform sensor delegates | every sensitive instant capture completes consent and visible indication before its sensor delegate can run; denied consent never reaches the sensor | SensitiveCaptureExecutorTests.ExecuteAsync_RequiresConsentAndIndicatorBeforeSensorAccess | behavioral | - | | browser-native-bootstrap | authoritative | new native messaging entry point | local browser extension pairing admission, generation fencing and CLI delegation | BrowserBootstrapService + BrowserBootstrapHost + BrowserBootstrapWslCommand | App composes and disposes the host only; GatewayConnectionManager retains all connection intent and lifecycle | disabled browser control, explicit disconnect, active gateway changes and unverified endpoints cannot deliver pairing material; no parallel relay or credential store | BrowserBootstrapServiceTests.DisconnectDuringCli_DiscardsPairing | behavioral | - | +| windows-browser-bootstrap-writer | authoritative | BrowserNativeRegistration + ChromeExtensionInstallRequest | duplicate Windows native generation and registry mutation | WindowsRegistrationPlatform + GenerationStore through OpenClaw.BrowserBootstrap management | installer and Companion use the bounded management clients; canonical TS retains read-only context and credential policy | explicit mode, current-user ownership, immutable private generations and foreign refusal; no topology fallback | BrowserBootstrapIntegrationContractTests.Bootstrap_UsesExistingOwnersAndExcludesIsolatedProfiles | source-shape | when the Windows native bootstrap integration is retired | ## Deferred test builders @@ -262,4 +263,4 @@ leading and trailing pipe. Columns, in order: service (not a value type) and `SetupContext` needs setup logger/journal/command-runner fakes. Both will be added alongside their subsystem PRs (gateway protocol and SetupEngine, respectively) so `OpenClaw.TestSupport` does not take a heavy -dependency on `OpenClaw.SetupEngine` prematurely. +dependency on `OpenClaw.SetupEngine` prematurely. \ No newline at end of file diff --git a/installer.iss b/installer.iss index 7b60399dc..7327690d1 100644 --- a/installer.iss +++ b/installer.iss @@ -133,11 +133,12 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [Code] var VCRuntimeInstallSucceeded: Boolean; - BrowserNativeHostRegistered: Boolean; LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; +#include "scripts\BrowserBootstrapManagement.iss" + #if vcRedist != "" procedure InstallVCRuntime; var @@ -310,6 +311,13 @@ begin if not LocalGatewayCleanupSucceeded then Exit; + { Native generations may retain foreign files or orphan Store references. They are not installer garbage. } + if DirExists(ExpandConstant('{localappdata}\OpenClawTray\browser-native')) then + begin + Log('Retained native generations require ownership-aware cleanup; preserving generated app state.'); + Exit; + end; + if DelTree(ExpandConstant('{app}'), True, True, True) then Log('Deleted generated app state from {app}.') else @@ -343,58 +351,28 @@ begin Log('{#MyStartupTaskName} startup task already absent or unavailable.'); end; -procedure RegisterBrowserNativeHost; +procedure RegisterBrowserIntegration; var - ResultCode: Integer; + StoreAction: String; begin - BrowserNativeHostRegistered := False; #ifndef DevBuild - if Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), - '--register', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - BrowserNativeHostRegistered := ResultCode = 0; - if not BrowserNativeHostRegistered then - Log('Native browser registration was not ready. No extension installation may be requested.'); -#endif -end; - -procedure RequestChromeExtension; -var - ResultCode: Integer; -begin -#ifndef DevBuild - if not BrowserNativeHostRegistered or not WizardIsTaskSelected('chromeextension') then - Exit; - if Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), - '--request-extension', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - begin - if ResultCode = 0 then - Log('Requested the official Chrome Store extension. Chrome permission approval is still required.') - else - Log('Chrome extension request was not written. Existing foreign registrations are preserved.'); - end; + StoreAction := 'preserve'; + if WizardIsTaskSelected('chromeextension') then StoreAction := 'request'; + if not RunBrowserManagement('install', StoreAction) then + Log('Browser setup was refused or its outcome is uncertain. Existing registrations and pairing are preserved.'); #endif end; procedure CurStepChanged(CurStep: TSetupStep); begin - // Files are installed before native registration, which must succeed before the Store request. - if CurStep = ssPostInstall then - begin - RegisterBrowserNativeHost; - RequestChromeExtension; - end; + if CurStep = ssPostInstall then RegisterBrowserIntegration; end; procedure UnregisterBrowserNativeHost; -var - ResultCode: Integer; begin #ifndef DevBuild - Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), - '--remove-extension-request', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - if not Exec(ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe'), - '--unregister', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - Log('Native browser host unregister was unavailable. Foreign registrations are preserved.'); + if not RunBrowserManagement('uninstall', 'remove') then + Log('Browser cleanup was refused or its outcome is uncertain. Retained generations must not be deleted.'); #endif end; diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index d80b05308..48e6ce10d 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -5,7 +5,7 @@ - + @@ -44,4 +44,6 @@ + + diff --git a/scripts/BrowserBootstrapManagement.iss b/scripts/BrowserBootstrapManagement.iss new file mode 100644 index 000000000..8dac40cf0 --- /dev/null +++ b/scripts/BrowserBootstrapManagement.iss @@ -0,0 +1,269 @@ +; Shared by installer AND uninstaller. Fixed argv, bounded stdin/stdout, no shell or JSON command-line interpolation. +; Native process containment and EOF completion are distinct from Chrome's EOF revocation. +function BBCreatePipe(var R, W: NativeInt; SA: AnsiString; Size: Cardinal): Boolean; + external 'CreatePipe@kernel32.dll stdcall'; +function BBHandleFlags(H: NativeInt; Mask, Flags: Cardinal): Boolean; + external 'SetHandleInformation@kernel32.dll stdcall'; +function BBClose(H: NativeInt): Boolean; + external 'CloseHandle@kernel32.dll stdcall'; +function BBCreateProcess(App: String; Cmd: String; ProcessSA, ThreadSA: NativeInt; Inherit: Boolean; + Flags: Cardinal; Env: NativeInt; Directory: String; Startup, ProcessInfo: AnsiString): Boolean; + external 'CreateProcessW@kernel32.dll stdcall'; +function BBWrite(H: NativeInt; Data: AnsiString; Count: Cardinal; var Written: Cardinal; Overlapped: NativeInt): Boolean; + external 'WriteFile@kernel32.dll stdcall'; +function BBRead(H: NativeInt; Data: AnsiString; Count: Cardinal; var ReadCount: Cardinal; Overlapped: NativeInt): Boolean; + external 'ReadFile@kernel32.dll stdcall'; +function BBPeek(H: NativeInt; Buffer: NativeInt; BufferSize: Cardinal; ReadBytes: NativeInt; var Available: Cardinal; LeftBytes: NativeInt): Boolean; + external 'PeekNamedPipe@kernel32.dll stdcall'; +function BBWait(H: NativeInt; Milliseconds: Cardinal): Cardinal; + external 'WaitForSingleObject@kernel32.dll stdcall'; +function BBExit(H: NativeInt; var Code: Cardinal): Boolean; + external 'GetExitCodeProcess@kernel32.dll stdcall'; +function BBResume(H: NativeInt): Cardinal; + external 'ResumeThread@kernel32.dll stdcall'; +function BBTerminate(H: NativeInt; Code: Cardinal): Boolean; + external 'TerminateProcess@kernel32.dll stdcall'; +function BBJob(Security, Name: NativeInt): NativeInt; + external 'CreateJobObjectW@kernel32.dll stdcall'; +function BBJobLimits(Job: NativeInt; InfoClass: Integer; Info: AnsiString; Size: Cardinal): Boolean; + external 'SetInformationJobObject@kernel32.dll stdcall'; +function BBAssign(Job, Process: NativeInt): Boolean; + external 'AssignProcessToJobObject@kernel32.dll stdcall'; +function BBTick: Int64; + external 'GetTickCount64@kernel32.dll stdcall'; +function BBError: Cardinal; + external 'GetLastError@kernel32.dll stdcall'; + +procedure BBPut(var Buffer: AnsiString; Offset, Bytes: Integer; Value: Int64); +var I: Integer; +begin + for I := 0 to Bytes - 1 do begin Buffer[Offset + I + 1] := Chr(Value and 255); Value := Value shr 8; end; +end; + +function BBGet(Buffer: AnsiString; Offset, Bytes: Integer): Int64; +var I: Integer; +begin + Result := 0; + for I := Bytes - 1 downto 0 do Result := (Result shl 8) or Ord(Buffer[Offset + I + 1]); +end; + +procedure BBDispose(var H: NativeInt); +begin + if H <> 0 then begin BBClose(H); H := 0; end; +end; + +function BBDrain(H: NativeInt; Limit: Integer; var Data: AnsiString; var Eof: Boolean): Boolean; +var Available, ReadCount: Cardinal; Chunk: AnsiString; Count: Integer; +begin + Result := False; + if Eof then begin Result := True; Exit; end; + if not BBPeek(H, 0, 0, 0, Available, 0) then begin + Eof := BBError = 109; + Result := Eof; Exit; + end; + if Available > Cardinal(Limit - Length(Data)) then Exit; + if Available > 0 then begin + Count := Available; if Count > 4096 then Count := 4096; + Chunk := StringOfChar(#0, Count); + if not BBRead(H, Chunk, Count, ReadCount, 0) then Exit; + if ReadCount > Cardinal(Count) then Exit; + Data := Data + Copy(Chunk, 1, ReadCount); + end; + Result := True; +end; + +procedure BBExpect(S: String; var P: Integer; C: Char); +begin + if (P > Length(S)) or (S[P] <> C) then RaiseException('Invalid management receipt.'); + P := P + 1; +end; + +function BBHex(S: String; var P: Integer): Integer; +var I, V: Integer; C: Char; +begin + Result := 0; + for I := 1 to 4 do begin + if P > Length(S) then RaiseException('Invalid management receipt.'); + C := S[P]; P := P + 1; + if (C >= '0') and (C <= '9') then V := Ord(C)-Ord('0') + else if (C >= 'a') and (C <= 'f') then V := Ord(C)-Ord('a')+10 + else if (C >= 'A') and (C <= 'F') then V := Ord(C)-Ord('A')+10 + else begin RaiseException('Invalid management receipt.'); V := 0; end; + Result := Result * 16 + V; + end; +end; + +function BBString(S: String; var P: Integer): String; +var C: Char; V, Low: Integer; +begin + Result := ''; BBExpect(S, P, '"'); + while P <= Length(S) do begin + C := S[P]; P := P + 1; + if C = '"' then Exit; + if Ord(C) < 32 then RaiseException('Invalid management receipt.'); + if C = '\' then begin + if P > Length(S) then RaiseException('Invalid management receipt.'); + C := S[P]; P := P + 1; + case C of + '"', '\', '/': Result := Result + C; + 'b': Result := Result + #8; + 'f': Result := Result + #12; + 'n': Result := Result + #10; + 'r': Result := Result + #13; + 't': Result := Result + #9; + 'u': begin + V := BBHex(S, P); + if (V >= $DC00) and (V <= $DFFF) then RaiseException('Invalid management receipt.'); + Result := Result + Chr(V); + if (V >= $D800) and (V <= $DBFF) then begin + BBExpect(S, P, '\'); BBExpect(S, P, 'u'); Low := BBHex(S, P); + if (Low < $DC00) or (Low > $DFFF) then RaiseException('Invalid management receipt.'); + Result := Result + Chr(Low); + end; + end; + else RaiseException('Invalid management receipt.'); + end; + end else Result := Result + C; + end; + RaiseException('Invalid management receipt.'); +end; + +procedure BBLiteral(S: String; var P: Integer; Value: String); +begin + if Copy(S, P, Length(Value)) <> Value then RaiseException('Invalid management receipt.'); + P := P + Length(Value); +end; + +function BBNullable(S: String; var P: Integer): String; +begin + if Copy(S, P, 4) = 'null' then begin BBLiteral(S, P, 'null'); Result := '#null'; end + else Result := BBString(S, P); +end; + +function BBGuid(S: String): Boolean; +var I: Integer; +begin + Result := False; + if (Length(S) <> 36) or (S = '00000000-0000-0000-0000-000000000000') then Exit; + for I := 1 to 36 do begin + if (I = 9) or (I = 14) or (I = 19) or (I = 24) then begin if S[I] <> '-' then Exit; end + else if not (((S[I] >= '0') and (S[I] <= '9')) or ((S[I] >= 'a') and (S[I] <= 'f'))) then Exit; + end; + Result := True; +end; + +function BBDescriptor(S: String; var P: Integer): Boolean; +var Key, G, M, E, B, R, Root: String; Seen, Count: Integer; +begin + Result := False; BBExpect(S, P, '{'); Seen := 0; Count := 0; + repeat + if Count > 0 then BBExpect(S, P, ','); Key := BBString(S, P); BBExpect(S, P, ':'); + if Key = 'generation' then begin if (Seen and 1) <> 0 then RaiseException('Duplicate receipt field.'); Seen := Seen or 1; G := BBString(S, P); end + else if Key = 'manifestPath' then begin if (Seen and 2) <> 0 then RaiseException('Duplicate receipt field.'); Seen := Seen or 2; M := BBString(S, P); end + else if Key = 'launcherPath' then begin if (Seen and 4) <> 0 then RaiseException('Duplicate receipt field.'); Seen := Seen or 4; E := BBString(S, P); end + else if Key = 'bindingPath' then begin if (Seen and 8) <> 0 then RaiseException('Duplicate receipt field.'); Seen := Seen or 8; B := BBString(S, P); end + else if Key = 'receiptPath' then begin if (Seen and 16) <> 0 then RaiseException('Duplicate receipt field.'); Seen := Seen or 16; R := BBString(S, P); end + else RaiseException('Unknown receipt field.'); + Count := Count + 1; + if Count > 5 then RaiseException('Invalid management receipt.'); + until (P <= Length(S)) and (S[P] = '}'); + BBExpect(S, P, '}'); + if (Seen <> 31) or not BBGuid(G) then Exit; + Root := ExpandConstant('{localappdata}') + '\OpenClawTray\browser-native\generations\' + G + '\'; + Result := (M = Root + 'ai.openclaw.browser_bootstrap.json') and (E = Root + 'OpenClaw.BrowserBootstrap.exe') and + (B = Root + 'OpenClaw.BrowserBootstrap.binding.json') and (R = Root + 'OpenClaw.BrowserBootstrap.owned.json'); +end; + +function BBReceipt(Bytes: AnsiString; ExitCode: Cardinal; Action, Store: String): Boolean; +var S, Key, Code, Registration, Mode, Inventory: String; P, Seen, Count, Bit: Integer; Ok, HasDescriptor: Boolean; +begin + Result := False; + try + if (Length(Bytes) < 2) or (Length(Bytes) > 32768) or (Bytes[Length(Bytes)] <> #10) then Exit; + S := Utf8Decode(Bytes); if Utf8Encode(S) <> Bytes then Exit; + P := 1; BBExpect(S, P, '{'); Seen := 0; Count := 0; Ok := False; HasDescriptor := False; + repeat + if Count > 0 then BBExpect(S, P, ','); Key := BBString(S, P); BBExpect(S, P, ':'); Bit := 0; + if Key = 'v' then begin Bit := 1; BBLiteral(S, P, '1'); end + else if Key = 'ok' then begin Bit := 2; Ok := Copy(S, P, 4) = 'true'; if Ok then BBLiteral(S, P, 'true') else BBLiteral(S, P, 'false'); end + else if Key = 'code' then begin Bit := 4; Code := BBString(S, P); end + else if Key = 'registration' then begin Bit := 8; Registration := BBNullable(S, P); end + else if Key = 'mode' then begin Bit := 16; Mode := BBNullable(S, P); end + else if Key = 'store' then begin Bit := 32; Inventory := BBNullable(S, P); end + else if Key = 'installation' then begin + Bit := 64; + if Copy(S, P, 4) = 'null' then BBLiteral(S, P, 'null') + else begin HasDescriptor := BBDescriptor(S, P); if not HasDescriptor then Exit; end; + end else Exit; + if (Seen and Bit) <> 0 then Exit; Seen := Seen or Bit; Count := Count + 1; + if Count > 7 then Exit; + until (P <= Length(S)) and (S[P] = '}'); + BBExpect(S, P, '}'); BBExpect(S, P, #10); + if (Seen <> 127) or (P <> Length(S) + 1) then Exit; + if (Inventory <> '#null') and (Inventory <> 'missing') and (Inventory <> 'requested') and (Inventory <> 'foreign') and (Inventory <> 'invalid') then Exit; + if not Ok or (Code <> 'ok') or (ExitCode <> 0) then Exit; { No failure or uncertain receipt becomes success. } + if Action = 'install' then Result := (Registration = 'owned') and (Mode = 'companion-managed-wsl') and HasDescriptor and ((Store = 'preserve') or (Inventory = 'requested')) + else Result := (Action = 'uninstall') and (Registration = 'missing') and (Mode = '#null') and not HasDescriptor and (Inventory = 'missing'); + except + Result := False; { Never log private receipt data or parser exceptions. } + end; +end; + +function RunBrowserManagement(Action, Store: String): Boolean; +var + InR, InW, OutR, OutW, ErrR, ErrW, Job, ProcessH, ThreadH: NativeInt; + PointerSize, StartupSize, SABytes, InfoBytes: Integer; + SA, Startup, PI, Limits, Input, Output, Errors: AnsiString; + Written, ExitCode: Cardinal; + Started: Int64; + Exe, Cmd: String; + OutEof, ErrEof, Done: Boolean; +begin + Result := False; + InR := 0; InW := 0; OutR := 0; OutW := 0; ErrR := 0; ErrW := 0; Job := 0; ProcessH := 0; ThreadH := 0; + if not (((Action = 'install') and ((Store = 'preserve') or (Store = 'request'))) or ((Action = 'uninstall') and (Store = 'remove'))) then Exit; + Exe := ExpandConstant('{app}\tools\browser-bootstrap\OpenClaw.BrowserBootstrap.exe'); + if not FileExists(Exe) then Exit; + Input := '{"v":1,"action":"' + Action + '","mode":"companion-managed-wsl","context":null,"expectedOrigins":["chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"],"store":"' + Store + '"}'; + PointerSize := SizeOf(ProcessH); StartupSize := 68; SABytes := 12; InfoBytes := 112; + if PointerSize = 8 then begin StartupSize := 104; SABytes := 24; InfoBytes := 144; end; + if (PointerSize <> 4) and (PointerSize <> 8) then Exit; + SA := StringOfChar(#0, SABytes); BBPut(SA, 0, 4, SABytes); BBPut(SA, PointerSize * 2, 4, 1); + Startup := StringOfChar(#0, StartupSize); PI := StringOfChar(#0, PointerSize * 2 + 8); + Limits := StringOfChar(#0, InfoBytes); BBPut(Limits, 16, 4, $2000); { KILL_ON_JOB_CLOSE } + Started := BBTick; + try + if not BBCreatePipe(InR, InW, SA, 4096) or not BBCreatePipe(OutR, OutW, SA, 4096) or not BBCreatePipe(ErrR, ErrW, SA, 4096) then Exit; + if not BBHandleFlags(InW, 1, 0) or not BBHandleFlags(OutR, 1, 0) or not BBHandleFlags(ErrR, 1, 0) then Exit; + Job := BBJob(0, 0); if (Job = 0) or not BBJobLimits(Job, 9, Limits, InfoBytes) then Exit; + BBPut(Startup, 0, 4, StartupSize); + if PointerSize = 4 then begin + BBPut(Startup, 44, 4, $100); BBPut(Startup, 56, 4, InR); BBPut(Startup, 60, 4, OutW); BBPut(Startup, 64, 4, ErrW); + end else begin + BBPut(Startup, 60, 4, $100); BBPut(Startup, 80, 8, InR); BBPut(Startup, 88, 8, OutW); BBPut(Startup, 96, 8, ErrW); + end; + Cmd := '"' + Exe + '" --manage'; + if not BBCreateProcess(Exe, Cmd, 0, 0, True, $08000004, 0, ExpandConstant('{app}'), Startup, PI) then Exit; + ProcessH := BBGet(PI, 0, PointerSize); ThreadH := BBGet(PI, PointerSize, PointerSize); + if not BBAssign(Job, ProcessH) then begin BBTerminate(ProcessH, 1); Exit; end; + if BBResume(ThreadH) = $FFFFFFFF then Exit; + BBDispose(InR); BBDispose(OutW); BBDispose(ErrW); BBDispose(ThreadH); + if not BBWrite(InW, Input, Length(Input), Written, 0) or (Written <> Cardinal(Length(Input))) then Exit; + BBDispose(InW); { Management request completion requires EOF. } + OutEof := False; ErrEof := False; Output := ''; Errors := ''; Done := False; + while BBTick - Started < 60000 do begin + if not BBDrain(OutR, 32768, Output, OutEof) or not BBDrain(ErrR, 0, Errors, ErrEof) then Exit; + Done := BBWait(ProcessH, 0) = 0; + if Done and OutEof and ErrEof then begin + if BBExit(ProcessH, ExitCode) then Result := BBReceipt(Output, ExitCode, Action, Store); + Exit; + end; + Sleep(10); + end; + finally + BBDispose(Job); { Timeout/failure revokes and terminates the entire owned process tree. } + if ProcessH <> 0 then BBWait(ProcessH, 2000); + BBDispose(InR); BBDispose(InW); BBDispose(OutR); BBDispose(OutW); BBDispose(ErrR); BBDispose(ErrW); + BBDispose(ThreadH); BBDispose(ProcessH); + end; +end; diff --git a/scripts/Test-BrowserBootstrapInstaller.ps1 b/scripts/Test-BrowserBootstrapInstaller.ps1 new file mode 100644 index 000000000..111c0bafd --- /dev/null +++ b/scripts/Test-BrowserBootstrapInstaller.ps1 @@ -0,0 +1,76 @@ +<# Runs the real shared Inno pipe client in a disposable GitHub-hosted fixture installer. +No production app is launched. This is not signed release, Chrome consent or WSL authority proof. #> +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ExecutablePath,[Parameter(Mandatory)][string]$CompilerPath) +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { throw 'Requires a disposable GitHub-hosted Windows runner.' } +$exe=(Resolve-Path -LiteralPath $ExecutablePath).Path +$include=(Resolve-Path (Join-Path $PSScriptRoot 'BrowserBootstrapManagement.iss')).Path +$id=[Guid]::NewGuid().ToString('N') +$proof=Join-Path $env:RUNNER_TEMP ('browser-inno-proof-'+$id) +$install=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) ('OpenClaw-Inno-Proof-'+$id) +New-Item -ItemType Directory -Path $proof | Out-Null +$keys=@('Software\\Google\\Chrome\\NativeMessagingHosts\\ai.openclaw.browser_bootstrap','Software\\Chromium\\NativeMessagingHosts\\ai.openclaw.browser_bootstrap','Software\\Google\\Chrome\\Extensions\\kcdjddhmeafeomebliikmbpblkmkfoig') +$keys=$keys | ForEach-Object { $_.Replace('\\','\') } +foreach($hive in @([Microsoft.Win32.RegistryHive]::CurrentUser,[Microsoft.Win32.RegistryHive]::LocalMachine)) { + foreach($view in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)) { + $root=[Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,$view) + try { foreach($path in $keys) { $key=$root.OpenSubKey($path);if($null -ne $key){$key.Dispose();throw 'Proof refuses existing native/Store registration.'} } } finally {$root.Dispose()} + } +} +$script=@" +[Setup] +AppName=OpenClaw Management Transport Proof +AppVersion=0.0.0 +AppId=OpenClawManagementProof$id +DefaultDirName=$install +PrivilegesRequired=lowest +Uninstallable=yes +CreateAppDir=yes +DisableProgramGroupPage=yes +OutputDir=$proof +OutputBaseFilename=transport-proof +Compression=none +[Files] +Source: "$exe"; DestDir: "{app}\tools\browser-bootstrap" +[Code] +#include "$include" +procedure CurStepChanged(Step: TSetupStep); +begin + if Step = ssPostInstall then begin + if not RunBrowserManagement('install', 'request') then RaiseException('INSTALL_PIPE_FAILED'); + if not SaveStringToFile('$proof\install.ok', 'MANAGEMENT_INSTALL_OK', False) then RaiseException('Marker failed'); + end; +end; +procedure CurUninstallStepChanged(Step: TUninstallStep); +begin + if Step = usUninstall then begin + if not RunBrowserManagement('uninstall', 'remove') then RaiseException('UNINSTALL_PIPE_FAILED'); + if not SaveStringToFile('$proof\uninstall.ok', 'MANAGEMENT_UNINSTALL_OK', False) then RaiseException('Marker failed'); + end; +end; +"@ +$iss=Join-Path $proof 'transport-proof.iss' +[IO.File]::WriteAllText($iss,$script,[Text.UTF8Encoding]::new($true)) +& $CompilerPath $iss +if($LASTEXITCODE -ne 0){throw 'Inno transport harness did not compile.'} +function Run-Bounded([string]$Path,[string[]]$Arguments) { + $p=Start-Process -FilePath $Path -ArgumentList $Arguments -PassThru + try {if(-not $p.WaitForExit(90000)){$p.Kill($true);throw 'Inno proof timed out.'};if($p.ExitCode -ne 0){throw "Inno proof exit $($p.ExitCode)."}} + finally {$p.Dispose()} +} +Run-Bounded (Join-Path $proof 'transport-proof.exe') @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') +if(-not (Test-Path (Join-Path $proof 'install.ok'))){throw 'Installer did not accept a clean management receipt.'} +foreach($view in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)) { + $root=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::CurrentUser,$view) + try {foreach($path in $keys){$key=$root.OpenSubKey($path);try{if($null -eq $key){throw 'Expected registration missing after install.'}}finally{if($null -ne $key){$key.Dispose()}}}}finally{$root.Dispose()} +} +Run-Bounded (Join-Path $install 'unins000.exe') @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') +if(-not (Test-Path (Join-Path $proof 'uninstall.ok'))){throw 'Uninstaller did not accept a clean management receipt.'} +foreach($view in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)) { + $root=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::CurrentUser,$view) + try {foreach($path in $keys){$key=$root.OpenSubKey($path);if($null -ne $key){$key.Dispose();throw 'Owned registration remained after uninstall.'}}}finally{$root.Dispose()} +} +Write-Host 'INNO_MANAGEMENT_TRANSPORT_PROOF_OK: actual installer/uninstaller stdin, EOF, bounded receipt and owned registration cleanup.' +$global:LASTEXITCODE=0 diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index 73aae9e4d..0a6eab3d2 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -1,190 +1,155 @@ -<# -.SYNOPSIS - Exercises the packaged native .exe with binary frames and a synthetic current-user tray pipe. -.DESCRIPTION - Run only on a disposable Windows proof host. Refuses any pre-existing host registration. - This proves native framing/registry/IPC, not Chrome permission approval or real WSL pairing. -#> +<# Dedicated disposable Windows CI only. Refuses pre-existing native/Store registrations or product generations. +Proves real management/registry/framing/IPC, with a synthetic tray response. Not production Gateway/Chrome approval proof. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$ExecutablePath) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -& (Join-Path $PSScriptRoot 'test-browser-native-framing.ps1') -$exe = (Resolve-Path -LiteralPath $ExecutablePath).Path -$key = 'Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap' +$source = (Resolve-Path -LiteralPath $ExecutablePath).Path $origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/' -$extensionKey = 'Software\Google\Chrome\Extensions\kcdjddhmeafeomebliikmbpblkmkfoig' -$root = [Microsoft.Win32.RegistryKey]::OpenBaseKey('CurrentUser', 'Registry32') -foreach ($hive in @('CurrentUser', 'LocalMachine')) { - foreach ($view in @('Registry32', 'Registry64')) { - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) - try { - foreach ($probeKey in @($key, $extensionKey)) { - $existing = $base.OpenSubKey($probeKey) - if ($null -ne $existing) { $existing.Dispose(); throw 'Proof refuses an existing native host or extension registration.' } - } - } finally { $base.Dispose() } +$nativeKeys = @('Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap', 'Software\Chromium\NativeMessagingHosts\ai.openclaw.browser_bootstrap') +$storeKey = 'Software\Google\Chrome\Extensions\kcdjddhmeafeomebliikmbpblkmkfoig' +$rootPath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'OpenClawTray\browser-native\generations' +if (Test-Path -LiteralPath $rootPath) { throw 'Proof refuses an existing product-generation directory.' } +foreach ($hive in @([Microsoft.Win32.RegistryHive]::CurrentUser,[Microsoft.Win32.RegistryHive]::LocalMachine)) { + foreach ($view in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)) { + $root = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,$view) + try { foreach ($path in @($nativeKeys)+@($storeKey)) { + $existing=$root.OpenSubKey($path) + if ($null -ne $existing) { $existing.Dispose(); throw 'Proof refuses an existing native or Store registration.' } + }} finally { $root.Dispose() } } } - -function Start-Native([string]$CallerOrigin) { - $start = [Diagnostics.ProcessStartInfo]::new($exe) - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.ArgumentList.Add($CallerOrigin) - $start.ArgumentList.Add('--parent-window=0') - [Diagnostics.Process]::Start($start) +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +public static class OpenClawBoundedProofRead { + public static async Task Read(Stream input,int limit,CancellationToken ct) { + var data=new byte[limit+1]; int size=0; + while(true) { int n=await input.ReadAsync(data,size,data.Length-size,ct); if(n==0) { var result=new byte[size];Array.Copy(data,result,size);return result; } + size+=n;if(size>limit)throw new IOException("Proof transport bound exceeded."); } + } +} +'@ +function Start-Native([string]$File, [string[]]$Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($File) + $start.UseShellExecute=$false; $start.CreateNoWindow=$true + $start.RedirectStandardInput=$true; $start.RedirectStandardOutput=$true; $start.RedirectStandardError=$true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + return [Diagnostics.Process]::Start($start) } -function Write-Frame([IO.Stream]$Stream, [byte[]]$Bytes) { - $header = [BitConverter]::GetBytes([uint32]$Bytes.Length) - $Stream.Write($header, 0, 4) - $Stream.Write($Bytes, 0, $Bytes.Length) - $Stream.Flush() +function Invoke-Management([string]$Json, [bool]$CloseInput=$true) { + $p=Start-Native $script:exe @('--manage') + $ct=[Threading.CancellationTokenSource]::new(60000) + try { + $output=[OpenClawBoundedProofRead]::Read($p.StandardOutput.BaseStream,32768,$ct.Token) + $errors=[OpenClawBoundedProofRead]::Read($p.StandardError.BaseStream,0,$ct.Token) + $bytes=[Text.Encoding]::UTF8.GetBytes($Json) + $p.StandardInput.BaseStream.Write($bytes,0,$bytes.Length) + $p.StandardInput.BaseStream.Flush() + if ($CloseInput) { $p.StandardInput.Close() } + [void][Threading.Tasks.Task]::WhenAll($output,$errors,$p.WaitForExitAsync($ct.Token)).GetAwaiter().GetResult() + $data=$output.GetAwaiter().GetResult() + if ($data.Length -lt 2 -or $data[-1] -ne 10 -or $data[-2] -ne 125) { throw 'Management response framing invalid.' } + $text=[Text.UTF8Encoding]::new($false,$true).GetString($data) + $result=$text | ConvertFrom-Json + if ($result.v -ne 1 -or $p.ExitCode -ne [int](-not $result.ok)) { throw 'Management exit/result mismatch.' } + return $result + } finally { + $ct.Cancel() + if (-not $p.HasExited) { $p.Kill($true); [void]$p.WaitForExit(3000) } + $p.Dispose();$ct.Dispose() + } +} +function Management-Request([string]$Action,[string]$Store) { + return (@{v=1;action=$Action;mode='companion-managed-wsl';context=$null;expectedOrigins=@($origin);store=$Store} | ConvertTo-Json -Compress) +} +function Write-Frame([IO.Stream]$Stream,[byte[]]$Bytes) { + $header=[BitConverter]::GetBytes([uint32]$Bytes.Length) + $Stream.Write($header,0,4);$Stream.Write($Bytes,0,$Bytes.Length);$Stream.Flush() } function Read-Frame([IO.Stream]$Stream) { - $ct = [Threading.CancellationTokenSource]::new(10000) + $ct=[Threading.CancellationTokenSource]::new(10000) try { - $header = [byte[]]::new(4) - [void]$Stream.ReadExactlyAsync([Memory[byte]]$header, $ct.Token).AsTask().GetAwaiter().GetResult() - $length = [BitConverter]::ToUInt32($header, 0) - if ($length -eq 0 -or $length -gt 4096) { throw 'Invalid response frame length.' } - $bytes = [byte[]]::new($length) - [void]$Stream.ReadExactlyAsync([Memory[byte]]$bytes, $ct.Token).AsTask().GetAwaiter().GetResult() - [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json - } finally { $ct.Dispose() } + $header=[byte[]]::new(4) + [void]$Stream.ReadExactlyAsync([Memory[byte]]$header,$ct.Token).AsTask().GetAwaiter().GetResult() + $length=[BitConverter]::ToUInt32($header,0) + if ($length -eq 0 -or $length -gt 4096) { throw 'Invalid native response frame length.' } + $bytes=[byte[]]::new($length) + [void]$Stream.ReadExactlyAsync([Memory[byte]]$bytes,$ct.Token).AsTask().GetAwaiter().GetResult() + return [Text.UTF8Encoding]::new($false,$true).GetString($bytes) | ConvertFrom-Json + } finally {$ct.Dispose()} } function Assert-Exit([Diagnostics.Process]$Process) { - if (-not $Process.WaitForExit(10000)) { $Process.Kill($true); throw 'Native host did not exit.' } - if ($Process.ExitCode -ne 0) { throw 'Native host returned nonzero exit.' } - if ($Process.StandardError.ReadToEnd().Length -ne 0) { throw 'Native host unexpectedly wrote diagnostics.' } - if ($Process.StandardOutput.BaseStream.ReadByte() -ne -1) { throw 'Native host wrote trailing unframed output.' } + if (-not $Process.WaitForExit(10000)) { $Process.Kill($true);throw 'Native host did not exit.' } + if ($Process.ExitCode -ne 0 -or $Process.StandardOutput.BaseStream.ReadByte() -ne -1 -or $Process.StandardError.BaseStream.ReadByte() -ne -1) { throw 'Native output or exit invalid.' } } - -$registered = $false -$requestRegistered = $false +# A published CI artifact is copied into a private install directory before source-admission proof. +$staging=Join-Path ([IO.Path]::GetTempPath()) ('OpenClawBootstrapProof-'+[Guid]::NewGuid().ToString('N')) +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User +$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false);$acl.SetOwner($sid) +foreach ($principal in @($sid.Value,'S-1-5-18','S-1-5-32-544')) { + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($principal),'FullControl','ContainerInherit,ObjectInherit','None','Allow')) +} +[IO.FileSystemAclExtensions]::Create([IO.DirectoryInfo]::new($staging),$acl) +$exe=Join-Path $staging 'OpenClaw.BrowserBootstrap.exe' +if ((Get-Item -LiteralPath $source).Length -gt 268435456) { throw 'Published executable exceeds the proof bound.' } +$sourceBytes=[IO.File]::ReadAllBytes($source) +$copy=[IO.FileStream]::new($exe,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) +try {$copy.Write($sourceBytes,0,$sourceBytes.Length);$copy.Flush($true)} finally {$copy.Dispose()} +if ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $exe).Hash) { throw 'Staged executable differs from published artifact.' } +$installed=$false try { - & $exe --request-extension - if ($LASTEXITCODE -eq 0) { throw 'Store request succeeded before native registration.' } - $early = $root.OpenSubKey($extensionKey) - if ($null -ne $early) { $early.Dispose(); throw 'Store registry key appeared before native registration.' } - & $exe --register - if ($LASTEXITCODE -ne 0) { throw 'Native registration failed.' } - $registered = $true - & $exe --request-extension - if ($LASTEXITCODE -ne 0) { throw 'Owned HKCU Store request failed.' } - $requestRegistered = $true - $requestKey = $root.OpenSubKey($extensionKey) - try { - if ($requestKey.GetValue('update_url') -ne 'https://clients2.google.com/service/update2/crx' -or - $requestKey.GetValue('openclaw_native_host') -ne $exe -or $requestKey.ValueCount -ne 2) { - throw 'Store request registry payload was not exact.' - } - } finally { $requestKey.Dispose() } - & $exe --request-extension - if ($LASTEXITCODE -ne 0) { throw 'Owned Store request was not idempotent.' } - $process = Start-Native 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/' + & (Join-Path $PSScriptRoot 'test-browser-native-framing.ps1') + $empty=Invoke-Management (Management-Request 'inspect' 'preserve') + if (-not $empty.ok -or $empty.registration -ne 'missing' -or (Test-Path -LiteralPath $rootPath)) { throw 'Missing inspection mutated product state.' } + $invalid=Invoke-Management ((Management-Request 'inspect' 'preserve').Replace('"v":1','"v":1.0')) + if ($invalid.ok -or $invalid.code -ne 'invalid_request' -or $null -ne $invalid.registration) { throw 'Lexical version gate failed.' } + $noEof=Invoke-Management (Management-Request 'install' 'request') $false + if ($noEof.ok -or $noEof.code -ne 'invalid_request' -or (Test-Path -LiteralPath $rootPath)) { throw 'Missing management EOF was not rejected before mutation.' } + $registration=Invoke-Management (Management-Request 'install' 'preserve') + $installed=$true + if (-not $registration.ok -or $registration.registration -ne 'owned' -or $registration.store -ne 'missing') { throw "Native generation installation failed ($($registration.code))." } + $nativeExe=$registration.installation.launcherPath + $again=Invoke-Management (Management-Request 'install' 'preserve') + if (-not $again.ok -or $again.installation.generation -ne $registration.installation.generation) { throw 'Identical registration was not idempotent.' } + $store=Invoke-Management (Management-Request 'install' 'request') + if (-not $store.ok -or $store.store -ne 'requested') { throw 'Native-first Store request failed.' } + $request=[Text.Encoding]::UTF8.GetBytes('{"v":1,"op":"bootstrap","nonce":"AAAAAAAAAAAAAAAAAAAAAA"}') + $foreign=Start-Native $nativeExe @('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/','--parent-window=0') + try { Write-Frame $foreign.StandardInput.BaseStream $request; $reply=Read-Frame $foreign.StandardOutput.BaseStream + if ($reply.ok -or $reply.code -ne 'origin_forbidden') { throw 'Foreign Chrome origin accepted.' };Assert-Exit $foreign + } finally {if(-not $foreign.HasExited){$foreign.Kill($true)};$foreign.Dispose()} + $pipe=[IO.Pipes.NamedPipeServerStream]::new('OpenClawTray.BrowserBootstrap.v1',[IO.Pipes.PipeDirection]::InOut,1,[IO.Pipes.PipeTransmissionMode]::Byte,([IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::CurrentUserOnly)) try { - $reply = Read-Frame $process.StandardOutput.BaseStream - if ($reply.ok -or $reply.code -ne 'origin_forbidden') { throw 'Foreign origin was not rejected.' } - Assert-Exit $process - } finally { $process.Dispose() } - - $process = Start-Native $origin - try { - $header = [BitConverter]::GetBytes([uint32]4097) - $process.StandardInput.BaseStream.Write($header, 0, 4) - $process.StandardInput.Close() - $reply = Read-Frame $process.StandardOutput.BaseStream - if ($reply.ok -or $reply.code -ne 'invalid_frame') { throw 'Oversized frame was not rejected.' } - Assert-Exit $process - } finally { $process.Dispose() } - - $pipe = [IO.Pipes.NamedPipeServerStream]::new('OpenClawTray.BrowserBootstrap.v1', - [IO.Pipes.PipeDirection]::InOut, 1, [IO.Pipes.PipeTransmissionMode]::Byte, - [IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::CurrentUserOnly) - try { - $waiting = $pipe.WaitForConnectionAsync() - $process = Start-Native $origin + $connected=$pipe.WaitForConnectionAsync() + $native=Start-Native $nativeExe @($origin,'--parent-window=0') try { - $json = '{"v":1,"op":"bootstrap","nonce":"AAAAAAAAAAAAAAAAAAAAAA"}' - Write-Frame $process.StandardInput.BaseStream ([Text.Encoding]::UTF8.GetBytes($json)) - if (-not $waiting.Wait(10000)) { throw 'Native host did not connect to tray pipe.' } - $request = Read-Frame $pipe - if ($request.op -ne 'bootstrap' -or $request.nonce -ne 'AAAAAAAAAAAAAAAAAAAAAA') { throw 'Native request changed.' } - $response = '{"v":1,"ok":true,"nonce":"AAAAAAAAAAAAAAAAAAAAAA","pairingString":"synthetic-native-proof"}' - Write-Frame $pipe ([Text.Encoding]::UTF8.GetBytes($response)) - $reply = Read-Frame $process.StandardOutput.BaseStream - if (-not $reply.ok -or $reply.pairingString -ne 'synthetic-native-proof' -or $reply.nonce -ne $request.nonce) { throw 'Native response changed.' } - Assert-Exit $process - } finally { - if (-not $process.HasExited) { $process.Kill($true) } - $process.Dispose() - } - } finally { $pipe.Dispose() } - & $exe --remove-extension-request - if ($LASTEXITCODE -ne 0) { throw 'Owned Store request cleanup failed.' } - $requestRegistered = $false - $remainingRequest = $root.OpenSubKey($extensionKey) - if ($null -ne $remainingRequest) { $remainingRequest.Dispose(); throw 'Owned Store request remained after cleanup.' } - $storeSentinel = 'openclaw-proof-foreign-' + [Guid]::NewGuid().ToString('N') - $foreignStore = $root.CreateSubKey($extensionKey) - $foreignStore.SetValue('update_url', 'https://clients2.google.com/service/update2/crx') - $foreignStore.SetValue('proof_owner', $storeSentinel) - $foreignStore.Dispose() + Write-Frame $native.StandardInput.BaseStream $request + if (-not $connected.Wait(10000)) { throw 'Current-user IPC did not connect.' } + $incoming=Read-Frame $pipe + if ($incoming.nonce -ne 'AAAAAAAAAAAAAAAAAAAAAA') { throw 'Native request nonce changed.' } + Write-Frame $pipe ([Text.Encoding]::UTF8.GetBytes('{"v":1,"ok":true,"nonce":"AAAAAAAAAAAAAAAAAAAAAA","pairingString":"synthetic-proof-only"}')) + $reply=Read-Frame $native.StandardOutput.BaseStream + if (-not $reply.ok -or $reply.pairingString -ne 'synthetic-proof-only') { throw 'Native pipe reply failed.' } + Assert-Exit $native + } finally {if(-not $native.HasExited){$native.Kill($true)};$native.Dispose()} + } finally {$pipe.Dispose()} + $removed=Invoke-Management (Management-Request 'uninstall' 'remove') + if (-not $removed.ok -or $removed.registration -ne 'missing' -or $removed.store -ne 'missing') { throw 'Owned cleanup failed.' } + $installed=$false + $root=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::CurrentUser,[Microsoft.Win32.RegistryView]::Registry32) try { - & $exe --request-extension - if ($LASTEXITCODE -eq 0) { throw 'Store request adopted a foreign registry entry.' } - & $exe --remove-extension-request - if ($LASTEXITCODE -eq 0) { throw 'Store cleanup adopted a foreign registry entry.' } - $foreignStore = $root.OpenSubKey($extensionKey) - try { - if ($foreignStore.ValueCount -ne 2 -or $foreignStore.GetValue('proof_owner') -ne $storeSentinel) { - throw 'Foreign Store registry entry changed.' - } - } finally { $foreignStore.Dispose() } - } finally { - $foreignStore = $root.OpenSubKey($extensionKey) - if ($null -ne $foreignStore) { - $owned = $foreignStore.GetValue('proof_owner') -eq $storeSentinel - $foreignStore.Dispose() - if ($owned) { $root.DeleteSubKey($extensionKey, $false) } - } - } - & $exe --unregister - if ($LASTEXITCODE -ne 0) { throw 'Native unregister failed.' } - $registered = $false - $remaining = $root.OpenSubKey($key) - if ($null -ne $remaining) { $remaining.Dispose(); throw 'Owned registration remained after uninstall.' } - # Verify a foreign entry is neither overwritten nor removed. The sentinel is this proof's own key. - $sentinel = 'openclaw-proof-foreign-' + [Guid]::NewGuid().ToString('N') - $foreign = $root.CreateSubKey($key) - $foreign.SetValue('', $sentinel) - $foreign.Dispose() - try { - & $exe --register - if ($LASTEXITCODE -eq 0) { throw 'Native registration adopted a foreign entry.' } - & $exe --unregister - if ($LASTEXITCODE -eq 0) { throw 'Native unregister adopted a foreign entry.' } - $foreign = $root.OpenSubKey($key) - try { - if ($foreign.GetValue('') -ne $sentinel) { throw 'Foreign registration was changed.' } - } finally { $foreign.Dispose() } - } finally { - $foreign = $root.OpenSubKey($key) - if ($null -ne $foreign) { - $owned = $foreign.GetValue('') -eq $sentinel - $foreign.Dispose() - if ($owned) { $root.DeleteSubKey($key, $false) } - } - } - Write-Host 'NATIVE_BOOTSTRAP_PROOF_OK: binary framing, exact origin, bounded request, current-user IPC, native-first HKCU Store request, owned cleanup, foreign-entry preservation.' + $foreign=$root.CreateSubKey($nativeKeys[0]);$foreign.SetValue('', 'C:\foreign-proof-host.json');$foreign.Dispose() + $denied=Invoke-Management (Management-Request 'install' 'preserve') + if ($denied.ok -or $denied.code -ne 'foreign_registration') { throw 'Foreign registration not preserved.' } + $foreign=$root.OpenSubKey($nativeKeys[0]);try {if($foreign.GetValue('') -ne 'C:\foreign-proof-host.json'){throw 'Foreign entry changed.'}} finally {$foreign.Dispose()} + $root.DeleteSubKey($nativeKeys[0],$false) + } finally {$root.Dispose()} + Write-Host 'NATIVE_BOOTSTRAP_PROOF_OK: actual executable, bounded management EOF, owned generation, both native products/views, framing/origin/current-user IPC, native-first Store request, cleanup and foreign preservation.' } finally { - if ($requestRegistered) { & $exe --remove-extension-request } - if ($registered) { & $exe --unregister } - $root.Dispose() + if ($installed) { $cleanup=Invoke-Management (Management-Request 'uninstall' 'remove');if(-not $cleanup.ok){Write-Warning 'Proof cleanup remains incomplete.'} } + # No recursive deletion of product generations, which may retain references or foreign files. } -# Expected rejection probes leave LASTEXITCODE=1. Only a fully completed proof reaches here. -$global:LASTEXITCODE = 0 +$global:LASTEXITCODE=0 diff --git a/scripts/Test-ReleaseExecutableSignatures.ps1 b/scripts/Test-ReleaseExecutableSignatures.ps1 index fd9c4cbbd..080c5ac7e 100644 --- a/scripts/Test-ReleaseExecutableSignatures.ps1 +++ b/scripts/Test-ReleaseExecutableSignatures.ps1 @@ -52,8 +52,9 @@ function Get-BinaryClassification { '^OpenClaw\.SetupEngine\.UI\.dll$' { return "OpenClawOwned" } '^OpenClaw\.SetupEngine\.dll$' { return "OpenClawOwned" } '^OpenClaw\.Shared\.dll$' { return "OpenClawOwned" } + '^OpenClaw\.BrowserBootstrap\.Contracts\.dll$' { return "OpenClawOwned" } '^OpenClawTray\.FunctionalUI\.dll$' { return "OpenClawOwned" } - '^tools\\browser-bootstrap\\OpenClaw\.BrowserNativeHost\.(exe|dll)$' { return "OpenClawOwned" } + '^tools\\browser-bootstrap\\OpenClaw\.BrowserBootstrap\.exe$' { return "OpenClawOwned" } '(^|\\)createdump\.exe$' { return "ThirdPartyExcluded" } '(^|\\)RestartAgent\.exe$' { return "ThirdPartyExcluded" } '^tools\\mxc\\[^\\]+\\wxc-exec\.exe$' { return "ThirdPartyExcluded" } @@ -128,9 +129,9 @@ foreach ($binary in $binaries) { "OpenClaw.SetupEngine.UI.dll", "OpenClaw.SetupEngine.dll", "OpenClaw.Shared.dll", + "OpenClaw.BrowserBootstrap.Contracts.dll", "OpenClawTray.FunctionalUI.dll", - "tools\browser-bootstrap\OpenClaw.BrowserNativeHost.exe", - "tools\browser-bootstrap\OpenClaw.BrowserNativeHost.dll" + "tools\browser-bootstrap\OpenClaw.BrowserBootstrap.exe" ) | ForEach-Object { $requiredBinary = $_ if (-not ($binaries | Where-Object RelativePath -eq $requiredBinary)) { @@ -152,4 +153,4 @@ if ($errors.Count -gt 0) { exit 1 } -Write-Host "Release binary signing policy passed." -ForegroundColor Green +Write-Host "Release binary signing policy passed." -ForegroundColor Green \ No newline at end of file diff --git a/src/OpenClaw.BrowserBootstrap.Contracts/Directory.Build.targets b/src/OpenClaw.BrowserBootstrap.Contracts/Directory.Build.targets new file mode 100644 index 000000000..36ed3bf05 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap.Contracts/Directory.Build.targets @@ -0,0 +1 @@ + diff --git a/src/OpenClaw.BrowserBootstrap.Contracts/ManagementContract.cs b/src/OpenClaw.BrowserBootstrap.Contracts/ManagementContract.cs new file mode 100644 index 000000000..a46444e25 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap.Contracts/ManagementContract.cs @@ -0,0 +1,253 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.BrowserBootstrap.Contracts; + +public sealed class ContractException(string code) : Exception(code) +{ + public string Code { get; } = code; +} +public sealed record NativeContext(string NodePath, string CliPath, string StateDir, string ConfigPath, string BrowserProfile); +public sealed record ManagementRequest(int V, string Action, string Mode, NativeContext? Context, string[] ExpectedOrigins, string Store); +public sealed record Installation(string Generation, string ManifestPath, string LauncherPath, string BindingPath, string ReceiptPath); +public sealed record ManagementResponse(int V, bool Ok, string Code, string? Registration, string? Mode, string? Store, Installation? Installation) +{ + public static ManagementResponse Failure(string code) => new(1, false, code, null, null, null, null); +} +public sealed record HostBinding(int Version, string Mode, string ManifestPath, string[] ExpectedOrigins, NativeContext? NativeWindows); +public sealed record HostReceipt(string Owner, int Version, string Generation, string OwnerSid, bool TransportVerified, + string ExecutableSha256, string BindingSha256, string ManifestSha256); +public sealed record HostManifest(string Name, string Description, string Path, string Type, + [property: System.Text.Json.Serialization.JsonPropertyName("allowed_origins")] string[] AllowedOrigins); + +/// Private Windows management contract, not the Chrome native v1 protocol or user configuration. +public static class ManagementContract +{ + public const int Limit = 32768; + public const string Companion = "companion-managed-wsl", Native = "native-windows-cli"; + public const string Origin = "chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"; + public const string HostName = "ai.openclaw.browser_bootstrap"; + public const string Description = "OpenClaw browser extension bootstrap"; + public const string ExeName = "OpenClaw.BrowserBootstrap.exe"; + public const string BindingName = "OpenClaw.BrowserBootstrap.binding.json"; + public const string ReceiptName = "OpenClaw.BrowserBootstrap.owned.json"; + public const string ManifestName = HostName + ".json"; + public const string Owner = "openclaw-browser-native-host"; + public static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private static readonly UTF8Encoding Utf8 = new(false, true); + private static readonly string[] Codes = ["ok", "invalid_request", "context_conflict", "foreign_registration", "unsafe_path", + "unsafe_acl", "binding_invalid", "browser_control_disabled", "transport_failed", "busy", "cancelled", "io_error", "platform_unsupported"]; + + public static JsonDocument Document(byte[] bytes) + { + try + { + Require(bytes.Length is > 0 and <= Limit); + var text = Utf8.GetString(bytes); + Require(text[0] != '\uFEFF'); + CheckEscapedSurrogates(text); + var doc = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 16 }); + try { Walk(doc.RootElement); Require(doc.RootElement.ValueKind == JsonValueKind.Object); } + catch { doc.Dispose(); throw; } + return doc; + } + catch (Exception ex) when (ex is JsonException or DecoderFallbackException or InvalidOperationException or FormatException) + { throw new ContractException("invalid_request"); } + } + private static void CheckEscapedSurrogates(string json) + { + // JsonDocument replaces unpaired escaped surrogates in GetString. Reject those before decoding. + for (var i = 0; i < json.Length; i++) + { + if (json[i] != '\\') continue; + if (++i >= json.Length) throw new ContractException("invalid_request"); + if (json[i] != 'u') continue; + Require(i + 4 < json.Length && ushort.TryParse(json.AsSpan(i + 1, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)); + var unit = ushort.Parse(json.AsSpan(i + 1, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + i += 4; + if (unit is >= 0xdc00 and <= 0xdfff) throw new ContractException("invalid_request"); + if (unit is < 0xd800 or > 0xdbff) continue; + Require(i + 6 < json.Length && json[i + 1] == '\\' && json[i + 2] == 'u' && + ushort.TryParse(json.AsSpan(i + 3, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var low) && low is >= 0xdc00 and <= 0xdfff); + i += 6; + } + } + private static void Walk(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var p in element.EnumerateObject()) { Require(names.Add(p.Name)); Walk(p.Value); } + } + else if (element.ValueKind == JsonValueKind.Array) foreach (var item in element.EnumerateArray()) Walk(item); + } + public static void Fields(JsonElement root, params string[] names) => Require(root.ValueKind == JsonValueKind.Object && + root.EnumerateObject().Count() == names.Length && names.All(n => root.TryGetProperty(n, out _))); + public static string String(JsonElement root, string name) + { + var e = root.GetProperty(name); Require(e.ValueKind == JsonValueKind.String); return e.GetString()!; + } + public static void Version(JsonElement root, string name) => Require(root.GetProperty(name).GetRawText() == "1"); + public static string RejectionOrigin(IEnumerable allowed) + { + var set=allowed.ToHashSet(StringComparer.Ordinal); + for(var i=0;i<=32;i++) + { + var id=new string('a',30)+(char)('a'+i/16)+(char)('a'+i%16); + var origin="chrome-extension://"+id+"/"; + if(!set.Contains(origin))return origin; + } + throw new ContractException("invalid_request"); + } + public static bool IsMode(string? mode) => mode is Companion or Native; + public static bool IsProfile(string s) => s.Length is >= 1 and <= 64 && + (s[0] is >= 'a' and <= 'z' or >= '0' and <= '9') && s.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-'); + public static bool IsGuid(string s) => s.Length == 36 && Guid.TryParseExact(s, "D", out var g) && g != Guid.Empty && g.ToString("D") == s; + public static bool IsHash(string s) => s.Length == 64 && s.All(c => c is >= 'a' and <= 'f' or >= '0' and <= '9'); + public static bool IsSid(string s) + { + var parts = s.Split('-'); + if (s.Length > 184 || parts.Length is < 4 or > 18 || parts[0] != "S" || parts[1] != "1") return false; + for (var i = 2; i < parts.Length; i++) + if (parts[i].Length == 0 || (parts[i].Length > 1 && parts[i][0] == '0') || !parts[i].All(char.IsAsciiDigit) || + !ulong.TryParse(parts[i], out var n) || n > (i == 2 ? 281474976710655UL : uint.MaxValue)) return false; + return true; + } + public static bool PathEquals(string a, string b) + { + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) if (Fold(a[i]) != Fold(b[i])) return false; + return true; + } + private static char Fold(char c) => c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; + private static bool EcmaSpace(char c) => c is >= '\u0009' and <= '\u000d' or '\u0020' or '\u00a0' or '\u1680' or + >= '\u2000' and <= '\u200a' or '\u2028' or '\u2029' or '\u202f' or '\u205f' or '\u3000' or '\ufeff'; + public static bool IsPath(string s) + { + if (s.Length is < 3 or > 4096 || !char.IsAsciiLetter(s[0]) || s[1] != ':' || s[2] != '\\' || s.Contains('/')) return false; + if (s.Length == 3) return true; + foreach (var part in s[3..].Split('\\')) + { + if (part.Length == 0 || part is "." or ".." || EcmaSpace(part[0]) || EcmaSpace(part[^1]) || part[^1] == '.' || + part.Any(c => c < 32 || "<>:\"|?*".Contains(c))) return false; + for (var i = 0; i < part.Length; i++) + if (char.IsSurrogate(part[i]) && (!char.IsHighSurrogate(part[i]) || ++i >= part.Length || !char.IsLowSurrogate(part[i]))) return false; + var stem = part.Split('.')[0].ToUpperInvariant(); + if (stem is "CON" or "PRN" or "AUX" or "NUL" or "CONIN$" or "CONOUT$" or "CLOCK$" || (stem.Length == 4 && (stem.StartsWith("COM") || stem.StartsWith("LPT")) && + (stem[3] is >= '1' and <= '9' or '\u00b9' or '\u00b2' or '\u00b3'))) return false; + } + return true; + } + public static NativeContext Context(JsonElement e) + { + Fields(e, "nodePath", "cliPath", "stateDir", "configPath", "browserProfile"); + var c = new NativeContext(String(e,"nodePath"), String(e,"cliPath"), String(e,"stateDir"), String(e,"configPath"), String(e,"browserProfile")); + Require(new[]{c.NodePath,c.CliPath,c.StateDir,c.ConfigPath}.All(IsPath) && IsProfile(c.BrowserProfile)); + Require(PathEquals(c.NodePath.Split('\\')[^1], "node.exe") && PathEquals(c.CliPath.Split('\\')[^1], "openclaw.mjs")); + return c; + } + public static string[] Origins(JsonElement e, string mode) + { + Require(e.ValueKind == JsonValueKind.Array); + var a = e.EnumerateArray().Select(x => { Require(x.ValueKind == JsonValueKind.String); return x.GetString()!; }).ToArray(); + Require(a.Length is >= 1 and <= 32 && a.Contains(Origin)); + for (var i = 0; i < a.Length; i++) + Require(a[i].Length == 52 && a[i].StartsWith("chrome-extension://", StringComparison.Ordinal) && a[i][^1] == '/' && + a[i].AsSpan(19,32).ToArray().All(c => c is >= 'a' and <= 'p') && (i == 0 || string.CompareOrdinal(a[i-1],a[i]) < 0)); + Require(mode != Companion || (a.Length == 1 && a[0] == Origin)); + return a; + } + public static ManagementRequest ParseRequest(byte[] bytes) + { + using var doc = Document(bytes); var r = doc.RootElement; + Fields(r,"v","action","mode","context","expectedOrigins","store"); Version(r,"v"); + var mode = String(r,"mode"); var action = String(r,"action"); var store = String(r,"store"); + Require(IsMode(mode) && (action switch { "inspect" => store == "preserve", "install" => store is "preserve" or "request", + "uninstall" => store is "preserve" or "remove", _ => false })); + var ctx = r.GetProperty("context"); Require(mode != Companion || ctx.ValueKind == JsonValueKind.Null); + return new(1, action, mode, mode == Companion ? null : Context(ctx), Origins(r.GetProperty("expectedOrigins"),mode), store); + } + public static HostBinding ParseBinding(byte[] bytes) + { + using var doc = Document(bytes); var r = doc.RootElement; + Fields(r,"version","mode","manifestPath","expectedOrigins","nativeWindows"); Version(r,"version"); + var mode=String(r,"mode"); Require(IsMode(mode)); var ctx=r.GetProperty("nativeWindows"); + Require(mode != Companion || ctx.ValueKind == JsonValueKind.Null); var manifest=String(r,"manifestPath"); Require(IsPath(manifest)); + return new(1,mode,manifest,Origins(r.GetProperty("expectedOrigins"),mode),mode==Companion?null:Context(ctx)); + } + public static HostReceipt ParseReceipt(byte[] bytes) + { + using var doc=Document(bytes);var r=doc.RootElement; + Fields(r,"owner","version","generation","ownerSid","transportVerified","executableSha256","bindingSha256","manifestSha256"); Version(r,"version"); + Require(String(r,"owner")==Owner && r.GetProperty("transportVerified").ValueKind==JsonValueKind.True); + var value=new HostReceipt(Owner,1,String(r,"generation"),String(r,"ownerSid"),true,String(r,"executableSha256"),String(r,"bindingSha256"),String(r,"manifestSha256")); + Require(IsGuid(value.Generation)&&IsSid(value.OwnerSid)&&IsHash(value.ExecutableSha256)&&IsHash(value.BindingSha256)&&IsHash(value.ManifestSha256));return value; + } + public static HostManifest ParseManifest(byte[] bytes) + { + using var doc=Document(bytes);var r=doc.RootElement;Fields(r,"name","description","path","type","allowed_origins"); + Require(String(r,"name")==HostName && String(r,"description")==Description && String(r,"type")=="stdio" && IsPath(String(r,"path"))); + return new(HostName,Description,String(r,"path"),"stdio",Origins(r.GetProperty("allowed_origins"),Native)); + } + public static bool SameOwnership(ManagementRequest r, HostBinding b) => r.Mode==b.Mode && (r.Mode==Companion || + (r.Context is {} a && b.NativeWindows is {} c && PathEquals(a.StateDir,c.StateDir)&&PathEquals(a.ConfigPath,c.ConfigPath)&&a.BrowserProfile==c.BrowserProfile)); + public static bool Matches(ManagementRequest r, HostBinding b) => SameOwnership(r,b) && r.ExpectedOrigins.SequenceEqual(b.ExpectedOrigins) && + (r.Mode==Companion || (PathEquals(r.Context!.NodePath,b.NativeWindows!.NodePath)&&PathEquals(r.Context.CliPath,b.NativeWindows.CliPath))); + public static void ValidateMetadata(Installation d,string sid,byte[] bindingBytes,byte[] receiptBytes,byte[] manifestBytes,byte[] executable) + { + try + { + var b=ParseBinding(bindingBytes);var r=ParseReceipt(receiptBytes);var m=ParseManifest(manifestBytes); + Require(IsGuid(d.Generation)&&r.Generation==d.Generation&&r.OwnerSid==sid); + ValidateResponse(new(1,true,"ok","owned",b.Mode,"missing",d)); + Require(b.ManifestPath==d.ManifestPath&&m.Path==d.LauncherPath&&b.ExpectedOrigins.SequenceEqual(m.AllowedOrigins)); + string Hash(byte[] x)=>Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(x)); + Require(r.ExecutableSha256==Hash(executable)&&r.BindingSha256==Hash(bindingBytes)&&r.ManifestSha256==Hash(manifestBytes)); + } + catch(ContractException){throw new ContractException("binding_invalid");} + } + public static void ValidateFileNames(IEnumerable names) + { + var expected=new[]{ExeName,BindingName,ReceiptName,ManifestName}.Order(StringComparer.Ordinal); + if(!names.Order(StringComparer.Ordinal).SequenceEqual(expected))throw new ContractException("binding_invalid"); + } + public static byte[] Serialize(T value) => JsonSerializer.SerializeToUtf8Bytes(value,Json); + public static byte[] ResponseBytes(ManagementResponse r) + { + ValidateResponse(r); var data=Serialize(r); Require(data.Length 1 and <= Limit && bytes[^1]=='\n' && bytes[^2]=='}' && bytes[0]=='{' && !bytes.AsSpan(0,bytes.Length-1).Contains((byte)'\n') && !bytes.Contains((byte)'\r')); + var inString=false;var escaped=false; + foreach(var value in bytes[..^1]) + { + if(inString){if(escaped)escaped=false;else if(value=='\\')escaped=true;else if(value=='"')inString=false;} + else{if(value=='"')inString=true;else Require(value is not (9 or 10 or 13 or 32));} + } + using var doc=Document(bytes[..^1]);var r=doc.RootElement; + Fields(r,"v","ok","code","registration","mode","store","installation");Version(r,"v");Require(r.GetProperty("ok").ValueKind is JsonValueKind.True or JsonValueKind.False); + string? Nullable(string n) => r.GetProperty(n).ValueKind==JsonValueKind.Null?null:String(r,n); + Installation? d=null;var i=r.GetProperty("installation"); + if(i.ValueKind!=JsonValueKind.Null){Fields(i,"generation","manifestPath","launcherPath","bindingPath","receiptPath");d=new(String(i,"generation"),String(i,"manifestPath"),String(i,"launcherPath"),String(i,"bindingPath"),String(i,"receiptPath"));} + var result=new ManagementResponse(1,r.GetProperty("ok").GetBoolean(),String(r,"code"),Nullable("registration"),Nullable("mode"),Nullable("store"),d); + ValidateResponse(result);Require(exitCode==(result.Ok?0:1));return result; + } + public static void Require(bool condition) { if(!condition)throw new ContractException("invalid_request"); } +} diff --git a/src/OpenClaw.BrowserBootstrap.Contracts/OpenClaw.BrowserBootstrap.Contracts.csproj b/src/OpenClaw.BrowserBootstrap.Contracts/OpenClaw.BrowserBootstrap.Contracts.csproj new file mode 100644 index 000000000..5c5998fba --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap.Contracts/OpenClaw.BrowserBootstrap.Contracts.csproj @@ -0,0 +1 @@ +net10.0enableenable diff --git a/src/OpenClaw.BrowserBootstrap/BrowserControlPreference.cs b/src/OpenClaw.BrowserBootstrap/BrowserControlPreference.cs new file mode 100644 index 000000000..917ccaf70 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/BrowserControlPreference.cs @@ -0,0 +1,22 @@ +using System.Text; +using System.Text.Json; + +namespace OpenClaw.BrowserBootstrap; + +internal static class BrowserControlPreference +{ + // Keep the existing persisted preference and its 1 MiB cap, not a new user configuration owner. + public static bool Allows(byte[]? bytes) + { + if(bytes is null)return true; + if(bytes.Length>1024*1024)return false; + try + { + using var doc=JsonDocument.Parse(new UTF8Encoding(false,true).GetString(bytes)); + if(doc.RootElement.ValueKind!=JsonValueKind.Object)return false; + var values=doc.RootElement.EnumerateObject().Where(p=>string.Equals(p.Name,"NodeBrowserProxyEnabled",StringComparison.OrdinalIgnoreCase)).ToArray(); + return values.Length==0||values.Length==1&&values[0].Value.ValueKind==JsonValueKind.True; + } + catch(Exception e) when(e is JsonException or DecoderFallbackException){return false;} + } +} diff --git a/src/OpenClaw.BrowserBootstrap/Directory.Build.targets b/src/OpenClaw.BrowserBootstrap/Directory.Build.targets new file mode 100644 index 000000000..36ed3bf05 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/Directory.Build.targets @@ -0,0 +1 @@ + diff --git a/src/OpenClaw.BrowserBootstrap/GenerationStore.cs b/src/OpenClaw.BrowserBootstrap/GenerationStore.cs new file mode 100644 index 000000000..54d195700 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/GenerationStore.cs @@ -0,0 +1,146 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using OpenClaw.BrowserBootstrap.Contracts; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.BrowserBootstrap; + +[SupportedOSPlatform("windows")] +internal sealed class GenerationStore(WindowsAuthority authority) +{ + public Installation Descriptor(string generation) + { + if (!ManagementContract.IsGuid(generation)) throw new ContractException("binding_invalid"); + var dir=Path.Combine(authority.Root,generation); + var d=new Installation(generation,Path.Combine(dir,ManagementContract.ManifestName),Path.Combine(dir,ManagementContract.ExeName), + Path.Combine(dir,ManagementContract.BindingName),Path.Combine(dir,ManagementContract.ReceiptName)); + if(!new[]{d.ManifestPath,d.LauncherPath,d.BindingPath,d.ReceiptPath}.All(ManagementContract.IsPath))throw new ContractException("unsafe_path"); + return d; + } + public Generation Read(string manifestPath) + { + try + { + if(!ManagementContract.IsPath(manifestPath))throw new ContractException("foreign_registration"); + var dir=Path.GetDirectoryName(manifestPath)!;var id=Path.GetFileName(dir); + if(!ManagementContract.IsGuid(id)||!ManagementContract.PathEquals(Path.GetDirectoryName(dir)!,authority.Root)||Path.GetFileName(manifestPath)!=ManagementContract.ManifestName) + throw new ContractException("foreign_registration"); + var d=Descriptor(id); + using var root=authority.Admit(authority.Root,true,true);using var generation=authority.Admit(dir,true,true); + var names=Directory.EnumerateFileSystemEntries(dir).Select(Path.GetFileName).Order(StringComparer.Ordinal).ToArray(); + ManagementContract.ValidateFileNames(names!); + var bindingBytes=authority.Read(d.BindingPath,ManagementContract.Limit,true); + var manifestBytes=authority.Read(d.ManifestPath,ManagementContract.Limit,true); + var receiptBytes=authority.Read(d.ReceiptPath,ManagementContract.Limit,true); + var receipt=ManagementContract.ParseReceipt(receiptBytes); + var binding=ManagementContract.ParseBinding(bindingBytes);var manifest=ManagementContract.ParseManifest(manifestBytes); + if(receipt.OwnerSid!=authority.Sid||receipt.Generation!=id)throw new ContractException("unsafe_acl"); + ManagementContract.ValidateMetadata(d,authority.Sid,bindingBytes,receiptBytes,manifestBytes,authority.Read(d.LauncherPath,256*1024*1024,true)); + return new(binding,receipt,d); + } + catch(ContractException e) when(e.Code=="invalid_request"){throw new ContractException("binding_invalid");} + } + public IDisposable RuntimeLease(Generation generation) + { + var leases=new List(); + try + { + var fresh=Read(generation.Installation.ManifestPath); + if(fresh.Receipt!=generation.Receipt)throw new ContractException("binding_invalid"); + foreach(var file in new[]{fresh.Installation.LauncherPath,fresh.Installation.BindingPath,fresh.Installation.ReceiptPath,fresh.Installation.ManifestPath}) + leases.Add(authority.Admit(file,false,true)); + if(fresh.Binding.NativeWindows is {} c) + { + leases.Add(authority.Admit(c.NodePath,false,false));leases.Add(authority.Admit(c.CliPath,false,false)); + leases.Add(authority.Admit(c.StateDir,true,true,true));leases.Add(authority.Admit(c.ConfigPath,false,true,true)); + } + return new Leases(leases); + } + catch{foreach(var l in leases)l.Dispose();throw;} + } + public Generation Prepare(ManagementRequest request,CancellationToken ct,Generation[]? existing=null) + { + var source=Environment.ProcessPath??throw new ContractException("unsafe_path"); + var bytes=authority.Read(source,256*1024*1024,false); + if(bytes.Length<64||bytes[0]!='M'||bytes[1]!='Z')throw new ContractException("unsafe_path"); + var pe=BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(60)); + if(pe<64||pe>bytes.Length-6||BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(pe))!=0x4550|| + BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(pe+4)) is not (0x8664 or 0xaa64))throw new ContractException("unsafe_path"); + foreach(var previous in existing ?? []) + if(ManagementContract.Matches(request,previous.Binding) && previous.Receipt.ExecutableSha256==Hash(bytes)) + { using var admitted=RuntimeLease(previous); return previous; } + var d=Descriptor(Guid.NewGuid().ToString("D")); + var binding=new HostBinding(1,request.Mode,d.ManifestPath,request.ExpectedOrigins,request.Context); + var manifest=new HostManifest(ManagementContract.HostName,ManagementContract.Description,d.LauncherPath,"stdio",request.ExpectedOrigins); + var b=ManagementContract.Serialize(binding);var m=ManagementContract.Serialize(manifest); + var receipt=new HostReceipt(ManagementContract.Owner,1,d.Generation,authority.Sid,true,Hash(bytes),Hash(b),Hash(m)); + var r=ManagementContract.Serialize(receipt); + if(new[]{b.Length,m.Length,r.Length}.Any(n=>n>ManagementContract.Limit))throw new ContractException("unsafe_path"); + try { _=ManagementContract.ResponseBytes(new(1,true,"ok","owned",request.Mode,"requested",d)); } + catch(ContractException) { throw new ContractException("unsafe_path"); } + if(request.Context is {} context) + { + using var node=authority.Admit(context.NodePath,false,false);using var cli=authority.Admit(context.CliPath,false,false); + using var state=authority.Admit(context.StateDir,true,true,true);using var config=authority.Admit(context.ConfigPath,false,true,true); + } + ct.ThrowIfCancellationRequested(); + authority.EnsurePrivateDirectory(authority.Root);authority.EnsurePrivateDirectory(Path.GetDirectoryName(d.ManifestPath)!); + var files=new[]{(d.LauncherPath,bytes),(d.BindingPath,b),(d.ManifestPath,m),(d.ReceiptPath,r)}; + string? receiptIdentity=null; + foreach(var (file,data) in files) + { + ct.ThrowIfCancellationRequested();using var stream=new FileStream(file,FileMode.CreateNew,FileAccess.Write,FileShare.None); + if(file==d.ReceiptPath)receiptIdentity=authority.FileIdentity(stream.SafeFileHandle); + stream.Write(data);stream.Flush(true);authority.CheckHandle(stream.SafeFileHandle,true); + } + try + { + // This private candidate is never reported as ACTIVE. Both keyless child probes must finish before any registry publication. + Probe(d.LauncherPath,request.ExpectedOrigins[0],invalid:true,ct).GetAwaiter().GetResult(); + var denied=ManagementContract.RejectionOrigin(request.ExpectedOrigins); + Probe(d.LauncherPath,denied,invalid:false,ct).GetAwaiter().GetResult(); + return Read(d.ManifestPath); + } + catch(Exception failure) + { + // No cleanup of foreign replacements or referenced generations. Make our unchanged candidate receipt unpublishable. + try + { + using var admitted=authority.Admit(d.ReceiptPath,false,true); + using var file=new FileStream(d.ReceiptPath,FileMode.Open,FileAccess.ReadWrite,FileShare.None); + authority.CheckHandle(file.SafeFileHandle,true); + if(receiptIdentity is not null && authority.FileIdentity(file.SafeFileHandle)==receiptIdentity && file.Length==r.Length) + { + var currentBytes=new byte[r.Length];file.ReadExactly(currentBytes); + if(currentBytes.SequenceEqual(r)){file.Position=0;var failed=ManagementContract.Serialize(receipt with{TransportVerified=false});file.Write(failed);file.SetLength(failed.Length);file.Flush(true);} + } + } + catch{/* Uncertain ownership is never permission to remove or overwrite data. */} + if(failure is OperationCanceledException)throw; + throw new ContractException("transport_failed"); + } + } + private static async Task Probe(string exe,string origin,bool invalid,CancellationToken ct) + { + var info=new ProcessStartInfo(exe){UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true}; + info.ArgumentList.Add(origin);info.ArgumentList.Add("--parent-window=0"); + using var child=Process.Start(info)??throw new ContractException("transport_failed"); + using var timeout=CancellationTokenSource.CreateLinkedTokenSource(ct);timeout.CancelAfter(TimeSpan.FromSeconds(15)); + try + { + var request=ManagementContract.Serialize(new{v=1,op="bootstrap",nonce=invalid?"!":Convert.ToBase64String(new byte[16]).TrimEnd('=')}); + await BrowserNativeProtocol.WriteAsync(child.StandardInput.BaseStream,request,timeout.Token); // Do not close Chrome stdin. + var response=await BrowserNativeProtocol.ReadAsync(child.StandardOutput.BaseStream,4096,timeout.Token); + var expected=BrowserNativeProtocol.Failure(invalid?"invalid_request":"origin_forbidden"); + var extra=new byte[1]; + if(!response.SequenceEqual(expected)||await child.StandardOutput.BaseStream.ReadAsync(extra,timeout.Token)!=0|| + await child.StandardError.BaseStream.ReadAsync(extra,timeout.Token)!=0)throw new ContractException("transport_failed"); + await child.WaitForExitAsync(timeout.Token);if(child.ExitCode!=0)throw new ContractException("transport_failed"); + } + finally{if(!child.HasExited)child.Kill(true);using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2));await child.WaitForExitAsync(end.Token);} + } + public static string Hash(byte[] data)=>Convert.ToHexStringLower(SHA256.HashData(data)); + private sealed class Leases(List leases):IDisposable{public void Dispose(){foreach(var l in leases)l.Dispose();}} +} diff --git a/src/OpenClaw.BrowserBootstrap/NativeTransport.cs b/src/OpenClaw.BrowserBootstrap/NativeTransport.cs new file mode 100644 index 000000000..910d502a8 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/NativeTransport.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using System.IO.Pipes; +using OpenClaw.BrowserBootstrap.Contracts; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.BrowserBootstrap; + +/// Bounded one-shot transport. Chrome EOF revokes both explicit backends; management EOF does not. +internal static class NativeTransport +{ + internal static ProcessStartInfo CreateStartInfo(Generation generation, string caller) + { + var c=generation.Binding.NativeWindows ?? throw new ContractException("binding_invalid"); + var start=new ProcessStartInfo(c.NodePath){UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true, + RedirectStandardError=true,WorkingDirectory=Path.GetDirectoryName(generation.Installation.LauncherPath)!}; + foreach(var arg in new[]{c.CliPath,"browser","extension","native-host","--manifest",generation.Installation.ManifestPath,"--launcher",generation.Installation.LauncherPath})start.ArgumentList.Add(arg); + foreach(var origin in generation.Binding.ExpectedOrigins){start.ArgumentList.Add("--expected-origin");start.ArgumentList.Add(origin);} + foreach(var arg in new[]{"--browser-profile",c.BrowserProfile,caller})start.ArgumentList.Add(arg); + foreach(var k in start.Environment.Keys.ToArray())if(k.StartsWith("OPENCLAW_",StringComparison.OrdinalIgnoreCase)||k.StartsWith("NODE_",StringComparison.OrdinalIgnoreCase))start.Environment.Remove(k); + start.Environment["OPENCLAW_STATE_DIR"]=c.StateDir;start.Environment["OPENCLAW_CONFIG_PATH"]=c.ConfigPath;start.Environment["OPENCLAW_NO_RESPAWN"]="1"; + return start; + } + internal static bool IsCaller(string[] args) => args.Length is 1 or 2 && args[0].Length==52 && + args[0].StartsWith("chrome-extension://",StringComparison.Ordinal)&&args[0][^1]=='/'&&args[0].AsSpan(19,32).ToArray().All(c=>c is >= 'a' and <= 'p') && + (args.Length==1 || args[1].StartsWith("--parent-window=",StringComparison.Ordinal)&&ulong.TryParse(args[1][16..],System.Globalization.NumberStyles.None,System.Globalization.CultureInfo.InvariantCulture,out _)); + internal static async Task RunAsync(Stream input,Stream output,string[] args,Func load, + Func runtimeLease, CancellationToken cancel, + Func>? exchange = null) + { + byte[] response; + if(!IsCaller(args)) response=BrowserNativeProtocol.Failure("origin_forbidden"); + else + { + using var lifetime=CancellationTokenSource.CreateLinkedTokenSource(cancel);lifetime.CancelAfter(TimeSpan.FromSeconds(30)); + Task? gone=null; + try + { + var request=await BrowserNativeProtocol.ReadAsync(input,BrowserNativeProtocol.RequestLimit,lifetime.Token); + var generation=load(); + // Native Windows delegates request/origin policy to the admitted canonical TS process. + // Companion rejection probes must work before the tray/WSL gateway is running. + if(generation.Binding.Mode==ManagementContract.Companion && !generation.Binding.ExpectedOrigins.Contains(args[0],StringComparer.Ordinal)) response=BrowserNativeProtocol.Failure("origin_forbidden"); + else + { + if(generation.Binding.Mode==ManagementContract.Companion) _=BrowserNativeProtocol.ParseRequest(request); + gone=WatchDisconnect(input,lifetime); + lifetime.Token.ThrowIfCancellationRequested(); + using var lease=runtimeLease(generation); + response = exchange is not null + ? await exchange(request,generation,args[0],lifetime.Token) + : generation.Binding.Mode==ManagementContract.Companion + ? await PipeAsync(request,lifetime.Token) + : await ChildAsync(request,CreateStartInfo(generation,args[0]),lifetime.Token); + lifetime.Token.ThrowIfCancellationRequested(); + await BrowserNativeProtocol.WriteAsync(output,response,lifetime.Token); + return; + } + } + catch(OperationCanceledException) { return; } + catch(InvalidDataException e) when(e.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") { response=BrowserNativeProtocol.Failure(e.Message); } + catch(ContractException) { response=BrowserNativeProtocol.Failure("manifest_invalid"); } + catch { response=BrowserNativeProtocol.Failure("pairing_unavailable"); } + finally { await lifetime.CancelAsync(); Observe(gone); } + } + await BrowserNativeProtocol.WriteAsync(output,response,cancel); + } + private static async Task WatchDisconnect(Stream input,CancellationTokenSource lifetime) + { + var extra=new byte[1]; + try { var count = await input.ReadAsync(extra,lifetime.Token); _ = count; } + finally { await lifetime.CancelAsync(); } + } + private static async Task PipeAsync(byte[] request,CancellationToken ct) + { + using var pipe=new NamedPipeClientStream(".",BrowserNativeProtocol.PipeName,PipeDirection.InOut,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(2000,ct); + await BrowserNativeProtocol.WriteAsync(pipe,request,ct); + return await BrowserNativeProtocol.ReadAsync(pipe,BrowserNativeProtocol.ResponseLimit,ct); + } + internal static async Task ChildAsync(byte[] request,ProcessStartInfo info,CancellationToken ct) + { + using var child=new Process{StartInfo=info};if(!child.Start())throw new IOException(); + var errors=Drain(child.StandardError.BaseStream,ct); + try + { + await BrowserNativeProtocol.WriteAsync(child.StandardInput.BaseStream,request,ct); + // The native protocol is one frame, not EOF-terminated. Keep stdin open until the child exits. + var result=await BrowserNativeProtocol.ReadAsync(child.StandardOutput.BaseStream,BrowserNativeProtocol.ResponseLimit,ct); + var extra=new byte[1];if(await child.StandardOutput.BaseStream.ReadAsync(extra,ct)!=0)throw new InvalidDataException(); + await child.WaitForExitAsync(ct);if(child.ExitCode!=0)throw new IOException(); + return result; + } + finally + { + try{if(!child.HasExited)child.Kill(true);using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2));await child.WaitForExitAsync(end.Token);} + finally{Observe(errors);} + } + } + private static async Task Drain(Stream stream,CancellationToken ct){var bytes=new byte[4096];while(await stream.ReadAsync(bytes,ct)!=0){} } + private static void Observe(Task? t){if(t is not null)_=t.ContinueWith(x=>_=x.Exception,CancellationToken.None,TaskContinuationOptions.OnlyOnFaulted|TaskContinuationOptions.ExecuteSynchronously,TaskScheduler.Default);} +} diff --git a/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj b/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj new file mode 100644 index 000000000..c8d8ef43d --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj @@ -0,0 +1,4 @@ + +Exenet10.0enableenableOpenClaw.BrowserBootstrap + + diff --git a/src/OpenClaw.BrowserBootstrap/Program.cs b/src/OpenClaw.BrowserBootstrap/Program.cs new file mode 100644 index 000000000..e596e484d --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/Program.cs @@ -0,0 +1,69 @@ +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + var management=args.Length>0&&args[0]=="--manage"; + using var process=System.Diagnostics.Process.GetCurrentProcess(); + var elapsed=DateTime.UtcNow-process.StartTime.ToUniversalTime(); + using var overall=new CancellationTokenSource(TimeSpan.FromMilliseconds(Math.Max(1,60000-elapsed.TotalMilliseconds))); + try + { + if(management) + { + ManagementResponse response; + try + { + ManagementContract.Require(args.Length==1); + using var inputDeadline=CancellationTokenSource.CreateLinkedTokenSource(overall.Token); + inputDeadline.CancelAfter(TimeSpan.FromMilliseconds(Math.Max(1,5000-elapsed.TotalMilliseconds))); + var bytes=await ReadManagementAsync(Console.OpenStandardInput(),inputDeadline.Token); + var request=ManagementContract.ParseRequest(bytes); + if(!OperatingSystem.IsWindows())response=ManagementResponse.Failure("platform_unsupported"); + else + { + using var deadline=CancellationTokenSource.CreateLinkedTokenSource(overall.Token); + // Mutex acquisition, all mutations and release execute on the same thread. + response=await Task.Run(()=> OperatingSystem.IsWindows() + ? new RegistrationService(new WindowsRegistrationPlatform()).Execute(request,deadline.Token) + : ManagementResponse.Failure("platform_unsupported")); + } + } + catch(OperationCanceledException){response=ManagementResponse.Failure("invalid_request");} + catch(Exception ex){response=ManagementResponse.Failure(ex is ContractException c?c.Code:"io_error");} + var output=ManagementContract.ResponseBytes(response); + using var writeDeadline=CancellationTokenSource.CreateLinkedTokenSource(overall.Token); + writeDeadline.CancelAfter(TimeSpan.FromSeconds(2)); + await Console.OpenStandardOutput().WriteAsync(output,writeDeadline.Token); + return response.Ok?0:1; + } + if(!OperatingSystem.IsWindows())return 1; + var authority=new WindowsAuthority();var generations=new GenerationStore(authority); + var exe=Environment.ProcessPath??throw new IOException(); + var manifest=Path.Combine(Path.GetDirectoryName(exe)!,ManagementContract.ManifestName); + using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await NativeTransport.RunAsync(Console.OpenStandardInput(),Console.OpenStandardOutput(),args,()=> + { + if(!OperatingSystem.IsWindows())throw new ContractException("platform_unsupported"); + var g=generations.Read(manifest); + if(!ManagementContract.PathEquals(g.Installation.LauncherPath,exe))throw new ContractException("binding_invalid"); + return g; + },generations.RuntimeLease,timeout.Token); + return 0; + } + catch{return 1;} // No stdout/stderr diagnostics outside a complete typed response. + } + internal static async Task ReadManagementAsync(Stream input,CancellationToken ct) + { + var buffer=new byte[ManagementContract.Limit+1];var size=0; + while(true) + { + var n=await input.ReadAsync(buffer.AsMemory(size),ct); + if(n==0)return buffer[..size]; + size+=n;if(size>ManagementContract.Limit)throw new ContractException("invalid_request"); + } + } +} diff --git a/src/OpenClaw.BrowserBootstrap/README.md b/src/OpenClaw.BrowserBootstrap/README.md new file mode 100644 index 000000000..b87786d62 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/README.md @@ -0,0 +1,17 @@ +# Windows Chrome bootstrap + +One packaged self-contained `OpenClaw.BrowserBootstrap.exe` owns Windows registration and transports Chrome native v1 requests. It does not implement a relay or replace Gateway authority. + +- `--manage` accepts one bounded strict JSON request through stdin followed by EOF, and emits one bounded JSON receipt plus LF and a matching exit code. It never accepts paths or JSON through extra argv. +- `companion-managed-wsl` uses the fixed current-user pipe and existing active-Gateway, connection intent, preference, generation and managed-distro provenance owners. +- `native-windows-cli` invokes only its explicitly bound, admitted Windows Node/CLI and leaves config, credentials, origin and relay policy with canonical TS. +- Missing runtime/Companion never selects the other topology. Existing conflicting/legacy registrations are preserved, not migrated. +- The current-user Known Folder owns private immutable generations. The executable, binding and manifest hashes, current SID, DACLs, canonical path and origin links must validate. Complete Chrome/Chromium registry views are verified before any optional owned Store request. +- Management EOF completes a request. Chrome EOF revokes pending transport work. +- Installer and uninstaller use the same bounded native-pipe client in `scripts/BrowserBootstrapManagement.iss`; no credentials, shell pipeline, policy override or secondary writer is involved. + +The cutover combines Peter Steinberger’s managed-WSL integration and fuller-stack-dev’s canonical Windows transport/setup contribution. Preserve both contributor trailers when publishing reused work. + +## Proof boundary + +Portable tests validate the agreed request/response and metadata grammar, state tuples, partial failures, framing and cancellation. Windows CI must separately prove the published executable, ACL/registry behavior and installer compilation. The executable registry/IPC smoke uses synthetic pairing material and is not production WSL/Chrome permission or signed installer upgrade proof. Dedicated Windows production-path evidence remains required before merge. diff --git a/src/OpenClaw.BrowserBootstrap/RegistrationService.cs b/src/OpenClaw.BrowserBootstrap/RegistrationService.cs new file mode 100644 index 000000000..3c29887cb --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/RegistrationService.cs @@ -0,0 +1,121 @@ +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap; + +internal sealed record Generation(HostBinding Binding, HostReceipt Receipt, Installation Installation); +internal sealed record NativeInventory(string? State, Generation[] Generations, string? Error = null) +{ + public Generation? Active => State == "owned" && Generations.Length == 1 ? Generations[0] : null; +} +internal sealed record StoreInventory(string? State, string? Error = null); +internal sealed record Inventory(NativeInventory Native, StoreInventory Store); +internal interface IRegistrationPlatform +{ + IDisposable Acquire(); + Inventory Observe(ManagementRequest request); + bool BrowserControlAllowsRequest(); + void ValidateRuntime(Generation generation); + Generation Prepare(ManagementRequest request, CancellationToken ct); + void PublishNative(ManagementRequest request, Generation generation, CancellationToken ct); + void RequestStore(ManagementRequest request, Generation generation, CancellationToken ct); + void RemoveStore(ManagementRequest request, CancellationToken ct); + void RemoveNative(ManagementRequest request, CancellationToken ct); +} + +/// Only management mutation policy. Synchronous execution keeps a named mutex on its owning thread. +internal sealed class RegistrationService(IRegistrationPlatform platform) +{ + public ManagementResponse Execute(ManagementRequest request, CancellationToken ct) + { + Inventory? observed = null; + try + { + using var gate = platform.Acquire(); + try + { + ct.ThrowIfCancellationRequested(); + observed = platform.Observe(request); + GuardNative(observed.Native, request); + if (request.Action == "inspect") + { + GuardStore(observed.Store); + if (observed.Native.State == "invalid") throw new ContractException("binding_invalid"); + if (observed.Native.Active is {} existing) + { + if (!ManagementContract.Matches(request, existing.Binding)) throw new ContractException("binding_invalid"); + platform.ValidateRuntime(existing); + } + } + else if (request.Action == "install") + { + if (request.Store == "request" && !platform.BrowserControlAllowsRequest()) throw new ContractException("browser_control_disabled"); + var generation = platform.Prepare(request, ct); + ct.ThrowIfCancellationRequested(); + platform.PublishNative(request, generation, ct); + observed = platform.Observe(request); + GuardNative(observed.Native, request); + if (observed.Native.Active is not {} active || !ManagementContract.Matches(request, active.Binding)) throw new ContractException("binding_invalid"); + if (request.Store == "request") + { + if (!platform.BrowserControlAllowsRequest()) throw new ContractException("browser_control_disabled"); + GuardStoreForMutation(observed.Store); + platform.RequestStore(request, active, ct); + } + } + else + { + if (request.Store == "remove") + { + GuardStoreForMutation(observed.Store); + platform.RemoveStore(request, ct); + observed = platform.Observe(request); + if (observed.Store.State != "missing") throw new ContractException(observed.Store.Error ?? "io_error"); + } + platform.RemoveNative(request, ct); + } + observed = platform.Observe(request); + GuardNative(observed.Native, request); + if (request.Action == "install" && (observed.Native.Active is not {} g || !ManagementContract.Matches(request,g.Binding))) throw new ContractException("io_error"); + if (request.Action == "uninstall" && observed.Native.State != "missing") throw new ContractException("io_error"); + if (request.Store == "request" && observed.Store.State != "requested") throw new ContractException(observed.Store.Error ?? "io_error"); + if (request.Store == "remove" && observed.Store.State != "missing") throw new ContractException(observed.Store.Error ?? "io_error"); + return Project(request, observed, "ok"); + } + catch (Exception ex) + { + // Still hold serialization while collecting post-state, never claim rollback. + try { observed = platform.Observe(request); } catch { observed = null; } + return Project(request, observed, Code(ex)); + } + } + catch (Exception ex) { return ManagementResponse.Failure(Code(ex)); } + } + private static void GuardNative(NativeInventory native, ManagementRequest request) + { + if (native.Error is {} error) throw new ContractException(error); + if (native.State is null) throw new ContractException("io_error"); + if (native.State == "foreign") throw new ContractException("foreign_registration"); + if (native.Generations.Any(g => !ManagementContract.SameOwnership(request,g.Binding))) throw new ContractException("context_conflict"); + } + private static void GuardStore(StoreInventory store) + { + GuardStoreForMutation(store); + if (store.State == "invalid") throw new ContractException("binding_invalid"); + } + private static void GuardStoreForMutation(StoreInventory store) + { + if (store.Error is {} error) throw new ContractException(error); + if (store.State is null) throw new ContractException("io_error"); + if (store.State == "foreign") throw new ContractException("foreign_registration"); + } + private static ManagementResponse Project(ManagementRequest request, Inventory? state, string code) + { + var active = state?.Native.Active; + return new(1, code=="ok", code, state?.Native.State, active?.Binding.Mode, state?.Store.State, + active is not null && ManagementContract.Matches(request,active.Binding) ? active.Installation : null); + } + internal static string Code(Exception ex) => ex switch + { + ContractException c => c.Code, OperationCanceledException => "cancelled", UnauthorizedAccessException => "unsafe_acl", _ => "io_error" + }; +} diff --git a/src/OpenClaw.BrowserBootstrap/WindowsAuthority.cs b/src/OpenClaw.BrowserBootstrap/WindowsAuthority.cs new file mode 100644 index 000000000..3f9a41714 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/WindowsAuthority.cs @@ -0,0 +1,165 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap; + +/// Owns current-user Windows identity, handle-bound filesystem admission and private creation. +[SupportedOSPlatform("windows")] +internal sealed class WindowsAuthority +{ + public string Sid { get; } + public string Root { get; } + private readonly HashSet trusted; + private readonly string trustedInstallerSid; + public WindowsAuthority() + { + Sid = WindowsIdentity.GetCurrent().User?.Value ?? throw new ContractException("unsafe_acl"); + if (!ManagementContract.IsSid(Sid) || Sid is "S-1-5-18" or "S-1-5-19" or "S-1-5-20") throw new ContractException("unsafe_acl"); + trusted = new(StringComparer.Ordinal) { Sid, "S-1-5-18", "S-1-5-32-544" }; + trustedInstallerSid = ((SecurityIdentifier)new NTAccount("NT SERVICE", "TrustedInstaller").Translate(typeof(SecurityIdentifier))).Value; + // On Windows this BCL API resolves the user's shell Known Folder, not LOCALAPPDATA env overrides. + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify); + Root = Path.Combine(local, "OpenClawTray", "browser-native", "generations"); + if (!ManagementContract.IsPath(Root)) throw new ContractException("unsafe_path"); + } + public bool IsTrusted(string sid) => trusted.Contains(sid); + public void CheckAcl(RawSecurityDescriptor sd, bool privacy, bool registry = false, bool protectedAncestor = false) + { + bool Allowed(string sid) => trusted.Contains(sid) || (!privacy && !registry && protectedAncestor && sid == trustedInstallerSid); + if (sd.Owner is null || !Allowed(sd.Owner.Value) || sd.DiscretionaryAcl is null) throw new ContractException("unsafe_acl"); + // Creating unrelated siblings is not authority to replace an admitted existing child. + var fileWrite = protectedAncestor ? 0x500d0150 : 0x500d0156; + const int registryWrite = 0x000d0006 | 0x10000000 | 0x40000000; + foreach (GenericAce ace in sd.DiscretionaryAcl) + { + if ((ace.AceFlags & AceFlags.InheritOnly) != 0) continue; + if (ace is not CommonAce q || q.IsCallback || q.AceQualifier is not (AceQualifier.AccessAllowed or AceQualifier.AccessDenied)) + throw new ContractException("unsafe_acl"); + if(q.AceQualifier != AceQualifier.AccessAllowed) continue; + if (!Allowed(q.SecurityIdentifier.Value) && + (privacy || (q.AccessMask & (registry ? registryWrite : fileWrite)) != 0)) throw new ContractException("unsafe_acl"); + } + } + public Lease Admit(string path, bool directory, bool privacy, bool allowMissing = false) + { + if (!ManagementContract.IsPath(path)) throw new ContractException("unsafe_path"); + var lease = new Lease(); + try + { + var chain = new List(); + for (string? p = path; p is not null; p = Path.GetDirectoryName(p)) chain.Add(p); + chain.Reverse(); + var missing = false; + foreach (var p in chain) + { + var isLeaf = ManagementContract.PathEquals(p, path); + var h = CreateFile(p, 0x00020080, 3, IntPtr.Zero, 3, 0x00200000 | 0x02000000, IntPtr.Zero); + if (h.IsInvalid) + { + var error = Marshal.GetLastWin32Error(); h.Dispose(); + if (allowMissing && error is 2 or 3) { missing = true; continue; } + throw new ContractException(error == 5 ? "unsafe_acl" : "unsafe_path"); + } + lease.Handles.Add(h); + if (missing || !GetFileInformationByHandle(h, out var info) || (info.Attributes & 0x400) != 0 || + ((info.Attributes & 0x10) != 0) != (!isLeaf || directory)) throw new ContractException("unsafe_path"); + var final = new char[32768]; + var count = GetFinalPathNameByHandle(h, final, (uint)final.Length, 0); + if (count == 0 || count >= final.Length) throw new ContractException("unsafe_path"); + var canonical = new string(final, 0, (int)count); + if (canonical.StartsWith(@"\\?\", StringComparison.Ordinal)) canonical = canonical[4..]; + if (!ManagementContract.PathEquals(canonical, p)) throw new ContractException("unsafe_path"); + CheckHandle(h, isLeaf && privacy, protectedAncestor: !isLeaf); + } + lease.Exists = !missing; + return lease; + } + catch { lease.Dispose(); throw; } + } + public void CheckHandle(SafeFileHandle handle, bool privacy, bool protectedAncestor = false) + { + var code = GetSecurityInfo(handle, 1, 5, out _, out _, out _, out _, out var sd); + if (code != 0) throw new ContractException("unsafe_acl"); + try + { + var length = GetSecurityDescriptorLength(sd); + if (length == 0 || length > 1024 * 1024) throw new ContractException("unsafe_acl"); + var bytes = new byte[length]; Marshal.Copy(sd, bytes, 0, bytes.Length); + CheckAcl(new RawSecurityDescriptor(bytes, 0), privacy, protectedAncestor: protectedAncestor); + } + finally { LocalFree(sd); } + } + public string FileIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out var info)) throw new ContractException("unsafe_path"); + return $"{info.Volume:x8}:{info.IndexHigh:x8}{info.IndexLow:x8}"; + } + public byte[] Read(string path, int limit, bool privacy) + { + using var parents = Admit(path, false, privacy); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + CheckHandle(stream.SafeFileHandle, privacy); + if (stream.Length is <= 0 || stream.Length > limit) throw new ContractException("binding_invalid"); + var data = new byte[(int)stream.Length]; stream.ReadExactly(data); return data; + } + public void EnsurePrivateDirectory(string path) + { + using var current = Admit(path, true, true, allowMissing: true); + if (current.Exists) return; + var parent = Path.GetDirectoryName(path) ?? throw new ContractException("unsafe_path"); + if (!Directory.Exists(parent)) EnsurePrivateDirectory(parent); + using var admittedParent = Admit(parent, true, false); + var acl = new DirectorySecurity(); acl.SetAccessRuleProtection(true, false); acl.SetOwner(new SecurityIdentifier(Sid)); + foreach (var sid in trusted) acl.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(sid), FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(path).Create(acl); + using var verified = Admit(path, true, true); + } + public IDisposable Lock() + { + // Mutex ACL APIs are supplied by the Windows access-control package. + var acl = new MutexSecurity(); acl.SetAccessRuleProtection(true, false); acl.SetOwner(new SecurityIdentifier(Sid)); + foreach (var sid in trusted) acl.AddAccessRule(new MutexAccessRule(new SecurityIdentifier(sid), MutexRights.FullControl, AccessControlType.Allow)); + var mutex = MutexAcl.Create(false, @"Global\OpenClaw.Browser.NativeRegistration." + Sid, out _, acl); + try + { + var bytes = mutex.GetAccessControl().GetSecurityDescriptorBinaryForm(); + var sd = new RawSecurityDescriptor(bytes, 0); + if (sd.Owner is null || !trusted.Contains(sd.Owner.Value) || sd.DiscretionaryAcl is null) throw new ContractException("unsafe_acl"); + foreach (GenericAce ace in sd.DiscretionaryAcl) + if (ace is QualifiedAce q && q.AceQualifier == AceQualifier.AccessAllowed && !trusted.Contains(q.SecurityIdentifier.Value)) throw new ContractException("unsafe_acl"); + bool acquired; + try { acquired = mutex.WaitOne(TimeSpan.FromSeconds(5)); } + catch (AbandonedMutexException) { acquired = true; } + if (!acquired) throw new ContractException("busy"); + return new MutexLease(mutex); + } + catch { mutex.Dispose(); throw; } + } + private sealed class MutexLease(Mutex mutex) : IDisposable + { + public void Dispose() { mutex.ReleaseMutex(); mutex.Dispose(); } + } + internal sealed class Lease : IDisposable + { + public bool Exists { get; set; } + internal List Handles { get; } = []; + public void Dispose() { foreach (var h in Handles) h.Dispose(); } + } + [StructLayout(LayoutKind.Sequential)] private struct FileInformation + { + public uint Attributes, CreationLow, CreationHigh, AccessLow, AccessHigh, WriteLow, WriteHigh, Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateFileW")] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation info); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true)] + private static extern uint GetFinalPathNameByHandle(SafeFileHandle h, [Out] char[] path, uint length, uint flags); + [DllImport("advapi32.dll")] private static extern uint GetSecurityInfo(SafeFileHandle h, uint type, uint flags, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor); + [DllImport("advapi32.dll")] private static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs new file mode 100644 index 000000000..b4f281a93 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs @@ -0,0 +1,224 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Text.Json; +using Microsoft.Win32; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap; + +/// Sole native and Store registry writer. No alternate TS/PowerShell mutation backend. +[SupportedOSPlatform("windows")] +internal sealed class WindowsRegistrationPlatform : IRegistrationPlatform +{ + internal const string StoreKey = @"Software\Google\Chrome\Extensions\kcdjddhmeafeomebliikmbpblkmkfoig"; + internal const string StoreOwner = "openclaw_native_host", UpdateUrl = "https://clients2.google.com/service/update2/crx"; + private static readonly string[] NativeKeys = [@"Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap",@"Software\Chromium\NativeMessagingHosts\ai.openclaw.browser_bootstrap"]; + private readonly WindowsAuthority authority; + internal GenerationStore Generations { get; } + private readonly string[] nativeKeys=NativeKeys; + internal WindowsRegistrationPlatform(){authority=new();Generations=new(authority);} + public IDisposable Acquire()=>authority.Lock(); + public void ValidateRuntime(Generation generation){using var lease=Generations.RuntimeLease(generation);} + public Generation Prepare(ManagementRequest request,CancellationToken ct)=>Generations.Prepare(request,ct,ObserveNative().Generations); + internal sealed record Value(string Name,RegistryValueKind Kind,string? Text); + internal sealed record Row(RegistryHive Hive,RegistryView View,string Path,bool Exists,Value[] Values); + private RegistryView[] Views=>Environment.Is64BitOperatingSystem?[RegistryView.Registry32,RegistryView.Registry64]:[RegistryView.Registry32]; + private void CheckKey(RegistryKey key)=>authority.CheckAcl(new RawSecurityDescriptor(key.GetAccessControl().GetSecurityDescriptorBinaryForm(),0),false,true); + private Row FromKey(RegistryHive hive,RegistryView view,string path,RegistryKey key) + { + CheckKey(key); + if(key.SubKeyCount!=0)throw new ContractException("foreign_registration"); + return new(hive,view,path,true,key.GetValueNames().Order(StringComparer.Ordinal).Select(n=>new Value(n,key.GetValueKind(n),key.GetValue(n,null,RegistryValueOptions.DoNotExpandEnvironmentNames) as string)).ToArray()); + } + private Row ReadRow(RegistryHive hive,RegistryView view,string path) + { + using var root=RegistryKey.OpenBaseKey(hive,view);using var key=root.OpenSubKey(path,false); + if(key is null)return new(hive,view,path,false,[]); + return FromKey(hive,view,path,key); + } + private Row[] Rows(string[] paths)=>(from path in paths from hive in new[]{RegistryHive.CurrentUser,RegistryHive.LocalMachine} from view in Views select ReadRow(hive,view,path)).ToArray(); + private static bool Same(Row a,Row b)=>a.Hive==b.Hive&&a.View==b.View&&a.Path==b.Path&&a.Exists==b.Exists&&a.Values.SequenceEqual(b.Values); + public Inventory Observe(ManagementRequest request)=>new(ObserveNative(),ObserveStore(request)); + private NativeInventory ObserveNative(Row[]? snapshot=null) + { + try + { + var rows=snapshot??Rows(nativeKeys); + if(rows.Any(r=>r.Values.Any(v=>v.Name!=""||v.Kind!=RegistryValueKind.String||v.Text is null)||r.Hive==RegistryHive.LocalMachine&&r.Values.Length!=0))return new("foreign",[]); + var paths=rows.SelectMany(r=>r.Values).Select(v=>v.Text!).Distinct(StringComparer.Ordinal).ToArray(); + if(paths.Length==0)return new("missing",[]); + var generations=paths.Select(Generations.Read).ToArray(); + var user=rows.Where(r=>r.Hive==RegistryHive.CurrentUser).ToArray(); + var complete=generations.Length==1&&user.All(r=>r.Values.Length==1&&r.Values[0].Text==generations[0].Installation.ManifestPath); + return new(complete?"owned":"invalid",generations); + } + catch(ContractException e) when(e.Code=="foreign_registration"){return new("foreign",[]);} + catch(ContractException e) when(e.Code=="binding_invalid"||e.Code=="invalid_request"){return new("invalid",[],"binding_invalid");} + catch(Exception e){return new(null,[],RegistrationService.Code(e));} + } + private StoreInventory ObserveStore(ManagementRequest request,Row[]? snapshot=null) + { + try + { + var rows=snapshot??Rows([StoreKey]); + if(rows.Any(r=>r.Hive==RegistryHive.LocalMachine&&r.Values.Length!=0))return new("foreign"); + var states=new List(); + foreach(var row in rows.Where(r=>r.Hive==RegistryHive.CurrentUser)) + { + if(!row.Exists){states.Add("missing");continue;} + if(row.Values.Length is <1 or >2||row.Values.Any(v=>v.Name is not (StoreOwner or "update_url")||v.Kind!=RegistryValueKind.String||v.Text is null))return new("foreign"); + var owner=row.Values.SingleOrDefault(v=>v.Name==StoreOwner)?.Text; + if(owner is null||!ManagementContract.IsPath(owner)||Path.GetFileName(owner)!=ManagementContract.ExeName)return new("foreign"); + Generation generation; + try{generation=Generations.Read(Path.Combine(Path.GetDirectoryName(owner)!,ManagementContract.ManifestName));} + catch(ContractException e) when(e.Code is "binding_invalid" or "foreign_registration" or "invalid_request"){return new("foreign");} + if(owner!=generation.Installation.LauncherPath||!ManagementContract.SameOwnership(request,generation.Binding))return new("foreign"); + var update=row.Values.SingleOrDefault(v=>v.Name=="update_url"); + if(update is not null&&update.Text!=UpdateUrl)return new("foreign"); + states.Add(update is null?"invalid":"requested"); + } + return new(states.All(s=>s=="missing")?"missing":states.All(s=>s=="requested")?"requested":"invalid"); + } + catch(ContractException e) when(e.Code=="foreign_registration"){return new("foreign");} + catch(Exception e){return new(null,RegistrationService.Code(e));} + } + public bool BrowserControlAllowsRequest() + { + try + { + var file=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData,Environment.SpecialFolderOption.DoNotVerify),"OpenClawTray","settings.json"); + if(!File.Exists(file))return true; + return BrowserControlPreference.Allows(authority.Read(file,1024*1024,false)); + } + catch{return false;} + } + public void PublishNative(ManagementRequest request,Generation generation,CancellationToken ct) + { + var snapshot=Rows(nativeKeys); + var current=ObserveNative(snapshot); + if(current.Error is {} error)throw new ContractException(error); + if(current.State=="foreign")throw new ContractException("foreign_registration"); + if(current.Generations.Any(g=>!ManagementContract.SameOwnership(request,g.Binding)))throw new ContractException("context_conflict"); + _=Generations.Read(generation.Installation.ManifestPath); + Change(nativeKeys,snapshot,[new("",RegistryValueKind.String,generation.Installation.ManifestPath)],false,ct); + } + public void RequestStore(ManagementRequest request,Generation generation,CancellationToken ct) + { + var snapshot=Rows([StoreKey]); + var state=ObserveStore(request,snapshot); + if(state.Error is {} error)throw new ContractException(error); + if(state.State=="foreign")throw new ContractException("foreign_registration"); + if(!BrowserControlAllowsRequest())throw new ContractException("browser_control_disabled"); + var live=ObserveNative(); + if(live.Active?.Installation!=generation.Installation)throw new ContractException("binding_invalid"); + _=Generations.Read(generation.Installation.ManifestPath); + // Owner is published first; update_url is the separately observable Store request. + Change([StoreKey],snapshot,[new(StoreOwner,RegistryValueKind.String,generation.Installation.LauncherPath),new("update_url",RegistryValueKind.String,UpdateUrl)],false,ct,guard:()=> + { + if(!BrowserControlAllowsRequest())throw new ContractException("browser_control_disabled"); + if(ObserveNative().Active?.Installation!=generation.Installation)throw new ContractException("binding_invalid"); + _=Generations.Read(generation.Installation.ManifestPath); + }); + } + public void RemoveStore(ManagementRequest request,CancellationToken ct) + { + var snapshot=Rows([StoreKey]); + var state=ObserveStore(request,snapshot);if(state.Error is {} e)throw new ContractException(e); + if(state.State=="foreign")throw new ContractException("foreign_registration"); + Change([StoreKey],snapshot,[],true,ct); + } + public void RemoveNative(ManagementRequest request,CancellationToken ct) + { + var snapshot=Rows(nativeKeys); + var state=ObserveNative(snapshot);if(state.Error is {} e)throw new ContractException(e); + if(state.State=="foreign")throw new ContractException("foreign_registration"); + if(state.Generations.Any(g=>!ManagementContract.SameOwnership(request,g.Binding)))throw new ContractException("context_conflict"); + Change(nativeKeys,snapshot,[],true,ct); + // Retain generations. Store/other views may reference them, and GC is not an ABI postcondition. + } + private void Change(string[] paths,Row[] expected,Value[] values,bool remove,CancellationToken ct,Action? guard=null) + { + foreach(var identity in expected.Where(r=>r.Hive==RegistryHive.CurrentUser).Select(r=>(r.Hive,r.View,r.Path)).ToArray()) + { + foreach(var value in remove ? new Value?[]{null} : values.Cast()) + { + var target=expected.Single(r=>r.Hive==identity.Hive&&r.View==identity.View&&r.Path==identity.Path); + ct.ThrowIfCancellationRequested(); + var current=Rows(paths); + if(!expected.Zip(current).All(p=>Same(p.First,p.Second)))throw new ContractException("io_error"); + // Empty keys are absence under the acknowledged ABI, not ownership or permission to delete them. + if(remove&&target.Values.Length==0)continue; + guard?.Invoke(); + MutateAtomically(target,value,ct); + var desired=value is null?Array.Empty():target.Values.Where(v=>v.Name!=value.Name).Append(value).OrderBy(v=>v.Name,StringComparer.Ordinal).ToArray(); + var after=Rows(paths); + for(var i=0;i - - Exe - net10.0 - OpenClaw.BrowserNativeHost - enable - enable - - - - - - - - diff --git a/src/OpenClaw.BrowserNativeHost/Program.cs b/src/OpenClaw.BrowserNativeHost/Program.cs deleted file mode 100644 index 6892a4e22..000000000 --- a/src/OpenClaw.BrowserNativeHost/Program.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.IO.Pipes; -using OpenClaw.Shared.Browser; - -if (!OperatingSystem.IsWindows()) return 1; -if (args.Length == 1 && args[0] is "--register" or "--unregister" or "--request-extension" or "--remove-extension-request") -{ - try - { - var success = args[0] is "--request-extension" or "--remove-extension-request" - ? ChromeExtensionInstallRequest.Apply(Environment.ProcessPath!, args[0] == "--remove-extension-request") - : BrowserNativeRegistration.Apply(Environment.ProcessPath!, args[0] == "--unregister"); - return success ? 0 : 1; - } - catch { return 1; } // Never emit manifest paths or user data to Chrome's stdout. -} - -byte[] response; -using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25)); -try -{ - if (!BrowserNativeProtocol.IsAllowedCaller(args)) - response = BrowserNativeProtocol.Failure("origin_forbidden"); - else if (!BrowserNativeRegistration.IsRegistered(Environment.ProcessPath!)) - response = BrowserNativeProtocol.Failure("manifest_invalid"); - else - { - var payload = await BrowserNativeProtocol.ReadAsync(Console.OpenStandardInput(), BrowserNativeProtocol.RequestLimit, deadline.Token); - _ = BrowserNativeProtocol.ParseRequest(payload); - // Windows authenticates both ends to the current user. No HTTP endpoint or copied bearer token. - using var pipe = new NamedPipeClientStream(".", BrowserNativeProtocol.PipeName, - PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); - await pipe.ConnectAsync(2000, deadline.Token); - await BrowserNativeProtocol.WriteAsync(pipe, payload, deadline.Token); - response = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.ResponseLimit, deadline.Token); - } -} -catch (InvalidDataException ex) when (ex.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") -{ - response = BrowserNativeProtocol.Failure(ex.Message); -} -catch -{ - response = BrowserNativeProtocol.Failure("pairing_unavailable"); -} -try -{ - using var writeDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await BrowserNativeProtocol.WriteAsync(Console.OpenStandardOutput(), response, writeDeadline.Token); - return 0; -} -catch { return 1; } diff --git a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs index 5fbdc3b71..a66ac592a 100644 --- a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs @@ -2,65 +2,75 @@ namespace OpenClaw.Shared.Browser; -/// Current-user authenticated, single-flight bootstrap IPC. No port, token file, or gateway transport. -public sealed class BrowserBootstrapPipeServer( - Func> handle, +/// Current-user authenticated, single-flight bootstrap IPC with client-disconnect cancellation. +public sealed class BrowserBootstrapPipeServer(Func> handle, string pipeName = BrowserNativeProtocol.PipeName) : IAsyncDisposable { private readonly CancellationTokenSource _stop = new(); private Task? _loop; - public void Start() { if (_loop is not null) throw new InvalidOperationException("Already started."); _loop = RunAsync(); } - private async Task RunAsync() { while (!_stop.IsCancellationRequested) { try { - using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, - 4096, 16384); + using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 16384); await pipe.WaitForConnectionAsync(_stop.Token); - using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); - deadline.CancelAfter(TimeSpan.FromSeconds(20)); - byte[] response; - try - { - var request = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.RequestLimit, deadline.Token); - response = await handle(request, deadline.Token); - } - catch (InvalidDataException ex) when (ex.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") - { - response = BrowserNativeProtocol.Failure(ex.Message); - } - catch (Exception) when (!_stop.IsCancellationRequested) - { - response = BrowserNativeProtocol.Failure("pairing_unavailable"); - } - await BrowserNativeProtocol.WriteAsync(pipe, response, deadline.Token); + await HandleConnectionAsync(pipe); } catch (OperationCanceledException) when (_stop.IsCancellationRequested) { return; } catch (Exception) when (!_stop.IsCancellationRequested) { - // Do not log requests, responses, or exception messages containing pairing material. + // Requests, responses and exception messages may contain pairing material. Never log them. await Task.Delay(250, _stop.Token); } } } - - public async ValueTask DisposeAsync() + private async Task HandleConnectionAsync(Stream pipe) { - await _stop.CancelAsync(); - if (_loop is not null) + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); + lifetime.CancelAfter(TimeSpan.FromSeconds(20)); + Task? disconnected = null; + try + { + byte[] response; + try + { + var request = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.RequestLimit, lifetime.Token); + disconnected = WatchDisconnectAsync(pipe, lifetime); + lifetime.Token.ThrowIfCancellationRequested(); + response = await handle(request, lifetime.Token); + } + catch (InvalidDataException ex) when (ex.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") + { response = BrowserNativeProtocol.Failure(ex.Message); } + catch (Exception) when (!lifetime.IsCancellationRequested) + { response = BrowserNativeProtocol.Failure("pairing_unavailable"); } + lifetime.Token.ThrowIfCancellationRequested(); + await BrowserNativeProtocol.WriteAsync(pipe, response, lifetime.Token); + } + finally { - try { await _loop; } - catch (OperationCanceledException) { } + await lifetime.CancelAsync(); + if (disconnected is not null) + _ = disconnected.ContinueWith(t => _ = t.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + } + private static async Task WatchDisconnectAsync(Stream pipe, CancellationTokenSource lifetime) + { + try { var count = await pipe.ReadAsync(new byte[1], lifetime.Token); _ = count; } + finally { await lifetime.CancelAsync(); } + } + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + if (_loop is not null) { try { await _loop; } catch (OperationCanceledException) { } } _stop.Dispose(); } } diff --git a/src/OpenClaw.Shared/Browser/BrowserManagementClient.cs b/src/OpenClaw.Shared/Browser/BrowserManagementClient.cs new file mode 100644 index 000000000..acb5058eb --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserManagementClient.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.Shared.Browser; + +/// Fixed-argv bounded caller for the sole Windows writer. Transport failure is not a fabricated inventory. +public static class BrowserManagementClient +{ + public static ManagementRequest Companion(string action,string store)=>new(1,action,ManagementContract.Companion,null,[ManagementContract.Origin],store); + public static ProcessStartInfo StartInfo(string executable)=>new(executable) + { + UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true, + ArgumentList={"--manage"},WorkingDirectory=Path.GetDirectoryName(executable)! + }; + public static async Task RunAsync(string executable,ManagementRequest request,CancellationToken ct) + { + var input=ManagementContract.Serialize(request);_=ManagementContract.ParseRequest(input); + using var deadline=CancellationTokenSource.CreateLinkedTokenSource(ct);deadline.CancelAfter(TimeSpan.FromSeconds(60)); + using var child=new Process{StartInfo=StartInfo(executable)}; + try + { + if(!Path.IsPathFullyQualified(executable)||!child.Start())return null; + var stdout=ReadBounded(child.StandardOutput.BaseStream,ManagementContract.Limit,deadline.Token); + var stderr=ReadBounded(child.StandardError.BaseStream,0,deadline.Token); + await child.StandardInput.BaseStream.WriteAsync(input,deadline.Token);child.StandardInput.Close(); + await Task.WhenAll(stdout,stderr,child.WaitForExitAsync(deadline.Token)); + var result=ManagementContract.ParseResponse(await stdout,child.ExitCode); + if(result.Ok&&request.Action=="install"&&(result.Registration!="owned"||result.Mode!=request.Mode||result.Installation is null||request.Store=="request"&&result.Store!="requested"))return null; + if(result.Ok&&request.Action=="uninstall"&&(result.Registration!="missing"||request.Store=="remove"&&result.Store!="missing"))return null; + return result; + } + catch(Exception ex) when(ex is IOException or InvalidOperationException or System.ComponentModel.Win32Exception or OperationCanceledException or ContractException){return null;} + finally + { + try{if(child.Id>0&&!child.HasExited)child.Kill(true);using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2));await child.WaitForExitAsync(end.Token);} + catch(Exception ex) when(ex is InvalidOperationException or System.ComponentModel.Win32Exception or OperationCanceledException){} + } + } + private static async Task ReadBounded(Stream stream,int limit,CancellationToken ct) + { + var bytes=new byte[limit+1];var size=0; + while(true){var n=await stream.ReadAsync(bytes.AsMemory(size),ct);if(n==0)return bytes[..size];size+=n;if(size>limit)throw new IOException("Invalid management transport.");} + } +} diff --git a/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs b/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs deleted file mode 100644 index 344017b00..000000000 --- a/src/OpenClaw.Shared/Browser/BrowserNativeRegistration.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Runtime.Versioning; -using System.Text.Json; -using Microsoft.Win32; - -namespace OpenClaw.Shared.Browser; - -/// Owns only the per-user native host. This never requests an extension installation. -public static class BrowserNativeRegistration -{ - private const string Key = @"Software\Google\Chrome\NativeMessagingHosts\ai.openclaw.browser_bootstrap"; - private static string ManifestPath => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "OpenClawTray", "browser-native", "ai.openclaw.browser_bootstrap.json"); - - public static string BuildManifest(string executable) => JsonSerializer.Serialize(new - { - name = BrowserNativeProtocol.HostName, - description = "OpenClaw Companion browser pairing", - path = Path.GetFullPath(executable), - type = "stdio", - allowed_origins = new[] { BrowserNativeProtocol.Origin } - }); - - public static bool ManifestMatches(string json, string executable) - { - try - { - using var doc = JsonDocument.Parse(json); - using var expected = JsonDocument.Parse(BuildManifest(executable)); - return doc.RootElement.ValueKind == JsonValueKind.Object && - doc.RootElement.EnumerateObject().Select(p => p.Name).Distinct(StringComparer.Ordinal).Count() == 5 && - doc.RootElement.EnumerateObject().Count() == 5 && - JsonElement.DeepEquals(doc.RootElement, expected.RootElement); - } - catch (JsonException) { return false; } - } - - private static bool StoredManifestMatches(string executable) - { - var info = new FileInfo(ManifestPath); - return info.Exists && info.Length is > 0 and <= 4096 && - (info.Attributes & FileAttributes.ReparsePoint) == 0 && - ManifestMatches(File.ReadAllText(ManifestPath), executable); - } - - [SupportedOSPlatform("windows")] - public static bool IsRegistered(string executable) - { - using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); - using var key = root.OpenSubKey(Key); - return key?.GetValue("") is string registered && - string.Equals(registered, ManifestPath, StringComparison.OrdinalIgnoreCase) && - File.Exists(ManifestPath) && - (File.GetAttributes(ManifestPath) & FileAttributes.ReparsePoint) == 0 && - StoredManifestMatches(executable); - } - - [SupportedOSPlatform("windows")] - private static bool HasForeignRegistration(string executable) - { - foreach (var hive in new[] { RegistryHive.CurrentUser, RegistryHive.LocalMachine }) - foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) - { - using var root = RegistryKey.OpenBaseKey(hive, view); - using var key = root.OpenSubKey(Key); - if (key is null) continue; - if (key.GetValue("") is not string path || - !string.Equals(path, ManifestPath, StringComparison.OrdinalIgnoreCase) || - !StoredManifestMatches(executable)) return true; - } - return false; - } - - [SupportedOSPlatform("windows")] - public static bool Apply(string executable, bool remove = false) - { - using var mutex = new Mutex(false, @"Local\OpenClawTray.BrowserNativeRegistration"); - if (!mutex.WaitOne(TimeSpan.FromSeconds(5))) return false; - try - { - using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); - using var existing = root.OpenSubKey(Key); - // Do not shadow a host installed by another product, user install, or registry view. - if (!remove && HasForeignRegistration(executable)) return false; - if (existing is not null && !IsRegistered(executable)) return false; - if (remove) - { - if (existing is null) return true; - // Never delete a key with someone else's additional values or children. - if (existing.SubKeyCount != 0 || existing.GetValueNames().Any(name => name != "")) return false; - existing.Dispose(); - root.DeleteSubKey(Key, false); - File.Delete(ManifestPath); - return true; - } - if (!File.Exists(executable)) return false; - var directory = Path.GetDirectoryName(ManifestPath)!; - Directory.CreateDirectory(directory); - for (var parent = new DirectoryInfo(directory); parent is not null; parent = parent.Parent) - if ((parent.Attributes & FileAttributes.ReparsePoint) != 0) return false; - if (File.Exists(ManifestPath)) - { - if (!StoredManifestMatches(executable)) return false; - } - else - { - using var file = new FileStream(ManifestPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - using var writer = new StreamWriter(file); - writer.Write(BuildManifest(executable)); - } - using var key = root.CreateSubKey(Key); - if (key.GetValue("") is { } current && - (current is not string currentPath || - !string.Equals(currentPath, ManifestPath, StringComparison.OrdinalIgnoreCase))) return false; - if (!StoredManifestMatches(executable)) return false; - key.SetValue("", ManifestPath, RegistryValueKind.String); - return IsRegistered(executable); - } - finally { mutex.ReleaseMutex(); } - } -} diff --git a/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs b/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs deleted file mode 100644 index 4e170ee95..000000000 --- a/src/OpenClaw.Shared/Browser/ChromeExtensionInstallRequest.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.Text.Json; -using Microsoft.Win32; -using Microsoft.Win32.SafeHandles; - -namespace OpenClaw.Shared.Browser; - -/// Owns a normal per-user Chrome Store request, never an enterprise policy or Chrome profile preference. -public static class ChromeExtensionInstallRequest -{ - public const string UpdateUrl = "https://clients2.google.com/service/update2/crx"; - public const string RegistryPath = @"Software\Google\Chrome\Extensions\" + BrowserNativeProtocol.ExtensionId; - public const string OwnerValue = "openclaw_native_host"; - - public static bool IsOwned(IReadOnlyDictionary values, string executable, bool allowIncomplete = false) => - values.Count is >= 1 and <= 2 && values.Keys.All(k => k is OwnerValue or "update_url") && - values.TryGetValue(OwnerValue, out var owner) && owner is string path && - string.Equals(path, Path.GetFullPath(executable), StringComparison.OrdinalIgnoreCase) && - ((values.TryGetValue("update_url", out var url) && url is string update && update == UpdateUrl) || - (allowIncomplete && !values.ContainsKey("update_url"))); - - public static bool SettingsAllowRequest(string? json) - { - if (json is null) return true; // Fresh install uses SettingsData's Browser control default. - try - { - using var document = JsonDocument.Parse(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) return false; - var fields = document.RootElement.EnumerateObject() - .Where(p => p.Name == "NodeBrowserProxyEnabled").ToArray(); - return fields.Length == 0 || (fields.Length == 1 && fields[0].Value.ValueKind == JsonValueKind.True); - } - catch (JsonException) { return false; } - } - - private static bool SavedBrowserControlAllowsRequest() - { - var settingsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OpenClawTray", "settings.json"); - var file = new FileInfo(settingsPath); - if (!file.Exists) return true; - // Inspect only the persisted preference. Never deserialize/decrypt or log credential fields. - return file.Length <= 1024 * 1024 && SettingsAllowRequest(File.ReadAllText(settingsPath)); - } - - [SupportedOSPlatform("windows")] - public static bool Apply(string executable, bool remove = false) - { - using var mutex = new Mutex(false, @"Local\OpenClawTray.ChromeExtensionInstallRequest"); - if (!mutex.WaitOne(TimeSpan.FromSeconds(5))) return false; - try - { - using var root = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Default); - using var existing = root.OpenSubKey(RegistryPath); - if (remove) - { - if (existing is null) return true; - if (existing.SubKeyCount != 0 || !IsOwned(ReadValues(existing), executable, allowIncomplete: true)) return false; - existing.Dispose(); - root.DeleteSubKey(RegistryPath, false); - return true; - } - // Native host must already be usable before Chrome can see update_url. - if (!BrowserNativeRegistration.IsRegistered(executable) || !SavedBrowserControlAllowsRequest()) return false; - // Chromium prefers HKLM32 over HKCU. Never shadow or modify another registration. - foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser }) - foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) - { - using var otherRoot = RegistryKey.OpenBaseKey(hive, view); - using var other = otherRoot.OpenSubKey(RegistryPath); - if (other is null) continue; - if (hive == RegistryHive.LocalMachine || other.SubKeyCount != 0 || - !IsOwned(ReadValues(other), executable)) return false; - } - if (existing is not null) return IsOwned(ReadValues(existing), executable); - - // RegistryKey.CreateSubKey cannot distinguish "created" from "opened". The - // disposition protects a foreign entry created between our inspection and write. - var status = RegCreateKeyEx(root.Handle, RegistryPath, 0, null, 0, 0x2001f, - IntPtr.Zero, out var handle, out var disposition); - using (handle) - { - if (status != 0) return false; - using var key = RegistryKey.FromHandle(handle); - if (disposition != 1) return IsOwned(ReadValues(key), executable); - key.SetValue(OwnerValue, Path.GetFullPath(executable), RegistryValueKind.String); - // This is the actual Store request. Chrome still requires the user's approval. - key.SetValue("update_url", UpdateUrl, RegistryValueKind.String); - return IsOwned(ReadValues(key), executable); - } - } - finally { mutex.ReleaseMutex(); } - } - - [SupportedOSPlatform("windows")] - private static Dictionary ReadValues(RegistryKey key) => - key.GetValueNames().ToDictionary(name => name, name => key.GetValue(name), StringComparer.Ordinal); - - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = false)] - private static extern int RegCreateKeyEx(SafeRegistryHandle key, string subKey, int reserved, - string? keyClass, int options, int desiredAccess, IntPtr securityAttributes, - out SafeRegistryHandle result, out int disposition); -} diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index 1717c45be..2a12aec74 100644 --- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj +++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj @@ -24,5 +24,6 @@ + diff --git a/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets new file mode 100644 index 000000000..48046b99f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets @@ -0,0 +1,30 @@ + + + $(MSBuildThisFileDirectory)..\OpenClaw.BrowserBootstrap\OpenClaw.BrowserBootstrap.csproj + $(RuntimeIdentifier) + win-arm64 + win-x64 + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj\browser-bootstrap\$(Configuration)\$(BrowserBootstrapRid)\')) + + + + + + tools\browser-bootstrap\OpenClaw.BrowserBootstrap.exe + PreserveNewestNever + + + + + + + + tools\browser-bootstrap\OpenClaw.BrowserBootstrap.exePreserveNewest + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets b/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets deleted file mode 100644 index 8b9f5e62f..000000000 --- a/src/OpenClaw.Tray.WinUI/BrowserNativeHost.targets +++ /dev/null @@ -1,45 +0,0 @@ - - - $(MSBuildThisFileDirectory)..\OpenClaw.BrowserNativeHost\OpenClaw.BrowserNativeHost.csproj - $(RuntimeIdentifier) - win-arm64 - win-x64 - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj\browser-native\$(Configuration)\$(BrowserNativeHostRid)\')) - - - - - - <_BrowserNativeHostFile Include="$(BrowserNativeHostPayload)**\*" /> - - tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - Never - - - - - - - - - tools\browser-bootstrap\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - - - - - - <_RequiredBrowserNativeFile Include="OpenClaw.BrowserNativeHost.exe;OpenClaw.BrowserNativeHost.dll;OpenClaw.BrowserNativeHost.runtimeconfig.json;OpenClaw.BrowserNativeHost.deps.json;hostpolicy.dll;hostfxr.dll;System.Private.CoreLib.dll" /> - - - - diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index f70091cf0..2bad8f8f2 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -439,5 +439,5 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs b/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs index 90ff6428c..3088fc453 100644 --- a/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs +++ b/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs @@ -13,6 +13,8 @@ internal sealed class BrowserBootstrapHost : IAsyncDisposable private readonly SettingsManager _settings; private readonly BrowserBootstrapService _service; private readonly BrowserBootstrapPipeServer _pipe; + private readonly CancellationTokenSource _stop = new(); + private Task? _registration; public BrowserBootstrapHost(GatewayRegistry registry, IGatewayConnectionManager manager, SettingsManager settings, ManagedLocalGatewayPortProvenanceService provenance) @@ -40,8 +42,9 @@ public void Start() { try { - BrowserNativeRegistration.Apply(Path.Combine(AppContext.BaseDirectory, - "tools", "browser-bootstrap", "OpenClaw.BrowserNativeHost.exe")); + _registration = BrowserManagementClient.RunAsync(Path.Combine(AppContext.BaseDirectory, + "tools", "browser-bootstrap", "OpenClaw.BrowserBootstrap.exe"), + BrowserManagementClient.Companion("install", "preserve"), _stop.Token); } catch (Exception) { /* Optional integration must not prevent tray startup. */ } } @@ -58,6 +61,9 @@ public void Start() public async ValueTask DisposeAsync() { _service.Invalidate(); + await _stop.CancelAsync(); + if (_registration is not null) await _registration; + _stop.Dispose(); _registry.Changed -= OnRegistryChanged; _manager.StateChanged -= OnStateChanged; _settings.Saved -= OnSettingsSaved; diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/GenerationMetadataTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/GenerationMetadataTests.cs new file mode 100644 index 000000000..0d9ae7c97 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/GenerationMetadataTests.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public class GenerationMetadataTests +{ + private static string Hash(byte[] b)=>Convert.ToHexStringLower(SHA256.HashData(b)); + [Fact] + public void ExactBytesAndCanonicalLinksAreRequired() + { + var g=RegistrationServiceTests.Make(ManagementContractTests.Request());var d=g.Installation; + var image=Encoding.UTF8.GetBytes("synthetic metadata fixture, not executable proof"); + var b=ManagementContract.Serialize(g.Binding); + var m=ManagementContract.Serialize(new HostManifest(ManagementContract.HostName,ManagementContract.Description,d.LauncherPath,"stdio",g.Binding.ExpectedOrigins)); + var receipt=g.Receipt with{ExecutableSha256=Hash(image),BindingSha256=Hash(b),ManifestSha256=Hash(m)}; + var r=ManagementContract.Serialize(receipt); + ManagementContract.ValidateMetadata(d,receipt.OwnerSid,b,r,m,image); + Assert.Throws(()=>ManagementContract.ValidateMetadata(d,receipt.OwnerSid,[..b,(byte)' '],r,m,image)); + Assert.Throws(()=>ManagementContract.ValidateMetadata(d,receipt.OwnerSid,b,r,m,[..image,0])); + Assert.Throws(()=>ManagementContract.ValidateMetadata(d,"S-1-5-21-1-2-3-4",b,r,m,image)); + Assert.Throws(()=>ManagementContract.ValidateMetadata(d,receipt.OwnerSid,b,ManagementContract.Serialize(receipt with{Generation=Guid.NewGuid().ToString("D")}),m,image)); + Assert.Throws(()=>ManagementContract.ValidateMetadata(d with{BindingPath=@"C:\another\OpenClaw.BrowserBootstrap.binding.json"},receipt.OwnerSid,b,r,m,image)); + } + [Theory][InlineData("OpenClaw Companion browser pairing")][InlineData("OpenClaw browser extension bootstrap\n")] + public void OldOrNormalizedManifestDescriptionIsNotOwned(string description) + { + var d=RegistrationServiceTests.Make(ManagementContractTests.Request()).Installation; + Assert.Throws(()=>ManagementContract.ParseManifest(ManagementContract.Serialize(new HostManifest(ManagementContract.HostName,description,d.LauncherPath,"stdio",[ManagementContract.Origin])))); + } + [Fact]public void DiscriminatedShapeAndCompletedReceipt() + { + var g=RegistrationServiceTests.Make(ManagementContractTests.Request()); + Assert.Throws(()=>ManagementContract.ParseBinding(ManagementContract.Serialize(g.Binding with{NativeWindows=null}))); + Assert.Throws(()=>ManagementContract.ParseBinding(ManagementContract.Serialize(g.Binding with{Mode=ManagementContract.Companion}))); + Assert.Throws(()=>ManagementContract.ParseReceipt(ManagementContract.Serialize(g.Receipt with{TransportVerified=false}))); + var old="""{"version":1,"nodePath":"C:\\node.exe","cliPath":"C:\\openclaw.mjs","manifestPath":"C:\\host.json","stateDir":"C:\\state","configPath":"C:\\state\\openclaw.json","expectedOrigins":["chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"]}"""; + Assert.Throws(()=>ManagementContract.ParseBinding(Encoding.UTF8.GetBytes(old))); + var names=new[]{ManagementContract.ExeName,ManagementContract.BindingName,ManagementContract.ReceiptName,ManagementContract.ManifestName}; + ManagementContract.ValidateFileNames(names); + Assert.Throws(()=>ManagementContract.ValidateFileNames([..names,"foreign.txt"])); + Assert.Throws(()=>ManagementContract.ValidateFileNames(names.Select(x=>x.ToLowerInvariant()))); + } + [Theory][InlineData(null,true)][InlineData("{}",true)][InlineData("{\"NodeBrowserProxyEnabled\":false}",false)] + [InlineData("{\"NodeBrowserProxyEnabled\":true}",true)][InlineData("{\"NodeBrowserProxyEnabled\":true,\"NodeBrowserProxyEnabled\":false}",false)] + [InlineData("{\"NodeBrowserProxyEnabled\":\"true\"}",false)][InlineData("[]",false)][InlineData("broken",false)] + public void ExistingPreferenceAuthority(string? value,bool allowed)=>Assert.Equal(allowed,BrowserControlPreference.Allows(value is null?null:Encoding.UTF8.GetBytes(value))); + [Fact]public void SettingsSizeIsNotTheManagementWireSize() + { + var json="{\"Other\":\""+new string('x',40000)+"\",\"NodeBrowserProxyEnabled\":false}"; + Assert.False(BrowserControlPreference.Allows(Encoding.UTF8.GetBytes(json))); + Assert.True(BrowserControlPreference.Allows(Encoding.UTF8.GetBytes(json.Replace("false","true")))); + } + [Fact]public void CanonicalNativeChildHasNoAmbientOverrides() + { + var generation=RegistrationServiceTests.Make(ManagementContractTests.Request()); + var info=NativeTransport.CreateStartInfo(generation,ManagementContract.Origin); + Assert.Equal(generation.Binding.NativeWindows!.NodePath,info.FileName); + Assert.False(info.UseShellExecute);Assert.True(info.RedirectStandardInput); + Assert.Equal(new[]{generation.Binding.NativeWindows.CliPath,"browser","extension","native-host","--manifest",generation.Installation.ManifestPath, + "--launcher",generation.Installation.LauncherPath,"--expected-origin",ManagementContract.Origin,"--browser-profile","chrome",ManagementContract.Origin},info.ArgumentList); + Assert.Equal("1",info.Environment["OPENCLAW_NO_RESPAWN"]); + Assert.Equal(generation.Binding.NativeWindows.StateDir,info.Environment["OPENCLAW_STATE_DIR"]); + Assert.DoesNotContain(info.Environment.Keys,k=>k.StartsWith("NODE_",StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs new file mode 100644 index 000000000..c1dcade46 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs @@ -0,0 +1,108 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public class ManagementContractTests +{ + internal const string Native = """{"v":1,"action":"inspect","mode":"native-windows-cli","context":{"nodePath":"C:\\Program Files\\nodejs\\node.exe","cliPath":"C:\\OpenClaw\\openclaw.mjs","stateDir":"C:\\Users\\Fixture\\.openclaw","configPath":"C:\\Users\\Fixture\\.openclaw\\openclaw.json","browserProfile":"chrome"},"expectedOrigins":["chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"],"store":"preserve"}"""; + internal const string Companion = """{"v":1,"action":"inspect","mode":"companion-managed-wsl","context":null,"expectedOrigins":["chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"],"store":"preserve"}"""; + internal static ManagementRequest Request(string json=Native)=>ManagementContract.ParseRequest(Encoding.UTF8.GetBytes(json)); + [Theory] + [InlineData("chrome",true)][InlineData("0",true)][InlineData("a-",true)][InlineData("a--",true)] + [InlineData("",false)][InlineData("-a",false)][InlineData("A",false)][InlineData("a_b",false)][InlineData("a.b",false)] + [InlineData(" a",false)][InlineData("a ",false)][InlineData("a\n",false)][InlineData("a\r",false)][InlineData("a\r\n",false)] + [InlineData("a\u2028",false)][InlineData("a\u2029",false)][InlineData("a",false)][InlineData("á",false)][InlineData("a\0",false)] + public void FrozenProfile(string value,bool valid)=>Assert.Equal(valid,ManagementContract.IsProfile(value)); + [Fact] public void ProfileLength(){Assert.True(ManagementContract.IsProfile(new('a',64)));Assert.False(ManagementContract.IsProfile(new('a',65)));} + [Theory][InlineData("1.0")][InlineData("1e0")][InlineData("\"1\"")][InlineData("true")][InlineData("null")][InlineData("2")] + public void VersionIsLexical(string version)=>Assert.Throws(()=>Request(Native.Replace("\"v\":1","\"v\":"+version))); + [Theory] + [InlineData("\"v\":1,\"\\u0076\":1")][InlineData("\"v\":1,\"v\":1")] + public void DuplicateDecodedKey(string fragment)=>Assert.Throws(()=>Request(Native.Replace("\"v\":1",fragment))); + [Theory][InlineData(" ")][InlineData("\t\r\n")] + public void InputWhitespaceAllowed(string ws)=>Assert.Equal(1,Request(ws+Native+ws).V); + [Theory][InlineData("{}")] [InlineData("true")][InlineData("#")] + public void NoTrailingValue(string extra)=>Assert.Throws(()=>Request(Native+extra)); + [Theory][InlineData("gatewayId")][InlineData("rootDir")][InlineData("sourcePath")][InlineData("pipeName")][InlineData("token")] + public void NoSelectors(string key)=>Assert.Throws(()=>Request(Native[..^1]+",\""+key+"\":\"no\"}")); + [Theory][InlineData("inspect","request")][InlineData("inspect","remove")][InlineData("install","remove")][InlineData("uninstall","request")] + public void IllegalActions(string action,string store)=>Assert.Throws(()=>Request(Native.Replace("\"inspect\"","\""+action+"\"").Replace("\"preserve\"","\""+store+"\""))); + [Fact]public void ContextAndEscapedSurrogates() + { + Assert.Throws(()=>Request(Companion.Replace("null","{}"))); + Assert.Throws(()=>Request(Native.Replace("\"browserProfile\":\"chrome\"","\"browserProfile\":\"chrome\",\"nodePath\":\"evil\""))); + Assert.Throws(()=>Request(Native.Replace("Fixture","\\ud800"))); + Assert.Equal(ManagementContract.Companion,Request(Companion).Mode); + } + [Fact]public void ExactByteBoundAndStrictUtf8() + { + var bytes=Encoding.UTF8.GetBytes(Native);var padded=bytes.Concat(Enumerable.Repeat((byte)' ',32768-bytes.Length)).ToArray(); + Assert.NotNull(ManagementContract.ParseRequest(padded));Assert.Throws(()=>ManagementContract.ParseRequest([..padded,(byte)' '])); + foreach(var prefix in new byte[][]{[0xef,0xbb,0xbf],[0xc0,0xaf],[0x80],[0xe2,0x82]})Assert.Throws(()=>ManagementContract.ParseRequest([..prefix,..bytes])); + } + [Theory] + [InlineData(@"C:\Program Files\nodejs\node.exe",true)][InlineData(@"C:\OpenClaw\openclaw.mjs",true)] + [InlineData(@"C:relative",false)][InlineData(@"\\server\share\node.exe",false)][InlineData(@"\\wsl$\Distro\node.exe",false)] + [InlineData(@"\\?\C:\x",false)][InlineData(@"\\.\x",false)][InlineData("C:/x",false)][InlineData(@"C:\x\..\node.exe",false)] + [InlineData(@"C:\x:stream",false)][InlineData(@"C:\x\\node.exe",false)][InlineData(@"C:\CON",false)][InlineData(@"C:\x.\file",false)] + [InlineData(@"C:\x \file",false)][InlineData("C:\\x\u00a0\\file",false)][InlineData("C:\\x\ufeff\\file",false)] + public void CanonicalPathGrammar(string path,bool valid)=>Assert.Equal(valid,ManagementContract.IsPath(path)); + [Fact] public void PathLengthAndPortableCase() + { + Assert.True(ManagementContract.IsPath("C:\\"+new string('a',4093)));Assert.False(ManagementContract.IsPath("C:\\"+new string('a',4094))); + Assert.True(ManagementContract.PathEquals(@"C:\Root\node.exe",@"c:\ROOT\NODE.EXE"));Assert.False(ManagementContract.PathEquals(@"C:\é",@"C:\É")); + Assert.False(ManagementContract.PathEquals("C:\\é","C:\\e\u0301")); + } + [Theory][InlineData("evilnode.exe")][InlineData("node.exe.bak")] + public void NodeBasename(string name)=>Assert.Throws(()=>Request(Native.Replace("node.exe",name))); + [Theory][InlineData("12345678-1234-4234-8234-123456789abc",true)][InlineData("12345678-1234-4234-8234-123456789ABC",false)] + [InlineData("00000000-0000-0000-0000-000000000000",false)][InlineData("------------------------------------",false)] + [InlineData("{12345678-1234-4234-8234-123456789abc}",false)][InlineData("12345678-1234-4234-8234-123456789abc\n",false)] + public void GuidGrammar(string id,bool valid)=>Assert.Equal(valid,ManagementContract.IsGuid(id)); + [Theory][InlineData("S-1-5-21-111-222-333-1001",true)][InlineData("s-1-5-21-1",false)][InlineData("S-2-5-21-1",false)] + [InlineData("S-1-5-021-1",false)][InlineData("S-1-281474976710656-1",false)][InlineData("S-1-5-4294967296",false)][InlineData("S-1-5-1\n",false)] + public void SidGrammar(string sid,bool valid)=>Assert.Equal(valid,ManagementContract.IsSid(sid)); + [Fact]public void HashGrammar() + { + Assert.True(ManagementContract.IsHash(new('0',64)));Assert.True(ManagementContract.IsHash(string.Concat(Enumerable.Repeat("abcdef0123456789",4)))); + foreach(var s in new[]{new string('A',64),new string('0',63),new string('0',65),new string('0',64)+"\n","sha256:"+new string('0',64)})Assert.False(ManagementContract.IsHash(s)); + } + [Fact]public void RejectionProbeCannotBeExhaustedByAValidAllowlist() + { + var repeated=Enumerable.Range(0,16).Select(i=>"chrome-extension://"+new string((char)('a'+i),32)+"/").Append(ManagementContract.Origin).ToArray(); + Assert.DoesNotContain(ManagementContract.RejectionOrigin(repeated),repeated); + var full=Enumerable.Range(0,31).Select(i=>"chrome-extension://"+new string('a',30)+(char)('a'+i/16)+(char)('a'+i%16)+"/").Append(ManagementContract.Origin).ToArray(); + Assert.DoesNotContain(ManagementContract.RejectionOrigin(full),full); + } + [Fact]public void OriginsAreNotNormalized() + { + var origin="\""+ManagementContract.Origin+"\"";var extra="\"chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/\""; + Assert.Equal(2,Request(Native.Replace(origin,extra+","+origin)).ExpectedOrigins.Length); + foreach(var value in new[]{origin+","+extra,origin+","+origin,"\"chrome-extension://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/\"",origin.Replace("/\"","?x/\"")}) + Assert.Throws(()=>Request(Native.Replace(origin,value))); + Assert.Throws(()=>Request(Companion.Replace(origin,extra+","+origin))); + } + [Fact]public void ResponseCleanExitAndShape() + { + var r=new ManagementResponse(1,true,"ok","missing",null,"missing",null);var bytes=ManagementContract.ResponseBytes(r); + Assert.Equal(r,ManagementContract.ParseResponse(bytes,0));Assert.Throws(()=>ManagementContract.ParseResponse(bytes,1)); + foreach(var bad in new[]{bytes[..^1],bytes.Concat(new byte[]{10}).ToArray(),bytes[..^1].Concat(new byte[]{13,10}).ToArray(),Encoding.UTF8.GetBytes(" ").Concat(bytes).ToArray()}) + Assert.Throws(()=>ManagementContract.ParseResponse(bad,0)); + Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Store="disabled"})); + Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Mode=ManagementContract.Native})); + Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Ok=false})); + Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Registration="owned"})); + } + [Fact]public async Task ManagementEofRequiredAndBounded() + { + Assert.Equal(Encoding.UTF8.GetBytes(Native),await Program.ReadManagementAsync(new MemoryStream(Encoding.UTF8.GetBytes(Native)),default)); + await Assert.ThrowsAsync(()=>Program.ReadManagementAsync(new MemoryStream(new byte[32769]),default)); + using var cts=new CancellationTokenSource(30);await Assert.ThrowsAnyAsync(()=>Program.ReadManagementAsync(new NeverEof(),cts.Token)); + } + private sealed class NeverEof:MemoryStream + { + public override async ValueTask ReadAsync(Memory buffer,CancellationToken cancellationToken=default){await Task.Delay(Timeout.Infinite,cancellationToken);return 0;} + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs new file mode 100644 index 000000000..188498eff --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs @@ -0,0 +1,60 @@ +using System.Buffers.Binary; +using System.Text; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public class NativeCancellationTests +{ + [Theory][InlineData(ManagementContract.Companion)][InlineData(ManagementContract.Native)] + public async Task ChromeEofRevokesBothBackendsBeforeResponse(string mode) + { + var request=ManagementContractTests.Request() with {Mode=mode}; + if(mode==ManagementContract.Companion)request=request with {Context=null}; + var generation=RegistrationServiceTests.Make(request); + using var input=new DisconnectableFrame();using var output=new MemoryStream(); + var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),default, + async (_,_,_,ct)=>{entered.SetResult();try{await Task.Delay(Timeout.Infinite,ct);}catch(OperationCanceledException){cancelled.SetResult();throw;}return [];}); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));input.Disconnect(); + await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(2));await run.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Equal(0,output.Length); + } + [Fact]public async Task UnsafeRuntimeNeverStartsBackend() + { + var generation=RegistrationServiceTests.Make(ManagementContractTests.Request()); + using var input=new DisconnectableFrame();using var output=new MemoryStream();var starts=0; + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>throw new ContractException("unsafe_acl"),default, + (_,_,_,_)=>{starts++;return Task.FromResult(Array.Empty());}); + Assert.Equal(0,starts); + Assert.Contains("manifest_invalid",Encoding.UTF8.GetString(output.ToArray())); + } + [Fact]public async Task RevokedBackendCannotPublishEvenIfItIgnoresCancellation() + { + var generation=RegistrationServiceTests.Make(ManagementContractTests.Request()); + using var input=new DisconnectableFrame();using var output=new MemoryStream(); + var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),default, + (_,_,_,_)=>{entered.SetResult();return release.Task;}); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));input.Disconnect(); + await input.Disconnected.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Delay(20);release.SetResult(Encoding.UTF8.GetBytes("synthetic-credential-response")); + await run.WaitAsync(TimeSpan.FromSeconds(2));Assert.Equal(0,output.Length); + } + private sealed class Lease:IDisposable{public void Dispose(){}} + private sealed class DisconnectableFrame:MemoryStream + { + private readonly TaskCompletionSource disconnected=new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Disconnected=>disconnected.Task; + public void Disconnect()=>disconnected.TrySetResult(); + public DisconnectableFrame():base(Frame()){} + private static byte[] Frame(){var data=Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}");var bytes=new byte[data.Length+4];BinaryPrimitives.WriteInt32LittleEndian(bytes,data.Length);data.CopyTo(bytes,4);return bytes;} + public override async ValueTask ReadAsync(Memory buffer,CancellationToken ct=default) + { + if(Position diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/RegistrationServiceTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/RegistrationServiceTests.cs new file mode 100644 index 000000000..4f7a2776c --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/RegistrationServiceTests.cs @@ -0,0 +1,106 @@ +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap.Tests; + +/// Portable state tuples use injected facts, never claim Windows ACL or registry execution. +public class RegistrationServiceTests +{ + public static IEnumerable Cases() + { + yield return ["empty-inspect","inspect","preserve",true,"ok","missing",null!,"missing",false]; + yield return ["empty-uninstall","uninstall","preserve",true,"ok","missing",null!,"missing",false]; + yield return ["empty-remove","uninstall","remove",true,"ok","missing",null!,"missing",false]; + yield return ["owned-inspect","inspect","preserve",true,"ok","owned",ManagementContract.Native,"missing",true]; + yield return ["owned-store","inspect","preserve",true,"ok","owned",ManagementContract.Native,"requested",true]; + yield return ["native-denied","inspect","preserve",false,"unsafe_acl",null!,null!,null!,false]; + yield return ["store-denied","inspect","preserve",false,"unsafe_acl","owned",ManagementContract.Native,null!,true]; + yield return ["orphan-inspect","inspect","preserve",true,"ok","missing",null!,"requested",false]; + yield return ["preserve-foreign","install","preserve",true,"ok","owned",ManagementContract.Native,"foreign",true]; + yield return ["preserve-unknown","install","preserve",true,"ok","owned",ManagementContract.Native,null!,true]; + yield return ["request","install","request",true,"ok","owned",ManagementContract.Native,"requested",true]; + yield return ["late-optout","install","request",false,"browser_control_disabled","owned",ManagementContract.Native,"missing",true]; + yield return ["late-optout-existing","install","request",false,"browser_control_disabled","owned",ManagementContract.Native,"requested",true]; + yield return ["early-optout","install","request",false,"browser_control_disabled","missing",null!,"missing",false]; + yield return ["request-foreign","install","request",false,"foreign_registration","owned",ManagementContract.Native,"foreign",true]; + yield return ["partial-store","install","request",false,"io_error","owned",ManagementContract.Native,"invalid",true]; + yield return ["unknown-store","install","request",false,"io_error","owned",ManagementContract.Native,null!,true]; + yield return ["remove-native-fails","uninstall","remove",false,"io_error","owned",ManagementContract.Native,"missing",true]; + yield return ["remove-foreign","uninstall","remove",false,"foreign_registration","owned",ManagementContract.Native,"foreign",true]; + yield return ["uninstall-preserve","uninstall","preserve",true,"ok","missing",null!,"requested",false]; + yield return ["remove-orphan","uninstall","remove",true,"ok","missing",null!,"missing",false]; + yield return ["other-mode","install","preserve",false,"context_conflict","owned",ManagementContract.Companion,"missing",false]; + yield return ["changed-runtime","inspect","preserve",false,"binding_invalid","owned",ManagementContract.Native,"missing",false]; + yield return ["other-profile","install","preserve",false,"context_conflict","owned",ManagementContract.Native,"missing",false]; + yield return ["mixed-view","inspect","preserve",false,"binding_invalid","invalid",null!,"missing",false]; + yield return ["foreign-native","install","preserve",false,"foreign_registration","foreign",null!,"missing",false]; + yield return ["busy","install","preserve",false,"busy",null!,null!,null!,false]; + } + [Theory][MemberData(nameof(Cases))] + public void FrozenStateTuple(string scenario,string action,string store,bool ok,string code,string? registration,string? mode,string? observedStore,bool descriptor) + { + var request=ManagementContractTests.Request() with{Action=action,Store=store};var fake=new Fake(scenario,request); + var response=new RegistrationService(fake).Execute(request,default); + Assert.Equal(ok,response.Ok);Assert.Equal(code,response.Code);Assert.Equal(registration,response.Registration); + Assert.Equal(mode,response.Mode);Assert.Equal(observedStore,response.Store);Assert.Equal(descriptor,response.Installation is not null); + _=ManagementContract.ResponseBytes(response); + if(scenario is "other-mode" or "other-profile" or "foreign-native" or "early-optout" or "busy")Assert.Empty(fake.Mutations); + if(scenario=="remove-foreign")Assert.DoesNotContain("remove-native",fake.Mutations); + if(scenario=="remove-native-fails")Assert.Equal(new[]{"remove-store","remove-native"},fake.Mutations); + if(action=="inspect")Assert.Empty(fake.Mutations); + } + [Fact]public void EmptyUninstallRemainsIdempotent() + { + var r=ManagementContractTests.Request() with{Action="uninstall",Store="remove"};var f=new Fake("empty-remove",r);var service=new RegistrationService(f); + Assert.True(service.Execute(r,default).Ok);Assert.True(service.Execute(r,default).Ok);Assert.DoesNotContain("prepare",f.Mutations); + } + [Fact]public void SameContextRuntimeUpgradeIsExplicit() + { + var r=ManagementContractTests.Request() with{Action="install"};var f=new Fake("changed-runtime",r); + Assert.True(new RegistrationService(f).Execute(r,default).Ok);Assert.Contains("prepare",f.Mutations); + } + private sealed class Fake:IRegistrationPlatform + { + private readonly string scenario;private readonly ManagementRequest request; + private NativeInventory native=new("missing",[]);private StoreInventory store=new("missing"); + private bool enabled=true;public List Mutations{get;}=[]; + public Fake(string scenario,ManagementRequest request) + { + this.scenario=scenario;this.request=request; + if(scenario is "owned-inspect" or "owned-store" or "store-denied" or "remove-native-fails" or "remove-foreign" or "uninstall-preserve" or "changed-runtime" or "other-profile")native=new("owned",[Make(request)]); + if(scenario is "owned-store" or "late-optout-existing" or "orphan-inspect" or "uninstall-preserve" or "remove-orphan" or "remove-native-fails")store=new("requested"); + if(scenario is "preserve-foreign" or "request-foreign" or "remove-foreign")store=new("foreign"); + if(scenario is "preserve-unknown" or "store-denied")store=new(null,"unsafe_acl"); + if(scenario=="native-denied"){native=new(null,[],"unsafe_acl");store=new(null,"unsafe_acl");} + if(scenario=="early-optout")enabled=false; + if(scenario=="other-mode")native=new("owned",[Make(request with{Mode=ManagementContract.Companion,Context=null})]); + if(scenario=="other-profile")native=new("owned",[Make(request with{Context=request.Context! with{BrowserProfile="other"}})]); + if(scenario=="changed-runtime")native=new("owned",[Make(request with{Context=request.Context! with{NodePath=@"C:\Old\node.exe"}})]); + if(scenario=="mixed-view")native=new("invalid",[Make(request)]); + if(scenario=="foreign-native")native=new("foreign",[]); + } + public IDisposable Acquire(){if(scenario=="busy")throw new ContractException("busy");return new Gate();} + public Inventory Observe(ManagementRequest r)=>new(native,store); + public bool BrowserControlAllowsRequest()=>enabled; + public void ValidateRuntime(Generation g){} + public Generation Prepare(ManagementRequest r,CancellationToken ct){Mutations.Add("prepare");return Make(r);} + public void PublishNative(ManagementRequest r,Generation g,CancellationToken ct){Mutations.Add("publish-native");native=new("owned",[g]);if(scenario.StartsWith("late-optout",StringComparison.Ordinal))enabled=false;} + public void RequestStore(ManagementRequest r,Generation g,CancellationToken ct) + { + Mutations.Add("request-store"); + if(scenario=="partial-store"){store=new("invalid");throw new IOException();} + if(scenario=="unknown-store"){store=new(null,"io_error");throw new IOException();} + store=new("requested"); + } + public void RemoveStore(ManagementRequest r,CancellationToken ct){Mutations.Add("remove-store");store=new("missing");} + public void RemoveNative(ManagementRequest r,CancellationToken ct){Mutations.Add("remove-native");if(scenario=="remove-native-fails")throw new IOException();native=new("missing",[]);} + private sealed class Gate:IDisposable{public void Dispose(){}} + } + internal static Generation Make(ManagementRequest request) + { + const string guid="12345678-1234-4234-8234-123456789abc"; + const string root=@"C:\Users\Fixture\AppData\Local\OpenClawTray\browser-native\generations\"+guid+@"\"; + var d=new Installation(guid,root+ManagementContract.ManifestName,root+ManagementContract.ExeName,root+ManagementContract.BindingName,root+ManagementContract.ReceiptName); + return new(new(1,request.Mode,d.ManifestPath,request.ExpectedOrigins,request.Context), + new(ManagementContract.Owner,1,guid,"S-1-5-21-111-222-333-1001",true,new('0',64),new('0',64),new('0',64)),d); + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs new file mode 100644 index 000000000..de4b9cf66 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Win32; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public sealed class WindowsRegistryFactAttribute:FactAttribute +{ + public WindowsRegistryFactAttribute(){if(!OperatingSystem.IsWindows())Skip="Requires a native Windows registry; never simulated as passing.";} +} +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class RegistryTransactionTests +{ + [WindowsRegistryFact] + public void TrustedInstallerIsOnlyAnExistingPublicAncestorException() + { + if(!OperatingSystem.IsWindows())return; + var sid=(System.Security.Principal.SecurityIdentifier)new System.Security.Principal.NTAccount("NT SERVICE","TrustedInstaller").Translate(typeof(System.Security.Principal.SecurityIdentifier)); + var sd=new System.Security.AccessControl.RawSecurityDescriptor($"O:{sid.Value}G:SYD:(A;;FA;;;{sid.Value})"); + var authority=new WindowsAuthority(); + authority.CheckAcl(sd,false,protectedAncestor:true); + var siblings=new System.Security.AccessControl.RawSecurityDescriptor($"O:{authority.Sid}G:SYD:(A;;FA;;;{authority.Sid})(A;;0x6;;;WD)"); + authority.CheckAcl(siblings,false,protectedAncestor:true); + Assert.ThrowsAny(()=>authority.CheckAcl(siblings,false)); + var replacement=new System.Security.AccessControl.RawSecurityDescriptor($"O:{authority.Sid}G:SYD:(A;;FA;;;{authority.Sid})(A;;0x10000;;;WD)"); + Assert.ThrowsAny(()=>authority.CheckAcl(replacement,false,protectedAncestor:true)); + Assert.ThrowsAny(()=>authority.CheckAcl(sd,true,protectedAncestor:true)); + Assert.ThrowsAny(()=>authority.CheckAcl(sd,false)); + Assert.ThrowsAny(()=>authority.CheckAcl(sd,false,registry:true,protectedAncestor:true)); + } + [WindowsRegistryFact] + public void ForeignChangeBeforeTransactionalAdmissionIsPreserved() + { + if(!OperatingSystem.IsWindows())return; + var path=@"Software\OpenClawBootstrapTests\"+Guid.NewGuid().ToString("N"); + using var root=RegistryKey.OpenBaseKey(RegistryHive.CurrentUser,RegistryView.Registry32); + try + { + using(var key=root.CreateSubKey(path)){key.SetValue("","owned");} + var expected=new WindowsRegistrationPlatform.Row(RegistryHive.CurrentUser,RegistryView.Registry32,path,true,[new("",RegistryValueKind.String,"owned")]); + using(var key=root.OpenSubKey(path,true)){key!.SetValue("","foreign");} + Assert.ThrowsAny(()=>new WindowsRegistrationPlatform().MutateAtomically(expected,new("",RegistryValueKind.String,"replacement"),default)); + using var after=root.OpenSubKey(path);Assert.Equal("foreign",after!.GetValue("")); + } + finally{root.DeleteSubKeyTree(path,false);} + } + [WindowsRegistryFact] + public async Task ForeignValueAddedDuringDeleteCannotBeDeletedByOurTransaction() + { + if(!OperatingSystem.IsWindows())return; + var path=@"Software\OpenClawBootstrapTests\"+Guid.NewGuid().ToString("N"); + using var root=RegistryKey.OpenBaseKey(RegistryHive.CurrentUser,RegistryView.Registry32); + Task? foreign=null; + try + { + using(var key=root.CreateSubKey(path)){key.SetValue("","owned");} + var expected=new WindowsRegistrationPlatform.Row(RegistryHive.CurrentUser,RegistryView.Registry32,path,true,[new("",RegistryValueKind.String,"owned")]); + var error=Record.Exception(()=>new WindowsRegistrationPlatform().MutateAtomically(expected,null,default,()=> + { + foreign=Task.Run(()=>{if(!OperatingSystem.IsWindows())return;using var other=RegistryKey.OpenBaseKey(RegistryHive.CurrentUser,RegistryView.Registry32);using var key=other.CreateSubKey(path);key.SetValue("foreign","retained");}); + // If the kernel serializes the writer, commit first and let it proceed afterwards. + // If the foreign writer wins, TxR must abort our stale delete. + _=foreign.Wait(TimeSpan.FromSeconds(2)); + })); + Assert.NotNull(foreign);await foreign!.WaitAsync(TimeSpan.FromSeconds(10)); + using var after=root.OpenSubKey(path);Assert.NotNull(after);Assert.Equal("retained",after.GetValue("foreign")); + _=error; // Either serialization order is valid; foreign data must survive. + } + finally{if(foreign is not null)await foreign.WaitAsync(TimeSpan.FromSeconds(10));root.DeleteSubKeyTree(path,false);} + } +} diff --git a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs index 35b026e48..9fcb09cf4 100644 --- a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs @@ -29,14 +29,25 @@ await BrowserNativeProtocol.WriteAsync(pipe, } [Fact] - public void Manifest_RequiresExactExecutableOriginAndSchema() + public async Task Disconnect_CancelsTheActualHandlerBeforeDelivery() { - var path = Path.GetFullPath("OpenClaw.BrowserNativeHost.exe"); - var manifest = BrowserNativeRegistration.BuildManifest(path); - Assert.True(BrowserNativeRegistration.ManifestMatches(manifest, path)); - Assert.False(BrowserNativeRegistration.ManifestMatches(manifest, path + "other")); - Assert.False(BrowserNativeRegistration.ManifestMatches(manifest.Replace(BrowserNativeProtocol.ExtensionId, new string('a', 32)), path)); - Assert.False(BrowserNativeRegistration.ManifestMatches(manifest.Replace("\"stdio\"", "\"http\""), path)); - Assert.False(BrowserNativeRegistration.ManifestMatches(manifest[..^1] + ",\"type\":\"stdio\"}", path)); + var name = "OpenClaw.BrowserBootstrap.Test." + Guid.NewGuid().ToString("N"); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = new BrowserBootstrapPipeServer(async (_, ct) => + { + started.TrySetResult(); + try { await Task.Delay(Timeout.Infinite, ct); } + catch (OperationCanceledException) { cancelled.TrySetResult(); throw; } + return BrowserNativeProtocol.Failure("pairing_unavailable"); + }, name); + server.Start(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(timeout.Token); + await BrowserNativeProtocol.WriteAsync(pipe, Encoding.UTF8.GetBytes("{\"v\":1}"), timeout.Token); + await started.Task.WaitAsync(timeout.Token); + pipe.Dispose(); + await cancelled.Task.WaitAsync(timeout.Token); } } diff --git a/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs b/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs deleted file mode 100644 index 8e3cd8ecf..000000000 --- a/tests/OpenClaw.Shared.Tests/ChromeExtensionInstallRequestTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using OpenClaw.Shared.Browser; - -namespace OpenClaw.Shared.Tests; - -public class ChromeExtensionInstallRequestTests -{ - private static readonly string Executable = Path.GetFullPath("OpenClaw.BrowserNativeHost.exe"); - private static Dictionary Owned => new() - { - [ChromeExtensionInstallRequest.OwnerValue] = Executable, - ["update_url"] = ChromeExtensionInstallRequest.UpdateUrl - }; - - [Theory] - [InlineData(null, true)] - [InlineData("{}", true)] - [InlineData("{\"NodeBrowserProxyEnabled\":true}", true)] - [InlineData("{\"NodeBrowserProxyEnabled\":false}", false)] - [InlineData("{\"NodeBrowserProxyEnabled\":false,\"NodeBrowserProxyEnabled\":true}", false)] - [InlineData("{\"NodeBrowserProxyEnabled\":\"true\"}", false)] - [InlineData("invalid", false)] - public void SavedBrowserControlOptOut_IsPreserved(string? json, bool expected) => - Assert.Equal(expected, ChromeExtensionInstallRequest.SettingsAllowRequest(json)); - - [Fact] - public void ExactOwnedStoreRequest_IsRecognized() => - Assert.True(ChromeExtensionInstallRequest.IsOwned(Owned, Executable)); - - [Fact] - public void MatchingStoreUrlWithoutOwner_IsNotAdopted() - { - var values = Owned; - values.Remove(ChromeExtensionInstallRequest.OwnerValue); - Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); - } - - [Fact] - public void ForeignOwnerOrUrl_IsPreserved() - { - Assert.False(ChromeExtensionInstallRequest.IsOwned(Owned, Executable + ".other")); - var values = Owned; - values["update_url"] = "https://example.invalid/crx"; - Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); - Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable, allowIncomplete: true)); - } - - [Fact] - public void AdditionalValues_AreNotOwned() - { - var values = Owned; - values["path"] = "foreign.crx"; - Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); - } - - [Fact] - public void PartialOwnCreation_CanBeCleanedButIsNotAnInstalledRequest() - { - var values = Owned; - values.Remove("update_url"); - Assert.False(ChromeExtensionInstallRequest.IsOwned(values, Executable)); - Assert.True(ChromeExtensionInstallRequest.IsOwned(values, Executable, allowIncomplete: true)); - Assert.False(ChromeExtensionInstallRequest.IsOwned(new Dictionary(), Executable, allowIncomplete: true)); - } -} diff --git a/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs index 1b5ccff6f..ffc1ad4ef 100644 --- a/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs @@ -10,14 +10,21 @@ public void Installer_RegistersNativeHelperBeforeTrayWithoutRequestingElevation( { var installer = Read("installer.iss"); Assert.Contains("if CurStep = ssPostInstall then", installer); - Assert.Contains("RegisterBrowserNativeHost;", installer); - Assert.Contains("BrowserNativeHostRegistered := ResultCode = 0", installer); + Assert.Contains("RegisterBrowserIntegration;", installer); Assert.Contains("UsePreviousTasks=yes", installer); - Assert.Contains("not WizardIsTaskSelected('chromeextension')", installer); - Assert.Contains("--request-extension", installer); - Assert.Contains("--remove-extension-request", installer); - Assert.Matches(@"RegisterBrowserNativeHost;\r?\n RequestChromeExtension;", installer); - Assert.Contains("No extension installation may be requested", installer); + Assert.Contains("WizardIsTaskSelected('chromeextension')", installer); + Assert.Contains("RunBrowserManagement('install', StoreAction)", installer); + Assert.Contains("RunBrowserManagement('uninstall', 'remove')", installer); + var transport=Read("scripts","BrowserBootstrapManagement.iss"); + Assert.Contains(" --manage",transport); + Assert.Contains("BBWrite(InW, Input",transport); + Assert.Contains("BBDispose(InW)",transport); + Assert.Contains("32768",transport); + Assert.Contains("60000",transport); + Assert.Contains("KILL_ON_JOB_CLOSE",transport); + Assert.Contains("BBReceipt(Output, ExitCode",transport); + Assert.DoesNotContain("--register",installer); + Assert.DoesNotContain("--request-extension",installer); Assert.Contains("PrivilegesRequired=lowest", installer); Assert.Contains("UnregisterBrowserNativeHost;", installer); Assert.DoesNotContain("ExtensionInstallForcelist", installer); @@ -43,15 +50,16 @@ public void Bootstrap_UsesExistingOwnersAndExcludesIsolatedProfiles() [Fact] public void Packaging_RequiresRealExeAndBothArchitecturesProveIt() { - var targets = Read("src", "OpenClaw.Tray.WinUI", "BrowserNativeHost.targets"); + var targets = Read("src", "OpenClaw.Tray.WinUI", "BrowserBootstrap.targets"); Assert.Contains("SelfContained=true", targets); Assert.Contains("win-x64", targets); Assert.Contains("win-arm64", targets); - Assert.Contains("OpenClaw.BrowserNativeHost.exe", targets); + Assert.Contains("OpenClaw.BrowserBootstrap.exe", targets); var workflow = Read(".github", "workflows", "ci.yml"); Assert.Equal(2, workflow.Split("- name: Prove packaged native browser host").Length - 1); - Assert.Contains("OpenClaw.BrowserNativeHost.dll", workflow); + Assert.DoesNotContain("OpenClaw.BrowserNativeHost.dll", workflow); + Assert.Contains("OpenClaw.BrowserBootstrap.Contracts.dll", workflow); Assert.Contains("Test-BrowserNativeHost.ps1", workflow); - Assert.Contains("tools\\browser-bootstrap\\OpenClaw.BrowserNativeHost.exe", Read("scripts", "Test-ReleaseExecutableSignatures.ps1")); + Assert.Contains("tools\\browser-bootstrap\\OpenClaw.BrowserBootstrap.exe", Read("scripts", "Test-ReleaseExecutableSignatures.ps1")); } } diff --git a/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs b/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs new file mode 100644 index 000000000..e38627f4d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using System.Xml.Linq; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public class BrowserBootstrapPublishTests +{ + [Theory][InlineData(false)][InlineData(true)] + public async Task ProductionPublishTarget_CopiesOnlySingleExeOrFails(bool missing) + { + var root=TestRepositoryPaths.GetRepositoryRoot(); + var source=XDocument.Load(Path.Combine(root,"src","OpenClaw.Tray.WinUI","BrowserBootstrap.targets")); + var add=new XElement(source.Root!.Elements("Target").Single(x=>(string?)x.Attribute("Name")=="AddBrowserBootstrapToPublishItems")); + var verify=new XElement(source.Root.Elements("Target").Single(x=>(string?)x.Attribute("Name")=="VerifyBrowserBootstrapPublished")); + Assert.Equal("ComputeResolvedFilesToPublishList",(string?)add.Attribute("AfterTargets")); + using var temp=new TempDirectory(); + var payload=temp.Combine("payload")+Path.DirectorySeparatorChar;Directory.CreateDirectory(payload); + var output=temp.Combine("published")+Path.DirectorySeparatorChar; + if(!missing)File.WriteAllText(Path.Combine(payload,"OpenClaw.BrowserBootstrap.exe"),"synthetic executable artifact"); + var harness=temp.Combine("publish.proj"); + new XDocument(new XElement("Project",new XElement("PropertyGroup",new XElement("PublishDir",output),new XElement("BrowserBootstrapPayload",payload)), + new XElement("Target",new XAttribute("Name","BuildBrowserBootstrapPayload")), + new XElement("Target",new XAttribute("Name","ComputeResolvedFilesToPublishList")),add, + new XElement("Target",new XAttribute("Name","Publish"),new XAttribute("DependsOnTargets","ComputeResolvedFilesToPublishList"), + new XElement("Copy",new XAttribute("SourceFiles","@(ResolvedFileToPublish)"),new XAttribute("DestinationFiles","@(ResolvedFileToPublish->'$(PublishDir)%(RelativePath)')"))),verify)).Save(harness); + var start=new ProcessStartInfo("dotnet"){WorkingDirectory=root,UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var arg in new[]{"msbuild",harness,"-t:Publish","-nologo","-v:minimal"})start.ArgumentList.Add(arg); + using var process=Process.Start(start)!;var stdout=process.StandardOutput.ReadToEndAsync();var stderr=process.StandardError.ReadToEndAsync(); + using var timeout=new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try{await process.WaitForExitAsync(timeout.Token);}catch(OperationCanceledException){process.Kill(true);throw;} + var log=await stdout+await stderr; + if(missing){Assert.NotEqual(0,process.ExitCode);return;} + Assert.True(process.ExitCode==0,log); + Assert.Equal("synthetic executable artifact",File.ReadAllText(Path.Combine(output,"tools","browser-bootstrap","OpenClaw.BrowserBootstrap.exe"))); + Assert.Single(Directory.GetFiles(output,"*",SearchOption.AllDirectories)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs b/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs deleted file mode 100644 index 4f9dafc01..000000000 --- a/tests/OpenClaw.Tray.Tests/BrowserNativeHostPublishTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Diagnostics; -using System.Xml.Linq; -using OpenClaw.TestSupport; - -namespace OpenClaw.Tray.Tests; - -public class BrowserNativeHostPublishTests -{ - [Theory] - [InlineData(null)] - [InlineData("hostpolicy.dll")] - [InlineData("hostfxr.dll")] - [InlineData("System.Private.CoreLib.dll")] - public async Task Publish_KeepsIndependentRuntimeAndRejectsMissingFiles(string? missing) - { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var project = XDocument.Load(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "BrowserNativeHost.targets")); - var add = new XElement(project.Root!.Elements("Target").Single(x => (string?)x.Attribute("Name") == "AddBrowserNativeHostToPublishItems")); - var verify = new XElement(project.Root.Elements("Target").Single(x => (string?)x.Attribute("Name") == "VerifyBrowserNativeHostPublished")); - Assert.Equal("ComputeResolvedFilesToPublishList", (string?)add.Attribute("AfterTargets")); - var build = project.Root.Elements("Target").Single(x => (string?)x.Attribute("Name") == "BuildBrowserNativeHostPayload"); - Assert.Empty(build.Descendants("ResolvedFileToPublish")); - Assert.Equal("Never", build.Descendants("ContentWithTargetPath").Single().Element("CopyToPublishDirectory")!.Value); - - using var temp = new TempDirectory(); - var payload = temp.Combine("payload"); - var published = temp.Combine("publish") + Path.DirectorySeparatorChar; - Directory.CreateDirectory(payload); - var required = verify.Descendants("_RequiredBrowserNativeFile").Single().Attribute("Include")!.Value.Split(';'); - foreach (var file in required.Where(file => file != missing)) - File.WriteAllText(Path.Combine(payload, file), "synthetic artifact: " + file); - var parentRuntime = temp.Combine("hostpolicy.dll"); - File.WriteAllText(parentRuntime, "parent runtime"); - var harness = temp.Combine("publish-native.proj"); - // Exercise the production late item insertion, copy paths and completeness gate. - // The stand-in resolution target retains a same-named runtime at the parent root. - new XDocument(new XElement("Project", - new XElement("PropertyGroup", new XElement("PublishDir", published)), - new XElement("Target", new XAttribute("Name", "BuildBrowserNativeHostPayload"), - new XElement("ItemGroup", new XElement("_BrowserNativeHostFile", new XAttribute("Include", Path.Combine(payload, "**", "*"))))), - new XElement("Target", new XAttribute("Name", "ComputeResolvedFilesToPublishList"), - new XElement("ItemGroup", new XElement("ResolvedFileToPublish", new XAttribute("Include", parentRuntime), - new XElement("RelativePath", "hostpolicy.dll")))), - add, - new XElement("Target", new XAttribute("Name", "Publish"), new XAttribute("DependsOnTargets", "ComputeResolvedFilesToPublishList"), - new XElement("Copy", new XAttribute("SourceFiles", "@(ResolvedFileToPublish)"), - new XAttribute("DestinationFiles", "@(ResolvedFileToPublish->'$(PublishDir)%(RelativePath)')"))), - verify)).Save(harness); - - var start = new ProcessStartInfo("dotnet") { WorkingDirectory = root, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (var arg in new[] { "msbuild", harness, "-t:Publish", "-nologo", "-v:minimal" }) start.ArgumentList.Add(arg); - using var process = Process.Start(start)!; - var stdout = process.StandardOutput.ReadToEndAsync(); - var stderr = process.StandardError.ReadToEndAsync(); - using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); - try { await process.WaitForExitAsync(timeout.Token); } - catch (OperationCanceledException) { process.Kill(true); throw; } - var output = await stdout + await stderr; - if (missing is not null) - { - Assert.NotEqual(0, process.ExitCode); - Assert.Contains("missing " + missing, output); - return; - } - Assert.True(process.ExitCode == 0, output); - Assert.Equal("parent runtime", File.ReadAllText(Path.Combine(published, "hostpolicy.dll"))); - foreach (var file in required) - Assert.Equal("synthetic artifact: " + file, File.ReadAllText(Path.Combine(published, "tools", "browser-bootstrap", file))); - } -} From b39910ce837f516d93d21aa701a499a7fe615904 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:43:55 +0000 Subject: [PATCH 12/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 419c1274-364c-4165-8c59-cb7f1f493677 From 65a117ae436a42f1b8fea7035fd36cbd5433708a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:47:18 +0000 Subject: [PATCH 13/77] test(browser): include bootstrap suite in CI contract inventory Require the ninth non-E2E suite and derive the strict runner count from the explicit project inventory. Reproduced the exact Windows fast-validation failure locally; repaired contract passes and fresh scoped autoreview is clean. --- scripts/test-ci-workflow-contract.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index fa62adac7..ed7d519e9 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -377,7 +377,8 @@ $testLanes = [ordered]@{ Projects = @( "tests/OpenClaw.Shared.Tests", "tests/OpenClaw.Connection.Tests", - "tests/OpenClaw.WinNode.Cli.Tests" + "tests/OpenClaw.WinNode.Cli.Tests", + "tests/OpenClaw.BrowserBootstrap.Tests" ) Artifact = "test-results-core" } @@ -517,8 +518,9 @@ foreach ($token in @( $runnerUses = [regex]::Matches( $workflow, "(?m)^\s+\./scripts/Invoke-CiTest\.ps1\s*$").Count -if ($runnerUses -ne 8) { - throw "Expected all 8 non-E2E test projects to use Invoke-CiTest.ps1, found $runnerUses." +$expectedRunnerUses = ($testLanes.Values | ForEach-Object { $_.Projects.Count } | Measure-Object -Sum).Sum +if ($runnerUses -ne $expectedRunnerUses) { + throw "Expected all $expectedRunnerUses non-E2E test projects to use Invoke-CiTest.ps1, found $runnerUses." } if ($workflow -match "(?m)^\s+dotnet-coverage collect\s*$") { throw "Workflow test steps must not invoke dotnet-coverage directly." From b47996d206ee53791e80c7eaeb906d40b121a1c4 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:47:24 +0000 Subject: [PATCH 14/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 44e3f8cf-ed9c-4ab9-b824-078556e512cb From 6df9a6ce7fa7eb6280a217ffcefa22a67be7f758 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:56:38 +0000 Subject: [PATCH 15/77] fix(browser): ship the standalone bootstrap publish profile Track the credential-free single-file profile previously omitted by the generic pubxml ignore rule, fail before nested publish when missing, and verify bundle settings. Update the core cache test inventory for the complete bootstrap graph and normalize MSBuild paths for portable validation. Focused packaging/cache tests and fresh scoped review pass. --- .gitignore | 2 ++ .../Properties/PublishProfiles/Windows.pubxml | 1 + src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets | 1 + .../BrowserBootstrapPublishTests.cs | 14 ++++++++++++++ .../CiNugetCacheWorkflowTests.cs | 7 ++++--- 5 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 src/OpenClaw.BrowserBootstrap/Properties/PublishProfiles/Windows.pubxml diff --git a/.gitignore b/.gitignore index 301423f18..c35f6ab54 100644 --- a/.gitignore +++ b/.gitignore @@ -182,6 +182,8 @@ publish/ # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml +# Required credential-free native bootstrap packaging contract. +!src/OpenClaw.BrowserBootstrap/Properties/PublishProfiles/Windows.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to diff --git a/src/OpenClaw.BrowserBootstrap/Properties/PublishProfiles/Windows.pubxml b/src/OpenClaw.BrowserBootstrap/Properties/PublishProfiles/Windows.pubxml new file mode 100644 index 000000000..74993eafa --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/Properties/PublishProfiles/Windows.pubxml @@ -0,0 +1 @@ +truetruetrueembedded diff --git a/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets index 48046b99f..da08de220 100644 --- a/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets +++ b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets @@ -7,6 +7,7 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj\browser-bootstrap\$(Configuration)\$(BrowserBootstrapRid)\')) + diff --git a/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs b/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs index e38627f4d..8e368876c 100644 --- a/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs @@ -6,6 +6,20 @@ namespace OpenClaw.Tray.Tests; public class BrowserBootstrapPublishTests { + [Fact] + public void RequiredPublishProfileBundlesTheStandaloneExecutable() + { + var root=TestRepositoryPaths.GetRepositoryRoot(); + var profile=XDocument.Load(Path.Combine(root,"src","OpenClaw.BrowserBootstrap","Properties","PublishProfiles","Windows.pubxml")); + foreach(var name in new[]{"SelfContained","PublishSingleFile","IncludeNativeLibrariesForSelfExtract"}) + Assert.Equal("true",profile.Descendants(name).Single().Value); + var target=XDocument.Load(Path.Combine(root,"src","OpenClaw.Tray.WinUI","BrowserBootstrap.targets")); + var build=target.Root!.Elements("Target").Single(x=>(string?)x.Attribute("Name")=="BuildBrowserBootstrapPayload"); + Assert.Equal("Error",build.Elements().First().Name.LocalName); + Assert.Contains("Windows.pubxml",(string?)build.Elements().First().Attribute("Condition")); + Assert.Contains("PublishProfile=Windows",(string?)build.Element("MSBuild")!.Attribute("Properties")); + } + [Theory][InlineData(false)][InlineData(true)] public async Task ProductionPublishTarget_CopiesOnlySingleExeOrFails(bool missing) { diff --git a/tests/OpenClaw.Tray.Tests/CiNugetCacheWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/CiNugetCacheWorkflowTests.cs index 4f1fab462..0a013c9f9 100644 --- a/tests/OpenClaw.Tray.Tests/CiNugetCacheWorkflowTests.cs +++ b/tests/OpenClaw.Tray.Tests/CiNugetCacheWorkflowTests.cs @@ -9,6 +9,7 @@ public sealed class CiNugetCacheWorkflowTests "tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj", "tests/OpenClaw.Connection.Tests/OpenClaw.Connection.Tests.csproj", "tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj", + "tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj", ]; [Fact] @@ -63,7 +64,7 @@ public void CoreCacheKey_HashesEveryRestoreGraphManifest() var cacheStep = ExtractStep(coreJob, "Cache NuGet packages", "Restore core test projects"); var projectGraph = GetProjectGraph(root); - Assert.Equal(9, projectGraph.Count); + Assert.Equal(12, projectGraph.Count); foreach (var buildInput in GetBuildInputs(root, projectGraph)) Assert.Contains($"'{buildInput}'", cacheStep, StringComparison.Ordinal); @@ -98,7 +99,7 @@ private static SortedSet GetProjectGraph(string root) var include = reference.Attribute("Include")?.Value; Assert.False(string.IsNullOrWhiteSpace(include), $"ProjectReference has no Include in {project}"); - var referencedPath = Path.GetFullPath(Path.Combine(projectDirectory, include)); + var referencedPath = Path.GetFullPath(Path.Combine(projectDirectory, include.Replace('\\', Path.DirectorySeparatorChar))); pending.Push(Path.GetRelativePath(root, referencedPath).Replace('\\', '/')); } } @@ -168,7 +169,7 @@ private static void AddBuildFileAndImports( if (string.IsNullOrWhiteSpace(project) || project.Contains("$(", StringComparison.Ordinal)) continue; - var importedPath = Path.GetFullPath(Path.Combine(buildDirectory, project)); + var importedPath = Path.GetFullPath(Path.Combine(buildDirectory, project.Replace('\\', Path.DirectorySeparatorChar))); if (File.Exists(importedPath)) AddBuildFileAndImports(root, importedPath, inputs); } From d9487e88fa9db82cffd448c90a3d93807b1ce2ef Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:56:48 +0000 Subject: [PATCH 16/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 925fbbfa-fb99-42c4-b323-2157e9967e18 From a1be75b81eef8a4f2fea678b773255cfddbf4958 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:20:29 +0000 Subject: [PATCH 17/77] fix(browser): enforce native deadlines and preserve raced registry state Bound management awaits independently of Windows pipe read cancellation. Recheck nontransacted registry state after staging a mutation and before commit; preserve the failing foreign-value race regression and add uncontended mutation coverage. Portable producer tests: 129 passed, four native Windows cases skipped. Fresh scoped review clean; actual Windows proof remains required. --- scripts/Test-BrowserNativeHost.ps1 | 1 + src/OpenClaw.BrowserBootstrap/Program.cs | 6 +++++- .../WindowsRegistrationPlatform.cs | 5 +++++ .../ManagementContractTests.cs | 14 ++++++++++++++ .../RegistryTransactionTests.cs | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index 0a6eab3d2..b810fa6f1 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -40,6 +40,7 @@ function Start-Native([string]$File, [string[]]$Arguments) { return [Diagnostics.Process]::Start($start) } function Invoke-Management([string]$Json, [bool]$CloseInput=$true) { + Write-Host ('MANAGEMENT_PROOF action={0} closeInput={1}' -f ($Json | ConvertFrom-Json).action,$CloseInput) $p=Start-Native $script:exe @('--manage') $ct=[Threading.CancellationTokenSource]::new(60000) try { diff --git a/src/OpenClaw.BrowserBootstrap/Program.cs b/src/OpenClaw.BrowserBootstrap/Program.cs index e596e484d..e5b598aaa 100644 --- a/src/OpenClaw.BrowserBootstrap/Program.cs +++ b/src/OpenClaw.BrowserBootstrap/Program.cs @@ -61,7 +61,11 @@ internal static async Task ReadManagementAsync(Stream input,Cancellation var buffer=new byte[ManagementContract.Limit+1];var size=0; while(true) { - var n=await input.ReadAsync(buffer.AsMemory(size),ct); + // Windows standard-input reads do not interrupt an outstanding synchronous pipe read. + // Bound the await itself; this one-shot process exits after the typed failure response. + var read=input.ReadAsync(buffer.AsMemory(size),ct).AsTask(); + _=read.ContinueWith(t=>_=t.Exception,CancellationToken.None,TaskContinuationOptions.OnlyOnFaulted|TaskContinuationOptions.ExecuteSynchronously,TaskScheduler.Default); + var n=await read.WaitAsync(ct); if(n==0)return buffer[..size]; size+=n;if(size>ManagementContract.Limit)throw new ContractException("invalid_request"); } diff --git a/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs index b4f281a93..9ff2f7dfb 100644 --- a/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs +++ b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs @@ -206,6 +206,11 @@ internal void MutateAtomically(Row expected,Value? value,CancellationToken ct,Ac if(removed!=0)throw new ContractException(removed==5?"unsafe_acl":"io_error"); } else key.SetValue(value.Name,value.Text!,value.Kind); + // TxR reads are not a snapshot lock. A foreign write can win after our first + // comparison but before we stage the mutation. Our staged changes remain + // invisible to this non-transacted read; compare again while holding the + // transactional write conflict boundary, before allowing commit. + if(!Same(expected,ReadRow(expected.Hive,expected.View,expected.Path)))throw new ContractException("foreign_registration"); ct.ThrowIfCancellationRequested(); if(!CommitTransaction(transaction))throw new ContractException("io_error"); } diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs index c1dcade46..df216788f 100644 --- a/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs +++ b/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs @@ -95,6 +95,20 @@ [Fact]public void ResponseCleanExitAndShape() Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Ok=false})); Assert.Throws(()=>ManagementContract.ResponseBytes(r with{Registration="owned"})); } + [Fact] + public async Task ManagementDeadlineDoesNotDependOnUnderlyingReadCancellation() + { + using var input=new UncancellableReadStream(); + using var deadline=new CancellationTokenSource(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync(()=>Program.ReadManagementAsync(input,deadline.Token).WaitAsync(TimeSpan.FromSeconds(2))); + input.Complete(); + } + private sealed class UncancellableReadStream:MemoryStream + { + private readonly TaskCompletionSource read=new(TaskCreationOptions.RunContinuationsAsynchronously); + public override ValueTask ReadAsync(Memory buffer,CancellationToken cancellationToken=default)=>new(read.Task); + public void Complete()=>read.TrySetResult(0); + } [Fact]public async Task ManagementEofRequiredAndBounded() { Assert.Equal(Encoding.UTF8.GetBytes(Native),await Program.ReadManagementAsync(new MemoryStream(Encoding.UTF8.GetBytes(Native)),default)); diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs index de4b9cf66..91492c54b 100644 --- a/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs +++ b/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs @@ -27,6 +27,25 @@ public void TrustedInstallerIsOnlyAnExistingPublicAncestorException() Assert.ThrowsAny(()=>authority.CheckAcl(sd,false,registry:true,protectedAncestor:true)); } [WindowsRegistryFact] + public void UncontendedTransactionsCreateUpdateAndRemoveOwnedValues() + { + if(!OperatingSystem.IsWindows())return; + var path=@"Software\OpenClawBootstrapTests\"+Guid.NewGuid().ToString("N"); + using var root=RegistryKey.OpenBaseKey(RegistryHive.CurrentUser,RegistryView.Registry32); + try + { + var platform=new WindowsRegistrationPlatform(); + var missing=new WindowsRegistrationPlatform.Row(RegistryHive.CurrentUser,RegistryView.Registry32,path,false,[]); + platform.MutateAtomically(missing,new("",RegistryValueKind.String,"owned"),default); + var owned=missing with{Exists=true,Values=[new("",RegistryValueKind.String,"owned")]}; + platform.MutateAtomically(owned,new("",RegistryValueKind.String,"updated"),default); + using(var key=root.OpenSubKey(path))Assert.Equal("updated",key!.GetValue("")); + platform.MutateAtomically(owned with{Values=[new("",RegistryValueKind.String,"updated")]},null,default); + using var after=root.OpenSubKey(path);Assert.Null(after); + } + finally{root.DeleteSubKeyTree(path,false);} + } + [WindowsRegistryFact] public void ForeignChangeBeforeTransactionalAdmissionIsPreserved() { if(!OperatingSystem.IsWindows())return; From b59dcbec9af9c67aa4529bb1c256dac7a29f8e93 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:20:29 +0000 Subject: [PATCH 18/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 3f759ffa-0773-464f-8841-42ccff9bf165 From a4e4db8a57e97fe9e866c8b9ece5c28a9ba28be9 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:28:33 +0000 Subject: [PATCH 19/77] fix(browser): use Pascal comments in installer code include --- scripts/BrowserBootstrapManagement.iss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/BrowserBootstrapManagement.iss b/scripts/BrowserBootstrapManagement.iss index 8dac40cf0..ea17cb572 100644 --- a/scripts/BrowserBootstrapManagement.iss +++ b/scripts/BrowserBootstrapManagement.iss @@ -1,5 +1,5 @@ -; Shared by installer AND uninstaller. Fixed argv, bounded stdin/stdout, no shell or JSON command-line interpolation. -; Native process containment and EOF completion are distinct from Chrome's EOF revocation. +// Shared by installer AND uninstaller. Fixed argv, bounded stdin/stdout, no shell or JSON command-line interpolation. +// Native process containment and EOF completion are distinct from Chrome's EOF revocation. function BBCreatePipe(var R, W: NativeInt; SA: AnsiString; Size: Cardinal): Boolean; external 'CreatePipe@kernel32.dll stdcall'; function BBHandleFlags(H: NativeInt; Mask, Flags: Cardinal): Boolean; From 63d611263ab00ec7d6670eca98ef958d24898055 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:28:33 +0000 Subject: [PATCH 20/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: a932cc99-f9c5-4618-8aec-fda4b0748002 From c3b20f7c57245f99dfeabbd741728512ae2364f3 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:37:49 +0000 Subject: [PATCH 21/77] fix(browser): use supported Inno pointer types and compile early Use the version-supported INT_PTR ABI type instead of unavailable NativeInt. Compile the production include in fast validation without executing an installer; retain full installer compilation and disposable runtime proof. PowerShell parser and CI contracts pass, and fresh scoped review is clean. --- .github/workflows/ci.yml | 4 +++ scripts/BrowserBootstrapManagement.iss | 36 ++++++++++++------------ scripts/Test-BrowserBootstrapCompile.ps1 | 31 ++++++++++++++++++++ 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 scripts/Test-BrowserBootstrapCompile.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42d1c2f3..0221e90d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,10 @@ jobs: shell: pwsh run: ./scripts/test-ci-workflow-contract.ps1 + - name: Compile browser management Pascal include + shell: pwsh + run: ./scripts/Test-BrowserBootstrapCompile.ps1 + - name: Validate MSIX CI artifacts shell: pwsh run: ./scripts/test-msix-ci-artifacts.ps1 diff --git a/scripts/BrowserBootstrapManagement.iss b/scripts/BrowserBootstrapManagement.iss index ea17cb572..ca1106a4e 100644 --- a/scripts/BrowserBootstrapManagement.iss +++ b/scripts/BrowserBootstrapManagement.iss @@ -1,33 +1,33 @@ // Shared by installer AND uninstaller. Fixed argv, bounded stdin/stdout, no shell or JSON command-line interpolation. // Native process containment and EOF completion are distinct from Chrome's EOF revocation. -function BBCreatePipe(var R, W: NativeInt; SA: AnsiString; Size: Cardinal): Boolean; +function BBCreatePipe(var R, W: INT_PTR; SA: AnsiString; Size: Cardinal): Boolean; external 'CreatePipe@kernel32.dll stdcall'; -function BBHandleFlags(H: NativeInt; Mask, Flags: Cardinal): Boolean; +function BBHandleFlags(H: INT_PTR; Mask, Flags: Cardinal): Boolean; external 'SetHandleInformation@kernel32.dll stdcall'; -function BBClose(H: NativeInt): Boolean; +function BBClose(H: INT_PTR): Boolean; external 'CloseHandle@kernel32.dll stdcall'; -function BBCreateProcess(App: String; Cmd: String; ProcessSA, ThreadSA: NativeInt; Inherit: Boolean; - Flags: Cardinal; Env: NativeInt; Directory: String; Startup, ProcessInfo: AnsiString): Boolean; +function BBCreateProcess(App: String; Cmd: String; ProcessSA, ThreadSA: INT_PTR; Inherit: Boolean; + Flags: Cardinal; Env: INT_PTR; Directory: String; Startup, ProcessInfo: AnsiString): Boolean; external 'CreateProcessW@kernel32.dll stdcall'; -function BBWrite(H: NativeInt; Data: AnsiString; Count: Cardinal; var Written: Cardinal; Overlapped: NativeInt): Boolean; +function BBWrite(H: INT_PTR; Data: AnsiString; Count: Cardinal; var Written: Cardinal; Overlapped: INT_PTR): Boolean; external 'WriteFile@kernel32.dll stdcall'; -function BBRead(H: NativeInt; Data: AnsiString; Count: Cardinal; var ReadCount: Cardinal; Overlapped: NativeInt): Boolean; +function BBRead(H: INT_PTR; Data: AnsiString; Count: Cardinal; var ReadCount: Cardinal; Overlapped: INT_PTR): Boolean; external 'ReadFile@kernel32.dll stdcall'; -function BBPeek(H: NativeInt; Buffer: NativeInt; BufferSize: Cardinal; ReadBytes: NativeInt; var Available: Cardinal; LeftBytes: NativeInt): Boolean; +function BBPeek(H: INT_PTR; Buffer: INT_PTR; BufferSize: Cardinal; ReadBytes: INT_PTR; var Available: Cardinal; LeftBytes: INT_PTR): Boolean; external 'PeekNamedPipe@kernel32.dll stdcall'; -function BBWait(H: NativeInt; Milliseconds: Cardinal): Cardinal; +function BBWait(H: INT_PTR; Milliseconds: Cardinal): Cardinal; external 'WaitForSingleObject@kernel32.dll stdcall'; -function BBExit(H: NativeInt; var Code: Cardinal): Boolean; +function BBExit(H: INT_PTR; var Code: Cardinal): Boolean; external 'GetExitCodeProcess@kernel32.dll stdcall'; -function BBResume(H: NativeInt): Cardinal; +function BBResume(H: INT_PTR): Cardinal; external 'ResumeThread@kernel32.dll stdcall'; -function BBTerminate(H: NativeInt; Code: Cardinal): Boolean; +function BBTerminate(H: INT_PTR; Code: Cardinal): Boolean; external 'TerminateProcess@kernel32.dll stdcall'; -function BBJob(Security, Name: NativeInt): NativeInt; +function BBJob(Security, Name: INT_PTR): INT_PTR; external 'CreateJobObjectW@kernel32.dll stdcall'; -function BBJobLimits(Job: NativeInt; InfoClass: Integer; Info: AnsiString; Size: Cardinal): Boolean; +function BBJobLimits(Job: INT_PTR; InfoClass: Integer; Info: AnsiString; Size: Cardinal): Boolean; external 'SetInformationJobObject@kernel32.dll stdcall'; -function BBAssign(Job, Process: NativeInt): Boolean; +function BBAssign(Job, Process: INT_PTR): Boolean; external 'AssignProcessToJobObject@kernel32.dll stdcall'; function BBTick: Int64; external 'GetTickCount64@kernel32.dll stdcall'; @@ -47,12 +47,12 @@ begin for I := Bytes - 1 downto 0 do Result := (Result shl 8) or Ord(Buffer[Offset + I + 1]); end; -procedure BBDispose(var H: NativeInt); +procedure BBDispose(var H: INT_PTR); begin if H <> 0 then begin BBClose(H); H := 0; end; end; -function BBDrain(H: NativeInt; Limit: Integer; var Data: AnsiString; var Eof: Boolean): Boolean; +function BBDrain(H: INT_PTR; Limit: Integer; var Data: AnsiString; var Eof: Boolean): Boolean; var Available, ReadCount: Cardinal; Chunk: AnsiString; Count: Integer; begin Result := False; @@ -211,7 +211,7 @@ end; function RunBrowserManagement(Action, Store: String): Boolean; var - InR, InW, OutR, OutW, ErrR, ErrW, Job, ProcessH, ThreadH: NativeInt; + InR, InW, OutR, OutW, ErrR, ErrW, Job, ProcessH, ThreadH: INT_PTR; PointerSize, StartupSize, SABytes, InfoBytes: Integer; SA, Startup, PI, Limits, Input, Output, Errors: AnsiString; Written, ExitCode: Cardinal; diff --git a/scripts/Test-BrowserBootstrapCompile.ps1 b/scripts/Test-BrowserBootstrapCompile.ps1 new file mode 100644 index 000000000..d823a04d8 --- /dev/null +++ b/scripts/Test-BrowserBootstrapCompile.ps1 @@ -0,0 +1,31 @@ +# Compile the real Pascal include before slow app builds. Never execute an installer. +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +if (-not $IsWindows) { throw 'The Inno compile gate requires Windows.' } +$compiler = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe' +if (-not (Test-Path -LiteralPath $compiler)) { throw 'Inno Setup 6 compiler is unavailable.' } +$include = (Resolve-Path (Join-Path $PSScriptRoot 'BrowserBootstrapManagement.iss')).Path +$directory = Join-Path ([IO.Path]::GetTempPath()) ('openclaw-browser-compile-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $directory | Out-Null +try { + $script = @" +[Setup] +AppName=OpenClaw Browser Transport Compile Proof +AppVersion=0.0.0 +CreateAppDir=no +Uninstallable=no +PrivilegesRequired=lowest +Output=no +[Code] +#include "$include" +"@ + $file = Join-Path $directory 'compile.iss' + [IO.File]::WriteAllText($file, $script, [Text.UTF8Encoding]::new($true)) + & $compiler $file + if ($LASTEXITCODE -ne 0) { throw 'Browser management Pascal include failed to compile.' } + Write-Host 'BROWSER_MANAGEMENT_COMPILE_OK' +} finally { + Remove-Item -LiteralPath $directory -Recurse -Force +} From 6dddf3d2eabf3316d4dcc726109adab7fdccf7ea Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:37:50 +0000 Subject: [PATCH 22/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: edad0173-2768-4514-97e5-e7f40b8c2205 From b04861834969244e2a91e4788104f329e9b67cd4 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:30:21 +0000 Subject: [PATCH 23/77] test(browser): prove pinned Windows producer and canonical CLI together Reuse producer artifact from 6dddf3d2 and check out canonical consumer 2048e9b7 without source changes. One bounded public hosted Windows job exercises real management/generation/native pairing, rejection and EOF with redacted receipts. No synthetic Companion pairing, release or custom proof pool. --- .../browser-native-composed-proof.yml | 77 +++++++++ scripts/Test-BrowserNativeComposition.mjs | 151 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 .github/workflows/browser-native-composed-proof.yml create mode 100644 scripts/Test-BrowserNativeComposition.mjs diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml new file mode 100644 index 000000000..452144da3 --- /dev/null +++ b/.github/workflows/browser-native-composed-proof.yml @@ -0,0 +1,77 @@ +name: Browser native composed proof + +on: + push: + branches: [openclaw/automatic-chrome-extension-pairing] + paths: + - .github/workflows/browser-native-composed-proof.yml + - scripts/Test-BrowserNativeComposition.mjs + +permissions: + contents: read + actions: read + +jobs: + composed-native: + runs-on: windows-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v7 + with: + path: producer + persist-credentials: false + - uses: actions/checkout@v7 + with: + repository: openclaw/openclaw + ref: 2048e9b7fa3edcf9993925f29defb17af2c52ebf + path: consumer + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + - uses: pnpm/action-setup@v4 + with: + version: '12.4.0' + run_install: false + - name: Verify immutable producer artifact and successful source run + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $run = gh api repos/openclaw/openclaw-windows-node/actions/runs/35416240545 | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or $run.head_sha -cne '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea' -or $run.conclusion -cne 'success') { throw 'Producer run identity mismatch.' } + $artifact = gh api repos/openclaw/openclaw-windows-node/actions/artifacts/10576101237 | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or $artifact.expired -or $artifact.name -cne 'openclaw-tray-win-x64' -or $artifact.workflow_run.id -ne 35416240545 -or $artifact.digest -cne 'sha256:9beb60bac258eaf752e27fad6a60ddfbfcc79df130cc65d5e7b09c86fe26a859') { throw 'Producer artifact identity mismatch.' } + - uses: actions/download-artifact@v8 + with: + artifact-ids: '10576101237' + run-id: '35416240545' + github-token: ${{ github.token }} + path: artifact + merge-multiple: true + - name: Build exact canonical runtime without changing its source + working-directory: consumer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ((git rev-parse HEAD).Trim() -cne '2048e9b7fa3edcf9993925f29defb17af2c52ebf') { throw 'Consumer revision mismatch.' } + pnpm install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer dependency install failed.' } + pnpm build:docker + if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer runtime build failed.' } + git diff --exit-code + if ($LASTEXITCODE -ne 0) { throw 'Consumer tracked source changed during build.' } + - name: Execute real management generation admission pairing and rejection chain + shell: pwsh + run: | + node producer/scripts/Test-BrowserNativeComposition.mjs "${{ github.workspace }}/artifact" "${{ github.workspace }}/consumer" "${{ runner.temp }}/composed-native-receipt.json" + if ($LASTEXITCODE -ne 0) { throw 'Composed native proof failed; see redacted receipt.' } + - name: Retain redacted terminal receipt only + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-native-composed-receipt + path: ${{ runner.temp }}/composed-native-receipt.json + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs new file mode 100644 index 000000000..0b8638f43 --- /dev/null +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -0,0 +1,151 @@ +// Disposable hosted Windows only. No mock pairing, registry writer, or TS source patch. +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +const producerSha = '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea'; +const consumerSha = '2048e9b7fa3edcf9993925f29defb17af2c52ebf'; +const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; +const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; +const [artifact, core, receiptFile] = process.argv.slice(2); +const receipt = { producerSha, consumerSha, syntheticPairing: false, cases: [], status: 'failed' }; +let stage = 'preflight'; +let context; +let executable; +let attemptedInstall = false; +const ps = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCLAW_|NODE_)/i.test(key))); +function powershell(script) { + return execFileSync(ps, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + { encoding: 'utf8', timeout: 30000, maxBuffer: 32768, windowsHide: true }); +} +function check(name) { receipt.cases.push(name); console.log('COMPOSED_CASE_OK ' + name); } +function request(action, selected = context) { + return { v: 1, action, mode: 'native-windows-cli', context: selected, expectedOrigins: [origin], store: 'preserve' }; +} +async function exchange(file, args, input, { end = false, timeout = 65000, env = baseEnv } = {}) { + return await new Promise((resolve, reject) => { + const child = spawn(file, args, { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = Buffer.alloc(0), errBytes = 0, finished = false; + const timer = setTimeout(() => { + // Owned PID only, never a process-name/global kill. No caller command interpolation. + if (child.pid) { try { execFileSync(path.join(process.env.SystemRoot, 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); } catch {} } + finish(new Error('bounded_process_timeout')); + }, timeout); + function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); } + child.once('error', () => finish(new Error('process_start_failed'))); + child.stdout.on('data', (part) => { out = Buffer.concat([out, part]); if (out.length > 1048576) { child.kill(); finish(new Error('output_bound')); } }); + child.stderr.on('data', (part) => { errBytes += part.length; if (errBytes > 32768) { child.kill(); finish(new Error('stderr_bound')); } }); + child.once('close', (code) => finish(null, { code, out, errBytes })); + child.stdin.on('error', () => {}); + child.stdin.write(input); + if (end) child.stdin.end(); + }); +} +async function manage(value, end = true) { + const result = await exchange(executable, ['--manage'], Buffer.from(JSON.stringify(value)), { end }); + assert.equal(result.errBytes, 0, 'management_stderr'); + assert.ok(result.out.length <= 32768 && result.out.at(-1) === 10, 'management_receipt_bound'); + const body = JSON.parse(result.out.toString('utf8')); + assert.equal(result.code, body.ok ? 0 : 1, 'management_exit_mismatch'); + return body; +} +function frame(value) { const bytes = Buffer.from(JSON.stringify(value)); const header = Buffer.alloc(4); header.writeUInt32LE(bytes.length); return Buffer.concat([header, bytes]); } +function response(result) { + assert.equal(result.code, 0, 'native_exit'); assert.equal(result.errBytes, 0, 'native_stderr'); + assert.ok(result.out.length >= 4, 'native_response_missing'); + assert.equal(result.out.readUInt32LE(0), result.out.length - 4, 'native_response_length'); + return JSON.parse(result.out.subarray(4).toString('utf8')); +} +function refused(value, code) { assert.equal(value.ok, false, 'request_not_refused'); assert.equal(value.code, code, 'refusal_code'); assert.equal(value.pairingString, undefined, 'secret_on_refusal'); } +try { + assert.equal(process.platform, 'win32', 'native_Windows_required'); + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'disposable_GitHub_runner_required'); + assert.equal(process.env.RUNNER_ENVIRONMENT, 'github-hosted', 'self_hosted_runner_refused'); + assert.ok(artifact && core && receiptFile, 'explicit_artifact_core_receipt_required'); + assert.equal(execFileSync('git', ['-C', core, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), consumerSha, 'consumer_revision'); + const platform = await fs.readFile(path.join(core, 'extensions/browser/src/browser/extension-windows-platform.ts'), 'utf8'); + assert.ok(platform.includes('S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'), 'pinned_TrustedInstaller_ancestor_rule'); + // Refuse all existing product registration/generations. Do not adopt or erase them. + const known = JSON.parse(powershell( + "$ErrorActionPreference='Stop'; $local=[Environment]::GetFolderPath('LocalApplicationData'); " + + "$paths=@('Software\\Google\\Chrome\\NativeMessagingHosts\\ai.openclaw.browser_bootstrap','Software\\Chromium\\NativeMessagingHosts\\ai.openclaw.browser_bootstrap','Software\\Google\\Chrome\\Extensions\\kcdjddhmeafeomebliikmbpblkmkfoig'); " + + "foreach($h in @([Microsoft.Win32.RegistryHive]::CurrentUser,[Microsoft.Win32.RegistryHive]::LocalMachine)){foreach($v in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)){$r=[Microsoft.Win32.RegistryKey]::OpenBaseKey($h,$v);try{foreach($p in $paths){$k=$r.OpenSubKey($p);if($null-ne $k){$k.Dispose();throw 'Existing product registration'}}}finally{$r.Dispose()}}}; " + + "$root=Join-Path $local 'OpenClawTray\\browser-native\\generations';if(Test-Path -LiteralPath $root){throw 'Existing product generations'}; " + + "[Console]::Out.Write((ConvertTo-Json -Compress @{local=$local;sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value}))")); + stage = 'private-fixture'; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'OpenClawComposed-')); + const encoded = Buffer.from(fixture).toString('base64'); + powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded + "'));$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false);$acl.SetOwner($sid);foreach($s in @($sid.Value,'S-1-5-18','S-1-5-32-544')){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($s),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))};[IO.Directory]::SetAccessControl($p,$acl)"); + executable = path.join(fixture, 'OpenClaw.BrowserBootstrap.exe'); + const source = path.join(artifact, 'tools', 'browser-bootstrap', 'OpenClaw.BrowserBootstrap.exe'); + await fs.copyFile(source, executable, fs.constants.COPYFILE_EXCL); + const hash = async (file) => crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex'); + receipt.producerExecutableSha256 = await hash(source); + assert.equal(await hash(executable), receipt.producerExecutableSha256, 'producer_copy_hash'); + const state = path.join(fixture, 'state'); await fs.mkdir(state); + const configPath = path.join(state, 'openclaw.json'); + await fs.writeFile(configPath, JSON.stringify({ gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension', color: '#0088cc' } } } }), { flag: 'wx' }); + context = { nodePath: await fs.realpath(process.execPath), cliPath: await fs.realpath(path.join(core, 'openclaw.mjs')), stateDir: state, configPath, browserProfile: 'chrome' }; + stage = 'management-before-eof'; + refused(await manage(request('install'), false), 'invalid_request'); + check('management_missing_eof_rejected'); + stage = 'management-install-real-ts-probes'; attemptedInstall = true; + const installed = await manage(request('install')); + if (!installed.ok) throw new Error('management_install_' + installed.code); + assert.equal(installed.registration, 'owned'); assert.equal(installed.mode, 'native-windows-cli'); + assert.equal(installed.store, 'missing'); + const installation = installed.installation; + receipt.generation = installation.generation; + check('management_generation_and_real_ts_rejection_probes'); + const packet = frame({ v: 1, op: 'bootstrap', nonce }); + stage = 'composed-bootstrap'; + const paired = response(await exchange(installation.launcherPath, [origin], packet)); + assert.equal(paired.ok, true, 'canonical_pairing_failed'); assert.equal(paired.nonce, nonce); + const pairing = new URL(paired.pairingString); + assert.equal(pairing.protocol, 'ws:'); assert.equal(pairing.hostname, '127.0.0.1'); + assert.equal(pairing.pathname, '/browser/extension'); assert.ok(pairing.hash.length >= 33, 'host_local_relay_secret_missing'); + check('producer_to_canonical_ts_native_admission_and_real_pairing'); + stage = 'origin-rejection'; + refused(response(await exchange(installation.launcherPath, ['chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/'], packet)), 'origin_forbidden'); + check('origin_rejection'); + stage = 'management-context-rejection'; + refused(await manage(request('install', { ...context, browserProfile: 'other' })), 'context_conflict'); + refused(await manage(request('install', { ...context, browserProfile: 'Chrome!' })), 'invalid_request'); + check('management_context_and_profile_rejection'); + const nativeArgs = [context.cliPath, 'browser', 'extension', 'native-host', '--manifest', installation.manifestPath, '--launcher', installation.launcherPath, '--expected-origin', origin, '--browser-profile']; + const env = { ...baseEnv, OPENCLAW_STATE_DIR: state, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_NO_RESPAWN: '1' }; + stage = 'ts-profile-rejection'; + refused(response(await exchange(context.nodePath, [...nativeArgs, 'other', origin], packet, { env })), 'manifest_invalid'); + check('actual_ts_profile_rejection'); + stage = 'ts-context-rejection'; + const otherState = path.join(fixture, 'other-state'); await fs.mkdir(otherState); + refused(response(await exchange(context.nodePath, [...nativeArgs, 'chrome', origin], packet, { env: { ...env, OPENCLAW_STATE_DIR: otherState } })), 'manifest_invalid'); + check('actual_ts_state_context_rejection'); + stage = 'chrome-eof-cancellation'; + const cancelled = await exchange(installation.launcherPath, [origin], packet, { end: true, timeout: 10000 }); + assert.equal(cancelled.code, 0); assert.equal(cancelled.out.length, 0, 'pairing_published_after_eof'); assert.equal(cancelled.errBytes, 0); + check('chrome_eof_cancels_without_pairing'); + stage = 'final-generation-inspection'; + const inspected = await manage(request('inspect')); assert.equal(inspected.installation.generation, installation.generation); assert.equal(inspected.ok, true); + check('rejections_preserve_original_generation'); + receipt.status = 'passed'; +} catch (error) { + // Never serialize native stdout, pairing strings, config contents or a child diagnostic. + receipt.failureStage = stage; + receipt.failure = error instanceof Error && /^management_install_[a-z_]+$/.test(error.message) ? error.message : (error?.code === 'ERR_ASSERTION' ? 'assertion_failed' : 'execution_failed'); + process.exitCode = 1; +} finally { + if (attemptedInstall) { + try { + const removed = await manage(request('uninstall')); + receipt.cleanup = removed.ok && removed.registration === 'missing' ? 'owned_registration_removed' : 'incomplete'; + if (receipt.cleanup === 'incomplete') { receipt.status = 'failed'; process.exitCode = 1; } + } catch { receipt.cleanup = 'incomplete'; receipt.status = 'failed'; process.exitCode = 1; } + } + if (receiptFile) await fs.writeFile(receiptFile, JSON.stringify(receipt, null, 2) + '\n'); + console.log(JSON.stringify(receipt)); +} From d44aa7565b8df89bbf02d7f2904da71723c8444b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:30:25 +0000 Subject: [PATCH 24/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 554a0e70-ada9-473b-b88b-0ad93ada761f From 6b7b56d35fb383420b7c34042cbd3dd6567913fb Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:49:52 +0000 Subject: [PATCH 25/77] test(browser): canonicalize native composition fixture paths --- scripts/Test-BrowserNativeComposition.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 0b8638f43..e4bc415d9 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -77,7 +77,8 @@ try { "$root=Join-Path $local 'OpenClawTray\\browser-native\\generations';if(Test-Path -LiteralPath $root){throw 'Existing product generations'}; " + "[Console]::Out.Write((ConvertTo-Json -Compress @{local=$local;sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value}))")); stage = 'private-fixture'; - const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'OpenClawComposed-')); + // Windows TEMP may contain an 8.3 alias. The production contract requires canonical paths. + const fixture = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'OpenClawComposed-'))); const encoded = Buffer.from(fixture).toString('base64'); powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded + "'));$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false);$acl.SetOwner($sid);foreach($s in @($sid.Value,'S-1-5-18','S-1-5-32-544')){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($s),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))};[IO.Directory]::SetAccessControl($p,$acl)"); executable = path.join(fixture, 'OpenClaw.BrowserBootstrap.exe'); @@ -89,7 +90,16 @@ try { const state = path.join(fixture, 'state'); await fs.mkdir(state); const configPath = path.join(state, 'openclaw.json'); await fs.writeFile(configPath, JSON.stringify({ gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension', color: '#0088cc' } } } }), { flag: 'wx' }); - context = { nodePath: await fs.realpath(process.execPath), cliPath: await fs.realpath(path.join(core, 'openclaw.mjs')), stateDir: state, configPath, browserProfile: 'chrome' }; + context = { nodePath: await fs.realpath(process.execPath), cliPath: await fs.realpath(path.join(core, 'openclaw.mjs')), stateDir: await fs.realpath(state), configPath: await fs.realpath(configPath), browserProfile: 'chrome' }; + // Read-only diagnostics distinguish fixture aliases/reparse ancestors from product failures. + receipt.pathChecks = {}; + for (const [role, target] of Object.entries({ producer: executable, node: context.nodePath, cli: context.cliPath, state: context.stateDir, config: context.configPath })) { + const chain = []; + for (let cursor = target; ; cursor = path.dirname(cursor)) { chain.push(cursor); if (path.dirname(cursor) === cursor) break; } + const checks = await Promise.all(chain.map(async (entry) => ({ canonical: (await fs.realpath(entry)).toLowerCase() === entry.toLowerCase(), symbolic: (await fs.lstat(entry)).isSymbolicLink() }))); + receipt.pathChecks[role] = { canonical: checks.every((entry) => entry.canonical), symbolicAncestors: checks.some((entry) => entry.symbolic) }; + } + assert.ok(Object.values(receipt.pathChecks).every((entry) => entry.canonical && !entry.symbolicAncestors), 'fixture_path_admission'); stage = 'management-before-eof'; refused(await manage(request('install'), false), 'invalid_request'); check('management_missing_eof_rejected'); @@ -117,7 +127,7 @@ try { refused(await manage(request('install', { ...context, browserProfile: 'Chrome!' })), 'invalid_request'); check('management_context_and_profile_rejection'); const nativeArgs = [context.cliPath, 'browser', 'extension', 'native-host', '--manifest', installation.manifestPath, '--launcher', installation.launcherPath, '--expected-origin', origin, '--browser-profile']; - const env = { ...baseEnv, OPENCLAW_STATE_DIR: state, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_NO_RESPAWN: '1' }; + const env = { ...baseEnv, OPENCLAW_STATE_DIR: context.stateDir, OPENCLAW_CONFIG_PATH: context.configPath, OPENCLAW_NO_RESPAWN: '1' }; stage = 'ts-profile-rejection'; refused(response(await exchange(context.nodePath, [...nativeArgs, 'other', origin], packet, { env })), 'manifest_invalid'); check('actual_ts_profile_rejection'); From b1cb4b9a314c2e0b635bc5ab83677500c4be04c2 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:49:53 +0000 Subject: [PATCH 26/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 643b64d8-7242-422e-b8bb-927405cb0a5b From 1e4b9d6ff8181c7f4a906701b0b306177616848a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:20:37 +0000 Subject: [PATCH 27/77] test(browser): audit native ACLs and stage private composed runtime Use canonical consumer57f78c49 with unchanged producer6dd artifact. Diagnose exact role/ancestor/ACL predicates read-only, stage private verified Node and full source/dependencies without global permission changes, and observe revoked/reassigned generations with fresh state and redacted receipts. No production policy or ABI changes. --- .../browser-native-composed-proof.yml | 22 +++- scripts/BrowserNativePathAudit.cs | 108 ++++++++++++++++++ .../Initialize-BrowserNativeComposition.ps1 | 56 +++++++++ scripts/Test-BrowserNativeComposition.mjs | 99 ++++++++++++++-- 4 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 scripts/BrowserNativePathAudit.cs create mode 100644 scripts/Initialize-BrowserNativeComposition.ps1 diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index 452144da3..bafaff4c6 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -6,6 +6,8 @@ on: paths: - .github/workflows/browser-native-composed-proof.yml - scripts/Test-BrowserNativeComposition.mjs + - scripts/Initialize-BrowserNativeComposition.ps1 + - scripts/BrowserNativePathAudit.cs permissions: contents: read @@ -23,7 +25,7 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: 2048e9b7fa3edcf9993925f29defb17af2c52ebf + ref: 57f78c49d47f445b85d4859f038e8447e08983ba path: consumer persist-credentials: false - uses: actions/setup-node@v4 @@ -50,13 +52,19 @@ jobs: github-token: ${{ github.token }} path: artifact merge-multiple: true + - name: Audit original runtime ACLs and create private exact installation + shell: pwsh + run: | + if ((git -C producer rev-parse HEAD:src).Trim() -cne 'ea2e65d057b0efaea8dbddc229548a9f34843196') { throw 'Producer product source changed; artifact pin must be reviewed.' } + ./producer/scripts/Initialize-BrowserNativeComposition.ps1 -Consumer "${{ github.workspace }}/consumer" -Receipt "${{ runner.temp }}/composed-path-audit.json" + if ($LASTEXITCODE -ne 0) { throw 'Private runtime preparation failed; see redacted path audit.' } - name: Build exact canonical runtime without changing its source - working-directory: consumer + working-directory: ${{ env.COMPOSED_PRIVATE_CORE }} shell: pwsh run: | $ErrorActionPreference = 'Stop' - if ((git rev-parse HEAD).Trim() -cne '2048e9b7fa3edcf9993925f29defb17af2c52ebf') { throw 'Consumer revision mismatch.' } - pnpm install --frozen-lockfile + if ((git rev-parse HEAD).Trim() -cne '57f78c49d47f445b85d4859f038e8447e08983ba') { throw 'Consumer revision mismatch.' } + pnpm install --frozen-lockfile --store-dir "$env:COMPOSED_PRIVATE_ROOT/store" --package-import-method=copy if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer dependency install failed.' } pnpm build:docker if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer runtime build failed.' } @@ -65,13 +73,15 @@ jobs: - name: Execute real management generation admission pairing and rejection chain shell: pwsh run: | - node producer/scripts/Test-BrowserNativeComposition.mjs "${{ github.workspace }}/artifact" "${{ github.workspace }}/consumer" "${{ runner.temp }}/composed-native-receipt.json" + & $env:COMPOSED_PRIVATE_NODE producer/scripts/Test-BrowserNativeComposition.mjs "${{ github.workspace }}/artifact" $env:COMPOSED_PRIVATE_CORE "${{ runner.temp }}/composed-native-receipt.json" if ($LASTEXITCODE -ne 0) { throw 'Composed native proof failed; see redacted receipt.' } - name: Retain redacted terminal receipt only if: always() uses: actions/upload-artifact@v7 with: name: browser-native-composed-receipt - path: ${{ runner.temp }}/composed-native-receipt.json + path: | + ${{ runner.temp }}/composed-native-receipt.json + ${{ runner.temp }}/composed-path-audit.json if-no-files-found: warn retention-days: 7 diff --git a/scripts/BrowserNativePathAudit.cs b/scripts/BrowserNativePathAudit.cs new file mode 100644 index 000000000..fa322c40e --- /dev/null +++ b/scripts/BrowserNativePathAudit.cs @@ -0,0 +1,108 @@ +// Proof-only read-only mirror of producer6dd WindowsAuthority.CheckAcl/Admit predicates. No grants or mutation. +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; +public static class BrowserNativePathAudit +{ + public sealed class Row + { + public string Role, Component, Rule, Principal, Mask, MatchedMask; + public int AceIndex = -1, AceFlags, Win32; + public bool Accepted; + } + private static string Kind(string sid, string user) + { + if (sid == user) return "current_user"; + switch (sid) { + case "S-1-5-18": return "system"; + case "S-1-5-32-544": return "administrators"; + case "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464": return "trusted_installer"; + case "S-1-5-32-545": return "builtin_users"; + case "S-1-5-11": return "authenticated_users"; + case "S-1-1-0": return "everyone"; + default: return "other_principal"; + } + } + private static bool Allowed(string kind, bool ancestor) { + return kind == "current_user" || kind == "system" || kind == "administrators" || (ancestor && kind == "trusted_installer"); + } + public static Row[] Inspect(string role, string target, bool privacy, bool directory, bool allowMissing) + { + string user = WindowsIdentity.GetCurrent().User.Value; + var chain = new List(); + for (string p = target; p != null; p = Path.GetDirectoryName(p)) chain.Add(p); + chain.Reverse(); + var rows = new List(); + bool missing = false; + for (int i = 0; i < chain.Count; i++) { + bool leaf = i == chain.Count - 1; + var row = new Row { Role = role, Component = leaf ? "leaf" : "ancestor-" + (chain.Count - 1 - i), Rule = "accepted" }; + rows.Add(row); + using (var h = CreateFile(chain[i], 0x00020080, 3, IntPtr.Zero, 3, 0x00200000 | 0x02000000, IntPtr.Zero)) { + if (h.IsInvalid) { + row.Win32 = Marshal.GetLastWin32Error(); + row.Accepted = allowMissing && (row.Win32 == 2 || row.Win32 == 3); + row.Rule = row.Accepted ? "missing_allowed" : "open_failed"; + if (row.Accepted) missing = true; + continue; + } + if (missing) { row.Rule = "missing_chain_changed"; continue; } + FileInformation info; + if (!GetFileInformationByHandle(h, out info)) { row.Rule = "file_info_failed"; row.Win32 = Marshal.GetLastWin32Error(); continue; } + if ((info.Attributes & 0x400) != 0) { row.Rule = "reparse_point"; continue; } + if (((info.Attributes & 0x10) != 0) != (!leaf || directory)) { row.Rule = "kind_mismatch"; continue; } + var chars = new char[32768]; uint count = GetFinalPathNameByHandle(h, chars, (uint)chars.Length, 0); + if (count == 0 || count >= chars.Length) { row.Rule = "canonical_query_failed"; continue; } + string canonical = new string(chars, 0, (int)count); + if (canonical.StartsWith(@"\\?\", StringComparison.Ordinal)) canonical = canonical.Substring(4); + if (!String.Equals(canonical, chain[i], StringComparison.OrdinalIgnoreCase)) { row.Rule = "canonical_mismatch"; continue; } + IntPtr owner, group, dacl, sacl, descriptor; + uint error = GetSecurityInfo(h, 1, 5, out owner, out group, out dacl, out sacl, out descriptor); + if (error != 0) { row.Rule = "security_query_failed"; row.Win32 = (int)error; continue; } + try { + uint length = GetSecurityDescriptorLength(descriptor); + if (length == 0 || length > 1048576) { row.Rule = "descriptor_bound"; continue; } + var bytes = new byte[(int)length]; Marshal.Copy(descriptor, bytes, 0, bytes.Length); + var sd = new RawSecurityDescriptor(bytes, 0); + row.Principal = sd.Owner == null ? "missing_owner" : Kind(sd.Owner.Value, user); + if (!Allowed(row.Principal, !leaf)) { row.Rule = "untrusted_owner"; continue; } + if (sd.DiscretionaryAcl == null) { row.Rule = "null_dacl"; continue; } + uint writes = leaf ? 0x500d0156u : 0x500d0150u; + row.Accepted = true; + for (int a = 0; a < sd.DiscretionaryAcl.Count; a++) { + GenericAce ace = sd.DiscretionaryAcl[a]; + if ((ace.AceFlags & AceFlags.InheritOnly) != 0) continue; + var q = ace as CommonAce; + if (q == null || q.IsCallback || (q.AceQualifier != AceQualifier.AccessAllowed && q.AceQualifier != AceQualifier.AccessDenied)) { + row.Accepted = false; row.Rule = "unsupported_ace"; row.AceIndex = a; row.AceFlags = (int)ace.AceFlags; break; + } + if (q.AceQualifier != AceQualifier.AccessAllowed) continue; + string principal = Kind(q.SecurityIdentifier.Value, user); + uint mask = unchecked((uint)q.AccessMask); + if (!Allowed(principal, !leaf) && ((leaf && privacy) || (mask & writes) != 0)) { + row.Accepted = false; row.Rule = leaf && privacy ? "foreign_private_allow" : "foreign_allow_write"; + row.Principal = principal; row.AceIndex = a; row.AceFlags = (int)ace.AceFlags; + row.Mask = "0x" + mask.ToString("x8"); row.MatchedMask = "0x" + (mask & writes).ToString("x8"); break; + } + } + } finally { LocalFree(descriptor); } + } + } + return rows.ToArray(); + } + [StructLayout(LayoutKind.Sequential)] private struct FileInformation { + public uint Attributes, CreationLow, CreationHigh, AccessLow, AccessHigh, WriteLow, WriteHigh, Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true, EntryPoint="CreateFileW")] + private static extern SafeFileHandle CreateFile(string p, uint a, uint s, IntPtr sd, uint d, uint f, IntPtr t); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle h, out FileInformation i); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, EntryPoint="GetFinalPathNameByHandleW", SetLastError=true)] + private static extern uint GetFinalPathNameByHandle(SafeFileHandle h, [Out] char[] p, uint n, uint f); + [DllImport("advapi32.dll")] private static extern uint GetSecurityInfo(SafeFileHandle h, uint t, uint f, out IntPtr o, out IntPtr g, out IntPtr d, out IntPtr s, out IntPtr sd); + [DllImport("advapi32.dll")] private static extern uint GetSecurityDescriptorLength(IntPtr sd); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr p); +} diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 new file mode 100644 index 000000000..7d02b4ad6 --- /dev/null +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -0,0 +1,56 @@ +param([Parameter(Mandatory)][string]$Consumer, [Parameter(Mandatory)][string]$Receipt) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='57f78c49d47f445b85d4859f038e8447e08983ba' } +try { + if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } + Add-Type -Path (Join-Path $PSScriptRoot 'BrowserNativePathAudit.cs') + $node = (Get-Command node.exe -CommandType Application).Source + $node = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))') + if ($LASTEXITCODE -ne 0) { throw 'Node canonicalization failed' } + $cli = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' (Join-Path $Consumer 'openclaw.mjs')) + if ($LASTEXITCODE -ne 0) { throw 'CLI canonicalization failed' } + $result.stage = 'original-path-audit' + $result.original = @([BrowserNativePathAudit]::Inspect('global-node',$node,$false,$false,$false)) + @([BrowserNativePathAudit]::Inspect('checkout-cli',$cli,$false,$false,$false)) + if ((git -C $Consumer rev-parse HEAD).Trim() -cne $result.consumerSha -or $LASTEXITCODE -ne 0) { throw 'Consumer identity mismatch' } + $result.stage = 'private-installation' + $local = [Environment]::GetFolderPath('LocalApplicationData') + $root = Join-Path $local ('OpenClawComposed-' + [Guid]::NewGuid().ToString('N')) + if (Test-Path -LiteralPath $root) { throw 'Unexpected existing fixture' } + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true,$false) + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.SetOwner($sid) + foreach ($s in @($sid.Value,'S-1-5-18','S-1-5-32-544')) { + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($s),'FullControl','ContainerInherit,ObjectInherit','None','Allow')) + } + # This is the only ACL mutation: a new empty, task-owned private root. No global paths are modified. + $null = New-Item -ItemType Directory -Path $root + Set-Acl -LiteralPath $root -AclObject $acl + $nodeDir = Join-Path $root 'node' + $null = New-Item -ItemType Directory -Path $nodeDir + $privateNode = Join-Path $nodeDir 'node.exe' + Copy-Item -LiteralPath $node -Destination $privateNode + $result.nodeSha256 = (Get-FileHash -LiteralPath $node -Algorithm SHA256).Hash.ToLowerInvariant() + if ((Get-FileHash -LiteralPath $privateNode -Algorithm SHA256).Hash.ToLowerInvariant() -cne $result.nodeSha256) { throw 'Node copy identity mismatch' } + $privateCore = Join-Path $root 'consumer' + # A full exact checkout, not a shim or junction back into a globally writable checkout. + git -c core.longpaths=true clone --quiet --no-hardlinks --no-checkout -- $Consumer $privateCore + if ($LASTEXITCODE -ne 0) { throw 'Private checkout failed' } + git -C $privateCore -c core.longpaths=true checkout --quiet --detach $result.consumerSha + if ($LASTEXITCODE -ne 0 -or (git -C $privateCore rev-parse HEAD).Trim() -cne $result.consumerSha) { throw 'Private checkout identity mismatch' } + $result.accepted = @([BrowserNativePathAudit]::Inspect('private-node',$privateNode,$false,$false,$false)) + @([BrowserNativePathAudit]::Inspect('private-cli',(Join-Path $privateCore 'openclaw.mjs'),$false,$false,$false)) + if (@($result.accepted | Where-Object { !$_.Accepted }).Count -ne 0) { throw 'Private runtime admission failed' } + "COMPOSED_PRIVATE_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "COMPOSED_PRIVATE_CORE=$privateCore" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "COMPOSED_PRIVATE_NODE=$privateNode" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + $nodeDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + $result.status = 'passed' +} catch { + $result.failure = 'private_runtime_preparation_failed' +} finally { + $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Receipt -Encoding utf8 + # Receipt contains roles, ancestor distances, rule names and permission bits, never paths or SIDs. + $result | ConvertTo-Json -Depth 8 -Compress | Write-Output +} +if ($result.status -ne 'passed') { exit 1 } diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index e4bc415d9..7d3f5f9fb 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -3,13 +3,14 @@ import assert from 'node:assert/strict'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; -import os from 'node:os'; import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; const producerSha = '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea'; -const consumerSha = '2048e9b7fa3edcf9993925f29defb17af2c52ebf'; +const consumerSha = '57f78c49d47f445b85d4859f038e8447e08983ba'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; +const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension', color: '#0088cc' } } } }; const [artifact, core, receiptFile] = process.argv.slice(2); const receipt = { producerSha, consumerSha, syntheticPairing: false, cases: [], status: 'failed' }; let stage = 'preflight'; @@ -19,8 +20,8 @@ let attemptedInstall = false; const ps = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCLAW_|NODE_)/i.test(key))); function powershell(script) { - return execFileSync(ps, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], - { encoding: 'utf8', timeout: 30000, maxBuffer: 32768, windowsHide: true }); + return execFileSync(ps, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from("$ProgressPreference='SilentlyContinue';" + script, 'utf16le').toString('base64')], + { encoding: 'utf8', timeout: 30000, maxBuffer: 262144, windowsHide: true }); } function check(name) { receipt.cases.push(name); console.log('COMPOSED_CASE_OK ' + name); } function request(action, selected = context) { @@ -61,11 +62,56 @@ function response(result) { return JSON.parse(result.out.subarray(4).toString('utf8')); } function refused(value, code) { assert.equal(value.ok, false, 'request_not_refused'); assert.equal(value.code, code, 'refusal_code'); assert.equal(value.pairingString, undefined, 'secret_on_refusal'); } + +function auditPaths(roles) { + const auditFile = fileURLToPath(new URL('./BrowserNativePathAudit.cs', import.meta.url)); + const encoded = Buffer.from(JSON.stringify({ auditFile, roles })).toString('base64'); + return JSON.parse(powershell("$ErrorActionPreference='Stop';$r=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded + "'))|ConvertFrom-Json;Add-Type -Path $r.auditFile;$result=@(foreach($p in $r.roles){$rows=@([BrowserNativePathAudit]::Inspect($p.role,$p.target,[bool]$p.privacy,[bool]$p.directory,[bool]$p.allowMissing));$bad=@($rows|Where-Object{!$_.Accepted});[ordered]@{role=$p.role;accepted=($bad.Count-eq 0);components=$rows.Count;rejections=$bad}});[Console]::Out.Write((ConvertTo-Json -InputObject $result -Depth 8 -Compress))")); +} +async function stateSnapshot(root) { + const entries = []; + async function walk(dir) { + for (const name of (await fs.readdir(dir)).sort()) { + const file = path.join(dir, name), stat = await fs.lstat(file); + assert.ok(!stat.isSymbolicLink(), 'fixture_state_symlink'); + if (stat.isDirectory()) { assert.ok(entries.length < 256, 'fixture_state_bound'); entries.push([path.relative(root,file),'directory']); await walk(file); } + else { assert.ok(stat.size <= 1048576 && entries.length < 256, 'fixture_state_bound'); entries.push([path.relative(root,file),crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex')]); } + } + } + await walk(root); return JSON.stringify(entries); // Held privately; only equality booleans reach receipts. +} +async function isolatedContext(root, name) { + const stateDir = path.join(root,name); await fs.mkdir(stateDir); + const configPath = path.join(stateDir,'openclaw.json'); + await fs.writeFile(configPath,JSON.stringify(fixtureConfig),{flag:'wx'}); + return { ...context, stateDir: await fs.realpath(stateDir), configPath: await fs.realpath(configPath) }; +} +async function observePriorGeneration(installation, priorContext, packet) { + const before = await stateSnapshot(priorContext.stateDir); + const observed = { responseKind: 'execution_failed', responseOk: false, explicitlyRefused: false, pairingEmitted: false, code: null, stateChanged: false }; + let result; + try { result = await exchange(installation.launcherPath,[origin],packet); } + catch { /* Preserve post-launch state evidence even on timeout or transport failure. */ } + finally { observed.stateChanged = before !== await stateSnapshot(priorContext.stateDir); } + if (!result) return observed; + observed.responseKind = result.out.length === 0 ? 'no_response' : 'invalid_response'; + observed.exitCode = result.code; + try { + const body = response(result); + observed.responseOk = body?.ok === true; + observed.pairingEmitted = typeof body?.pairingString === 'string'; + observed.code = typeof body?.code === 'string' && /^[a-z_]+$/.test(body.code) ? body.code : null; + observed.explicitlyRefused = body?.v === 1 && body?.ok === false && observed.code !== null && Object.keys(body).sort().join(',') === 'code,ok,v'; + observed.responseKind = observed.explicitlyRefused ? 'typed_refusal' : observed.responseOk ? 'success' : 'invalid_response'; + } catch { /* Only classifications/booleans, never raw responses, enter the receipt. */ } + return observed; +} + try { assert.equal(process.platform, 'win32', 'native_Windows_required'); assert.equal(process.env.GITHUB_ACTIONS, 'true', 'disposable_GitHub_runner_required'); assert.equal(process.env.RUNNER_ENVIRONMENT, 'github-hosted', 'self_hosted_runner_refused'); - assert.ok(artifact && core && receiptFile, 'explicit_artifact_core_receipt_required'); + assert.ok(artifact && core && receiptFile && process.env.COMPOSED_PRIVATE_ROOT, 'explicit_private_artifact_core_receipt_required'); assert.equal(execFileSync('git', ['-C', core, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), consumerSha, 'consumer_revision'); const platform = await fs.readFile(path.join(core, 'extensions/browser/src/browser/extension-windows-platform.ts'), 'utf8'); assert.ok(platform.includes('S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'), 'pinned_TrustedInstaller_ancestor_rule'); @@ -78,7 +124,7 @@ try { "[Console]::Out.Write((ConvertTo-Json -Compress @{local=$local;sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value}))")); stage = 'private-fixture'; // Windows TEMP may contain an 8.3 alias. The production contract requires canonical paths. - const fixture = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'OpenClawComposed-'))); + const fixture = await fs.realpath(await fs.mkdtemp(path.join(process.env.COMPOSED_PRIVATE_ROOT, 'fixture-'))); const encoded = Buffer.from(fixture).toString('base64'); powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded + "'));$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false);$acl.SetOwner($sid);foreach($s in @($sid.Value,'S-1-5-18','S-1-5-32-544')){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($s),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))};[IO.Directory]::SetAccessControl($p,$acl)"); executable = path.join(fixture, 'OpenClaw.BrowserBootstrap.exe'); @@ -89,7 +135,7 @@ try { assert.equal(await hash(executable), receipt.producerExecutableSha256, 'producer_copy_hash'); const state = path.join(fixture, 'state'); await fs.mkdir(state); const configPath = path.join(state, 'openclaw.json'); - await fs.writeFile(configPath, JSON.stringify({ gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension', color: '#0088cc' } } } }), { flag: 'wx' }); + await fs.writeFile(configPath, JSON.stringify(fixtureConfig), { flag: 'wx' }); context = { nodePath: await fs.realpath(process.execPath), cliPath: await fs.realpath(path.join(core, 'openclaw.mjs')), stateDir: await fs.realpath(state), configPath: await fs.realpath(configPath), browserProfile: 'chrome' }; // Read-only diagnostics distinguish fixture aliases/reparse ancestors from product failures. receipt.pathChecks = {}; @@ -100,6 +146,13 @@ try { receipt.pathChecks[role] = { canonical: checks.every((entry) => entry.canonical), symbolicAncestors: checks.some((entry) => entry.symbolic) }; } assert.ok(Object.values(receipt.pathChecks).every((entry) => entry.canonical && !entry.symbolicAncestors), 'fixture_path_admission'); + stage = 'win32-path-audit'; + receipt.admission = auditPaths([ + {role:'producer',target:executable}, {role:'node',target:context.nodePath}, {role:'cli',target:context.cliPath}, + {role:'state',target:context.stateDir,privacy:true,directory:true}, {role:'config',target:context.configPath,privacy:true}, + {role:'generation-root',target:path.join(known.local,'OpenClawTray','browser-native','generations'),privacy:true,directory:true,allowMissing:true}, + ]); + assert.ok(receipt.admission.every((entry) => entry.accepted), 'win32_admission_failed'); stage = 'management-before-eof'; refused(await manage(request('install'), false), 'invalid_request'); check('management_missing_eof_rejected'); @@ -142,7 +195,37 @@ try { stage = 'final-generation-inspection'; const inspected = await manage(request('inspect')); assert.equal(inspected.installation.generation, installation.generation); assert.equal(inspected.ok, true); check('rejections_preserve_original_generation'); - receipt.status = 'passed'; + // Investigate registry lifecycle authority without modifying generation files or adding a bypass flag. + stage = 'revoked-generation-investigation'; + const removedOriginal = await manage(request('uninstall')); assert.equal(removedOriginal.ok,true); assert.equal(removedOriginal.registration,'missing'); + context = await isolatedContext(fixture,'revoked-state'); + const revokedFresh = await stateSnapshot(context.stateDir); + const revokeInstall = await manage(request('install')); assert.equal(revokeInstall.ok,true); + receipt.revocationPrepublicationStateChanged = revokedFresh !== await stateSnapshot(context.stateDir); + const removedRevoked = await manage(request('uninstall')); assert.equal(removedRevoked.ok,true); assert.equal(removedRevoked.registration,'missing'); + receipt.revokedGeneration = await observePriorGeneration(revokeInstall.installation,context,packet); + const revokedPostState = await manage(request('inspect')); + receipt.revokedGeneration.registrationRemainsMissing = revokedPostState.ok && revokedPostState.registration === 'missing'; + assert.ok(receipt.revokedGeneration.registrationRemainsMissing, 'revoked_registration_reappeared'); + stage = 'reassigned-generation-investigation'; + context = await isolatedContext(fixture,'previous-state'); + const previousContext = context; + const previous = await manage(request('install')); assert.equal(previous.ok,true); + const removedPrevious = await manage(request('uninstall')); assert.equal(removedPrevious.ok,true); assert.equal(removedPrevious.registration,'missing'); + context = await isolatedContext(fixture,'replacement-state'); + const replacement = await manage(request('install')); assert.equal(replacement.ok,true); + assert.notEqual(replacement.installation.generation,previous.installation.generation); + const replacementBefore = await stateSnapshot(context.stateDir); + receipt.reassignedGeneration = await observePriorGeneration(previous.installation,previousContext,packet); + receipt.reassignedGeneration.replacementStateChanged = replacementBefore !== await stateSnapshot(context.stateDir); + const current = await manage(request('inspect')); + assert.equal(current.ok,true); assert.equal(current.installation.generation,replacement.installation.generation); + receipt.reassignedGeneration.activeRegistrationPreserved = true; + // A positive prior-launcher result is evidence requiring agreed-contract review, not a new revocation policy. + const rejectedWithoutEffects = (entry) => entry.explicitlyRefused && !entry.pairingEmitted && !entry.stateChanged; + if (receipt.revocationPrepublicationStateChanged || !rejectedWithoutEffects(receipt.revokedGeneration) || !rejectedWithoutEffects(receipt.reassignedGeneration) || receipt.reassignedGeneration.replacementStateChanged) { + receipt.status = 'authority_review_required'; process.exitCode = 1; + } else { check('revoked_and_reassigned_generations_rejected_without_state_effects'); receipt.status = 'passed'; } } catch (error) { // Never serialize native stdout, pairing strings, config contents or a child diagnostic. receipt.failureStage = stage; From 1de6add8d944b0eba0a7ae3a81706b3460263e8b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:20:41 +0000 Subject: [PATCH 28/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 25f7a9de-cb69-4fb7-b69a-f84b313651be From a96370db9fcf2e84cb2fe672a19fb336050c4807 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:25:05 +0000 Subject: [PATCH 29/77] test(browser): identify redacted private runtime preparation failures --- scripts/Initialize-BrowserNativeComposition.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 index 7d02b4ad6..bcf6b4340 100644 --- a/scripts/Initialize-BrowserNativeComposition.ps1 +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -3,11 +3,15 @@ $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='57f78c49d47f445b85d4859f038e8447e08983ba' } try { + $result.host = @{ windows=[bool]$IsWindows; githubActions=($env:GITHUB_ACTIONS -ceq 'true'); githubHosted=($env:RUNNER_ENVIRONMENT -ceq 'github-hosted') } if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } + $result.stage = 'audit-helper-compilation' Add-Type -Path (Join-Path $PSScriptRoot 'BrowserNativePathAudit.cs') + $result.stage = 'canonical-node' $node = (Get-Command node.exe -CommandType Application).Source $node = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))') if ($LASTEXITCODE -ne 0) { throw 'Node canonicalization failed' } + $result.stage = 'canonical-cli' $cli = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' (Join-Path $Consumer 'openclaw.mjs')) if ($LASTEXITCODE -ne 0) { throw 'CLI canonicalization failed' } $result.stage = 'original-path-audit' @@ -48,6 +52,10 @@ try { $result.status = 'passed' } catch { $result.failure = 'private_runtime_preparation_failed' + $result.exceptionType = $_.Exception.GetType().Name + $result.errorId = if ($_.FullyQualifiedErrorId -cmatch '^[A-Za-z_][A-Za-z0-9_.]*(,[A-Za-z_][A-Za-z0-9_.]*)?$') { $_.FullyQualifiedErrorId } else { 'withheld' } + $result.compilerCodes = @([regex]::Matches($_.Exception.Message,'\bCS[0-9]{4}\b') | ForEach-Object { $_.Value } | Select-Object -Unique) + # Exception messages, invocation details and private paths/SIDs remain withheld. } finally { $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Receipt -Encoding utf8 # Receipt contains roles, ancestor distances, rule names and permission bits, never paths or SIDs. From 18db5d7c12b1fc8a4e1ff64d9640d280508bdb5e Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:25:05 +0000 Subject: [PATCH 30/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 386fe539-7c02-468f-a1b4-c557bbbff549 From e2f28a12577540f8ac5481c4485fb43897dd3436 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:28:53 +0000 Subject: [PATCH 31/77] test(browser): resolve private runtime from installed Node launcher --- scripts/Initialize-BrowserNativeComposition.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 index bcf6b4340..5c0b06fa0 100644 --- a/scripts/Initialize-BrowserNativeComposition.ps1 +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -7,10 +7,15 @@ try { if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } $result.stage = 'audit-helper-compilation' Add-Type -Path (Join-Path $PSScriptRoot 'BrowserNativePathAudit.cs') + $result.stage = 'node-discovery' + # Use the same installed launcher used by setup-node and the prior working harness. + $node = (& node --print process.execPath) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($node)) { throw 'Installed Node discovery failed' } $result.stage = 'canonical-node' - $node = (Get-Command node.exe -CommandType Application).Source $node = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))') if ($LASTEXITCODE -ne 0) { throw 'Node canonicalization failed' } + $result.nodeVersion = (& $node --version) + if ($LASTEXITCODE -ne 0 -or $result.nodeVersion -cne 'v24.16.0') { throw 'Installed Node version mismatch' } $result.stage = 'canonical-cli' $cli = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' (Join-Path $Consumer 'openclaw.mjs')) if ($LASTEXITCODE -ne 0) { throw 'CLI canonicalization failed' } From 909a5e05172d190dc65ab3236c0f57880371d261 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:28:54 +0000 Subject: [PATCH 32/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 40d95a99-e775-4c82-8516-c41f8c4c3ef1 From a5a5d86213ffc95a9c7e150fcf5a58feb06d6ff0 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:52:02 +0000 Subject: [PATCH 33/77] test(browser): validate native fixture against canonical current schema Remove doctor-only legacy profile color rejected by consumer57 strict schema. Validate the actual fixture through the built SDK read-only/core-only path and retain only redacted response classifications and harness coordinates. Capture synchronous child stderr instead of emitting raw diagnostics. No product-source or admission change. --- scripts/Test-BrowserNativeComposition.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 7d3f5f9fb..792470268 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -10,7 +10,7 @@ const producerSha = '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea'; const consumerSha = '57f78c49d47f445b85d4859f038e8447e08983ba'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; -const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension', color: '#0088cc' } } } }; +const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension' } } } }; const [artifact, core, receiptFile] = process.argv.slice(2); const receipt = { producerSha, consumerSha, syntheticPairing: false, cases: [], status: 'failed' }; let stage = 'preflight'; @@ -21,7 +21,7 @@ const ps = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'Windo const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCLAW_|NODE_)/i.test(key))); function powershell(script) { return execFileSync(ps, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from("$ProgressPreference='SilentlyContinue';" + script, 'utf16le').toString('base64')], - { encoding: 'utf8', timeout: 30000, maxBuffer: 262144, windowsHide: true }); + { encoding: 'utf8', timeout: 30000, maxBuffer: 262144, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); } function check(name) { receipt.cases.push(name); console.log('COMPOSED_CASE_OK ' + name); } function request(action, selected = context) { @@ -112,7 +112,7 @@ try { assert.equal(process.env.GITHUB_ACTIONS, 'true', 'disposable_GitHub_runner_required'); assert.equal(process.env.RUNNER_ENVIRONMENT, 'github-hosted', 'self_hosted_runner_refused'); assert.ok(artifact && core && receiptFile && process.env.COMPOSED_PRIVATE_ROOT, 'explicit_private_artifact_core_receipt_required'); - assert.equal(execFileSync('git', ['-C', core, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), consumerSha, 'consumer_revision'); + assert.equal(execFileSync('git', ['-C', core, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(), consumerSha, 'consumer_revision'); const platform = await fs.readFile(path.join(core, 'extensions/browser/src/browser/extension-windows-platform.ts'), 'utf8'); assert.ok(platform.includes('S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'), 'pinned_TrustedInstaller_ancestor_rule'); // Refuse all existing product registration/generations. Do not adopt or erase them. @@ -153,6 +153,11 @@ try { {role:'generation-root',target:path.join(known.local,'OpenClawTray','browser-native','generations'),privacy:true,directory:true,allowMissing:true}, ]); assert.ok(receipt.admission.every((entry) => entry.accepted), 'win32_admission_failed'); + stage = 'canonical-config-validation'; + const configValidationScript = 'const {readConfigFileSnapshot}=await import("openclaw/plugin-sdk/health");const s=await readConfigFileSnapshot({observe:false,pluginValidation:"core-only"});process.stdout.write(JSON.stringify({valid:s.valid}));'; + receipt.configValidation = JSON.parse(execFileSync(context.nodePath, ['--input-type=module', '-e', configValidationScript], { cwd: core, env: { ...baseEnv, OPENCLAW_STATE_DIR: context.stateDir, OPENCLAW_CONFIG_PATH: context.configPath }, encoding: 'utf8', timeout: 30000, maxBuffer: 32768, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })); + assert.equal(receipt.configValidation.valid, true, 'fixture_config_invalid'); + check('canonical_read_only_config_validation'); stage = 'management-before-eof'; refused(await manage(request('install'), false), 'invalid_request'); check('management_missing_eof_rejected'); @@ -167,7 +172,10 @@ try { const packet = frame({ v: 1, op: 'bootstrap', nonce }); stage = 'composed-bootstrap'; const paired = response(await exchange(installation.launcherPath, [origin], packet)); + receipt.bootstrapResponse = { versionValid: paired.v === 1, ok: paired.ok === true, nonceMatches: paired.nonce === nonce, pairingPresent: typeof paired.pairingString === 'string', code: ['manifest_invalid','pairing_unavailable','manual_required','invalid_request','origin_forbidden','invalid_frame','relay_unavailable'].includes(paired.code) ? paired.code : null }; + assert.equal(paired.v, 1, 'native_response_version'); assert.equal(paired.ok, true, 'canonical_pairing_failed'); assert.equal(paired.nonce, nonce); + stage = 'pairing-response-validation'; const pairing = new URL(paired.pairingString); assert.equal(pairing.protocol, 'ws:'); assert.equal(pairing.hostname, '127.0.0.1'); assert.equal(pairing.pathname, '/browser/extension'); assert.ok(pairing.hash.length >= 33, 'host_local_relay_secret_missing'); @@ -229,6 +237,8 @@ try { } catch (error) { // Never serialize native stdout, pairing strings, config contents or a child diagnostic. receipt.failureStage = stage; + const location = error instanceof Error ? error.stack?.match(/Test-BrowserNativeComposition\.mjs:(\d+):(\d+)/) : undefined; + if (location) receipt.failureLocation = { line: Number(location[1]), column: Number(location[2]) }; receipt.failure = error instanceof Error && /^management_install_[a-z_]+$/.test(error.message) ? error.message : (error?.code === 'ERR_ASSERTION' ? 'assertion_failed' : 'execution_failed'); process.exitCode = 1; } finally { From 16117cee523fce581afe37a20a382ca904a555e6 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:52:02 +0000 Subject: [PATCH 34/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 9a8a4ad0-b5e0-4179-966a-0b72790318cc From d7795a2de579a601d67c755b5c8bed32954beccf Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:03:31 +0000 Subject: [PATCH 35/77] fix(browser): serialize native activation with registration retirement Hold the existing SID registration owner through admitted backend work, confirmed cleanup and final native response delivery. Preserve exact real keyless probes, track their descendant tree, and prepare fresh-source Windows composition and retirement/EOF proofs. Local scoped review is clean. Native Windows execution and Linux guest settlement are not claimed by this commit. No merge or release. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../browser-native-composed-proof.yml | 41 ++-- scripts/BrowserNativeProofTiming.mjs | 3 + scripts/BrowserNativeProofTiming.test.mjs | 17 ++ scripts/Control-BrowserNativeProofChild.ps1 | 82 +++++++ scripts/Test-BrowserNativeComposition.mjs | 121 +++++++++- .../GenerationStore.cs | 36 ++- .../NativeKeylessProbe.cs | 27 +++ .../NativeRegistrationRuntime.cs | 37 +++ .../NativeTransport.cs | 70 ++++-- .../OpenClaw.BrowserBootstrap.csproj | 2 +- .../ProbeProcessTree.cs | 55 +++++ src/OpenClaw.BrowserBootstrap/Program.cs | 5 +- .../WindowsRegistrationPlatform.cs | 6 +- .../BrowserBootstrapWslCommand.cs | 31 ++- .../Browser/BrowserBootstrapPipeClient.cs | 46 ++++ .../Browser/BrowserBootstrapPipeServer.cs | 6 +- .../BrowserBootstrapProcessLifetime.cs | 24 ++ .../NativeCancellationTests.cs | 10 +- .../NativeChildLifetimeTests.cs | 34 +++ .../NativeRegistrationRuntimeTests.cs | 213 ++++++++++++++++++ .../ProbeProcessTreeTests.cs | 43 ++++ .../BrowserBootstrapPipeClientTests.cs | 120 ++++++++++ .../BrowserBootstrapPipeTests.cs | 30 +++ .../BrowserBootstrapProcessLifetimeTests.cs | 36 +++ 24 files changed, 1041 insertions(+), 54 deletions(-) create mode 100644 scripts/BrowserNativeProofTiming.mjs create mode 100644 scripts/BrowserNativeProofTiming.test.mjs create mode 100644 scripts/Control-BrowserNativeProofChild.ps1 create mode 100644 src/OpenClaw.BrowserBootstrap/NativeKeylessProbe.cs create mode 100644 src/OpenClaw.BrowserBootstrap/NativeRegistrationRuntime.cs create mode 100644 src/OpenClaw.BrowserBootstrap/ProbeProcessTree.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserBootstrapPipeClient.cs create mode 100644 src/OpenClaw.Shared/Browser/BrowserBootstrapProcessLifetime.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/NativeChildLifetimeTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/NativeRegistrationRuntimeTests.cs create mode 100644 tests/OpenClaw.BrowserBootstrap.Tests/ProbeProcessTreeTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeClientTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/BrowserBootstrapProcessLifetimeTests.cs diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index bafaff4c6..151dd5fa8 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -8,6 +8,13 @@ on: - scripts/Test-BrowserNativeComposition.mjs - scripts/Initialize-BrowserNativeComposition.ps1 - scripts/BrowserNativePathAudit.cs + - scripts/Control-BrowserNativeProofChild.ps1 + - scripts/BrowserNativeProofTiming.mjs + - scripts/BrowserNativeProofTiming.test.mjs + - src/OpenClaw.BrowserBootstrap/** + - src/OpenClaw.BrowserBootstrap.Contracts/** + - src/OpenClaw.Shared/Browser/** + - tests/OpenClaw.BrowserBootstrap.Tests/** permissions: contents: read @@ -35,27 +42,33 @@ jobs: with: version: '12.4.0' run_install: false - - name: Verify immutable producer artifact and successful source run + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - name: Verify proof ordering instrumentation + shell: pwsh + run: node --test producer/scripts/BrowserNativeProofTiming.test.mjs + - name: Build and identify the current reviewed producer shell: pwsh - env: - GH_TOKEN: ${{ github.token }} run: | $ErrorActionPreference = 'Stop' - $run = gh api repos/openclaw/openclaw-windows-node/actions/runs/35416240545 | ConvertFrom-Json - if ($LASTEXITCODE -ne 0 -or $run.head_sha -cne '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea' -or $run.conclusion -cne 'success') { throw 'Producer run identity mismatch.' } - $artifact = gh api repos/openclaw/openclaw-windows-node/actions/artifacts/10576101237 | ConvertFrom-Json - if ($LASTEXITCODE -ne 0 -or $artifact.expired -or $artifact.name -cne 'openclaw-tray-win-x64' -or $artifact.workflow_run.id -ne 35416240545 -or $artifact.digest -cne 'sha256:9beb60bac258eaf752e27fad6a60ddfbfcc79df130cc65d5e7b09c86fe26a859') { throw 'Producer artifact identity mismatch.' } - - uses: actions/download-artifact@v8 + if ((git -C producer rev-parse HEAD).Trim() -cne $env:GITHUB_SHA) { throw 'Producer source identity mismatch.' } + dotnet test producer/tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj -c Release + if ($LASTEXITCODE -ne 0) { throw 'Current producer tests failed.' } + dotnet publish producer/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o artifact/tools/browser-bootstrap + if ($LASTEXITCODE -ne 0) { throw 'Current producer build failed.' } + $image = Get-FileHash artifact/tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe -Algorithm SHA256 + @{producerSha=$env:GITHUB_SHA;executableSha256=$image.Hash.ToLowerInvariant()} | ConvertTo-Json -Compress | Set-Content artifact/producer-source.json -Encoding utf8NoBOM + - name: Retain the new producer artifact + uses: actions/upload-artifact@v7 with: - artifact-ids: '10576101237' - run-id: '35416240545' - github-token: ${{ github.token }} - path: artifact - merge-multiple: true + name: browser-native-producer-${{ github.sha }} + path: artifact/ + retention-days: 7 - name: Audit original runtime ACLs and create private exact installation shell: pwsh run: | - if ((git -C producer rev-parse HEAD:src).Trim() -cne 'ea2e65d057b0efaea8dbddc229548a9f34843196') { throw 'Producer product source changed; artifact pin must be reviewed.' } + if ((git -C producer rev-parse HEAD).Trim() -cne $env:GITHUB_SHA) { throw 'Producer checkout changed.' } ./producer/scripts/Initialize-BrowserNativeComposition.ps1 -Consumer "${{ github.workspace }}/consumer" -Receipt "${{ runner.temp }}/composed-path-audit.json" if ($LASTEXITCODE -ne 0) { throw 'Private runtime preparation failed; see redacted path audit.' } - name: Build exact canonical runtime without changing its source diff --git a/scripts/BrowserNativeProofTiming.mjs b/scripts/BrowserNativeProofTiming.mjs new file mode 100644 index 000000000..40aafcd68 --- /dev/null +++ b/scripts/BrowserNativeProofTiming.mjs @@ -0,0 +1,3 @@ +// Proof-only: timestamp settlement when it happens, not when a later await observes it. +export const recordCompletion = (promise, clock = () => performance.now()) => + promise.then(value => ({ value, completedAt: clock() })); diff --git a/scripts/BrowserNativeProofTiming.test.mjs b/scripts/BrowserNativeProofTiming.test.mjs new file mode 100644 index 000000000..9eeabd29e --- /dev/null +++ b/scripts/BrowserNativeProofTiming.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { recordCompletion } from './BrowserNativeProofTiming.mjs'; +for (const retry of [false,true]) test('retirement-before-frame remains detectable, retry='+retry,async()=>{ + let clock=0; + if(retry)assert.equal((await recordCompletion(Promise.resolve({ok:false,code:'busy'}),()=>++clock)).value.code,'busy'); + const retirement=Promise.withResolvers(); + const recorded=recordCompletion(retirement.promise,()=>++clock); + retirement.resolve({ok:true});await Promise.resolve(); + const firstFrameAt=++clock;const completed=await recorded; + assert.throws(()=>assert.ok(firstFrameAt<=completed.completedAt)); +}); +test('frame-before-retirement is accepted',async()=>{ + let clock=0;const firstFrameAt=++clock; + const completed=await recordCompletion(Promise.resolve({ok:true}),()=>++clock); + assert.ok(firstFrameAt<=completed.completedAt); +}); diff --git a/scripts/Control-BrowserNativeProofChild.ps1 b/scripts/Control-BrowserNativeProofChild.ps1 new file mode 100644 index 000000000..d822b3447 --- /dev/null +++ b/scripts/Control-BrowserNativeProofChild.ps1 @@ -0,0 +1,82 @@ +param([Parameter(Mandatory=$true)][string]$ControlDirectory) +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted') { throw 'Disposable hosted runner required.' } +# Test-only debugger-style scheduling. Never changes product code, registry, ACLs or privileges. +# SuspendThread/ResumeThread each change the count once; only our acquired handles are resumed. +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +public sealed class OwnedNodeFreeze : IDisposable { + readonly Process process; + readonly List handles = new List(); + public OwnedNodeFreeze(Process process) { + this.process=process; + try { + foreach(ProcessThread thread in process.Threads) { + IntPtr h=OpenThread(0x0802,false,(uint)thread.Id); + if(h==IntPtr.Zero)throw new InvalidOperationException("Thread admission failed"); + if(GetProcessIdOfThread(h)!=(uint)process.Id){CloseHandle(h);throw new InvalidOperationException("Thread identity changed");} + if(SuspendThread(h)==uint.MaxValue){CloseHandle(h);throw new InvalidOperationException("Thread suspension failed");} + handles.Add(h); + } + if(handles.Count==0)throw new InvalidOperationException("No owned threads"); + } catch { Dispose();throw; } + } + public void Dispose() { + bool failed=false; + foreach(IntPtr h in handles) { if(ResumeThread(h)==uint.MaxValue && !process.HasExited)failed=true;CloseHandle(h); } + handles.Clear(); + if(failed)throw new InvalidOperationException("Owned resume failed"); + } + [DllImport("kernel32.dll",SetLastError=true)]static extern IntPtr OpenThread(uint rights,bool inherit,uint id); + [DllImport("kernel32.dll",SetLastError=true)]static extern uint GetProcessIdOfThread(IntPtr thread); + [DllImport("kernel32.dll",SetLastError=true)]static extern uint SuspendThread(IntPtr thread); + [DllImport("kernel32.dll",SetLastError=true)]static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll")]static extern bool CloseHandle(IntPtr handle); +} +'@ +function Mark([string]$Name) { [IO.File]::WriteAllText((Join-Path $ControlDirectory $Name),'1') } +$freeze=$null;$native=$null;$node=$null +try { + $end=[DateTime]::UtcNow.AddSeconds(25) + Mark 'ready' + $inputFile=Join-Path $ControlDirectory 'input.json' + while(!(Test-Path -LiteralPath $inputFile)) { if([DateTime]::UtcNow -gt $end){throw 'Missing owned process'};Start-Sleep -Milliseconds 10 } + $bound=Get-Content -LiteralPath $inputFile -Raw|ConvertFrom-Json + $native=[Diagnostics.Process]::GetProcessById([int]$bound.nativePid) + $null=$native.Handle # Hold the kernel object so exit/PID reuse cannot select another process. + $nativeStart=$native.StartTime.ToUniversalTime() + if($native.MainModule.FileName -cne $bound.launcher){throw 'Producer image mismatch'} + Mark 'watching' + while($null -eq $node) { + if($native.HasExited -or [DateTime]::UtcNow -gt $end){throw 'Owned child not observed'} + $children=@(Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$native.Id)) + foreach($child in $children) { + if($child.ExecutablePath -ceq $bound.node) { + $candidate=[Diagnostics.Process]::GetProcessById([int]$child.ProcessId) + $null=$candidate.Handle + if($candidate.StartTime.ToUniversalTime() -lt $nativeStart -or $candidate.MainModule.FileName -cne $bound.node){$candidate.Dispose();throw 'Child identity mismatch'} + $node=$candidate;break + } + } + if($null -eq $node){Start-Sleep -Milliseconds 10} + } + $freeze=[OwnedNodeFreeze]::new($node) + Mark 'suspended' + while(!(Test-Path -LiteralPath (Join-Path $ControlDirectory 'resume')) -and !$node.HasExited) { + if([DateTime]::UtcNow -gt $end){throw 'Owned control deadline'} + Start-Sleep -Milliseconds 10 + } + $freeze.Dispose();$freeze=$null + Mark 'settled' +} catch { + Mark 'failed' + exit 1 +} finally { + if($null -ne $freeze){$freeze.Dispose()} + if($null -ne $node){$node.Dispose()} + if($null -ne $native){$native.Dispose()} +} diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 792470268..157dacced 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -5,8 +5,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; +import { recordCompletion } from './BrowserNativeProofTiming.mjs'; -const producerSha = '6dddf3d2eabf3316d4dcc726109adab7fdccf7ea'; +const producerSha = process.env.GITHUB_SHA; const consumerSha = '57f78c49d47f445b85d4859f038e8447e08983ba'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; @@ -27,25 +28,69 @@ function check(name) { receipt.cases.push(name); console.log('COMPOSED_CASE_OK ' function request(action, selected = context) { return { v: 1, action, mode: 'native-windows-cli', context: selected, expectedOrigins: [origin], store: 'preserve' }; } -async function exchange(file, args, input, { end = false, timeout = 65000, env = baseEnv } = {}) { +async function exchange(file, args, input, { end = false, timeout = 65000, env = baseEnv, onSpawn } = {}) { return await new Promise((resolve, reject) => { const child = spawn(file, args, { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); - let out = Buffer.alloc(0), errBytes = 0, finished = false; + let out = Buffer.alloc(0), errBytes = 0, finished = false, firstFrameAt = null; + function terminateOwnedTree() { + if(child.pid){try{execFileSync(path.join(process.env.SystemRoot,'System32','taskkill.exe'),['/PID',String(child.pid),'/T','/F'],{stdio:'ignore',timeout:5000});}catch{}} + } + child.once('spawn',async()=>{ + try { + if(onSpawn)await onSpawn(child); + if(finished)return; + child.stdin.write(input); + if(end)child.stdin.end(); + } catch { + terminateOwnedTree();finish(new Error('owned_control_failed')); + } + }); const timer = setTimeout(() => { // Owned PID only, never a process-name/global kill. No caller command interpolation. - if (child.pid) { try { execFileSync(path.join(process.env.SystemRoot, 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); } catch {} } + terminateOwnedTree(); finish(new Error('bounded_process_timeout')); }, timeout); function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); } child.once('error', () => finish(new Error('process_start_failed'))); - child.stdout.on('data', (part) => { out = Buffer.concat([out, part]); if (out.length > 1048576) { child.kill(); finish(new Error('output_bound')); } }); - child.stderr.on('data', (part) => { errBytes += part.length; if (errBytes > 32768) { child.kill(); finish(new Error('stderr_bound')); } }); - child.once('close', (code) => finish(null, { code, out, errBytes })); + child.stdout.on('data', (part) => { out = Buffer.concat([out, part]); if(firstFrameAt===null && out.length>=4 && out.length>=4+out.readUInt32LE(0))firstFrameAt=performance.now(); if (out.length > 1048576) { terminateOwnedTree(); finish(new Error('output_bound')); } }); + child.stderr.on('data', (part) => { errBytes += part.length; if (errBytes > 32768) { terminateOwnedTree(); finish(new Error('stderr_bound')); } }); + child.once('close', (code) => finish(null, { code, out, errBytes, firstFrameAt })); child.stdin.on('error', () => {}); - child.stdin.write(input); - if (end) child.stdin.end(); }); } +async function withFrozenNative(installation, packet, fixture, operation) { + const dir=await fs.mkdtemp(path.join(fixture,'owned-control-')); + const helper=spawn(ps,['-NoLogo','-NoProfile','-NonInteractive','-File',fileURLToPath(new URL('./Control-BrowserNativeProofChild.ps1',import.meta.url)),'-ControlDirectory',dir],{env:baseEnv,windowsHide:true,stdio:['ignore','ignore','ignore']}); + const helperTimer=setTimeout(()=>{if(helper.pid){try{execFileSync(path.join(process.env.SystemRoot,'System32','taskkill.exe'),['/PID',String(helper.pid),'/T','/F'],{stdio:'ignore',timeout:5000});}catch{}}},40000); + const helperDone=new Promise(resolve=>{helper.once('error',()=>{clearTimeout(helperTimer);resolve(-1);});helper.once('close',code=>{clearTimeout(helperTimer);resolve(code);});}); + let helperCode;void helperDone.then(code=>{helperCode=code;}); + const marker=async name=>{try{await fs.access(path.join(dir,name));return true;}catch{return false;}}; + async function waitMarker(name) { + const until=performance.now()+15000; + while(!await marker(name)) { + if(await marker('failed') || helperCode!==undefined || performance.now()>until)throw new Error('owned_child_control_failed'); + await new Promise(resolve=>setTimeout(resolve,20)); + } + } + let native,work; + try { + await waitMarker('ready'); + work=exchange(installation.launcherPath,[origin],packet,{onSpawn:async child=>{ + native=child; + await fs.writeFile(path.join(dir,'input.tmp'),JSON.stringify({nativePid:child.pid,launcher:installation.launcherPath,node:context.nodePath})); + await fs.rename(path.join(dir,'input.tmp'),path.join(dir,'input.json')); + await waitMarker('watching'); + }}); + void work.catch(()=>{}); + await waitMarker('suspended'); + return await operation({work,resume:()=>fs.writeFile(path.join(dir,'resume'),'1'),disconnect:()=>native.stdin.end()}); + } finally { + await fs.writeFile(path.join(dir,'resume'),'1'); + if(work)await work.catch(()=>{}); + const code=await helperDone; + assert.equal(code,0,'owned_child_control_cleanup'); + } +} async function manage(value, end = true) { const result = await exchange(executable, ['--manage'], Buffer.from(JSON.stringify(value)), { end }); assert.equal(result.errBytes, 0, 'management_stderr'); @@ -122,6 +167,10 @@ try { "foreach($h in @([Microsoft.Win32.RegistryHive]::CurrentUser,[Microsoft.Win32.RegistryHive]::LocalMachine)){foreach($v in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)){$r=[Microsoft.Win32.RegistryKey]::OpenBaseKey($h,$v);try{foreach($p in $paths){$k=$r.OpenSubKey($p);if($null-ne $k){$k.Dispose();throw 'Existing product registration'}}}finally{$r.Dispose()}}}; " + "$root=Join-Path $local 'OpenClawTray\\browser-native\\generations';if(Test-Path -LiteralPath $root){throw 'Existing product generations'}; " + "[Console]::Out.Write((ConvertTo-Json -Compress @{local=$local;sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value}))")); + assert.match(producerSha ?? '', /^[0-9a-f]{40}$/, 'producer_revision_required'); + const producerSource = JSON.parse(await fs.readFile(path.join(artifact,'producer-source.json'),'utf8')); + assert.equal(producerSource.producerSha,producerSha,'new_producer_source_mismatch'); + assert.equal(execFileSync('git',['-C',fileURLToPath(new URL('../',import.meta.url)),'rev-parse','HEAD'],{encoding:'utf8'}).trim(),producerSha,'producer_checkout_mismatch'); stage = 'private-fixture'; // Windows TEMP may contain an 8.3 alias. The production contract requires canonical paths. const fixture = await fs.realpath(await fs.mkdtemp(path.join(process.env.COMPOSED_PRIVATE_ROOT, 'fixture-'))); @@ -132,6 +181,7 @@ try { await fs.copyFile(source, executable, fs.constants.COPYFILE_EXCL); const hash = async (file) => crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex'); receipt.producerExecutableSha256 = await hash(source); + assert.equal(receipt.producerExecutableSha256,producerSource.executableSha256,'new_producer_image_mismatch'); assert.equal(await hash(executable), receipt.producerExecutableSha256, 'producer_copy_hash'); const state = path.join(fixture, 'state'); await fs.mkdir(state); const configPath = path.join(state, 'openclaw.json'); @@ -233,7 +283,58 @@ try { const rejectedWithoutEffects = (entry) => entry.explicitlyRefused && !entry.pairingEmitted && !entry.stateChanged; if (receipt.revocationPrepublicationStateChanged || !rejectedWithoutEffects(receipt.revokedGeneration) || !rejectedWithoutEffects(receipt.reassignedGeneration) || receipt.reassignedGeneration.replacementStateChanged) { receipt.status = 'authority_review_required'; process.exitCode = 1; - } else { check('revoked_and_reassigned_generations_rejected_without_state_effects'); receipt.status = 'passed'; } + } else { + check('revoked_and_reassigned_generations_rejected_without_state_effects'); + stage='real-inflight-retirement'; + await withFrozenNative(replacement.installation,packet,fixture,async control=>{ + const [inspection,retirement]=await Promise.all([manage(request('inspect')),manage(request('uninstall'))]); + for(const value of [inspection,retirement]) { + refused(value,'busy'); + assert.equal(value.registration,null);assert.equal(value.installation,null); + } + receipt.inflight={inspectionBusy:true,retirementBusy:true}; + // The child is proved live and paused. Try retirement concurrently with release; + // a bounded busy result is explicitly not counted as completed retirement. + const pendingRetirement=recordCompletion(manage(request('uninstall'))); + await control.resume(); + const result=await control.work; + assert.equal(response(result).ok,true,'admitted_operation_not_settled'); + let completion=await pendingRetirement; + let removed=completion.value; + if(!removed.ok) { + refused(removed,'busy'); + const stillActive=await manage(request('inspect'));assert.equal(stillActive.installation.generation,replacement.installation.generation); + completion=await recordCompletion(manage(request('uninstall')));removed=completion.value; + } + const retirementComplete=completion.completedAt; + assert.equal(removed.ok,true);assert.equal(removed.registration,'missing'); + assert.ok(result.firstFrameAt!==null && result.firstFrameAt<=retirementComplete,'credential_after_completed_retirement'); + receipt.inflight.responseBeforeCompletedRetirement=true; + }); + check('real_inflight_owner_serializes_inspection_retirement_and_response'); + stage='real-inflight-eof'; + context=await isolatedContext(fixture,'inflight-eof-state'); + const eofBefore=await stateSnapshot(context.stateDir); + const eofInstalled=await manage(request('install'));assert.equal(eofInstalled.ok,true); + assert.equal(await stateSnapshot(context.stateDir),eofBefore,'prepublication_state_effect'); + await withFrozenNative(eofInstalled.installation,packet,fixture,async control=>{ + assert.equal(await stateSnapshot(context.stateDir),eofBefore,'eof_fixture_already_effectful'); + control.disconnect(); + const cancelled=await control.work; + assert.equal(cancelled.code,0);assert.equal(cancelled.out.length,0);assert.equal(cancelled.errBytes,0); + }); + assert.equal(await stateSnapshot(context.stateDir),eofBefore,'state_effect_after_inflight_eof'); + const eofRemoved=await manage(request('uninstall'));assert.equal(eofRemoved.ok,true);assert.equal(eofRemoved.registration,'missing'); + check('real_inflight_eof_joins_owned_node_without_pairing_or_state_effect'); + stage='retired-parser-variants'; + for(const raw of [JSON.stringify({v:1,op:'bootstrap',nonce,extra:true}),JSON.stringify({v:1,op:'bootstrap',nonce}).replace(':1,',':1.0,'),JSON.stringify({v:1,op:'bootstrap',nonce}).replace(':1,',':1e0,')]) { + const payload=Buffer.from(raw),header=Buffer.alloc(4);header.writeUInt32LE(payload.length); + refused(response(await exchange(eofInstalled.installation.launcherPath,[origin],Buffer.concat([header,payload]))),'manifest_invalid'); + } + assert.equal(await stateSnapshot(context.stateDir),eofBefore,'retired_variant_state_effect'); + check('retired_valid_nonce_parser_variants_cannot_bypass_activation'); + receipt.status='passed'; + } } catch (error) { // Never serialize native stdout, pairing strings, config contents or a child diagnostic. receipt.failureStage = stage; diff --git a/src/OpenClaw.BrowserBootstrap/GenerationStore.cs b/src/OpenClaw.BrowserBootstrap/GenerationStore.cs index 54d195700..216c1932b 100644 --- a/src/OpenClaw.BrowserBootstrap/GenerationStore.cs +++ b/src/OpenClaw.BrowserBootstrap/GenerationStore.cs @@ -127,10 +127,21 @@ private static async Task Probe(string exe,string origin,bool invalid,Cancellati var info=new ProcessStartInfo(exe){UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true}; info.ArgumentList.Add(origin);info.ArgumentList.Add("--parent-window=0"); using var child=Process.Start(info)??throw new ContractException("transport_failed"); + ProbeProcessTree? tree=null; using var timeout=CancellationTokenSource.CreateLinkedTokenSource(ct);timeout.CancelAfter(TimeSpan.FromSeconds(15)); + using var stop=timeout.Token.Register(()=> + { + try{tree?.Terminate();}catch(System.ComponentModel.Win32Exception){} + try{if(!child.HasExited)child.Kill(true);}catch(InvalidOperationException){}catch(System.ComponentModel.Win32Exception){} + }); try { - var request=ManagementContract.Serialize(new{v=1,op="bootstrap",nonce=invalid?"!":Convert.ToBase64String(new byte[16]).TrimEnd('=')}); + if(!OperatingSystem.IsWindows())throw new ContractException("platform_unsupported"); + // The launcher cannot start Node before ReadAsync receives this frame. + // Assign the tree before releasing that existing protocol gate, not after fork. + tree=new ProbeProcessTree(child); + timeout.Token.ThrowIfCancellationRequested(); + var request=NativeKeylessProbe.Request(invalid); await BrowserNativeProtocol.WriteAsync(child.StandardInput.BaseStream,request,timeout.Token); // Do not close Chrome stdin. var response=await BrowserNativeProtocol.ReadAsync(child.StandardOutput.BaseStream,4096,timeout.Token); var expected=BrowserNativeProtocol.Failure(invalid?"invalid_request":"origin_forbidden"); @@ -139,7 +150,28 @@ private static async Task Probe(string exe,string origin,bool invalid,Cancellati await child.StandardError.BaseStream.ReadAsync(extra,timeout.Token)!=0)throw new ContractException("transport_failed"); await child.WaitForExitAsync(timeout.Token);if(child.ExitCode!=0)throw new ContractException("transport_failed"); } - finally{if(!child.HasExited)child.Kill(true);using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2));await child.WaitForExitAsync(end.Token);} + finally + { + stop.Dispose(); // Join the callback before closing any process/job handle. + using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + try{if(OperatingSystem.IsWindows())tree?.Terminate();if(!child.HasExited)child.Kill(true);}catch(InvalidOperationException){} + } + finally + { + try{await BrowserBootstrapProcessLifetime.JoinAsync(child,end.Token);} + finally + { + child.Dispose(); + if(OperatingSystem.IsWindows() && tree is not null) + { + try{await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(tree.JoinAsync(),end.Token);} + finally{tree.Dispose();} + } + } + } + } } public static string Hash(byte[] data)=>Convert.ToHexStringLower(SHA256.HashData(data)); private sealed class Leases(List leases):IDisposable{public void Dispose(){foreach(var l in leases)l.Dispose();}} diff --git a/src/OpenClaw.BrowserBootstrap/NativeKeylessProbe.cs b/src/OpenClaw.BrowserBootstrap/NativeKeylessProbe.cs new file mode 100644 index 000000000..3f6f541e8 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/NativeKeylessProbe.cs @@ -0,0 +1,27 @@ +using OpenClaw.BrowserBootstrap.Contracts; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.BrowserBootstrap; + +/// Only the exact existing negative probes can run while management owns the mutex. +internal static class NativeKeylessProbe +{ + internal static byte[] Request(bool invalid) => invalid + ? """{"v":1,"op":"bootstrap","nonce":"!"}"""u8.ToArray() + : """{"v":1,"op":"bootstrap","nonce":"AAAAAAAAAAAAAAAAAAAAAA"}"""u8.ToArray(); + + internal static string? ExpectedFailure(byte[] request, HostBinding binding, string caller) + { + if (binding.Mode != ManagementContract.Native) return null; + if (caller == binding.ExpectedOrigins[0] && request.AsSpan().SequenceEqual(Request(true))) return "invalid_request"; + if (caller == ManagementContract.RejectionOrigin(binding.ExpectedOrigins) && request.AsSpan().SequenceEqual(Request(false))) return "origin_forbidden"; + return null; + } + + internal static void RequireFailure(byte[] response, string expected) + { + // In particular, never forward an unexpected success or fabricate the expected probe pass. + if (!response.AsSpan().SequenceEqual(BrowserNativeProtocol.Failure(expected))) + throw new ContractException("transport_failed"); + } +} diff --git a/src/OpenClaw.BrowserBootstrap/NativeRegistrationRuntime.cs b/src/OpenClaw.BrowserBootstrap/NativeRegistrationRuntime.cs new file mode 100644 index 000000000..ffc7db9f2 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/NativeRegistrationRuntime.cs @@ -0,0 +1,37 @@ +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.BrowserBootstrap; + +internal interface INativeRegistrationPlatform +{ + IDisposable Acquire(); + NativeInventory ObserveNative(); + IDisposable LeaseRuntime(Generation generation); +} + +/// Serializes new native effects and their final response with registration retirement. +internal sealed class NativeRegistrationRuntime(INativeRegistrationPlatform platform) +{ + internal Task RunAsync(Generation expected, Func operation, CancellationToken ct) => + Task.Factory.StartNew(() => Execute(expected, operation, ct), CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + + private void Execute(Generation expected, Func operation, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + // Mutex ownership is thread-affine. This thread blocks on the complete async operation; + // neither acquisition nor disposal is delegated to an arbitrary continuation. + using var owner = platform.Acquire(); + ct.ThrowIfCancellationRequested(); + var observed = platform.ObserveNative(); + if (observed.Error is {} error) throw new ContractException(error); + var active = observed.Active; + if (active is null || active.Installation != expected.Installation || active.Receipt != expected.Receipt || + !ManagementContract.Serialize(active.Binding).AsSpan().SequenceEqual(ManagementContract.Serialize(expected.Binding))) + throw new ContractException("binding_invalid"); + using var runtime = platform.LeaseRuntime(active); + ct.ThrowIfCancellationRequested(); + // Includes backend joins and the final credential frame write/flush, not just generation. + operation(active, ct).GetAwaiter().GetResult(); + } +} diff --git a/src/OpenClaw.BrowserBootstrap/NativeTransport.cs b/src/OpenClaw.BrowserBootstrap/NativeTransport.cs index 910d502a8..caf0c12ae 100644 --- a/src/OpenClaw.BrowserBootstrap/NativeTransport.cs +++ b/src/OpenClaw.BrowserBootstrap/NativeTransport.cs @@ -24,7 +24,7 @@ internal static bool IsCaller(string[] args) => args.Length is 1 or 2 && args[0] args[0].StartsWith("chrome-extension://",StringComparison.Ordinal)&&args[0][^1]=='/'&&args[0].AsSpan(19,32).ToArray().All(c=>c is >= 'a' and <= 'p') && (args.Length==1 || args[1].StartsWith("--parent-window=",StringComparison.Ordinal)&&ulong.TryParse(args[1][16..],System.Globalization.NumberStyles.None,System.Globalization.CultureInfo.InvariantCulture,out _)); internal static async Task RunAsync(Stream input,Stream output,string[] args,Func load, - Func runtimeLease, CancellationToken cancel, + Func runtimeLease, NativeRegistrationRuntime activation, CancellationToken cancel, Func>? exchange = null) { byte[] response; @@ -45,25 +45,53 @@ internal static async Task RunAsync(Stream input,Stream output,string[] args,Fun if(generation.Binding.Mode==ManagementContract.Companion) _=BrowserNativeProtocol.ParseRequest(request); gone=WatchDisconnect(input,lifetime); lifetime.Token.ThrowIfCancellationRequested(); - using var lease=runtimeLease(generation); - response = exchange is not null - ? await exchange(request,generation,args[0],lifetime.Token) - : generation.Binding.Mode==ManagementContract.Companion - ? await PipeAsync(request,lifetime.Token) - : await ChildAsync(request,CreateStartInfo(generation,args[0]),lifetime.Token); - lifetime.Token.ThrowIfCancellationRequested(); - await BrowserNativeProtocol.WriteAsync(output,response,lifetime.Token); + async Task ExchangeAndDeliver(Generation current,CancellationToken ct) + { + byte[] result; + var expected=NativeKeylessProbe.ExpectedFailure(request,current.Binding,args[0]); + try + { + result = exchange is not null + ? await exchange(request,current,args[0],ct) + : current.Binding.Mode==ManagementContract.Companion + ? await PipeAsync(request,ct) + : await ChildAsync(request,CreateStartInfo(current,args[0]),ct); + if(expected is not null)NativeKeylessProbe.RequireFailure(result,expected); + if(result.Length is 0 or > BrowserNativeProtocol.ResponseLimit)throw new InvalidDataException("invalid_frame"); + } + catch(Exception) when(ct.IsCancellationRequested){return;} + catch(InvalidDataException e) when(expected is null && e.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") + {result=BrowserNativeProtocol.Failure(e.Message);} + catch(ContractException){result=BrowserNativeProtocol.Failure("manifest_invalid");} + catch{result=BrowserNativeProtocol.Failure("pairing_unavailable");} + if(ct.IsCancellationRequested)return; + // Admitted failures also finish under the owner. A failed/partial + // write must never cause a second frame after ownership is released. + try{await BrowserNativeProtocol.WriteAsync(output,result,ct);} + catch(Exception error){throw new DeliveryException(error);} + } + if(NativeKeylessProbe.ExpectedFailure(request,generation.Binding,args[0]) is not null) + { + // These exact inputs are guaranteed keyless by the canonical decoder/origin + // checks. Management already owns the mutex; still execute the actual TS. + using var lease=runtimeLease(generation); + await ExchangeAndDeliver(generation,lifetime.Token); + } + else await activation.RunAsync(generation,ExchangeAndDeliver,lifetime.Token); return; } } - catch(OperationCanceledException) { return; } + catch(DeliveryException){throw;} + catch(OperationCanceledException) when(lifetime.IsCancellationRequested) { return; } + catch(Exception) when(lifetime.IsCancellationRequested) { return; } catch(InvalidDataException e) when(e.Message is "invalid_frame" or "invalid_utf8" or "invalid_request") { response=BrowserNativeProtocol.Failure(e.Message); } - catch(ContractException) { response=BrowserNativeProtocol.Failure("manifest_invalid"); } + catch(ContractException e) { response=BrowserNativeProtocol.Failure(e.Code=="busy"?"pairing_unavailable":"manifest_invalid"); } catch { response=BrowserNativeProtocol.Failure("pairing_unavailable"); } finally { await lifetime.CancelAsync(); Observe(gone); } } await BrowserNativeProtocol.WriteAsync(output,response,cancel); } + private sealed class DeliveryException(Exception inner):IOException("Native response delivery failed.",inner) { } private static async Task WatchDisconnect(Stream input,CancellationTokenSource lifetime) { var extra=new byte[1]; @@ -74,12 +102,14 @@ private static async Task PipeAsync(byte[] request,CancellationToken ct) { using var pipe=new NamedPipeClientStream(".",BrowserNativeProtocol.PipeName,PipeDirection.InOut,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); await pipe.ConnectAsync(2000,ct); - await BrowserNativeProtocol.WriteAsync(pipe,request,ct); - return await BrowserNativeProtocol.ReadAsync(pipe,BrowserNativeProtocol.ResponseLimit,ct); + return await BrowserBootstrapPipeClient.ExchangeAsync(pipe,request,ct); } internal static async Task ChildAsync(byte[] request,ProcessStartInfo info,CancellationToken ct) { using var child=new Process{StartInfo=info};if(!child.Start())throw new IOException(); + // Windows redirected reads can remain pending until their writer closes. + // Kill on cancellation independently of reaching the async finally block. + using var stop=ct.Register(()=>{try{if(!child.HasExited)child.Kill(true);}catch(InvalidOperationException){}catch(System.ComponentModel.Win32Exception){}}); var errors=Drain(child.StandardError.BaseStream,ct); try { @@ -92,8 +122,18 @@ internal static async Task ChildAsync(byte[] request,ProcessStartInfo in } finally { - try{if(!child.HasExited)child.Kill(true);using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2));await child.WaitForExitAsync(end.Token);} - finally{Observe(errors);} + using var end=new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + try{if(!child.HasExited)child.Kill(true);}catch(InvalidOperationException){} + finally{await BrowserBootstrapProcessLifetime.JoinAsync(child,end.Token);} + } + finally + { + try{await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(errors,end.Token);} + catch(OperationCanceledException) when(ct.IsCancellationRequested && errors.IsCompleted){} + finally{Observe(errors);} + } } } private static async Task Drain(Stream stream,CancellationToken ct){var bytes=new byte[4096];while(await stream.ReadAsync(bytes,ct)!=0){} } diff --git a/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj b/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj index c8d8ef43d..9e0897325 100644 --- a/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj +++ b/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj @@ -1,4 +1,4 @@ Exenet10.0enableenableOpenClaw.BrowserBootstrap - + diff --git a/src/OpenClaw.BrowserBootstrap/ProbeProcessTree.cs b/src/OpenClaw.BrowserBootstrap/ProbeProcessTree.cs new file mode 100644 index 000000000..0718c8650 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/ProbeProcessTree.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace OpenClaw.BrowserBootstrap; + +/// Owns only a keyless probe tree, assigned before its input frame permits Node startup. +[SupportedOSPlatform("windows")] +internal sealed class ProbeProcessTree : IDisposable +{ + private readonly SafeFileHandle job; + internal ProbeProcessTree(Process launcher) + { + job=CreateJobObjectW(IntPtr.Zero,null); + if(job.IsInvalid)throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + try + { + var limits=new ExtendedLimits{Basic=new BasicLimits{Flags=0x2000}}; + if(!SetInformationJobObject(job,9,ref limits,Marshal.SizeOf()) || !AssignProcessToJobObject(job,launcher.SafeHandle)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + catch{job.Dispose();throw;} + } + internal void Terminate() + { + if(!TerminateJobObject(job,1))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + internal async Task JoinAsync() + { + // A launcher exit is not a descendant join. Keep management ownership until + // the job reports no active process, even after cancellation requested a kill. + while(true) + { + if(!QueryInformationJobObject(job,1,out var accounting,Marshal.SizeOf(),out _)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + if(accounting.ActiveProcesses==0)return; + await Task.Delay(10).ConfigureAwait(false); + } + } + public void Dispose()=>job.Dispose(); + [StructLayout(LayoutKind.Sequential)]private struct Accounting + {public long User,Kernel,PeriodUser,PeriodKernel;public uint PageFaults,TotalProcesses,ActiveProcesses,TerminatedProcesses;} + [StructLayout(LayoutKind.Sequential)]private struct BasicLimits + {public long User,JobUser;public uint Flags;public UIntPtr MinWorkingSet,MaxWorkingSet;public uint ActiveProcessLimit;public UIntPtr Affinity;public uint Priority,Scheduling;} + [StructLayout(LayoutKind.Sequential)]private struct IoCounters + {public ulong ReadOperations,WriteOperations,OtherOperations,ReadBytes,WriteBytes,OtherBytes;} + [StructLayout(LayoutKind.Sequential)]private struct ExtendedLimits + {public BasicLimits Basic;public IoCounters Io;public UIntPtr ProcessMemory,JobMemory,PeakProcessMemory,PeakJobMemory;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)]private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes,string? name); + [DllImport("kernel32.dll",SetLastError=true)][return:MarshalAs(UnmanagedType.Bool)]private static extern bool SetInformationJobObject(SafeFileHandle job,int kind,ref ExtendedLimits limits,int length); + [DllImport("kernel32.dll",SetLastError=true)][return:MarshalAs(UnmanagedType.Bool)]private static extern bool AssignProcessToJobObject(SafeFileHandle job,SafeProcessHandle process); + [DllImport("kernel32.dll",SetLastError=true)][return:MarshalAs(UnmanagedType.Bool)]private static extern bool TerminateJobObject(SafeFileHandle job,uint code); + [DllImport("kernel32.dll",SetLastError=true)][return:MarshalAs(UnmanagedType.Bool)]private static extern bool QueryInformationJobObject(SafeFileHandle job,int kind,out Accounting accounting,int length,out int returned); +} diff --git a/src/OpenClaw.BrowserBootstrap/Program.cs b/src/OpenClaw.BrowserBootstrap/Program.cs index e5b598aaa..ca3b7ce72 100644 --- a/src/OpenClaw.BrowserBootstrap/Program.cs +++ b/src/OpenClaw.BrowserBootstrap/Program.cs @@ -41,7 +41,8 @@ internal static async Task Main(string[] args) return response.Ok?0:1; } if(!OperatingSystem.IsWindows())return 1; - var authority=new WindowsAuthority();var generations=new GenerationStore(authority); + var platform=new WindowsRegistrationPlatform();var generations=platform.Generations; + var activation=new NativeRegistrationRuntime(platform); var exe=Environment.ProcessPath??throw new IOException(); var manifest=Path.Combine(Path.GetDirectoryName(exe)!,ManagementContract.ManifestName); using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)); @@ -51,7 +52,7 @@ await NativeTransport.RunAsync(Console.OpenStandardInput(),Console.OpenStandardO var g=generations.Read(manifest); if(!ManagementContract.PathEquals(g.Installation.LauncherPath,exe))throw new ContractException("binding_invalid"); return g; - },generations.RuntimeLease,timeout.Token); + },generations.RuntimeLease,activation,timeout.Token); return 0; } catch{return 1;} // No stdout/stderr diagnostics outside a complete typed response. diff --git a/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs index 9ff2f7dfb..36c20e7b3 100644 --- a/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs +++ b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs @@ -10,7 +10,7 @@ namespace OpenClaw.BrowserBootstrap; /// Sole native and Store registry writer. No alternate TS/PowerShell mutation backend. [SupportedOSPlatform("windows")] -internal sealed class WindowsRegistrationPlatform : IRegistrationPlatform +internal sealed class WindowsRegistrationPlatform : IRegistrationPlatform, INativeRegistrationPlatform { internal const string StoreKey = @"Software\Google\Chrome\Extensions\kcdjddhmeafeomebliikmbpblkmkfoig"; internal const string StoreOwner = "openclaw_native_host", UpdateUrl = "https://clients2.google.com/service/update2/crx"; @@ -20,6 +20,8 @@ internal sealed class WindowsRegistrationPlatform : IRegistrationPlatform private readonly string[] nativeKeys=NativeKeys; internal WindowsRegistrationPlatform(){authority=new();Generations=new(authority);} public IDisposable Acquire()=>authority.Lock(); + public IDisposable LeaseRuntime(Generation generation)=>Generations.RuntimeLease(generation); + public NativeInventory ObserveNative()=>ObserveNative(null); public void ValidateRuntime(Generation generation){using var lease=Generations.RuntimeLease(generation);} public Generation Prepare(ManagementRequest request,CancellationToken ct)=>Generations.Prepare(request,ct,ObserveNative().Generations); internal sealed record Value(string Name,RegistryValueKind Kind,string? Text); @@ -41,7 +43,7 @@ private Row ReadRow(RegistryHive hive,RegistryView view,string path) private Row[] Rows(string[] paths)=>(from path in paths from hive in new[]{RegistryHive.CurrentUser,RegistryHive.LocalMachine} from view in Views select ReadRow(hive,view,path)).ToArray(); private static bool Same(Row a,Row b)=>a.Hive==b.Hive&&a.View==b.View&&a.Path==b.Path&&a.Exists==b.Exists&&a.Values.SequenceEqual(b.Values); public Inventory Observe(ManagementRequest request)=>new(ObserveNative(),ObserveStore(request)); - private NativeInventory ObserveNative(Row[]? snapshot=null) + private NativeInventory ObserveNative(Row[]? snapshot) { try { diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs index c50afa794..e9b607a0b 100644 --- a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs +++ b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using OpenClaw.Shared.Browser; namespace OpenClaw.Connection; @@ -57,6 +58,12 @@ public static async Task RunAsync(string distro, CancellationToken ct) using var process = Process.Start(start) ?? throw new IOException("pairing_unavailable"); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(TimeSpan.FromSeconds(15)); + using var stop = deadline.Token.Register(() => + { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + catch (System.ComponentModel.Win32Exception) { } + }); var output = ReadBoundedAsync(process.StandardOutput, deadline.Token); var error = ReadBoundedAsync(process.StandardError, deadline.Token); try @@ -70,10 +77,28 @@ public static async Task RunAsync(string distro, CancellationToken ct) } finally { - if (!process.HasExited) + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + try + { + if (!process.HasExited) + { + try { process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + } + } + finally { await BrowserBootstrapProcessLifetime.JoinAsync(process, cleanup.Token); } + } + finally { - try { process.Kill(entireProcessTree: true); } - catch (InvalidOperationException) { } + try { await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(Task.WhenAll(output, error), cleanup.Token); } + catch (OperationCanceledException) when (deadline.IsCancellationRequested && output.IsCompleted && error.IsCompleted) { } + // Windows process/drain settlement is not Linux guest settlement proof. + _ = output.ContinueWith(t => _ = t.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _ = error.ContinueWith(t => _ = t.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } } diff --git a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeClient.cs b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeClient.cs new file mode 100644 index 000000000..697637fa5 --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeClient.cs @@ -0,0 +1,46 @@ +namespace OpenClaw.Shared.Browser; + +/// One request is settled only after its server handler closes the connection. +public static class BrowserBootstrapPipeClient +{ + public static async Task ExchangeAsync(Stream pipe, byte[] request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var completeRequest = false; + try + { + await BrowserNativeProtocol.WriteAsync(pipe, request, ct); + completeRequest = true; + var response = await BrowserNativeProtocol.ReadAsync(pipe, BrowserNativeProtocol.ResponseLimit, ct); + if (await pipe.ReadAsync(new byte[1], ct) != 0) throw new InvalidDataException("Unexpected extra response"); + ct.ThrowIfCancellationRequested(); + return response; + } + catch + { + // The existing server treats extra input after a frame as cancellation. Keep + // the connection open so its EOF acknowledges that the handler has settled. + // The server owns its existing request deadline and handler cleanup. A client + // timer cannot grant retirement while that handler is still running. An + // unsettled peer keeps activation held (management remains busy), not successful. + // No new timeout or force-unlock substitutes for observing its closure. + try + { + await pipe.WriteAsync(new byte[] { 0 }, CancellationToken.None); + await pipe.FlushAsync(CancellationToken.None); + var buffer = new byte[4096]; + // The response cap above still rejects invalid responses. Discarding + // invalid/late bytes uses fixed memory and must not release ownership + // before EOF merely because the rejected stream is oversized. + while (await pipe.ReadAsync(buffer, CancellationToken.None) != 0) { } + } + catch (Exception error) + { + // Never turn a partial send or unconfirmed cleanup into successful work. + throw new IOException("Pipe settlement was not confirmed", error); + } + if (!completeRequest) throw new IOException("Pipe request completion was not confirmed"); + throw; + } + } +} diff --git a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs index a66ac592a..2324cf193 100644 --- a/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs @@ -58,8 +58,10 @@ private async Task HandleConnectionAsync(Stream pipe) { await lifetime.CancelAsync(); if (disconnected is not null) - _ = disconnected.ContinueWith(t => _ = t.Exception, CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + { + try { await disconnected; } + catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { } + } } } private static async Task WatchDisconnectAsync(Stream pipe, CancellationTokenSource lifetime) diff --git a/src/OpenClaw.Shared/Browser/BrowserBootstrapProcessLifetime.cs b/src/OpenClaw.Shared/Browser/BrowserBootstrapProcessLifetime.cs new file mode 100644 index 000000000..1a1a3699a --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapProcessLifetime.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace OpenClaw.Shared.Browser; + +/// Cleanup failure does not authorize retirement of work that is still alive. +public static class BrowserBootstrapProcessLifetime +{ + public static Task JoinAsync(Process process, CancellationToken deadline) => + AwaitOwnedAsync(process.WaitForExitAsync(CancellationToken.None), deadline); + + public static async Task AwaitOwnedAsync(Task work, CancellationToken deadline) + { + try { await work.WaitAsync(deadline).ConfigureAwait(false); } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + // Preserve the expired-budget failure, but not at the cost of allowing + // retirement while this owned process/task is still active. An unjoined + // operation keeps the activation scope held and management busy. + try { await work.ConfigureAwait(false); } + catch { /* Work has settled; retain the original cleanup failure. */ } + throw; + } + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs index 188498eff..5c4773dee 100644 --- a/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs +++ b/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs @@ -12,10 +12,11 @@ public async Task ChromeEofRevokesBothBackendsBeforeResponse(string mode) var request=ManagementContractTests.Request() with {Mode=mode}; if(mode==ManagementContract.Companion)request=request with {Context=null}; var generation=RegistrationServiceTests.Make(request); + using var platform=new NativeRegistrationRuntimeTests.Platform(generation); using var input=new DisconnectableFrame();using var output=new MemoryStream(); var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cancelled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),default, + var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),new(platform),default, async (_,_,_,ct)=>{entered.SetResult();try{await Task.Delay(Timeout.Infinite,ct);}catch(OperationCanceledException){cancelled.SetResult();throw;}return [];}); await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));input.Disconnect(); await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(2));await run.WaitAsync(TimeSpan.FromSeconds(2)); @@ -24,8 +25,10 @@ public async Task ChromeEofRevokesBothBackendsBeforeResponse(string mode) [Fact]public async Task UnsafeRuntimeNeverStartsBackend() { var generation=RegistrationServiceTests.Make(ManagementContractTests.Request()); + using var platform=new NativeRegistrationRuntimeTests.Platform(generation); + platform.Admit=_=>throw new ContractException("unsafe_acl"); using var input=new DisconnectableFrame();using var output=new MemoryStream();var starts=0; - await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>throw new ContractException("unsafe_acl"),default, + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>throw new ContractException("unsafe_acl"),new(platform),default, (_,_,_,_)=>{starts++;return Task.FromResult(Array.Empty());}); Assert.Equal(0,starts); Assert.Contains("manifest_invalid",Encoding.UTF8.GetString(output.ToArray())); @@ -33,10 +36,11 @@ [Fact]public async Task UnsafeRuntimeNeverStartsBackend() [Fact]public async Task RevokedBackendCannotPublishEvenIfItIgnoresCancellation() { var generation=RegistrationServiceTests.Make(ManagementContractTests.Request()); + using var platform=new NativeRegistrationRuntimeTests.Platform(generation); using var input=new DisconnectableFrame();using var output=new MemoryStream(); var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),default, + var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>generation,_=>new Lease(),new(platform),default, (_,_,_,_)=>{entered.SetResult();return release.Task;}); await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));input.Disconnect(); await input.Disconnected.WaitAsync(TimeSpan.FromSeconds(2)); diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/NativeChildLifetimeTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/NativeChildLifetimeTests.cs new file mode 100644 index 000000000..f72ca1ee6 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/NativeChildLifetimeTests.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public class NativeChildLifetimeTests +{ + [Fact]public async Task CancellationKillsAndJoinsSilentOwnedChildBeforeReturning() + { + var directory=Path.Combine(Path.GetTempPath(),"OpenClawNativeChildTests-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(directory); + var marker=Path.Combine(directory,"started"); + using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var info=new ProcessStartInfo {UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true,WorkingDirectory=directory}; + if(OperatingSystem.IsWindows()) + { + info.FileName=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); + foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-Command","[IO.File]::WriteAllText('started.tmp',[string]$PID);[IO.File]::Move('started.tmp','started');Start-Sleep -Seconds 60"})info.ArgumentList.Add(a); + } + else + { + info.FileName="/bin/sh";info.ArgumentList.Add("-c");info.ArgumentList.Add("printf '%s' $$ > started.tmp; mv started.tmp started; sleep 60"); + } + var run=NativeTransport.ChildAsync(NativeKeylessProbe.Request(false),info,stop.Token); + try + { + while(!File.Exists(marker)){stop.Token.ThrowIfCancellationRequested();await Task.Delay(10,stop.Token);} + // The marker is a fixture synchronization point, not a timing-only race. + var pid=int.Parse(await File.ReadAllTextAsync(marker,stop.Token)); + using var owned=Process.GetProcessById(pid);stop.Cancel(); + await Assert.ThrowsAnyAsync(()=>run.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(run.IsCompleted);Assert.True(owned.HasExited); + } + finally{stop.Cancel();try{await run;}catch{}Directory.Delete(directory,true);} + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/NativeRegistrationRuntimeTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/NativeRegistrationRuntimeTests.cs new file mode 100644 index 000000000..292c7af30 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/NativeRegistrationRuntimeTests.cs @@ -0,0 +1,213 @@ +using System.Buffers.Binary; +using System.Text; +using OpenClaw.BrowserBootstrap.Contracts; +using System.Text.Json; + +namespace OpenClaw.BrowserBootstrap.Tests; + +public class NativeRegistrationRuntimeTests +{ + private static byte[] Failure(string code)=>JsonSerializer.SerializeToUtf8Bytes(new{v=1,ok=false,code}); + private static byte[] Pairing(string nonce,string pairingString)=>JsonSerializer.SerializeToUtf8Bytes(new{v=1,ok=true,nonce,pairingString}); + internal static Generation Make(string mode=ManagementContract.Native) + { + var request=ManagementContractTests.Request() with{Mode=mode}; + return RegistrationServiceTests.Make(mode==ManagementContract.Companion?request with{Context=null}:request); + } + internal sealed class Platform(Generation generation):INativeRegistrationPlatform,IDisposable + { + internal readonly Mutex Mutex=new(); + internal NativeInventory Inventory=new("owned",[generation]); + internal readonly List Acquired=[],Released=[]; + internal int Observations,Leases; + internal bool Abandoned; + internal Action? OnAcquireAttempt; + internal Func? Admit; + public IDisposable Acquire() + { + OnAcquireAttempt?.Invoke(); + try { if(!Mutex.WaitOne(TimeSpan.FromSeconds(5)))throw new ContractException("busy"); } + catch(AbandonedMutexException){Abandoned=true;} + lock(Acquired)Acquired.Add(Environment.CurrentManagedThreadId); + return new Release(()=>{lock(Released)Released.Add(Environment.CurrentManagedThreadId);Mutex.ReleaseMutex();}); + } + public NativeInventory ObserveNative(){Interlocked.Increment(ref Observations);return Inventory;} + public IDisposable LeaseRuntime(Generation g){Interlocked.Increment(ref Leases);return Admit?.Invoke(g)??new Release(()=>{});} + public void Dispose()=>Mutex.Dispose(); + } + internal sealed class Release(Action release):IDisposable{public void Dispose()=>release();} + internal sealed class Input(byte[] payload):MemoryStream(Frame(payload)) + { + internal readonly TaskCompletionSource Disconnected=new(TaskCreationOptions.RunContinuationsAsynchronously); + private static byte[] Frame(byte[] payload){var bytes=new byte[payload.Length+4];BinaryPrimitives.WriteInt32LittleEndian(bytes,payload.Length);payload.CopyTo(bytes,4);return bytes;} + public override async ValueTask ReadAsync(Memory buffer,CancellationToken ct=default) + { + if(Position(()=>new NativeRegistrationRuntime(p).RunAsync(g,(_,_)=>{effects++;return Task.CompletedTask;},default)); + Assert.Equal(0,effects);Assert.Equal(0,p.Leases);Assert.Equal(p.Acquired,p.Released); + } + [Theory][InlineData(false)][InlineData(true)] + public async Task ChangedGenerationOrReceiptRefusesBeforeEffects(bool receipt) + { + var g=Make();var changed=receipt?g with{Receipt=g.Receipt with{BindingSha256=new('1',64)}}: + g with{Installation=g.Installation with{Generation="aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"}}; + using var p=new Platform(g){Inventory=new("owned",[changed])}; + await Assert.ThrowsAsync(()=>new NativeRegistrationRuntime(p).RunAsync(g,(_,_)=>throw new Exception("effects entered"),default)); + Assert.Equal(0,p.Leases); + } + [Theory][InlineData(ManagementContract.Native,false)][InlineData(ManagementContract.Companion,false)] + [InlineData(ManagementContract.Native,true)][InlineData(ManagementContract.Companion,true)] + public async Task RetirementCannotCompleteBeforeFinalFrameFlush(string mode,bool failure) + { + var g=Make(mode);using var p=new Platform(g);using var input=new Input(NativeKeylessProbe.Request(false));using var output=new Output(); + var retirementWaiting=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run=NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + async (_,_,_,ct)=>{await Task.Yield();ct.ThrowIfCancellationRequested();if(failure)throw new IOException("synthetic backend failure");return Pairing("AAAAAAAAAAAAAAAAAAAAAA","synthetic-unit-only");}); + await output.Flushing.Task.WaitAsync(TimeSpan.FromSeconds(3)); + var retire=Task.Factory.StartNew(()=>{retirementWaiting.SetResult();using var held=p.Acquire();Assert.True(output.Flushed);p.Inventory=new("missing",[]);},CancellationToken.None,TaskCreationOptions.LongRunning,TaskScheduler.Default); + try + { + await retirementWaiting.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.Empty(p.Released);Assert.False(retire.IsCompleted); + output.FinishFlush.SetResult(); + await Task.WhenAll(run,retire).WaitAsync(TimeSpan.FromSeconds(3)); + Assert.Equal(p.Acquired,p.Released);Assert.Contains(failure?"pairing_unavailable":"synthetic-unit-only",Encoding.UTF8.GetString(output.ToArray())); + } + finally{output.FinishFlush.TrySetResult();} + } + [Theory][InlineData(ManagementContract.Native)][InlineData(ManagementContract.Companion)] + public async Task CompletedRetirementRefusesStaleLauncherWithoutBackend(string mode) + { + var g=Make(mode);using var p=new Platform(g);using(var owner=p.Acquire())p.Inventory=new("missing",[]); + using var input=new Input(NativeKeylessProbe.Request(false));using var output=new MemoryStream();var effects=0; + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>{effects++;return Task.FromResult(Pairing("AAAAAAAAAAAAAAAAAAAAAA","must-not-appear"));}); + Assert.Equal(0,effects);Assert.Contains("manifest_invalid",Encoding.UTF8.GetString(output.ToArray()));Assert.Equal("missing",p.Inventory.State); + } + [Theory][InlineData(false)][InlineData(true)] + public async Task ErrorAndCancellationReleaseOnAcquiringThread(bool cancel) + { + var g=Make();using var p=new Platform(g);using var stop=new CancellationTokenSource(); + await Assert.ThrowsAnyAsync(()=>new NativeRegistrationRuntime(p).RunAsync(g,async (_,ct)=> + {await Task.Yield();if(cancel){stop.Cancel();ct.ThrowIfCancellationRequested();}throw new IOException();},stop.Token)); + Assert.Equal(p.Acquired,p.Released);Assert.Single(p.Acquired); + Assert.True(p.Mutex.WaitOne(0));p.Mutex.ReleaseMutex(); + } + [Fact]public async Task CancelWhileWaitingNeverObservesOrStartsEffects() + { + var g=Make();using var p=new Platform(g);using var stop=new CancellationTokenSource(); + using var held=new ManualResetEventSlim();using var release=new ManualResetEventSlim(); + var attempt=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder=new Thread(()=>{p.Mutex.WaitOne();held.Set();release.Wait();p.Mutex.ReleaseMutex();});holder.Start();held.Wait(); + p.OnAcquireAttempt=()=>attempt.TrySetResult(); + try + { + var run=new NativeRegistrationRuntime(p).RunAsync(g,(_,_)=>throw new Exception("cancelled effects"),stop.Token); + await attempt.Task.WaitAsync(TimeSpan.FromSeconds(3));stop.Cancel();release.Set(); + await Assert.ThrowsAnyAsync(()=>run); + Assert.Equal(0,p.Observations);Assert.Equal(0,p.Leases);Assert.Equal(p.Acquired,p.Released); + } + finally{release.Set();holder.Join();} + } + [Fact]public async Task AbandonedOwnerRequiresFreshObservation() + { + var g=Make();using var p=new Platform(g);var abandoned=new Thread(()=>p.Mutex.WaitOne());abandoned.Start();abandoned.Join(); + p.Inventory=new("missing",[]); + await Assert.ThrowsAsync(()=>new NativeRegistrationRuntime(p).RunAsync(g,(_,_)=>throw new Exception("stale work"),default)); + Assert.True(p.Abandoned);Assert.Equal(1,p.Observations);Assert.Equal(p.Acquired,p.Released); + } + [Fact]public async Task CancellationKeepsOwnershipUntilBackendCleanupSettles() + { + var g=Make();using var p=new Platform(g);using var stop=new CancellationTokenSource(); + var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanup=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run=new NativeRegistrationRuntime(p).RunAsync(g,async (_,ct)=> + {entered.SetResult();try{await Task.Delay(Timeout.Infinite,ct);}finally{cleanup.SetResult();await finish.Task;}},stop.Token); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(3));stop.Cancel();await cleanup.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.False(p.Mutex.WaitOne(0));Assert.Empty(p.Released);finish.SetResult(); + await Assert.ThrowsAnyAsync(()=>run);Assert.Equal(p.Acquired,p.Released); + } + finally{finish.TrySetResult();} + } + [Theory][InlineData(true)][InlineData(false)] + public async Task ExactNegativeStillExecutesTransportWithoutAcquiring(bool invalid) + { + var g=Make();using var p=new Platform(g){Inventory=new("missing",[])}; + var caller=invalid?g.Binding.ExpectedOrigins[0]:ManagementContract.RejectionOrigin(g.Binding.ExpectedOrigins); + using var input=new Input(NativeKeylessProbe.Request(invalid));using var output=new MemoryStream();var calls=0; + await NativeTransport.RunAsync(input,output,[caller],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>{calls++;return Task.FromResult(Failure(invalid?"invalid_request":"origin_forbidden"));}); + Assert.Equal(1,calls);Assert.Empty(p.Acquired);Assert.Contains(invalid?"invalid_request":"origin_forbidden",Encoding.UTF8.GetString(output.ToArray())); + } + [Fact]public async Task BackendCleanupBudgetFailureIsNotChromeEofOrSuccess() + { + var g=Make();using var p=new Platform(g);using var input=new Input(NativeKeylessProbe.Request(false));using var output=new MemoryStream(); + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>throw new OperationCanceledException("synthetic settled cleanup failure")); + var text=Encoding.UTF8.GetString(output.ToArray());Assert.Contains("pairing_unavailable",text);Assert.DoesNotContain("pairingString",text); + Assert.Equal(p.Acquired,p.Released); + } + [Fact]public async Task UnexpectedProbeSuccessIsNotForwardedOrFabricatedAsPass() + { + var g=Make();using var p=new Platform(g);using var input=new Input(NativeKeylessProbe.Request(true));using var output=new MemoryStream(); + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>Task.FromResult(Pairing("AAAAAAAAAAAAAAAAAAAAAA","never-forward"))); + var text=Encoding.UTF8.GetString(output.ToArray());Assert.Contains("manifest_invalid",text);Assert.DoesNotContain("never-forward",text);Assert.DoesNotContain("invalid_request",text); + } + [Fact]public async Task PartialDeliveryIsNotRetriedOutsideTheOwner() + { + var g=Make();using var p=new Platform(g);using var input=new Input(NativeKeylessProbe.Request(false));using var output=new BrokenOutput(); + await Assert.ThrowsAnyAsync(()=>NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>Task.FromResult(Pairing("AAAAAAAAAAAAAAAAAAAAAA","synthetic-unit-only")))); + Assert.Equal(2,output.Writes);Assert.Equal(p.Acquired,p.Released); + } + [Fact]public async Task CancellationRacingPartialDeliveryRemainsTerminalFailure() + { + var g=Make();using var p=new Platform(g);using var stop=new CancellationTokenSource(); + using var input=new Input(NativeKeylessProbe.Request(false));using var output=new BrokenOutput(()=>stop.Cancel()); + await Assert.ThrowsAnyAsync(()=>NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),stop.Token, + (_,_,_,_)=>Task.FromResult(Pairing("AAAAAAAAAAAAAAAAAAAAAA","synthetic-unit-only")))); + Assert.True(stop.IsCancellationRequested);Assert.Equal(2,output.Writes);Assert.Equal(p.Acquired,p.Released); + } + private sealed class BrokenOutput(Action? beforeFailure=null):MemoryStream + { + internal int Writes; + public override ValueTask WriteAsync(ReadOnlyMemory data,CancellationToken ct=default) + {Writes++;if(Writes==2){beforeFailure?.Invoke();throw new IOException("synthetic partial write");}return base.WriteAsync(data,ct);} + } + public static IEnumerable Variants() + { + const string good="""{"v":1,"op":"bootstrap","nonce":"AAAAAAAAAAAAAAAAAAAAAA"}"""; + foreach(var raw in new[]{good,good.Replace(":1,",":1.0,"),good.Replace(":1,",":1e0,"),good.Replace("}",",\"extra\":true}"), + good.Replace("}",",\"v\":1}"),good.Replace("}",",\"\\u0076\":1}"),good.Replace("bootstrap","unknown"), + good.Replace("bootstrap","ensure_relay"),good.Replace("}",",\"relayPort\":\"1\"}")," "+Encoding.UTF8.GetString(NativeKeylessProbe.Request(true)), + "\uFEFF"+good,"{",good.Replace("\"v\":1,\"op\":\"bootstrap\"","\"op\":\"bootstrap\",\"v\":1")}) yield return [raw]; + } + [Theory][MemberData(nameof(Variants))] + public async Task AnyOtherCompleteInputNeedsActivationEvenWhenCSharpWouldReject(string raw) + { + var g=Make();using var p=new Platform(g){Inventory=new("missing",[])}; + using var input=new Input(Encoding.UTF8.GetBytes(raw));using var output=new MemoryStream();var effects=0; + await NativeTransport.RunAsync(input,output,[ManagementContract.Origin],()=>g,_=>new Release(()=>{}),new(p),default, + (_,_,_,_)=>{effects++;return Task.FromResult(Failure("invalid_request"));}); + Assert.Single(p.Acquired);Assert.Equal(0,effects);Assert.Contains("manifest_invalid",Encoding.UTF8.GetString(output.ToArray())); + } +} diff --git a/tests/OpenClaw.BrowserBootstrap.Tests/ProbeProcessTreeTests.cs b/tests/OpenClaw.BrowserBootstrap.Tests/ProbeProcessTreeTests.cs new file mode 100644 index 000000000..7be2b7aaa --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/ProbeProcessTreeTests.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.BrowserBootstrap.Tests; + +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class ProbeProcessTreeTests +{ + [WindowsRegistryFact] + public async Task LauncherExitDoesNotSettleItsStillRunningDescendant() + { + if(!OperatingSystem.IsWindows())return; + var shell=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); + const string script=""" + $ErrorActionPreference='Stop' + [Console]::ReadLine() | Out-Null + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=Join-Path $PSHOME 'powershell.exe' + $info.Arguments='-NoLogo -NoProfile -NonInteractive -Command Start-Sleep -Seconds 60' + $info.UseShellExecute=$false + $info.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($info) + [Console]::Out.WriteLine($child.Id) + """; + var start=new ProcessStartInfo(shell){UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var arg in new[]{"-NoLogo","-NoProfile","-NonInteractive","-EncodedCommand",Convert.ToBase64String(Encoding.Unicode.GetBytes(script))})start.ArgumentList.Add(arg); + using var root=Process.Start(start)!;using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var tree=new ProbeProcessTree(root); + try + { + // Existing input gating makes assignment happen before any descendant spawn. + await root.StandardInput.WriteLineAsync("start");await root.StandardInput.FlushAsync(); + var line=await root.StandardOutput.ReadLineAsync(timeout.Token); + using var descendant=Process.GetProcessById(int.Parse(line!)); + _=descendant.Handle; + await root.WaitForExitAsync(timeout.Token);root.Dispose();Assert.False(descendant.HasExited); + var settled=tree.JoinAsync();Assert.False(settled.IsCompleted); + tree.Terminate();await descendant.WaitForExitAsync(timeout.Token);Assert.True(descendant.HasExited); + descendant.Dispose();await settled.WaitAsync(timeout.Token); + } + finally{tree.Terminate();await tree.JoinAsync().WaitAsync(timeout.Token);} + } +} diff --git a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeClientTests.cs b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeClientTests.cs new file mode 100644 index 000000000..46cef6490 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeClientTests.cs @@ -0,0 +1,120 @@ +using System.IO.Pipes; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Shared.Tests; + +public class BrowserBootstrapPipeClientTests +{ + [Fact]public async Task ResponseFrameIsNotCompletionUntilPeerCloses() + { + var name="OpenClaw.BrowserBootstrap.Test."+Guid.NewGuid().ToString("N"); + using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var server=new NamedPipeServerStream(name,PipeDirection.InOut,1,PipeTransmissionMode.Byte,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); + using var client=new NamedPipeClientStream(".",name,PipeDirection.InOut,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); + var connecting=server.WaitForConnectionAsync(stop.Token);await client.ConnectAsync(stop.Token);await connecting; + var run=BrowserBootstrapPipeClient.ExchangeAsync(client,"{}"u8.ToArray(),stop.Token); + _=await BrowserNativeProtocol.ReadAsync(server,4096,stop.Token); + var response=BrowserNativeProtocol.Failure("pairing_unavailable"); + await BrowserNativeProtocol.WriteAsync(server,response,stop.Token); + Assert.False(run.IsCompleted);server.Dispose();Assert.Equal(response,await run); + } + [Fact]public async Task CancellationWaitsForActualHandlerCleanupAndDiscardsCredentials() + { + var name="OpenClaw.BrowserBootstrap.Test."+Guid.NewGuid().ToString("N"); + var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleaning=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var server=new BrowserBootstrapPipeServer(async (_,ct)=> + { + entered.SetResult();try{await Task.Delay(Timeout.Infinite,ct);} + finally{cleaning.SetResult();await finish.Task;} + return BrowserNativeProtocol.Pairing("AAAAAAAAAAAAAAAAAAAAAA","never-delivered"); + },name); + server.Start();using var stop=new CancellationTokenSource(); + using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var client=new NamedPipeClientStream(".",name,PipeDirection.InOut,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); + try + { + await client.ConnectAsync(timeout.Token); + var run=BrowserBootstrapPipeClient.ExchangeAsync(client,"{}"u8.ToArray(),stop.Token); + await entered.Task.WaitAsync(timeout.Token);stop.Cancel();await cleaning.Task.WaitAsync(timeout.Token); + // A real handler is still in cleanup. Expiring a client-only two-second + // timer must not make its caller eligible to retire the generation. + await Task.Delay(TimeSpan.FromMilliseconds(2200),timeout.Token); + Assert.False(run.IsCompleted);finish.SetResult(); + await Assert.ThrowsAnyAsync(()=>run); + } + finally{finish.TrySetResult();} + } + [Fact]public async Task PartialSendIsUnknownFailureEvenAfterPeerClosure() + { + using var pipe=new FaultStream(partial:true); + var error=await Assert.ThrowsAsync(()=>BrowserBootstrapPipeClient.ExchangeAsync(pipe,"{}"u8.ToArray(),default)); + Assert.Contains("request completion",error.Message);Assert.True(pipe.CancelByteWritten); + } + [Fact]public async Task UnconfirmedCleanupCannotCompleteBeforePeerSettlement() + { + using var pipe=new FaultStream(partial:false); + var run=BrowserBootstrapPipeClient.ExchangeAsync(pipe,"{}"u8.ToArray(),default); + try + { + await pipe.Cleaning.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.False(run.IsCompleted);pipe.Settled.SetResult(); + await Assert.ThrowsAsync(()=>run);Assert.True(pipe.CancelByteWritten); + } + finally{pipe.Settled.TrySetResult();} + } + [Fact]public async Task OversizedCleanupStreamCannotEndOwnershipBeforePeerClosure() + { + using var pipe=new OversizedCleanupStream(); + var run=BrowserBootstrapPipeClient.ExchangeAsync(pipe,"{}"u8.ToArray(),default); + try + { + await pipe.OverflowReached.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.False(run.IsCompleted); + pipe.Closed.SetResult();await Assert.ThrowsAsync(()=>run); + } + finally{pipe.Closed.TrySetResult();} + } + private sealed class OversizedCleanupStream:MemoryStream + { + private int reads; + internal readonly TaskCompletionSource OverflowReached=new(TaskCreationOptions.RunContinuationsAsynchronously); + internal readonly TaskCompletionSource Closed=new(TaskCreationOptions.RunContinuationsAsynchronously); + public override ValueTask WriteAsync(ReadOnlyMemory bytes,CancellationToken ct=default)=>ValueTask.CompletedTask; + public override ValueTask ReadAsync(Memory bytes,CancellationToken ct=default) + { + reads++; + if(reads==1)throw new IOException("synthetic initial read failure"); + if(reads<=259) + { + bytes.Span.Clear(); + if(reads==258)OverflowReached.TrySetResult(); + return ValueTask.FromResult(bytes.Length); + } + return new ValueTask(End()); + } + private async Task End(){await Closed.Task;return 0;} + } + private sealed class FaultStream(bool partial):MemoryStream + { + private int writes,reads; + internal bool CancelByteWritten; + internal readonly TaskCompletionSource Cleaning=new(TaskCreationOptions.RunContinuationsAsynchronously); + internal readonly TaskCompletionSource Settled=new(TaskCreationOptions.RunContinuationsAsynchronously); + public override ValueTask WriteAsync(ReadOnlyMemory bytes,CancellationToken ct=default) + { + writes++; + if(partial&&writes==2)throw new IOException("partial test write"); + if(bytes.Length==1&&bytes.Span[0]==0)CancelByteWritten=true; + return ValueTask.CompletedTask; + } + public override async ValueTask ReadAsync(Memory bytes,CancellationToken ct=default) + { + reads++; + if(partial)return 0; + if(reads==1)throw new IOException("test response failure"); + Cleaning.SetResult();await Settled.Task.WaitAsync(ct);return 0; + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs index 9fcb09cf4..921657ee5 100644 --- a/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs @@ -28,6 +28,36 @@ await BrowserNativeProtocol.WriteAsync(pipe, Assert.Equal("synthetic-test-pairing", result.RootElement.GetProperty("pairingString").GetString()); } + [Fact] + public async Task ExtraInputCancelsButServerCloseWaitsForHandlerCleanup() + { + var name="OpenClaw.BrowserBootstrap.Test."+Guid.NewGuid().ToString("N"); + var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleaning=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var server=new BrowserBootstrapPipeServer(async (_,ct)=> + { + entered.SetResult(); + try{await Task.Delay(Timeout.Infinite,ct);} + finally{cleaning.SetResult();await finish.Task;} + return BrowserNativeProtocol.Failure("pairing_unavailable"); + },name); + server.Start();using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var pipe=new NamedPipeClientStream(".",name,PipeDirection.InOut,PipeOptions.Asynchronous|PipeOptions.CurrentUserOnly); + try + { + await pipe.ConnectAsync(timeout.Token); + await BrowserNativeProtocol.WriteAsync(pipe,"{}"u8.ToArray(),timeout.Token); + await entered.Task.WaitAsync(timeout.Token); + await pipe.WriteAsync(new byte[]{0},timeout.Token);await pipe.FlushAsync(timeout.Token); + await cleaning.Task.WaitAsync(timeout.Token); + var end=pipe.ReadAsync(new byte[1],timeout.Token).AsTask(); + Assert.False(end.IsCompleted); + finish.SetResult();Assert.Equal(0,await end); + } + finally{finish.TrySetResult();} + } + [Fact] public async Task Disconnect_CancelsTheActualHandlerBeforeDelivery() { diff --git a/tests/OpenClaw.Shared.Tests/BrowserBootstrapProcessLifetimeTests.cs b/tests/OpenClaw.Shared.Tests/BrowserBootstrapProcessLifetimeTests.cs new file mode 100644 index 000000000..6283bec17 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapProcessLifetimeTests.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.Shared.Tests; + +public class BrowserBootstrapProcessLifetimeTests +{ + [Fact]public async Task ExpiredCleanupBudgetCannotCompleteWhileOwnedWorkIsPending() + { + var work=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var budget=new CancellationTokenSource();budget.Cancel(); + var wait=BrowserBootstrapProcessLifetime.AwaitOwnedAsync(work.Task,budget.Token); + try{Assert.False(wait.IsCompleted);work.SetResult();await Assert.ThrowsAnyAsync(()=>wait);} + finally{work.TrySetResult();} + } + [Fact]public async Task ExpiredCleanupBudgetStillJoinsTheRealOwnedProcess() + { + var info=new ProcessStartInfo{UseShellExecute=false,CreateNoWindow=true}; + if(OperatingSystem.IsWindows()) + { + info.FileName=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); + foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-Command","Start-Sleep -Seconds 60"})info.ArgumentList.Add(a); + } + else{info.FileName="/bin/sh";info.ArgumentList.Add("-c");info.ArgumentList.Add("sleep 60");} + using var process=Process.Start(info)!;using var budget=new CancellationTokenSource();budget.Cancel(); + var wait=BrowserBootstrapProcessLifetime.JoinAsync(process,budget.Token); + try + { + Assert.False(process.HasExited);Assert.False(wait.IsCompleted); + process.Kill(true); + await Assert.ThrowsAnyAsync(()=>wait.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(wait.IsCompleted);Assert.True(process.HasExited); + } + finally{if(!process.HasExited)process.Kill(true);await process.WaitForExitAsync();} + } +} From 4e3ad46f96d9ef0718607e16bb279fe28e8dce1e Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:13:55 +0000 Subject: [PATCH 36/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 7dba047c-43b8-494b-a4f8-265ed73be0a2 From e3db53c202034fce5ff06c47e34a56ba9f8eaede Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:22:35 +0000 Subject: [PATCH 37/77] fix(ci): build and settle the native browser proof fixtures Fetch full producer history for GitVersion, and close the synchronous synthetic IPC handler before awaiting the native host final response. Preserve all native validators, consumer pin, deadlines and assertions. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-native-composed-proof.yml | 1 + scripts/Test-BrowserNativeHost.ps1 | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index 151dd5fa8..149116bec 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -28,6 +28,7 @@ jobs: - uses: actions/checkout@v7 with: path: producer + fetch-depth: 0 persist-credentials: false - uses: actions/checkout@v7 with: diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 index b810fa6f1..4771e1e05 100644 --- a/scripts/Test-BrowserNativeHost.ps1 +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -132,6 +132,10 @@ try { $incoming=Read-Frame $pipe if ($incoming.nonce -ne 'AAAAAAAAAAAAAAAAAAAAAA') { throw 'Native request nonce changed.' } Write-Frame $pipe ([Text.Encoding]::UTF8.GetBytes('{"v":1,"ok":true,"nonce":"AAAAAAAAAAAAAAAAAAAAAA","pairingString":"synthetic-proof-only"}')) + # This synchronous fixture handler has settled. Match the production server + # closure before waiting for the native owner to publish its final frame. + $pipe.Dispose() + Write-Host 'IPC_PROOF handler_settled_connection_closed' $reply=Read-Frame $native.StandardOutput.BaseStream if (-not $reply.ok -or $reply.pairingString -ne 'synthetic-proof-only') { throw 'Native pipe reply failed.' } Assert-Exit $native From 783f178ca5579d057d4799fceb5cec5e8c4d69f8 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:31:47 +0000 Subject: [PATCH 38/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: afab64dc-d33f-40ce-bc86-39f2c5dc5241 From f7659f7191966c056a2ee4615a925b65903c1811 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:34:34 +0000 Subject: [PATCH 39/77] test(browser): trace owned WSL guest settlement on hosted Windows Keep production source and accepted native proof frozen at 783f178c. Reuse the disposable managed WSL fixture and label controlled CLI component evidence, red/unknown retirement boundaries, and scoped cleanup explicitly. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-guest-trace.yml | 75 ++++ scripts/BrowserWslGuestTrace.py | 125 ++++++ .../Setup/BrowserWslGuestTraceTests.cs | 389 ++++++++++++++++++ 3 files changed, 589 insertions(+) create mode 100644 .github/workflows/browser-wsl-guest-trace.yml create mode 100644 scripts/BrowserWslGuestTrace.py create mode 100644 tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs diff --git a/.github/workflows/browser-wsl-guest-trace.yml b/.github/workflows/browser-wsl-guest-trace.yml new file mode 100644 index 000000000..5c19a8c70 --- /dev/null +++ b/.github/workflows/browser-wsl-guest-trace.yml @@ -0,0 +1,75 @@ +name: Browser WSL guest component trace + +on: + push: + branches: [openclaw/automatic-chrome-extension-pairing] + paths: + - .github/workflows/browser-wsl-guest-trace.yml + - scripts/BrowserWslGuestTrace.py + - tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs + +permissions: + contents: read + +jobs: + guest-trace: + runs-on: windows-latest + timeout-minutes: 40 + env: + OPENCLAW_REPO_ROOT: ${{ github.workspace }} + OPENCLAW_RUN_E2E: '1' + OPENCLAW_BROWSER_WSL_TRACE: '1' + OPENCLAW_WSL_TRACE_RECEIPT: ${{ runner.temp }}/browser-wsl-guest-receipt.json + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify frozen production and accepted native proof + shell: pwsh + run: | + git diff --exit-code 783f178ca5579d057d4799fceb5cec5e8c4d69f8 HEAD -- src .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Frozen production/native proof changed.' } + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - name: Build existing managed WSL setup fixture + shell: pwsh + run: | + dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Tray restore failed.' } + dotnet restore tests/OpenClaw.E2ETests -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Fixture restore failed.' } + dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Tray build failed.' } + dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Fixture build failed.' } + - name: Trace exact owner in disposable managed WSL fixture + shell: pwsh + run: | + # Existing setup diagnostics may include synthetic fixture credentials. + # Neither raw test output, TRX output sections nor fixture files are uploaded. + $privateLog = Join-Path $env:RUNNER_TEMP 'wsl-trace-private-test.log' + $privateResults = Join-Path $env:RUNNER_TEMP 'wsl-trace-private-results' + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserWslGuestTraceTests --results-directory $privateResults --logger 'trx;LogFileName=trace.trx' *> $privateLog + $testExit = $LASTEXITCODE + if (!(Test-Path $env:OPENCLAW_WSL_TRACE_RECEIPT)) { throw 'No terminal component receipt. Setup/build capability must be diagnosed privately.' } + [xml]$trx = Get-Content (Join-Path $privateResults 'trace.trx') + $cases = @($trx.TestRun.Results.UnitTestResult) + if ($cases.Count -ne 1 -or $cases[0].outcome -ne 'Passed' -or $testExit -ne 0) { throw 'Component trace did not execute and finish all owned cleanup.' } + Write-Host 'Component trace collected; this is not a guest-settlement pass.' + - name: Retain only explicitly redacted component receipt + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-wsl-guest-component-receipt + path: ${{ env.OPENCLAW_WSL_TRACE_RECEIPT }} + if-no-files-found: warn + retention-days: 7 + - name: Evaluate guest settlement separately from trace execution + shell: pwsh + run: | + $r = Get-Content $env:OPENCLAW_WSL_TRACE_RECEIPT -Raw | ConvertFrom-Json + if ($r.status -ne 'trace_complete_not_a_settlement_pass' -or $r.cases.Count -ne 6) { throw 'Trace incomplete.' } + if (@($r.cases | Where-Object { $_.settlementVerdict -like 'RED_*' -or $_.settlementVerdict -like 'UNKNOWN_*' }).Count -gt 0) { throw 'RED or UNKNOWN: guest settlement is not established. Inspect redacted receipt.' } + Write-Host 'No recorded guest process was running at postcheck. Identity presence and zombie state remain in the receipt; absence, reaping and pre-completion ordering are not certified.' diff --git a/scripts/BrowserWslGuestTrace.py b/scripts/BrowserWslGuestTrace.py new file mode 100644 index 000000000..f53c1b24e --- /dev/null +++ b/scripts/BrowserWslGuestTrace.py @@ -0,0 +1,125 @@ +#!/usr/bin/python3 +"""Disposable-distro component fixture, never a production CLI or cancellation fix. +Only numeric process identities, fixed provenance booleans and synthetic data leave it. +""" +import json +import os +import pathlib +import pwd +import signal +import subprocess +import sys +import time + +ROOT = pathlib.Path(__file__).resolve().parent + + +def identity(pid): + try: + fields = pathlib.Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split() + return {"pid": pid, "start": int(fields[19]), "group": int(fields[2]), "state": fields[0]} + except FileNotFoundError: + return None + + +def save(path, value): + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(value)) + temporary.replace(path) + + +def current_case(): + case = (ROOT / "case").read_text().strip() + if case not in ("normal", "ipc_eof", "client_cancel", "deadline", "forced_client_exit", "pipe_retirement"): + raise RuntimeError("invalid fixture case") + return ROOT / case + + +def wait_release(folder): + # A final independent bound on fixture work, not a production deadline change. + end = time.monotonic() + 60 + while not (folder / "release").exists() and time.monotonic() < end: + time.sleep(0.025) + + +def run(role): + folder = current_case() + own = identity(os.getpid()) + save(folder / (role + ".json"), own) + child = None + if role != "leaf": + next_role = "worker" if role == "owner" else "leaf" + child = subprocess.Popen([sys.executable, str(pathlib.Path(__file__).resolve()), next_role]) + if role == "owner": + expected = ["browser", "extension", "pair", "--json", "--local-gateway"] + checks = { + "argvExact": sys.argv[2:] == expected, + "managedUser": pwd.getpwuid(os.getuid()).pw_name == "openclaw", + "homeDefault": os.environ.get("HOME") == "/home/openclaw", + "stateDefault": os.environ.get("OPENCLAW_STATE_DIR") == "/home/openclaw/.openclaw", + "configDefault": os.environ.get("OPENCLAW_CONFIG_PATH") == "/home/openclaw/.openclaw/openclaw.json", + "pathExact": os.environ.get("PATH") == "/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin", + "overridesAbsent": all(k not in os.environ for k in ("OPENCLAW_PROFILE", "OPENCLAW_HOME", "NODE_OPTIONS", "NODE_PATH")), + } + save(folder / "provenance.json", checks) + if not all(checks.values()): + raise RuntimeError("fixture provenance mismatch") + wait_release(folder) + if child is not None: + child.wait(timeout=5) + save(folder / (role + "-end.json"), {"monotonicNs": time.monotonic_ns()}) + if role == "owner": + # Deliberately NOT a real credential. The trace handler discards this payload. + print(json.dumps({"componentFixture": True}), flush=True) + + +def observe(): + folder = current_case() + records = [] + for role in ("owner", "worker", "leaf"): + path = folder / (role + ".json") + if not path.exists(): + continue + original = json.loads(path.read_text()) + now = identity(original["pid"]) + matches = now is not None and now["start"] == original["start"] + records.append({"role": role, **original, "identityPresent": matches, + "running": matches and now["state"] not in ("Z", "X"), + "observedState": now["state"] if matches else "absent"}) + provenance = folder / "provenance.json" + print(json.dumps({"records": records, "provenance": json.loads(provenance.read_text()) if provenance.exists() else None}), flush=True) + + +def cleanup(): + # Kill ONLY positively matched, request-owned PIDs. Never kill a group/distro/gateway. + folder = current_case() + for role in ("owner", "worker", "leaf"): + path = folder / (role + ".json") + if path.exists(): + original = json.loads(path.read_text()) + try: + descriptor = os.pidfd_open(original["pid"]) + except ProcessLookupError: + continue + try: + now = identity(original["pid"]) + if now is not None and now["start"] == original["start"] and now["state"] not in ("Z", "X"): + try: + signal.pidfd_send_signal(descriptor, signal.SIGKILL) + except ProcessLookupError: + pass + finally: + os.close(descriptor) + observe() + + +if __name__ == "__main__": + mode = sys.argv[1] + if mode in ("owner", "worker", "leaf"): + run(mode) + elif mode == "observe": + observe() + elif mode == "cleanup": + cleanup() + else: + raise RuntimeError("invalid fixture operation") diff --git a/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs new file mode 100644 index 000000000..b8efb1bd0 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs @@ -0,0 +1,389 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Connection; +using OpenClaw.Shared.Browser; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class BrowserWslTraceFactAttribute : FactAttribute +{ + public BrowserWslTraceFactAttribute() + { + if (!E2ETestGate.IsEnabled || Environment.GetEnvironmentVariable("OPENCLAW_BROWSER_WSL_TRACE") != "1") + Skip = "Dedicated disposable hosted WSL component trace only."; + } +} + +/// Test-only stand-in after unchanged production admission. Not credential or whole-Companion proof. +public sealed class BrowserWslGuestTraceTests +{ + private static readonly string[] Cases = ["normal", "ipc_eof", "client_cancel", "deadline", "forced_client_exit", "pipe_retirement"]; + private const string Cli = "/home/openclaw/.openclaw/bin/openclaw"; + private readonly List _cases = []; + private readonly List _errors = []; + private string _root = ""; + private string _stage = "initialization"; + private long _gatewayPid; + + [BrowserWslTraceFact] + public async Task ExactOwner_ObservesGuestSettlementWithoutTreatingWindowsJoinAsAcknowledgment() + { + Assert.True(OperatingSystem.IsWindows()); + Assert.Equal("true", Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + var receiptPath = Environment.GetEnvironmentVariable("OPENCLAW_WSL_TRACE_RECEIPT"); + Assert.False(string.IsNullOrWhiteSpace(receiptPath)); + var fixture = new E2ESetupFixture(); + Assert.StartsWith("OpenClawE2E-", fixture.DistroName); + _root = "/home/openclaw/.openclaw/browser-wsl-trace-" + Guid.NewGuid().ToString("N"); + var output = Console.Out; + var error = Console.Error; + var installed = false; + var restored = false; + var removed = false; + // Existing setup fixture logs include ephemeral credentials/config. Do not forward + // them to test/Actions output, and never upload TestResults/E2E for this workflow. + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + try + { + _stage = "managed_fixture_setup"; + await fixture.InitializeAsync(); + Assert.Null(fixture.SetupError); + await fixture.StopTrayAsync(); // Exact fixture-owned tray, never an unrelated app/gateway. + _stage = "provenance_preflight"; + var preflight = await Guest(fixture, "test \"$(id -un)\" = openclaw; test \"$HOME\" = /home/openclaw; test -x /usr/bin/python3; systemctl --user show openclaw-gateway.service -p MainPID --value"); + _gatewayPid = long.Parse(preflight.Trim()); + Assert.True(_gatewayPid > 1); + var repository = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT")!; + var helper = Convert.ToBase64String(await File.ReadAllBytesAsync(Path.Combine(repository, "scripts", "BrowserWslGuestTrace.py"))); + _stage = "install_controlled_guest_cli"; + // Set before mutation, so partial setup also enters exact-path restoration. + installed = true; + await Guest(fixture, $""" + set -euo pipefail + umask 077 + mkdir '{_root}' + mkdir -p /home/openclaw/.openclaw/bin + if test -e '{Cli}' || test -L '{Cli}'; then mv '{Cli}' '{_root}/original-cli'; fi + printf '%s' '{helper}' | base64 -d > '{_root}/guest.py' + printf '%s\n' '#!/bin/sh' 'exec /usr/bin/python3 {_root}/guest.py owner "$@"' > '{Cli}' + chmod 700 '{Cli}' + """); + foreach (var name in Cases) + { + _stage = name; + await TraceCase(fixture, name); + } + } + catch (Exception ex) + { + _errors.Add(_stage + ":" + ex.GetType().Name); // Never ex.Message or raw command output. + } + finally + { + if (installed) + { + try + { + await Guest(fixture, $""" + set -euo pipefail + if test -f '{_root}/case'; then /usr/bin/python3 '{_root}/guest.py' cleanup >/dev/null; fi + if test -f '{Cli}' && grep -Fq '{_root}/guest.py' '{Cli}'; then rm '{Cli}'; fi + if test -e '{_root}/original-cli' || test -L '{_root}/original-cli'; then + test ! -e '{Cli}' && test ! -L '{Cli}' + mv '{_root}/original-cli' '{Cli}' + fi + test "$(systemctl --user show openclaw-gateway.service -p MainPID --value)" = '{_gatewayPid}' + rm -rf '{_root}' + """); + restored = true; + } + catch (Exception ex) { _errors.Add("restore:" + ex.GetType().Name); } + } + try + { + await fixture.DisposeAsync(); + // The existing fixture owns and uninstalls only its unique distro. + var list = await WindowsWslList(); + removed = !list.Contains(fixture.DistroName, StringComparison.Ordinal); + if (!removed) _errors.Add("owned_distro_still_registered"); + } + catch (Exception ex) { _errors.Add("fixture_disposal:" + ex.GetType().Name); } + Console.SetOut(output); + Console.SetError(error); + var wsl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe"); + var receipt = new + { + schema = 1, + sourceSha = Environment.GetEnvironmentVariable("GITHUB_SHA"), + productionPin = "783f178ca5579d057d4799fceb5cec5e8c4d69f8", + scope = "unchanged WSL command and current-user pipe; controlled guest CLI, not real credentials or full Companion", + customProofPoolRan = false, + owner = new + { + executable = "%SystemRoot%/System32/wsl.exe", + imageSha256 = File.Exists(wsl) ? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(wsl))).ToLowerInvariant() : null, + argv = new[] { "--distribution", fixture.DistroName, "--exec", "/bin/bash", "--noprofile", "--norc", "-s" }, + scriptSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(BrowserBootstrapWslCommand.Script))).ToLowerInvariant(), + productionDeadlineSeconds = 15, + pipeDeadlineSeconds = 20, + gatewayMainPid = _gatewayPid, + guestProvenanceReportedAsBooleansOnly = true + }, + cases = _cases, + errors = _errors, + setupEvidence = SetupEvidence(fixture.ArtifactDir), + cleanup = new { originalCliRestored = restored, ownedDistroRemoved = removed }, + status = _errors.Count == 0 && _cases.Count == Cases.Length && restored && removed ? "trace_complete_not_a_settlement_pass" : "trace_incomplete" + }; + await File.WriteAllTextAsync(receiptPath!, JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true })); + } + Assert.Empty(_errors); + Assert.Equal(Cases.Length, _cases.Count); + Assert.True(restored && removed); + } + + private async Task TraceCase(E2ESetupFixture fixture, string name) + { + await Guest(fixture, $"mkdir '{_root}/{name}'; printf '%s' '{name}' > '{_root}/case'"); + var clock = Stopwatch.StartNew(); + var commandEnd = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handlerEnd = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observations = new List(); + string? commandOutcome = null; + string? clientOutcome = null; + long? clientEnd = null; + long? retirementEnd = null; + var pipeName = "OpenClaw.WslTrace." + Guid.NewGuid().ToString("N"); + var server = new BrowserBootstrapPipeServer(async (request, ct) => + { + try + { + _ = BrowserNativeProtocol.ParseRequest(request); + try + { + _ = await BrowserBootstrapWslCommand.RunAsync(fixture.DistroName, ct); + commandOutcome = "returned"; + } + catch (Exception ex) { commandOutcome = ex.GetType().Name; throw; } + finally { commandEnd.TrySetResult(clock.ElapsedMilliseconds); } + return BrowserNativeProtocol.Failure("pairing_unavailable"); // No fixture output crosses IPC. + } + finally { handlerEnd.TrySetResult(clock.ElapsedMilliseconds); } + }, pipeName); + server.Start(); + using var cancellation = new CancellationTokenSource(); + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + Task? retirement = null; + Task? client = null; + object? windowsIdentity = null; + JsonElement afterBoundary = default; + bool cleanupSettled = false; + bool completedBeforeCleanup = false; + bool survivor = false; + try + { + await pipe.ConnectAsync(budget.Token); + client = ObserveClient(); + var readyDeadline = DateTime.UtcNow.AddSeconds(10); + while (true) + { + var observation = await Observe(fixture); + observations.Add(new { atMs = clock.ElapsedMilliseconds, boundary = "startup", guest = observation }); + if (Ready(observation)) break; + Assert.False(commandEnd.Task.IsCompleted, "Current owner returned before the controlled guest entered."); + Assert.True(DateTime.UtcNow < readyDeadline, "Controlled guest failed to enter."); + await Task.Delay(75, budget.Token); + } + using var windowsOwner = await FindOwner(fixture.DistroName); + var ownerStart = windowsOwner.StartTime.ToUniversalTime().Ticks; + windowsIdentity = new { pid = windowsOwner.Id, startUtcTicks = ownerStart, exactSelectedDistroArgv = true }; + var actionMs = clock.ElapsedMilliseconds; + switch (name) + { + case "normal": await Guest(fixture, $"touch '{_root}/{name}/release'"); break; + case "ipc_eof": pipe.Dispose(); break; + case "client_cancel": cancellation.Cancel(); break; + case "forced_client_exit": + Assert.Equal(ownerStart, windowsOwner.StartTime.ToUniversalTime().Ticks); + windowsOwner.Kill(); // Only the exact production-launched Windows client, not wslhost/distro. + break; + case "pipe_retirement": retirement = DisposeServer(); break; + case "deadline": break; // Exercise unchanged 15-second owner deadline. + default: throw new InvalidOperationException("Unknown trace case."); + } + // Independent /proc observations are deliberately outside the request handler. + // A trace bound does not authorize retirement: cleanup is separately labeled. + var observationDeadline = DateTime.UtcNow.AddSeconds(25); + while (DateTime.UtcNow < observationDeadline) + { + var begin = clock.ElapsedMilliseconds; + var observation = await Observe(fixture); + var end = clock.ElapsedMilliseconds; + observations.Add(new { beginMs = begin, endMs = end, boundary = "inflight", guest = observation }); + if (commandEnd.Task.IsCompleted && (client.IsCompleted || name == "ipc_eof") && + (retirement is null || retirement.IsCompleted)) break; + await Task.Delay(100, budget.Token); + } + completedBeforeCleanup = commandEnd.Task.IsCompleted && handlerEnd.Task.IsCompleted; + var postBegin = clock.ElapsedMilliseconds; + afterBoundary = await Observe(fixture); + var postEnd = clock.ElapsedMilliseconds; + survivor = completedBeforeCleanup && Running(afterBoundary); + observations.Add(new { beginMs = postBegin, endMs = postEnd, boundary = "before_test_cleanup", guest = afterBoundary }); + _cases.Add(new + { + name, windowsIdentity, actionMs, + commandEndMs = commandEnd.Task.IsCompletedSuccessfully ? (long?)commandEnd.Task.Result : null, + handlerEndMs = handlerEnd.Task.IsCompletedSuccessfully ? (long?)handlerEnd.Task.Result : null, + clientEndMs = clientEnd, retirementEndMs = retirementEnd, + commandOutcome, clientOutcome, ownerCompletedBeforeTestCleanup = completedBeforeCleanup, + guestRunningAfterOwnerCompletion = survivor, + settlementVerdict = survivor ? "RED_guest_survives_owner_completion" : + completedBeforeCleanup && !Running(afterBoundary) ? "guest_not_running_at_postcheck_ordering_not_proven" : "UNKNOWN_owner_or_guest_unsettled", + observations + }); + } + finally + { + // Retain the red/unknown boundary BEFORE intervention; kill only matched test identities. + await Guest(fixture, $"/usr/bin/python3 '{_root}/guest.py' cleanup >/dev/null"); + cancellation.Cancel(); + pipe.Dispose(); + var cleanupDeadline = DateTime.UtcNow.AddSeconds(8); + do + { + var remaining = await Observe(fixture); + if (!Running(remaining)) { cleanupSettled = true; break; } + await Task.Delay(100); + } while (DateTime.UtcNow < cleanupDeadline); + if (client is not null) await client.WaitAsync(TimeSpan.FromSeconds(10)); + if (retirement is not null) await retirement.WaitAsync(TimeSpan.FromSeconds(10)); + else await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(cleanupSettled, "Owned guest fixture did not settle after identity-scoped cleanup."); + Assert.True(handlerEnd.Task.IsCompleted, "Pipe handler did not settle after fixture cleanup."); + } + + async Task ObserveClient() + { + try + { + _ = await BrowserBootstrapPipeClient.ExchangeAsync(pipe, + Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}"), cancellation.Token); + clientOutcome = "response_and_server_eof"; + } + catch (Exception ex) { clientOutcome = ex.GetType().Name; } + finally { clientEnd = clock.ElapsedMilliseconds; } + } + async Task DisposeServer() + { + await server.DisposeAsync(); + retirementEnd = clock.ElapsedMilliseconds; + } + } + + private static async Task FindOwner(string distro) + { + Assert.Matches("^OpenClawE2E-[a-f0-9]{8}$", distro); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true + }; + foreach (var arg in new[] { "-NoProfile", "-NonInteractive", "-Command", + $"Get-CimInstance Win32_Process -Filter \"ParentProcessId={Environment.ProcessId} AND Name='wsl.exe'\" | Where-Object {{ $_.CommandLine.Contains('--distribution {distro} --exec /bin/bash --noprofile --norc -s') }} | ForEach-Object {{ $_.ProcessId }}" }) + start.ArgumentList.Add(arg); + using var query = Process.Start(start)!; + var output = query.StandardOutput.ReadToEndAsync(); + var error = query.StandardError.ReadToEndAsync(); + await WaitForInspection(query, TimeSpan.FromSeconds(5)); + await error; + Assert.Equal(0, query.ExitCode); + var matches = (await output).Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); + Assert.Single(matches); + return Process.GetProcessById(matches[0]); + } + + private async Task Observe(E2ESetupFixture fixture) + { + using var document = JsonDocument.Parse(await Guest(fixture, $"/usr/bin/python3 '{_root}/guest.py' observe")); + return document.RootElement.Clone(); + } + + private static bool Ready(JsonElement observation) => + observation.GetProperty("records").GetArrayLength() == 3 && + observation.GetProperty("provenance").ValueKind == JsonValueKind.Object && + observation.GetProperty("provenance").EnumerateObject().All(p => p.Value.GetBoolean()) && + observation.GetProperty("records").EnumerateArray().All(p => p.GetProperty("running").GetBoolean()); + + private static bool Running(JsonElement observation) => observation.GetProperty("records").EnumerateArray().Any(p => p.GetProperty("running").GetBoolean()); + + private static async Task Guest(E2ESetupFixture fixture, string script) + { + var result = await fixture.RunInWslAsync("set -euo pipefail\n" + script, TimeSpan.FromSeconds(10), inputViaStdin: true); + Assert.False(result.TimedOut, "Selected-distro observation timed out."); + Assert.Equal(0, result.ExitCode); + return result.Stdout; + } + + private static object SetupEvidence(string artifactDirectory) + { + var path = Path.Combine(artifactDirectory, "setup-engine.jsonl"); + var steps = new List(); + var codes = new HashSet(StringComparer.OrdinalIgnoreCase); + if (File.Exists(path)) + { + foreach (var line in File.ReadLines(path)) + { + foreach (Match match in Regex.Matches(line, "0x[0-9a-fA-F]{8}")) + if (codes.Count < 8) codes.Add(match.Value); + try + { + using var json = JsonDocument.Parse(line); + if (!json.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object || + !data.TryGetProperty("step_id", out var id) || !data.TryGetProperty("outcome", out var outcome)) continue; + var step = id.GetString() ?? ""; + var result = outcome.GetString() ?? ""; + if (Regex.IsMatch(step, "^[a-zA-Z0-9_.-]{1,80}$") && result is "Succeeded" or "Failed" or "Skipped" or "Success" or "Failure") + steps.Add(new { step, result }); + } + catch (JsonException) { } + } + } + return new { steps, hresults = codes.ToArray(), rawLogsRetainedPrivatelyOnly = true }; + } + + private static async Task WaitForInspection(Process process, TimeSpan timeout) + { + try { await process.WaitForExitAsync().WaitAsync(timeout); } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); + } + } + } + + private static async Task WindowsWslList() + { + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe")) + { + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = Encoding.Unicode, CreateNoWindow = true + }; + start.ArgumentList.Add("--list"); start.ArgumentList.Add("--quiet"); + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + await WaitForInspection(process, TimeSpan.FromSeconds(10)); + await error; + Assert.Equal(0, process.ExitCode); + return await output; + } +} From 57fd3b0bfe7be2912633bfe93c67295df9e4b9ec Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:34:56 +0000 Subject: [PATCH 40/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 7b1b4687-7c3a-4fcc-82f1-13a9454b4a03 From c6fc223e30bb678caca0d0d5e21c757970f1950a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:36:55 +0000 Subject: [PATCH 41/77] fix(ci): resolve WSL trace receipt path at step runtime Move the unchanged receipt path out of job-level runner context. No trace, production, native proof pin, permission or deadline change. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-guest-trace.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-guest-trace.yml b/.github/workflows/browser-wsl-guest-trace.yml index 5c19a8c70..4030f7819 100644 --- a/.github/workflows/browser-wsl-guest-trace.yml +++ b/.github/workflows/browser-wsl-guest-trace.yml @@ -19,7 +19,6 @@ jobs: OPENCLAW_REPO_ROOT: ${{ github.workspace }} OPENCLAW_RUN_E2E: '1' OPENCLAW_BROWSER_WSL_TRACE: '1' - OPENCLAW_WSL_TRACE_RECEIPT: ${{ runner.temp }}/browser-wsl-guest-receipt.json steps: - uses: actions/checkout@v7 with: @@ -30,6 +29,7 @@ jobs: run: | git diff --exit-code 783f178ca5579d057d4799fceb5cec5e8c4d69f8 HEAD -- src .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 if ($LASTEXITCODE -ne 0) { throw 'Frozen production/native proof changed.' } + "OPENCLAW_WSL_TRACE_RECEIPT=$env:RUNNER_TEMP/browser-wsl-guest-receipt.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' From 7bd45d9872b06d99f3aaff4ba01569baa4f8f85d Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:37:00 +0000 Subject: [PATCH 42/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: e4166975-f3e2-45bc-b96a-249bd4709204 From db2fa48470b4b85fb7dfab0da3d9eb7b325d6d33 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:10:27 +0000 Subject: [PATCH 43/77] test(browser): observe WSL exit ordering with pre-armed pidfds Replace round-trip postchecks with a persistent independent observer and causal post-boundary challenges. Timestamp unchanged source-linked native activation and serialized retirement owners with explicit component scope. Preserve production source, native protocol, deadlines and accepted proof pins. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-guest-trace.yml | 5 +- scripts/BrowserWslGuestTrace.py | 71 +++++++ .../OpenClaw.E2ETests.csproj | 6 + .../Setup/BrowserWslGuestTraceTests.cs | 191 +++++++++++------- .../Setup/BrowserWslNativeOwnership.cs | 74 +++++++ .../Setup/BrowserWslPersistentObserver.cs | 126 ++++++++++++ 6 files changed, 396 insertions(+), 77 deletions(-) create mode 100644 tests/OpenClaw.E2ETests/Setup/BrowserWslNativeOwnership.cs create mode 100644 tests/OpenClaw.E2ETests/Setup/BrowserWslPersistentObserver.cs diff --git a/.github/workflows/browser-wsl-guest-trace.yml b/.github/workflows/browser-wsl-guest-trace.yml index 4030f7819..55487c35f 100644 --- a/.github/workflows/browser-wsl-guest-trace.yml +++ b/.github/workflows/browser-wsl-guest-trace.yml @@ -6,7 +6,8 @@ on: paths: - .github/workflows/browser-wsl-guest-trace.yml - scripts/BrowserWslGuestTrace.py - - tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs + - tests/OpenClaw.E2ETests/Setup/BrowserWsl*.cs + - tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj permissions: contents: read @@ -72,4 +73,4 @@ jobs: $r = Get-Content $env:OPENCLAW_WSL_TRACE_RECEIPT -Raw | ConvertFrom-Json if ($r.status -ne 'trace_complete_not_a_settlement_pass' -or $r.cases.Count -ne 6) { throw 'Trace incomplete.' } if (@($r.cases | Where-Object { $_.settlementVerdict -like 'RED_*' -or $_.settlementVerdict -like 'UNKNOWN_*' }).Count -gt 0) { throw 'RED or UNKNOWN: guest settlement is not established. Inspect redacted receipt.' } - Write-Host 'No recorded guest process was running at postcheck. Identity presence and zombie state remain in the receipt; absence, reaping and pre-completion ordering are not certified.' + Write-Host 'Persistent pidfd observer receipt is authoritative. Exit-notification precedence is not a reaping or whole-Companion claim.' diff --git a/scripts/BrowserWslGuestTrace.py b/scripts/BrowserWslGuestTrace.py index f53c1b24e..64433a433 100644 --- a/scripts/BrowserWslGuestTrace.py +++ b/scripts/BrowserWslGuestTrace.py @@ -7,6 +7,7 @@ import pathlib import pwd import signal +import selectors import subprocess import sys import time @@ -113,6 +114,74 @@ def cleanup(): observe() +def monitor(): + """One independent process, pre-armed pidfds, bounded stdout events and challenges.""" + folder = current_case() + selector = selectors.DefaultSelector() + bound = [] + announced = set() + try: + for role in ("owner", "worker", "leaf"): + original = json.loads((folder / (role + ".json")).read_text()) + descriptor = os.pidfd_open(original["pid"]) + bound.append((role, original, descriptor)) + now = identity(original["pid"]) + if now is None or now["start"] != original["start"] or now["state"] in ("Z", "X"): + raise RuntimeError("observer did not bind a live matching identity") + if os.getpgrp() == original["group"]: + raise RuntimeError("observer shares request process group") + selector.register(descriptor, selectors.EVENT_READ, role) + selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "input") + print(json.dumps({"type": "ready", "observer": identity(os.getpid()), + "identities": [{"role": role, **original} for role, original, _ in bound]}), flush=True) + buffer = b"" + challenges = 0 + deadline = time.monotonic() + 55 + while time.monotonic() < deadline: + events = selector.select(timeout=max(0, deadline - time.monotonic())) + # Deliver exit notifications before commands when both descriptors are readable. + for key, _ in events: + if key.data == "input": + continue + role, original, _ = next(item for item in bound if item[0] == key.data) + if role not in announced: + print(json.dumps({"type": "exit", "role": role, "pid": original["pid"], + "start": original["start"]}), flush=True) + announced.add(role) + selector.unregister(key.fd) + if not any(key.data == "input" for key, _ in events): + continue + part = os.read(sys.stdin.fileno(), 1024) + if not part: + return + buffer += part + if len(buffer) > 4096: + raise RuntimeError("observer command bound") + while b"\n" in buffer: + raw, buffer = buffer.split(b"\n", 1) + command = json.loads(raw) + if command == {"op": "stop"}: + print('{"type":"stopped"}', flush=True) + return + challenges += 1 + if challenges > 2 or command != {"op": "challenge", "id": challenges}: + raise RuntimeError("observer challenge schema") + records = [] + for role, original, _ in bound: + now = identity(original["pid"]) + matches = now is not None and now["start"] == original["start"] + records.append({"role": role, "pid": original["pid"], "start": original["start"], + "identityPresent": matches, + "running": matches and now["state"] not in ("Z", "X"), + "state": now["state"] if matches else "absent"}) + print(json.dumps({"type": "challenge", "id": challenges, "records": records}), flush=True) + raise RuntimeError("observer lifetime bound") + finally: + selector.close() + for _, _, descriptor in bound: + os.close(descriptor) + + if __name__ == "__main__": mode = sys.argv[1] if mode in ("owner", "worker", "leaf"): @@ -121,5 +190,7 @@ def cleanup(): observe() elif mode == "cleanup": cleanup() + elif mode == "monitor": + monitor() else: raise RuntimeError("invalid fixture operation") diff --git a/tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj b/tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj index c0bf7d1b9..6663eccb6 100644 --- a/tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj +++ b/tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj @@ -13,6 +13,12 @@ win-x64;win-arm64 + + + + + + diff --git a/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs index b8efb1bd0..903bcb280 100644 --- a/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs @@ -118,7 +118,7 @@ await Guest(fixture, $""" var wsl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe"); var receipt = new { - schema = 1, + schema = 2, sourceSha = Environment.GetEnvironmentVariable("GITHUB_SHA"), productionPin = "783f178ca5579d057d4799fceb5cec5e8c4d69f8", scope = "unchanged WSL command and current-user pipe; controlled guest CLI, not real credentials or full Companion", @@ -151,139 +151,180 @@ private async Task TraceCase(E2ESetupFixture fixture, string name) { await Guest(fixture, $"mkdir '{_root}/{name}'; printf '%s' '{name}' > '{_root}/case'"); var clock = Stopwatch.StartNew(); - var commandEnd = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var handlerEnd = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var observations = new List(); - string? commandOutcome = null; - string? clientOutcome = null; - long? clientEnd = null; - long? retirementEnd = null; + long commandEndTicks = 0, handlerEndTicks = 0, clientEndTicks = 0, pipeDisposedTicks = 0; + var commandSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + string? commandOutcome = null, clientOutcome = null; var pipeName = "OpenClaw.WslTrace." + Guid.NewGuid().ToString("N"); + using var ownership = new BrowserWslNativeOwnership(clock); var server = new BrowserBootstrapPipeServer(async (request, ct) => { try { _ = BrowserNativeProtocol.ParseRequest(request); - try + var command = BrowserBootstrapWslCommand.RunAsync(fixture.DistroName, ct); + _ = command.ContinueWith(_ => { - _ = await BrowserBootstrapWslCommand.RunAsync(fixture.DistroName, ct); - commandOutcome = "returned"; - } + Interlocked.Exchange(ref commandEndTicks, clock.ElapsedTicks); + commandSignal.TrySetResult(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + try { _ = await command; commandOutcome = "returned"; } catch (Exception ex) { commandOutcome = ex.GetType().Name; throw; } - finally { commandEnd.TrySetResult(clock.ElapsedMilliseconds); } - return BrowserNativeProtocol.Failure("pairing_unavailable"); // No fixture output crosses IPC. + return BrowserNativeProtocol.Failure("pairing_unavailable"); } - finally { handlerEnd.TrySetResult(clock.ElapsedMilliseconds); } + finally { Interlocked.Exchange(ref handlerEndTicks, clock.ElapsedTicks); } }, pipeName); server.Start(); using var cancellation = new CancellationTokenSource(); using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(40)); using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); - Task? retirement = null; - Task? client = null; - object? windowsIdentity = null; - JsonElement afterBoundary = default; + BrowserWslPersistentObserver? observer = null; + Task? client = null, pipeRetirement = null, management = null; bool cleanupSettled = false; - bool completedBeforeCleanup = false; - bool survivor = false; try { await pipe.ConnectAsync(budget.Token); - client = ObserveClient(); + client = ownership.Run(ObserveClient); + JsonElement initial; var readyDeadline = DateTime.UtcNow.AddSeconds(10); - while (true) + do { - var observation = await Observe(fixture); - observations.Add(new { atMs = clock.ElapsedMilliseconds, boundary = "startup", guest = observation }); - if (Ready(observation)) break; - Assert.False(commandEnd.Task.IsCompleted, "Current owner returned before the controlled guest entered."); - Assert.True(DateTime.UtcNow < readyDeadline, "Controlled guest failed to enter."); + initial = await Observe(fixture); // Startup only. Never used for ordering or post-boundary proof. + if (Ready(initial)) break; + Assert.False(commandSignal.Task.IsCompleted, "Owner ended before fixture readiness."); + Assert.True(DateTime.UtcNow < readyDeadline, "Fixture did not become ready."); await Task.Delay(75, budget.Token); - } + } while (true); using var windowsOwner = await FindOwner(fixture.DistroName); - var ownerStart = windowsOwner.StartTime.ToUniversalTime().Ticks; - windowsIdentity = new { pid = windowsOwner.Id, startUtcTicks = ownerStart, exactSelectedDistroArgv = true }; - var actionMs = clock.ElapsedMilliseconds; + var windowsStart = windowsOwner.StartTime.ToUniversalTime().Ticks; + observer = new BrowserWslPersistentObserver(fixture.DistroName, _root, clock); + var armed = await observer.Ready.WaitAsync(TimeSpan.FromSeconds(5)); + var identities = armed.Data.GetProperty("identities").EnumerateArray().ToArray(); + Assert.Equal(3, identities.Length); + foreach (var process in identities) + { + var original = initial.GetProperty("records").EnumerateArray().Single(p => p.GetProperty("role").GetString() == process.GetProperty("role").GetString()); + Assert.Equal(original.GetProperty("pid").GetInt32(), process.GetProperty("pid").GetInt32()); + Assert.Equal(original.GetProperty("start").GetInt64(), process.GetProperty("start").GetInt64()); + Assert.NotEqual(process.GetProperty("group").GetInt32(), armed.Data.GetProperty("observer").GetProperty("group").GetInt32()); + } + Assert.False(commandSignal.Task.IsCompleted, "Observer was not armed before owner completion."); + // Real serialization starts while activation is held. No platform callback waits for observation. + var managementRequestedTicks = clock.ElapsedTicks; + management = ownership.Retire(); + var actionTicks = clock.ElapsedTicks; + Assert.True(armed.ReceivedTicks < actionTicks); switch (name) { case "normal": await Guest(fixture, $"touch '{_root}/{name}/release'"); break; case "ipc_eof": pipe.Dispose(); break; case "client_cancel": cancellation.Cancel(); break; case "forced_client_exit": - Assert.Equal(ownerStart, windowsOwner.StartTime.ToUniversalTime().Ticks); - windowsOwner.Kill(); // Only the exact production-launched Windows client, not wslhost/distro. + Assert.Equal(windowsStart, windowsOwner.StartTime.ToUniversalTime().Ticks); + windowsOwner.Kill(); + break; + case "pipe_retirement": + pipeRetirement = server.DisposeAsync().AsTask(); + _ = pipeRetirement.ContinueWith(_ => Interlocked.Exchange(ref pipeDisposedTicks, clock.ElapsedTicks), + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); break; - case "pipe_retirement": retirement = DisposeServer(); break; - case "deadline": break; // Exercise unchanged 15-second owner deadline. + case "deadline": break; default: throw new InvalidOperationException("Unknown trace case."); } - // Independent /proc observations are deliberately outside the request handler. - // A trace bound does not authorize retirement: cleanup is separately labeled. - var observationDeadline = DateTime.UtcNow.AddSeconds(25); - while (DateTime.UtcNow < observationDeadline) + var observationEnd = Stopwatch.GetTimestamp() + 25 * Stopwatch.Frequency; + await Task.WhenAny(commandSignal.Task, Task.Delay(TimeSpan.FromSeconds(25))); + var first = await observer.Challenge(1); + var remaining = Math.Max(0, (observationEnd - Stopwatch.GetTimestamp()) / (double)Stopwatch.Frequency); + var owners = Task.WhenAll(client, management, pipeRetirement ?? Task.CompletedTask); + await Task.WhenAny(owners, Task.Delay(TimeSpan.FromSeconds(remaining))); + var second = await observer.Challenge(2); + var events = observer.Events; + foreach (var item in events.Where(e => e.Data.GetProperty("type").GetString() == "exit")) { - var begin = clock.ElapsedMilliseconds; - var observation = await Observe(fixture); - var end = clock.ElapsedMilliseconds; - observations.Add(new { beginMs = begin, endMs = end, boundary = "inflight", guest = observation }); - if (commandEnd.Task.IsCompleted && (client.IsCompleted || name == "ipc_eof") && - (retirement is null || retirement.IsCompleted)) break; - await Task.Delay(100, budget.Token); + var bound = identities.Single(p => p.GetProperty("role").GetString() == item.Data.GetProperty("role").GetString()); + Assert.Equal(bound.GetProperty("pid").GetInt32(), item.Data.GetProperty("pid").GetInt32()); + Assert.Equal(bound.GetProperty("start").GetInt64(), item.Data.GetProperty("start").GetInt64()); } - completedBeforeCleanup = commandEnd.Task.IsCompleted && handlerEnd.Task.IsCompleted; - var postBegin = clock.ElapsedMilliseconds; - afterBoundary = await Observe(fixture); - var postEnd = clock.ElapsedMilliseconds; - survivor = completedBeforeCleanup && Running(afterBoundary); - observations.Add(new { beginMs = postBegin, endMs = postEnd, boundary = "before_test_cleanup", guest = afterBoundary }); + var exits = events.Where(e => e.Data.GetProperty("type").GetString() == "exit").ToArray(); + Assert.Equal(exits.Length, exits.Select(e => e.Data.GetProperty("role").GetString()).Distinct().Count()); + bool Before(long boundary) => boundary > 0 && exits.Length == 3 && exits.All(e => e.ReceivedTicks < boundary); + bool RunningChallenge(BrowserWslPersistentObserver.Event item) + { + var records = item.Data.GetProperty("records").EnumerateArray().ToArray(); + Assert.Equal(3, records.Length); + foreach (var record in records) + { + var bound = identities.Single(p => p.GetProperty("role").GetString() == record.GetProperty("role").GetString()); + Assert.Equal(bound.GetProperty("pid").GetInt32(), record.GetProperty("pid").GetInt32()); + Assert.Equal(bound.GetProperty("start").GetInt64(), record.GetProperty("start").GetInt64()); + } + return records.Any(p => p.GetProperty("identityPresent").GetBoolean() && p.GetProperty("running").GetBoolean()); + } + var commandBoundary = Interlocked.Read(ref commandEndTicks); + var releaseBoundary = Interlocked.Read(ref ownership.ActivationReleaseBeginTicks); + var mutationBoundary = Interlocked.Read(ref ownership.ManagementMutationTicks); + var managementBoundary = Interlocked.Read(ref ownership.ManagementCompletionTicks); + var commandSurvivor = commandBoundary > 0 && first.SentTicks > commandBoundary && RunningChallenge(first.Response); + var retirementSurvivor = managementBoundary > 0 && second.SentTicks > managementBoundary && RunningChallenge(second.Response); _cases.Add(new { - name, windowsIdentity, actionMs, - commandEndMs = commandEnd.Task.IsCompletedSuccessfully ? (long?)commandEnd.Task.Result : null, - handlerEndMs = handlerEnd.Task.IsCompletedSuccessfully ? (long?)handlerEnd.Task.Result : null, - clientEndMs = clientEnd, retirementEndMs = retirementEnd, - commandOutcome, clientOutcome, ownerCompletedBeforeTestCleanup = completedBeforeCleanup, - guestRunningAfterOwnerCompletion = survivor, - settlementVerdict = survivor ? "RED_guest_survives_owner_completion" : - completedBeforeCleanup && !Running(afterBoundary) ? "guest_not_running_at_postcheck_ordering_not_proven" : "UNKNOWN_owner_or_guest_unsettled", - observations + name, clockFrequency = Stopwatch.Frequency, + windowsIdentity = new { pid = windowsOwner.Id, startUtcTicks = windowsStart, exactSelectedDistroArgv = true }, + observerReady = armed, actionTicks, managementRequestedTicks, + commandCompletionHookTicks = commandBoundary, handlerEndTicks = Interlocked.Read(ref handlerEndTicks), + pipeClientCompletionHookTicks = Interlocked.Read(ref clientEndTicks), pipeDisposalHookTicks = Interlocked.Read(ref pipeDisposedTicks), + runtimeLeaseReleaseTicks = Interlocked.Read(ref ownership.RuntimeLeaseReleaseTicks), + activationReleaseBeginTicks = releaseBoundary, activationReleasedTicks = Interlocked.Read(ref ownership.ActivationReleasedTicks), + managementMutationTicks = mutationBoundary, managementCompletionTicks = managementBoundary, + ownership.ManagementCode, commandOutcome, clientOutcome, + nativeBoundaryScope = "source-linked unchanged NativeRegistrationRuntime/RegistrationService; synthetic platform facts and real mutex; no shipping helper/registry/Chrome-frame claim", + completionHookMethod = "ExecuteSynchronously requested, registered while command alive; activation release stamped synchronously before ReleaseMutex; no observer wait in owner callbacks", + exitEvents = exits, + firstChallenge = new { first.SentTicks, first.Response }, secondChallenge = new { second.SentTicks, second.Response }, + allExitNotificationsBeforeCommandHook = Before(commandBoundary), + allExitNotificationsBeforeActivationRelease = Before(releaseBoundary), + allExitNotificationsBeforeManagementMutation = Before(mutationBoundary), + causalPostCommandSurvivor = commandSurvivor, causalPostManagementSurvivor = retirementSurvivor, + provenance = initial.GetProperty("provenance"), + settlementVerdict = commandSurvivor || retirementSurvivor ? "RED_causal_post_boundary_survivor" : + Before(commandBoundary) && Before(releaseBoundary) && Before(mutationBoundary) ? "OBSERVED_exit_notification_precedence" : "UNKNOWN_boundary_ordering" }); } finally { - // Retain the red/unknown boundary BEFORE intervention; kill only matched test identities. + // Neither observer shutdown nor request cleanup can improve a captured pre-cleanup verdict. + Exception? observerFailure = null; + try { if (observer is not null) await observer.DisposeAsync(); } + catch (Exception ex) { observerFailure = ex; } await Guest(fixture, $"/usr/bin/python3 '{_root}/guest.py' cleanup >/dev/null"); cancellation.Cancel(); pipe.Dispose(); var cleanupDeadline = DateTime.UtcNow.AddSeconds(8); do { - var remaining = await Observe(fixture); - if (!Running(remaining)) { cleanupSettled = true; break; } + if (!Running(await Observe(fixture))) { cleanupSettled = true; break; } await Task.Delay(100); } while (DateTime.UtcNow < cleanupDeadline); if (client is not null) await client.WaitAsync(TimeSpan.FromSeconds(10)); - if (retirement is not null) await retirement.WaitAsync(TimeSpan.FromSeconds(10)); + if (management is not null) await management.WaitAsync(TimeSpan.FromSeconds(10)); + if (pipeRetirement is not null) await pipeRetirement.WaitAsync(TimeSpan.FromSeconds(10)); else await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.True(cleanupSettled, "Owned guest fixture did not settle after identity-scoped cleanup."); - Assert.True(handlerEnd.Task.IsCompleted, "Pipe handler did not settle after fixture cleanup."); + Assert.True(cleanupSettled, "Owned guest cleanup did not settle."); + Assert.True(handlerEndTicks > 0, "Handler did not complete after cleanup."); + if (observerFailure is not null) throw new IOException("Observer cleanup failed.", observerFailure); } async Task ObserveClient() { try { - _ = await BrowserBootstrapPipeClient.ExchangeAsync(pipe, + var exchange = BrowserBootstrapPipeClient.ExchangeAsync(pipe, Encoding.UTF8.GetBytes("{\"v\":1,\"op\":\"bootstrap\",\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\"}"), cancellation.Token); + _ = exchange.ContinueWith(_ => Interlocked.Exchange(ref clientEndTicks, clock.ElapsedTicks), + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _ = await exchange; clientOutcome = "response_and_server_eof"; } catch (Exception ex) { clientOutcome = ex.GetType().Name; } - finally { clientEnd = clock.ElapsedMilliseconds; } - } - async Task DisposeServer() - { - await server.DisposeAsync(); - retirementEnd = clock.ElapsedMilliseconds; } } diff --git a/tests/OpenClaw.E2ETests/Setup/BrowserWslNativeOwnership.cs b/tests/OpenClaw.E2ETests/Setup/BrowserWslNativeOwnership.cs new file mode 100644 index 000000000..374055299 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/BrowserWslNativeOwnership.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using OpenClaw.BrowserBootstrap; +using OpenClaw.BrowserBootstrap.Contracts; + +namespace OpenClaw.E2ETests.Setup; + +/// Unchanged source-linked production owners; synthetic inventory, real thread-affine mutex. +/// Not shipping helper/registry/ACL evidence. No observer wait is inside an ownership callback. +internal sealed class BrowserWslNativeOwnership : INativeRegistrationPlatform, IRegistrationPlatform, IDisposable +{ + private readonly Stopwatch _clock; + private readonly Mutex _mutex = new(); + private readonly Generation _generation; + private NativeInventory _inventory; + private int _acquisitions; + internal long RuntimeLeaseReleaseTicks; + internal long ActivationReleaseBeginTicks; + internal long ActivationReleasedTicks; + internal long ManagementMutationTicks; + internal long ManagementCompletionTicks; + internal string? ManagementCode; + private readonly ManagementRequest _request = new(1, "uninstall", ManagementContract.Companion, null, [ManagementContract.Origin], "preserve"); + + internal BrowserWslNativeOwnership(Stopwatch clock) + { + _clock = clock; + const string id = "12345678-1234-4234-8234-123456789abc"; + const string root = @"C:\Fixture\browser-native\"; + var installation = new Installation(id, root + ManagementContract.ManifestName, root + ManagementContract.ExeName, + root + ManagementContract.BindingName, root + ManagementContract.ReceiptName); + _generation = new(new HostBinding(1, ManagementContract.Companion, installation.ManifestPath, [ManagementContract.Origin], null), + new HostReceipt(ManagementContract.Owner, 1, id, "S-1-5-21-111-222-333-1001", true, new('0',64),new('0',64),new('0',64)), installation); + _inventory = new("owned", [_generation]); + } + + internal Task Run(Func operation) => new NativeRegistrationRuntime(this).RunAsync(_generation, (_, _) => operation(), CancellationToken.None); + + internal Task Retire() => Task.Factory.StartNew(() => + { + var result = new RegistrationService(this).Execute(_request, CancellationToken.None); + // Synchronous return boundary, not a continuation scheduled after other work. + Interlocked.Exchange(ref ManagementCompletionTicks, _clock.ElapsedTicks); + ManagementCode = result.Code; + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + public IDisposable Acquire() + { + if (!_mutex.WaitOne(TimeSpan.FromSeconds(35))) throw new ContractException("busy"); + var activation = Interlocked.Increment(ref _acquisitions) == 1; + return new Release(() => + { + if (activation) Interlocked.Exchange(ref ActivationReleaseBeginTicks, _clock.ElapsedTicks); + _mutex.ReleaseMutex(); + if (activation) Interlocked.Exchange(ref ActivationReleasedTicks, _clock.ElapsedTicks); + }); + } + public NativeInventory ObserveNative() => _inventory; + public IDisposable LeaseRuntime(Generation generation) => new Release(() => + Interlocked.Exchange(ref RuntimeLeaseReleaseTicks, _clock.ElapsedTicks)); + public Inventory Observe(ManagementRequest request) => new(_inventory, new("missing")); + public bool BrowserControlAllowsRequest() => true; + public void ValidateRuntime(Generation generation) { } + public Generation Prepare(ManagementRequest request, CancellationToken ct) => throw new NotSupportedException(); + public void PublishNative(ManagementRequest request, Generation generation, CancellationToken ct) => throw new NotSupportedException(); + public void RequestStore(ManagementRequest request, Generation generation, CancellationToken ct) => throw new NotSupportedException(); + public void RemoveStore(ManagementRequest request, CancellationToken ct) => throw new NotSupportedException(); + public void RemoveNative(ManagementRequest request, CancellationToken ct) + { + _inventory = new("missing", []); + Interlocked.Exchange(ref ManagementMutationTicks, _clock.ElapsedTicks); + } + public void Dispose() => _mutex.Dispose(); + private sealed class Release(Action action) : IDisposable { public void Dispose() => action(); } +} diff --git a/tests/OpenClaw.E2ETests/Setup/BrowserWslPersistentObserver.cs b/tests/OpenClaw.E2ETests/Setup/BrowserWslPersistentObserver.cs new file mode 100644 index 000000000..0bd8a9e40 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/BrowserWslPersistentObserver.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.E2ETests.Setup; + +/// Independent observation only. No production completion path awaits this reader. +internal sealed class BrowserWslPersistentObserver : IAsyncDisposable +{ + internal sealed record Event(long ReceivedTicks, JsonElement Data); + private readonly Process _process; + private readonly Task _reader; + private readonly Task _errors; + private readonly Stopwatch _clock; + private readonly ConcurrentQueue _events = new(); + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource[] _challenges = [NewSignal(), NewSignal()]; + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + internal Task Ready => _ready.Task; + internal Event[] Events => _events.ToArray(); + + internal BrowserWslPersistentObserver(string distro, string root, Stopwatch clock) + { + _clock = clock; + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe")) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = new UTF8Encoding(false, true) + }; + foreach (var arg in new[] { "--distribution", distro, "--exec", "/usr/bin/python3", "-u", root + "/guest.py", "monitor" }) + start.ArgumentList.Add(arg); + start.Environment.Remove("WSLENV"); + _process = Process.Start(start) ?? throw new IOException("Observer launch failed."); + _errors = DrainErrors(); + _reader = Task.Factory.StartNew(ReadLoop, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + private void ReadLoop() + { + try + { + var bytes = _process.StandardOutput.BaseStream; + var buffer = new byte[4096]; + var offset = 0; + var count = 0; + while (true) + { + var next = bytes.ReadByte(); + if (next < 0) break; + if (next != 10) + { + if (offset == buffer.Length) throw new InvalidDataException("Observer line bound."); + buffer[offset++] = (byte)next; + continue; + } + // Stamp immediately on the dedicated reader thread, before parsing, locks or continuations. + var stamp = _clock.ElapsedTicks; + if (++count > 8) throw new InvalidDataException("Observer event bound."); + using var document = JsonDocument.Parse(buffer.AsMemory(0, offset)); + offset = 0; + var item = new Event(stamp, document.RootElement.Clone()); + var type = item.Data.GetProperty("type").GetString(); + if (type is not ("ready" or "exit" or "challenge" or "stopped")) throw new InvalidDataException("Observer event schema."); + _events.Enqueue(item); + if (type == "ready") _ready.TrySetResult(item); + if (type == "challenge") + { + var id = item.Data.GetProperty("id").GetInt32(); + if (id is < 1 or > 2) throw new InvalidDataException("Observer challenge id."); + _challenges[id - 1].TrySetResult(item); + } + } + if (offset != 0) throw new InvalidDataException("Partial observer event."); + } + catch (Exception ex) + { + _ready.TrySetException(new IOException("Observer failed.", ex)); + foreach (var signal in _challenges) signal.TrySetException(new IOException("Observer failed.", ex)); + throw; + } + } + + internal async Task<(long SentTicks, Event Response)> Challenge(int id) + { + if (id is < 1 or > 2) throw new ArgumentOutOfRangeException(nameof(id)); + var sent = _clock.ElapsedTicks; + await _process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new { op = "challenge", id })); + await _process.StandardInput.FlushAsync(); + var response = await _challenges[id - 1].Task.WaitAsync(TimeSpan.FromSeconds(5)); + return (sent, response); + } + + private async Task DrainErrors() + { + var bytes = new byte[1024]; + var total = 0; + while (true) + { + var read = await _process.StandardError.BaseStream.ReadAsync(bytes); + if (read == 0) return; + total += read; + if (total > 8192) throw new InvalidDataException("Observer error bound."); + } + } + + public async ValueTask DisposeAsync() + { + try + { + if (!_process.HasExited) + { + await _process.StandardInput.WriteLineAsync("{\"op\":\"stop\"}"); + _process.StandardInput.Close(); + await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + } + finally + { + if (!_process.HasExited) { _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); } + try { await Task.WhenAll(_reader, _errors).WaitAsync(TimeSpan.FromSeconds(5)); } + finally { _process.Dispose(); } + } + } +} From fe82006fac2539536ab3045fef430b08bfff76a1 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:10:33 +0000 Subject: [PATCH 44/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: cf71fe03-1dac-40bb-8dff-c18802a41653 From e849d4510ba080857215b70b8316e67cf5a3e227 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:23:59 +0000 Subject: [PATCH 45/77] test(browser): prototype acknowledged request-owned WSL settlement Exercise fixed stdin handoff, strict private frames, single-use permit, transient user-systemd cgroup ownership, successful detached-child cleanup, cancellation/loss boundaries, and observed epoch refusal before production wiring. Preserve public contracts and 16384 UTF16 payload capacity. Missing acknowledgment remains an explicit UNKNOWN checkpoint, not permission to retire. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../workflows/browser-wsl-owner-prototype.yml | 78 +++++ scripts/BrowserWslOwnerPrototype.cjs | 212 +++++++++++++ scripts/BrowserWslOwnerPrototype.test.cjs | 9 + .../Prototype/BrowserOwnerProtocolTests.cs | 290 ++++++++++++++++++ 4 files changed, 589 insertions(+) create mode 100644 .github/workflows/browser-wsl-owner-prototype.yml create mode 100644 scripts/BrowserWslOwnerPrototype.cjs create mode 100644 scripts/BrowserWslOwnerPrototype.test.cjs create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs diff --git a/.github/workflows/browser-wsl-owner-prototype.yml b/.github/workflows/browser-wsl-owner-prototype.yml new file mode 100644 index 000000000..c09228495 --- /dev/null +++ b/.github/workflows/browser-wsl-owner-prototype.yml @@ -0,0 +1,78 @@ +name: Browser WSL owner prototype + +on: + push: + branches: [openclaw/automatic-chrome-extension-pairing] + paths: + - .github/workflows/browser-wsl-owner-prototype.yml + - scripts/BrowserWslOwnerPrototype.cjs + - scripts/BrowserWslOwnerPrototype.test.cjs + - tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs + +permissions: + contents: read + +jobs: + owner-prototype: + runs-on: windows-latest + timeout-minutes: 40 + env: + OPENCLAW_REPO_ROOT: ${{ github.workspace }} + OPENCLAW_RUN_E2E: '1' + OPENCLAW_BROWSER_WSL_PROTOTYPE: '1' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify frozen production and accepted native proof + shell: pwsh + run: | + git diff --exit-code 783f178ca5579d057d4799fceb5cec5e8c4d69f8 HEAD -- src .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Frozen production/native proof changed.' } + "OPENCLAW_WSL_PROTOTYPE_RECEIPT=$env:RUNNER_TEMP/browser-wsl-owner-prototype.json" >> $env:GITHUB_ENV + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - name: Check private protocol without running Linux service work + shell: pwsh + run: node --test scripts/BrowserWslOwnerPrototype.test.cjs + - name: Build existing managed WSL setup fixture + shell: pwsh + run: | + dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Tray restore failed.' } + dotnet restore tests/OpenClaw.E2ETests -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Fixture restore failed.' } + dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Tray build failed.' } + dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Fixture build failed.' } + - name: Exercise private protocol and transient user service in disposable WSL + shell: pwsh + run: | + # Existing setup diagnostics may include synthetic fixture credentials. + # Neither raw test output, TRX output sections nor fixture files are uploaded. + $privateLog = Join-Path $env:RUNNER_TEMP 'wsl-prototype-private-test.log' + $privateResults = Join-Path $env:RUNNER_TEMP 'wsl-prototype-private-results' + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserWslOwnerPrototypeTests --results-directory $privateResults --logger 'trx;LogFileName=trace.trx' *> $privateLog + $testExit = $LASTEXITCODE + if (!(Test-Path $env:OPENCLAW_WSL_PROTOTYPE_RECEIPT)) { throw 'No terminal component receipt. Setup/build capability must be diagnosed privately.' } + [xml]$trx = Get-Content (Join-Path $privateResults 'trace.trx') + $cases = @($trx.TestRun.Results.UnitTestResult) + if ($cases.Count -ne 1 -or $cases[0].outcome -ne 'Passed' -or $testExit -ne 0) { throw 'Component trace did not execute and finish all owned cleanup.' } + Write-Host 'Prototype receipt collected; production wiring has not changed.' + - name: Retain only explicitly redacted component receipt + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-wsl-owner-prototype-receipt + path: ${{ env.OPENCLAW_WSL_PROTOTYPE_RECEIPT }} + if-no-files-found: warn + retention-days: 7 + - name: Evaluate the prototype checkpoint, not production completion + shell: pwsh + run: | + $r = Get-Content $env:OPENCLAW_WSL_PROTOTYPE_RECEIPT -Raw | ConvertFrom-Json + if ($r.status -ne 'prototype_checkpoint_passed' -or $r.cases.Count -ne 19) { throw 'Prototype checkpoint incomplete. No production wiring is authorized by a failed checkpoint.' } + Write-Host 'Private protocol/systemd checkpoint only. Missing acknowledgments remain UNKNOWN/BUSY; no bounded early-loss recovery is claimed.' diff --git a/scripts/BrowserWslOwnerPrototype.cjs b/scripts/BrowserWslOwnerPrototype.cjs new file mode 100644 index 000000000..c6fe6a045 --- /dev/null +++ b/scripts/BrowserWslOwnerPrototype.cjs @@ -0,0 +1,212 @@ +'use strict'; +// TEST-ONLY prototype. Never selected by the production pairing owner. +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { spawn, execFileSync } = require('node:child_process'); +const { TextDecoder } = require('node:util'); +const CONTROL = 2048, RESULT = 90000, CLI_BYTES = 65536, CLI_CHARS = 16384; +const decoder = new TextDecoder('utf-8', { fatal: true }); +const keys = { + permit: ['v','type','requestId','invocationId','challenge'], + cancel: ['v','type','requestId','invocationId'], + ready: ['v','type','requestId','invocationId','challenge','unit','bootId','pid','start','cgroup','environmentClean'], + result: ['v','type','requestId','invocationId','payload'], + settled: ['v','type','requestId','invocationId','outcome','startSealed','gateExited','cgroupEmpty','startJobSettled'] +}; +function parse(bytes) { + if(bytes.length>=3&&bytes[0]===0xef&&bytes[1]===0xbb&&bytes[2]===0xbf)throw Error("protocol_bom"); + const text = decoder.decode(bytes), value = JSON.parse(text); + if (!value || value.v !== 1 || !keys[value.type] || JSON.stringify(value) !== text || + Object.keys(value).sort().join() !== [...keys[value.type]].sort().join() || + !/^[a-f0-9]{32}$/.test(value.requestId) || !/^[a-f0-9]{32}$/.test(value.invocationId)) throw Error('protocol_schema'); + if (value.type === 'permit' && !/^[a-f0-9]{32}$/.test(value.challenge)) throw Error('protocol_challenge'); + return value; +} +function frame(value) { + const data = Buffer.from(JSON.stringify(value)); + if (data.length > (value.type === 'result' ? RESULT : CONTROL)) throw Error('protocol_bound'); + const h = Buffer.alloc(4); h.writeUInt32LE(data.length); return Buffer.concat([h,data]); +} +class Reader { + constructor(stream, onFrame, onGone) { + let data = Buffer.alloc(0), count = 0, failed = false; + const fail = () => { if (!failed) { failed = true; onGone('invalid'); } }; + stream.on('data', part => { + if (failed) return; + try { + if (data.length + part.length > RESULT + 4) throw Error('protocol_bound'); + data = Buffer.concat([data,part]); + while (data.length >= 4) { + const size = data.readUInt32LE(); + if (!size || size > RESULT) throw Error('protocol_bound'); + if (data.length < size + 4) break; + const message = parse(data.subarray(4, size + 4)); + if (size > (message.type === 'result' ? RESULT : CONTROL) || ++count > 4) throw Error('protocol_bound'); + data = data.subarray(size + 4); onFrame(message); + } + } catch { fail(); } + }); + stream.on('end', () => { if (!failed) { failed = true; onGone(data.length ? 'partial' : 'eof'); } }); + stream.on('error', fail); + } +} +function identity(pid) { + try { const a=fs.readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' '); return {pid,start:Number(a[19]),state:a[0]}; } + catch (e) { if(e.code==='ENOENT')return null; throw e; } +} +function show(unit) { + const text=execFileSync('/usr/bin/systemctl',['--user','show',unit,'-p','Id','-p','LoadState','-p','ActiveState','-p','SubState','-p','InvocationID','-p','ControlGroup','-p','MainPID','-p','Description'],{encoding:'utf8',timeout:2000,maxBuffer:8192,stdio:['ignore','pipe','pipe']}); + return Object.fromEntries(text.trim().split('\n').map(l=>{const p=l.indexOf('=');return [l.slice(0,p),l.slice(p+1)];})); +} +function empty(cgroup) { + if (!cgroup.startsWith('/user.slice/') || cgroup.includes('..')) throw Error('cgroup_binding'); + try { return /^populated 0$/m.test(fs.readFileSync('/sys/fs/cgroup'+cgroup+'/cgroup.events','utf8')); } + catch(e){if(e.code==='ENOENT')return true;throw e;} +} +function emit(value) { process.stdout.write(frame(value)); } +function binding(value, ready) { + if(value.requestId!==ready.requestId || value.invocationId!==ready.invocationId)throw Error('protocol_binding'); +} +function cliPayload(bytes) { + if(bytes.length>CLI_BYTES)throw Error('cli_bound'); + const text=decoder.decode(bytes); + if(text.length>CLI_CHARS)throw Error('cli_bound'); + return bytes.toString('base64'); +} +async function gate(s) { + const mine=identity(process.pid), unit=show(s.unit); + const cgroup=fs.readFileSync('/proc/self/cgroup','utf8').trim().split('\n').find(l=>l.startsWith('0::'))?.slice(3); + if(unit.Id!==s.unit || unit.Description!==s.description || Number(unit.MainPID)!==process.pid || unit.ControlGroup!==cgroup || !/^[a-f0-9]{32}$/.test(unit.InvocationID))throw Error('gate_binding'); + const clean=process.env.HOME==='/home/openclaw' && process.env.OPENCLAW_STATE_DIR==='/home/openclaw/.openclaw' && process.env.OPENCLAW_CONFIG_PATH==='/home/openclaw/.openclaw/openclaw.json' && + ['OPENCLAW_PROFILE','OPENCLAW_HOME','NODE_OPTIONS','NODE_PATH'].every(k=>!(k in process.env)); + if(!clean)throw Error('gate_environment'); + const ready={v:1,type:'ready',requestId:s.requestId,invocationId:unit.InvocationID,challenge:crypto.randomBytes(16).toString('hex'),unit:s.unit, + bootId:fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),pid:mine.pid,start:mine.start,cgroup,environmentClean:clean}; + let phase='waiting', child, stopped=false; + const revoke=()=>{ + if(stopped)return;stopped=true;phase='sealed'; + // A gate cannot wait for its own stop job. Queue only its positively bound unit. + try { const current=show(s.unit);if(current.InvocationID!==ready.invocationId)throw Error('epoch'); + execFileSync('/usr/bin/systemctl',['--user','stop','--no-block',s.unit],{timeout:2000,stdio:'ignore'}); + } catch { process.exitCode=42; } + }; + new Reader(process.stdin, value=>{ + binding(value,ready); + if(value.type==='cancel'){revoke();return;} + if(value.type!=='permit'||phase!=='waiting'||value.challenge!==ready.challenge)throw Error('permit_state'); + phase='running'; + child=spawn(s.command[0],s.command.slice(1),{stdio:['ignore','pipe','pipe'],env:process.env}); + let out=Buffer.alloc(0),errorBytes=0,failed=false; + child.stdout.on('data',part=>{if(out.length+part.length>CLI_BYTES){failed=true;revoke();}else out=Buffer.concat([out,part]);}); + child.stderr.on('data',part=>{errorBytes+=part.length;if(errorBytes>CLI_BYTES){failed=true;revoke();}}); + child.on('error',revoke); + child.on('close',code=>{ + if(stopped)return; + phase='sealed'; + if(code!==0||failed){revoke();return;} + try { emit({v:1,type:'result',requestId:s.requestId,invocationId:ready.invocationId,payload:cliPayload(out)}); } + catch { revoke();return; } + process.stdin.destroy();process.stdout.end(()=>process.exit(0)); + }); + },revoke); + emit(ready); +} +async function broker(s, source) { + if(!/^[a-f0-9]{32}$/.test(s.requestId))throw Error('request_id'); + // Single writer: each request uses a fresh name exactly once; no participant/recovery restarts it. + // Show/StopUnit is not an atomic invocation-conditional API. An actor controlling this same + // user-manager and trusted CLI/config could replace any unit between commands; that actor is + // outside the existing trusted-UID model. Refuse observed drift, never claim an atomic fence. + const unit='openclaw-browser-prototype-'+s.requestId+'.service'; + const description='OpenClaw browser prototype '+s.requestId; + const uid=process.getuid(),runtime='/run/user/'+uid; + const state={...s,mode:'gate',unit,description}; + const program='const settings='+JSON.stringify(state)+';const prototypeSource='+JSON.stringify(source)+';'+Buffer.from(source,'base64').toString(); + if(Buffer.byteLength(program)>100000)throw Error('inline_bound'); + const args=['--user','--quiet','--pipe','--service-type=exec','--unit='+unit,'--property=Description='+description, + '--property=ExitType=cgroup','--property=KillMode=control-group','--property=Restart=no','--property=RemainAfterExit=yes','--property=TimeoutStopSec=2s', + '/usr/bin/env','-i','HOME=/home/openclaw','PATH=/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin', + 'OPENCLAW_STATE_DIR=/home/openclaw/.openclaw','OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw/openclaw.json', + 'XDG_RUNTIME_DIR='+runtime,'DBUS_SESSION_BUS_ADDRESS=unix:path='+runtime+'/bus',s.node,'-e',program]; + let ready, result, cancelled=false, permit=false, runnerClosed=false, runnerCode, finished=false, stopping, stopFailed=false; + if(s.startDelayMs)args.splice(args.indexOf("/usr/bin/env"),0,"--property=ExecStartPre=/bin/sleep 1"); + const runner=spawn('/usr/bin/systemd-run',args,{stdio:['pipe','pipe','pipe']}); + runner.stdin.on('error',()=>{}); + let stderr=0; + runner.stderr.on('data',p=>{stderr+=p.length;if(stderr>8192)cancelled=true;}); + const closed=new Promise(resolve=>{runner.once('error',()=>{runnerClosed=true;runnerCode=-1;resolve();});runner.once('close',code=>{runnerClosed=true;runnerCode=code;resolve();});}); + const stop=()=>{ + cancelled=true; + if(ready && !stopping) { + stopping=(async()=>{ + if(s.stopDelayMs)await new Promise(resolve=>setTimeout(resolve,s.stopDelayMs)); + if(fs.readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim()!==ready.bootId)throw Error("stop_epoch"); + const now=show(unit); + if(now.LoadState==='not-found')return; // NOT a settlement decision; bound cgroup and runner must also finish. + if(now.InvocationID!==ready.invocationId||now.Description!==description)throw Error('stop_binding'); + await new Promise((resolve,reject)=>{ + const p=spawn('/usr/bin/systemctl',['--user','stop',unit],{stdio:'ignore'}); + p.on('error',reject);p.on('close',code=>code===0?resolve():reject(Error('stop_failed'))); + }); + })(); + stopping.catch(()=>{stopFailed=true;}); + } + }; + new Reader(process.stdin,value=>{ + if(!ready)throw Error('before_ready');binding(value,ready); + if(value.type==='cancel'){stop();return;} + if(value.type!=='permit'||permit||cancelled||value.challenge!==ready.challenge)throw Error('permit_state'); + permit=true;runner.stdin.write(frame(value)); + },stop); + new Reader(runner.stdout,value=>{ + if(value.type==='ready') { + if(ready||value.requestId!==s.requestId||value.unit!==unit||!value.environmentClean)throw Error('ready_state'); + const now=show(unit), actual=identity(value.pid); + if(now.InvocationID!==value.invocationId||now.ControlGroup!==value.cgroup||now.Description!==description||Number(now.MainPID)!==value.pid||actual?.start!==value.start)throw Error('ready_binding'); + ready=value; + if(cancelled)stop(); + else if(s.readyDelayMs)setTimeout(()=>{if(!cancelled)emit(ready);},s.readyDelayMs); + else emit(ready); + return; + } + if(!ready)throw Error('before_ready');binding(value,ready); + if(value.type!=='result'||result||!permit||cancelled)throw Error('result_state'); + const payload=Buffer.from(value.payload,'base64');if(payload.toString('base64')!==value.payload)throw Error('result_encoding');cliPayload(payload); + result=value; // Only the broker can forward RESULT, and it still cannot imply SETTLED. + },()=>{if(!result)stop();}); + let checking=false; + const interval=setInterval(async()=>{ + if(finished||checking)return; + checking=true; + try { + if(!ready){if(runnerClosed){finished=true;clearInterval(interval);process.exit(43);}return;} + if(stopFailed)throw Error("stop_refused"); + if(!result&&!cancelled)return; + if(!stopping){ + // Seal successful work too: detached descendants may outlive a successful CLI. + // Stop only the matched unit, THEN wait for its cgroup to empty. + if(fs.readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim()!==ready.bootId)throw Error("stop_epoch"); + const current=show(unit); + if(current.LoadState!=="not-found" && (current.InvocationID!==ready.invocationId||current.Description!==description))throw Error("stop_binding"); + stopping=(async()=>{await new Promise((resolve,reject)=>{ + const p=spawn('/usr/bin/systemctl',['--user','stop',unit],{stdio:'ignore'}); + p.on('error',reject);p.on('close',code=>code===0?resolve():reject(Error('stop_failed'))); + });})();stopping.catch(()=>{stopFailed=true;}); + } + if(!empty(ready.cgroup))return; + await stopping;await closed; + if(!empty(ready.cgroup)||!runnerClosed)throw Error('not_settled'); + finished=true;clearInterval(interval); + if(result&&!cancelled)emit(result); + if(s.settledDelayMs)await new Promise(resolve=>setTimeout(resolve,s.settledDelayMs)); + emit({v:1,type:'settled',requestId:s.requestId,invocationId:ready.invocationId,outcome:cancelled?'cancelled':'completed',startSealed:true,gateExited:true,cgroupEmpty:true,startJobSettled:true}); + process.stdin.destroy();process.stdout.end(()=>process.exit(0)); + } catch { finished=true;clearInterval(interval);process.exit(44); } + finally { checking=false; } + },20); +} +module.exports={parse,frame,Reader,cliPayload,CONTROL,RESULT}; +if(typeof settings!=='undefined') { + process.stdout.on('error',()=>{process.exitCode=45;}); + (settings.mode==='gate'?gate(settings):broker(settings,prototypeSource)).catch(()=>{process.stderr.write('prototype_failed\n');process.exit(46);}); +} diff --git a/scripts/BrowserWslOwnerPrototype.test.cjs b/scripts/BrowserWslOwnerPrototype.test.cjs new file mode 100644 index 000000000..948ecab02 --- /dev/null +++ b/scripts/BrowserWslOwnerPrototype.test.cjs @@ -0,0 +1,9 @@ +'use strict'; +const {test}=require('node:test');const assert=require('node:assert/strict');const {PassThrough}=require('node:stream'); +const {parse,frame,Reader,cliPayload}=require('./BrowserWslOwnerPrototype.cjs'); +const permit={v:1,type:'permit',requestId:'a'.repeat(32),invocationId:'b'.repeat(32),challenge:'c'.repeat(32)}; +test('canonical control and fragmented framing',()=>{const s=new PassThrough(),seen=[];new Reader(s,x=>seen.push(x),()=>{});const f=frame(permit);s.write(f.subarray(0,2));s.write(f.subarray(2,5));s.write(f.subarray(5));assert.deepEqual(seen,[permit]);}); +test('duplicate unknown noncanonical and malformed controls rejected',()=>{const text=JSON.stringify(permit);for(const raw of [text.replace('{','{"v":1,'),text.replace('}',',"extra":true}'),' '+text,text.replace('"v":1','"v":1.0'),'{',text.replace('a'.repeat(32),'bad')])assert.throws(()=>parse(Buffer.from(raw)));}); +test('invalid UTF8 and BOM controls rejected',()=>{assert.throws(()=>parse(Buffer.from([0xff])));assert.throws(()=>parse(Buffer.concat([Buffer.from([0xef,0xbb,0xbf]),Buffer.from(JSON.stringify(permit))])));}); +test('overbound and partial frames revoke',()=>{for(const mode of ['large','partial']){const s=new PassThrough();let gone=false;new Reader(s,()=>assert.fail(),()=>gone=true);const h=Buffer.alloc(4);h.writeUInt32LE(mode==='large'?1000000:20);s.write(h);if(mode==='large')assert.equal(gone,true);else{s.emit('end');assert.equal(gone,true);}}}); +test('legacy 16384 UTF16 capacity is not reduced by framing',()=>{for(const text of ['x'.repeat(16384),'界'.repeat(16384),'😀'.repeat(8192)]){const bytes=Buffer.from(text);const payload=cliPayload(bytes);const message={v:1,type:'result',requestId:permit.requestId,invocationId:permit.invocationId,payload};const encoded=frame(message);assert.equal(Buffer.from(parse(encoded.subarray(4)).payload,'base64').toString(),text);assert.ok(encoded.length<90004);}assert.throws(()=>cliPayload(Buffer.from('x'.repeat(16385))));}); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs new file mode 100644 index 000000000..b86d7adee --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs @@ -0,0 +1,290 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenClaw.Connection; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class BrowserWslPrototypeFactAttribute : FactAttribute +{ + public BrowserWslPrototypeFactAttribute() + { + if (!E2ETestGate.IsEnabled || Environment.GetEnvironmentVariable("OPENCLAW_BROWSER_WSL_PROTOTYPE") != "1") + Skip = "Dedicated disposable user-systemd prototype only."; + } +} + +public sealed class BrowserWslOwnerPrototypeTests +{ + private static readonly string[] Cases = ["normal", "normal_setsid", "real_cli", "payload_capacity", "cancel", "eof", "deadline_setsid", + "loss_before_ready", "loss_partial_permit", "loss_full_permit", "loss_after_result", "loss_after_settled", + "late_start_job", "collision", "manager_epoch_replaced", "wrong_epoch", "duplicate_permit", "oversized", "out_of_order"]; + private readonly List _cases = []; + private readonly List _errors = []; + private string _source = "", _node = ""; + private long _gatewayPid; + + [BrowserWslPrototypeFact] + public async Task PrivateProtocolAndTransientScope_PrecedeProductionWiring() + { + Assert.True(OperatingSystem.IsWindows()); + Assert.Equal("github-hosted", Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")); + var receipt = Environment.GetEnvironmentVariable("OPENCLAW_WSL_PROTOTYPE_RECEIPT")!; + Assert.False(string.IsNullOrWhiteSpace(receipt)); + var fixture = new E2ESetupFixture(); + var stdout = Console.Out; var stderr = Console.Error; + bool removed = false, gatewayUnchanged = false; + Console.SetOut(TextWriter.Null); Console.SetError(TextWriter.Null); + try + { + await fixture.InitializeAsync(); Assert.Null(fixture.SetupError); await fixture.StopTrayAsync(); + _source = await File.ReadAllTextAsync(Path.Combine(Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT")!, "scripts", "BrowserWslOwnerPrototype.cjs")); + var preflight = await Guest(fixture, "export PATH=/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin; test \"$(id -un)\" = openclaw; command -v node; systemctl --user show openclaw-gateway.service -p MainPID --value"); + var lines = preflight.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + _node = lines[0]; _gatewayPid = long.Parse(lines[1]); + Assert.StartsWith("/", _node); Assert.True(_gatewayPid > 1); + foreach (var name in Cases) + { + try { await RunCase(fixture, name); } + catch (Exception ex) { _errors.Add(name + ":" + ex.GetType().Name); } + } + gatewayUnchanged = (await Guest(fixture, "systemctl --user show openclaw-gateway.service -p MainPID --value")).Trim() == _gatewayPid.ToString(); + } + catch (Exception ex) { _errors.Add("fixture:" + ex.GetType().Name); } + finally + { + try { await fixture.DisposeAsync(); removed = true; } + catch (Exception ex) { _errors.Add("dispose:" + ex.GetType().Name); } + Console.SetOut(stdout); Console.SetError(stderr); + await File.WriteAllTextAsync(receipt, JsonSerializer.Serialize(new + { + schema = 1, sourceSha = Environment.GetEnvironmentVariable("GITHUB_SHA"), + prototypeSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(_source))).ToLowerInvariant(), + productionUnchanged = true, publicProtocolUnchanged = true, scope = "private protocol/systemd prototype, not production owner repair or whole-Companion proof", + cases = _cases, errors = _errors, gatewayUnchanged, ownedFixtureDisposed = removed, + payloadLimitUtf16 = 16384, privateResultFrameBound = 90000, + status = _errors.Count == 0 && _cases.Count == Cases.Length && removed && gatewayUnchanged ? "prototype_checkpoint_passed" : "prototype_incomplete" + }, new JsonSerializerOptions { WriteIndented = true })); + } + Assert.Empty(_errors); Assert.Equal(Cases.Length, _cases.Count); Assert.True(removed && gatewayUnchanged); + } + + private async Task RunCase(E2ESetupFixture fixture, string name) + { + var id = Guid.NewGuid().ToString("N"); var unit = "openclaw-browser-prototype-" + id + ".service"; + var description = "OpenClaw browser prototype " + id; + var hold = name is "deadline_setsid" or "loss_full_permit" or "duplicate_permit"; + var workload = hold + ? "const{spawn}=require('node:child_process');spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{detached:true,stdio:'ignore'});setTimeout(()=>{},60000);" + : name == "normal_setsid" ? "const fs=require('node:fs');const p=require('node:child_process').spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{detached:true,stdio:'ignore'});p.unref();const a=fs.readFileSync('/proc/'+p.pid+'/stat','utf8').split(') ').at(-1).split(' ');process.stdout.write(JSON.stringify({pid:p.pid,start:Number(a[19]),session:Number(a[3]),sameCgroup:fs.readFileSync('/proc/'+p.pid+'/cgroup','utf8')===fs.readFileSync('/proc/self/cgroup','utf8')}));" + : name == "payload_capacity" ? "process.stdout.write('界'.repeat(16384));" : "process.stdout.write(JSON.stringify({prototype:true}));"; + var settings = new { mode = "broker", requestId = id, node = _node, realCli = name == "real_cli", + command = name == "real_cli" ? new[] { "selected-cli", "browser", "extension", "pair", "--json", "--local-gateway" } : new[] { _node, "-e", workload }, + readyDelayMs = name == "loss_before_ready" ? 2000 : 0, startDelayMs = name == "late_start_job" ? 1000 : 0, + settledDelayMs = name == "loss_after_result" ? 500 : 0, stopDelayMs = name == "manager_epoch_replaced" ? 1500 : 0 }; + var source64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_source)); + var js = "const settings=" + JsonSerializer.Serialize(settings) + ";if(settings.realCli)settings.command[0]=process.argv[1];const prototypeSource='" + source64 + "';" + _source; + var program64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(js)); + var prefix = BrowserBootstrapWslCommand.Script[..BrowserBootstrapWslCommand.Script.IndexOf("for cli in", StringComparison.Ordinal)]; + var script = prefix + "\ncli=''; for candidate in /home/openclaw/.openclaw/bin/openclaw /opt/openclaw/bin/openclaw /usr/local/bin/openclaw; do if test -x \"$candidate\"; then cli=\"$candidate\"; break; fi; done\ntest -n \"$cli\"\nexec '" + _node + "' -e \"$(printf '%s' '" + program64 + "' | base64 -d)\" \"$cli\"\n"; + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(35)); + var clock = Stopwatch.StartNew(); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe")) + { UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in new[] { "--distribution", fixture.DistroName, "--exec", "/bin/bash", "--noprofile", "--norc", "-s" }) start.ArgumentList.Add(arg); + start.Environment.Remove("WSLENV"); + bool collision = name == "collision", settled = false, resultSeen = false, canonicalValid = false, capacityPreserved = false, stopped = false; + bool beforeReadyLoss = name is "loss_before_ready" or "late_start_job"; + JsonElement? ready = null; long? readyMs = null, permitMs = null, resultMs = null, settledMs = null; + string? failure = null, outcome = null; int processCountBeforeLoss = 0; bool setsidObserved = false, epochRefused = false; + bool replacement = name == "manager_epoch_replaced"; + object? normalDetachedIdentity = null; + if (collision) await Guest(fixture, $"systemd-run --user --quiet --unit='{unit}' --property='Description=Fixture collision {id}' /bin/sleep 60"); + using var process = Process.Start(start)!; + var errors = Drain(process.StandardError.BaseStream); + try + { + await process.StandardInput.WriteAsync(script.AsMemory(), budget.Token); + await process.StandardInput.FlushAsync(budget.Token); // Intentionally keep stdin open across bash -> inline broker. + if (beforeReadyLoss) + { + await WaitUnit(fixture, unit, p => p.GetProperty("present").GetBoolean(), budget.Token); + process.Kill(); + } + else if (!collision) + { + ready = await ReadFrame(process.StandardOutput.BaseStream, budget.Token); + Assert.Equal("ready", ready.Value.GetProperty("type").GetString()); + Assert.Equal(id, ready.Value.GetProperty("requestId").GetString()); Assert.Equal(unit, ready.Value.GetProperty("unit").GetString()); + Assert.True(ready.Value.GetProperty("environmentClean").GetBoolean()); + readyMs = clock.ElapsedMilliseconds; + var invocation = ready.Value.GetProperty("invocationId").GetString()!; + var permit = new { v = 1, type = "permit", requestId = id, invocationId = invocation, challenge = ready.Value.GetProperty("challenge").GetString() }; + var bytes = Encode(permit); + if (replacement) + { + await Write(process, Encode(new { v = 1, type = "cancel", requestId = id, invocationId = invocation }), budget.Token); + await Guest(fixture, $"systemctl --user stop '{unit}'; systemd-run --user --quiet --unit='{unit}' --property='Description=Fixture epoch {id}' /bin/sleep 60"); + } + else if (name == "cancel") await Write(process, Encode(new { v = 1, type = "cancel", requestId = id, invocationId = invocation }), budget.Token); + else if (name == "eof") process.StandardInput.Close(); + else if (name == "wrong_epoch") await Write(process, Encode(new { v = 1, type = "permit", requestId = id, invocationId = new string('0', 32), challenge = permit.challenge }), budget.Token); + else if (name == "out_of_order") await Write(process, Encode(new { v = 1, type = "result", requestId = id, invocationId = invocation, payload = "" }), budget.Token); + else if (name == "oversized") { var header = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(header, 1000000); await Write(process, header, budget.Token); } + else if (name == "loss_partial_permit") { await Write(process, bytes[..(bytes.Length / 2)], budget.Token); process.Kill(); } + else + { + permitMs = clock.ElapsedMilliseconds; await Write(process, bytes, budget.Token); + if (hold) + { + var observed = await WaitUnit(fixture, unit, p => p.GetProperty("processes").GetArrayLength() >= 3, budget.Token); + processCountBeforeLoss = observed.GetProperty("processes").GetArrayLength(); + setsidObserved = observed.GetProperty("processes").EnumerateArray().Select(p => p.GetProperty("session").GetInt32()).Distinct().Count() >= 2; + Assert.True(setsidObserved); + } + if (name == "loss_full_permit") process.Kill(); + if (name == "duplicate_permit") await Write(process, bytes, budget.Token); + if (name == "deadline_setsid") + { + await Task.Delay(TimeSpan.FromMilliseconds(Math.Max(0, 15000 - clock.ElapsedMilliseconds)), budget.Token); + process.StandardInput.Close(); + } + } + while (!process.HasExited || process.StandardOutput.BaseStream.CanRead) + { + JsonElement message; + try { message = await ReadFrame(process.StandardOutput.BaseStream, budget.Token); } + catch (EndOfStreamException) { break; } + Assert.Equal(id, message.GetProperty("requestId").GetString()); Assert.Equal(invocation, message.GetProperty("invocationId").GetString()); + switch (message.GetProperty("type").GetString()) + { + case "result": + Assert.False(settled); Assert.False(resultSeen); resultSeen = true; resultMs = clock.ElapsedMilliseconds; + var payload = new UTF8Encoding(false, true).GetString(Convert.FromBase64String(message.GetProperty("payload").GetString()!)); + Assert.True(payload.Length <= 16384); + if (name == "real_cli") { _ = BrowserBootstrapService.ParsePairing(payload, fixture.GatewayPort); canonicalValid = true; } + if (name == "payload_capacity") { Assert.Equal(new string('界', 16384), payload); capacityPreserved = true; } + if (name == "normal_setsid") + { + using var child = JsonDocument.Parse(payload); var d = child.RootElement; + Assert.Equal(d.GetProperty("pid").GetInt32(), d.GetProperty("session").GetInt32()); + Assert.True(d.GetProperty("sameCgroup").GetBoolean()); setsidObserved = true; + normalDetachedIdentity = new { pid = d.GetProperty("pid").GetInt32(), start = d.GetProperty("start").GetInt64() }; + } + if (name == "loss_after_result" && !process.HasExited) process.Kill(); + break; + case "settled": + Assert.False(settled); + outcome = message.GetProperty("outcome").GetString(); + Assert.Contains(outcome, new[] { "completed", "cancelled", "failed" }); + if (outcome == "completed") Assert.True(resultSeen); + settled = true; settledMs = clock.ElapsedMilliseconds; + foreach (var key in new[] { "startSealed", "gateExited", "cgroupEmpty", "startJobSettled" }) Assert.True(message.GetProperty(key).GetBoolean()); + if (name == "loss_after_settled" && !process.HasExited) process.Kill(); + break; + default: throw new InvalidDataException("Unexpected prototype frame."); + } + } + } + await process.WaitForExitAsync(budget.Token); await errors.WaitAsync(budget.Token); + var post = await WaitUnit(fixture, unit, p => collision || replacement ? p.GetProperty("present").GetBoolean() : p.GetProperty("processes").GetArrayLength() == 0, budget.Token); + if (collision || replacement) Assert.False(post.GetProperty("owned").GetBoolean()); + if (replacement) + { + Assert.NotEqual(ready!.Value.GetProperty("invocationId").GetString(), post.GetProperty("invocation").GetString()); + Assert.Equal("Fixture epoch " + id, post.GetProperty("description").GetString()); + Assert.True(post.GetProperty("processes").GetArrayLength() > 0); epochRefused = true; + } + var expectedUnknown = name.StartsWith("loss_", StringComparison.Ordinal) && name != "loss_after_settled" || name is "late_start_job" or "collision" or "manager_epoch_replaced"; + Assert.Equal(!expectedUnknown, settled); + if (settled) Assert.True(settledMs <= 17000, "Prototype exceeded unchanged request plus soft cleanup budget."); + if (name == "real_cli") Assert.True(canonicalValid); + if (name == "payload_capacity") Assert.True(capacityPreserved); + _cases.Add(new { name, requestId = id, windowsPid = process.Id, readyMs, permitMs, resultMs, settledMs, + stdinHandoffVerified = ready.HasValue, resultSeen, resultReleased = settled && resultSeen && outcome == "completed", outcome, + positiveSettlement = settled, retirementAllowed = settled, expectedUnknown, + classification = settled ? "positive_settlement" : "UNKNOWN_BUSY_no_ack_no_retirement", + canonicalValid, capacityPreserved, processCountBeforeLoss, setsidObserved, normalDetachedIdentity, + postQueryEmpty = post.GetProperty("processes").GetArrayLength() == 0, collisionRefused = collision, + protocolCasePassed = true, prototypeOnly = true, windowsProcessAndDrainsJoined = true, + submissionFenceProven = false, observedEpochDriftRefused = epochRefused, atomicManagerReplacementFenceProven = false, actualProductionRetirementExercised = false }); + } + catch (Exception ex) + { + failure = ex.GetType().Name; + _cases.Add(new { name, protocolCasePassed = false, failureType = failure, + processExited = process.HasExited, exitCode = process.HasExited ? (int?)process.ExitCode : null, + readySeen = ready.HasValue, resultSeen, positiveSettlement = settled, retirementAllowed = false, + prototypeOnly = true }); + throw; + } + finally + { + if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } + var post = await Probe(fixture, unit); + if (post.GetProperty("present").GetBoolean()) + { + var expectedDescription = collision ? "Fixture collision " + id : replacement ? "Fixture epoch " + id : description; + Assert.Equal(expectedDescription, post.GetProperty("description").GetString()); + await Guest(fixture, $"systemctl --user stop '{unit}'"); + } + stopped = (await Probe(fixture, unit)).GetProperty("processes").GetArrayLength() == 0; + Assert.True(stopped); + try { await errors.WaitAsync(TimeSpan.FromSeconds(3)); } catch when (failure is not null) { } + } + } + + private static byte[] Encode(object value) + { + var body = JsonSerializer.SerializeToUtf8Bytes(value); var bytes = new byte[body.Length + 4]; + BinaryPrimitives.WriteInt32LittleEndian(bytes, body.Length); body.CopyTo(bytes, 4); return bytes; + } + private static async Task Write(Process process, byte[] bytes, CancellationToken ct) + { await process.StandardInput.BaseStream.WriteAsync(bytes, ct); await process.StandardInput.BaseStream.FlushAsync(ct); } + private static async Task ReadFrame(Stream stream, CancellationToken ct) + { + var header = new byte[4]; await stream.ReadExactlyAsync(header, ct); + var size = BinaryPrimitives.ReadUInt32LittleEndian(header); if (size is 0 or > 90000) throw new InvalidDataException(); + var bytes = new byte[(int)size]; await stream.ReadExactlyAsync(bytes, ct); + _ = new UTF8Encoding(false, true).GetString(bytes); + using var doc = JsonDocument.Parse(bytes); var value = doc.RootElement; + Assert.Equal(value.EnumerateObject().Count(), value.EnumerateObject().Select(p => p.Name).Distinct().Count()); + Assert.Equal(1, value.GetProperty("v").GetInt32()); + var type = value.GetProperty("type").GetString(); + var expected = type switch + { + "ready" => "v,type,requestId,invocationId,challenge,unit,bootId,pid,start,cgroup,environmentClean", + "result" => "v,type,requestId,invocationId,payload", + "settled" => "v,type,requestId,invocationId,outcome,startSealed,gateExited,cgroupEmpty,startJobSettled", + _ => throw new InvalidDataException("Unknown prototype frame.") + }; + Assert.Equal(expected.Split(',').Order(), value.EnumerateObject().Select(p => p.Name).Order()); + if (type != "result" && size > 2048) throw new InvalidDataException("Control frame bound."); + Assert.Matches("^[a-f0-9]{32}$", value.GetProperty("requestId").GetString()!); + Assert.Matches("^[a-f0-9]{32}$", value.GetProperty("invocationId").GetString()!); + return value.Clone(); + } + private static async Task Drain(Stream stream) + { var bytes = new byte[1024]; var total = 0; int read; while ((read = await stream.ReadAsync(bytes)) != 0) if ((total += read) > 8192) throw new InvalidDataException(); } + private static async Task Guest(E2ESetupFixture fixture, string script) + { + var result = await fixture.RunInWslAsync("set -euo pipefail\n" + script, TimeSpan.FromSeconds(10), inputViaStdin: true); + Assert.False(result.TimedOut); Assert.Equal(0, result.ExitCode); return result.Stdout; + } + private static async Task WaitUnit(E2ESetupFixture fixture, string unit, Func predicate, CancellationToken ct) + { + var until = Stopwatch.StartNew(); + do { var observed = await Probe(fixture, unit); if (predicate(observed)) return observed; await Task.Delay(50, ct); } + while (until.Elapsed < TimeSpan.FromSeconds(10)); + throw new TimeoutException("Prototype unit observation."); + } + private static async Task Probe(E2ESetupFixture fixture, string unit) + { + Assert.Matches("^openclaw-browser-prototype-[a-f0-9]{32}\\.service$", unit); + var code = "import subprocess,json,pathlib\nu='" + unit + "'\ns=subprocess.run(['systemctl','--user','show',u,'-p','LoadState','-p','Description','-p','ControlGroup','-p','InvocationID'],capture_output=True,text=True)\nv=dict(l.split('=',1) for l in s.stdout.splitlines() if '=' in l)\ng=v.get('ControlGroup','')\np=[]\nif g.startswith('/user.slice/') and '..' not in g:\n f=pathlib.Path('/sys/fs/cgroup'+g+'/cgroup.procs')\n if f.exists():\n for pid in f.read_text().split():\n try:\n a=pathlib.Path('/proc/'+pid+'/stat').read_text().rsplit(') ',1)[1].split();p.append({'pid':int(pid),'start':int(a[19]),'session':int(a[3])})\n except FileNotFoundError:pass\nprint(json.dumps({'present':v.get('LoadState')=='loaded','owned':v.get('Description','').startswith('OpenClaw browser prototype '),'description':v.get('Description',''),'invocation':v.get('InvocationID',''),'processes':p}))"; + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(code)); + using var doc = JsonDocument.Parse(await Guest(fixture, $"printf '%s' '{b64}' | base64 -d | /usr/bin/python3")); return doc.RootElement.Clone(); + } +} From 3ab68995ea41d4b94b5afc3de5d21931cb1fd84b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:24:16 +0000 Subject: [PATCH 46/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 847c6ecd-a165-4e81-93e2-6a3c023df253 From 2778c1d52e447b7a792c0b8c8bd81016b14e020e Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:39:29 +0000 Subject: [PATCH 47/77] fix(test): normalize WSL prototype stdin before broker handoff Apply the production-equivalent CRLF/CR normalization to actual stdin. Require a bounded broker-entry witness before classifying any protocol case and preserve exact collision identity. Add and run the portable transport regression; retain the failed first hosted receipt. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../workflows/browser-wsl-owner-prototype.yml | 6 ++++ scripts/BrowserWslOwnerPrototype.cjs | 1 + .../Prototype/BrowserOwnerProtocolTests.cs | 36 +++++++++++++++---- .../BrowserPrototypeTransportTests.cs | 11 ++++++ 4 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.cs diff --git a/.github/workflows/browser-wsl-owner-prototype.yml b/.github/workflows/browser-wsl-owner-prototype.yml index c09228495..5c6d47a54 100644 --- a/.github/workflows/browser-wsl-owner-prototype.yml +++ b/.github/workflows/browser-wsl-owner-prototype.yml @@ -8,6 +8,7 @@ on: - scripts/BrowserWslOwnerPrototype.cjs - scripts/BrowserWslOwnerPrototype.test.cjs - tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs + - tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.cs permissions: contents: read @@ -48,6 +49,11 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Tray build failed.' } dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 --no-restore if ($LASTEXITCODE -ne 0) { throw 'Fixture build failed.' } + - name: Verify the actual stdin newline normalization + shell: pwsh + run: | + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserPrototypeTransportTests + if ($LASTEXITCODE -ne 0) { throw 'Prototype stdin transport regression failed.' } - name: Exercise private protocol and transient user service in disposable WSL shell: pwsh run: | diff --git a/scripts/BrowserWslOwnerPrototype.cjs b/scripts/BrowserWslOwnerPrototype.cjs index c6fe6a045..3c5641a97 100644 --- a/scripts/BrowserWslOwnerPrototype.cjs +++ b/scripts/BrowserWslOwnerPrototype.cjs @@ -113,6 +113,7 @@ async function gate(s) { } async function broker(s, source) { if(!/^[a-f0-9]{32}$/.test(s.requestId))throw Error('request_id'); + process.stderr.write("OC_PROTO_BROKER_STARTED\n"); // Single writer: each request uses a fresh name exactly once; no participant/recovery restarts it. // Show/StopUnit is not an atomic invocation-conditional API. An actor controlling this same // user-manager and trusted CLI/config could replace any unit between commands; that actor is diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs index b86d7adee..f29c2263a 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs @@ -101,11 +101,18 @@ private async Task RunCase(E2ESetupFixture fixture, string name) string? failure = null, outcome = null; int processCountBeforeLoss = 0; bool setsidObserved = false, epochRefused = false; bool replacement = name == "manager_epoch_replaced"; object? normalDetachedIdentity = null; - if (collision) await Guest(fixture, $"systemd-run --user --quiet --unit='{unit}' --property='Description=Fixture collision {id}' /bin/sleep 60"); + string? collisionInvocation = null; + if (collision) + { + await Guest(fixture, $"systemd-run --user --quiet --unit='{unit}' --property='Description=Fixture collision {id}' /bin/sleep 60"); + collisionInvocation = (await Probe(fixture, unit)).GetProperty("invocation").GetString(); + } using var process = Process.Start(start)!; var errors = Drain(process.StandardError.BaseStream); try { + script = NormalizeInput(script); + Assert.DoesNotContain("\r", script); await process.StandardInput.WriteAsync(script.AsMemory(), budget.Token); await process.StandardInput.FlushAsync(budget.Token); // Intentionally keep stdin open across bash -> inline broker. if (beforeReadyLoss) @@ -188,9 +195,16 @@ private async Task RunCase(E2ESetupFixture fixture, string name) } } } - await process.WaitForExitAsync(budget.Token); await errors.WaitAsync(budget.Token); + await process.WaitForExitAsync(budget.Token); + var brokerStarted = await errors.WaitAsync(budget.Token); + Assert.True(brokerStarted, "Prototype broker entry not observed."); var post = await WaitUnit(fixture, unit, p => collision || replacement ? p.GetProperty("present").GetBoolean() : p.GetProperty("processes").GetArrayLength() == 0, budget.Token); if (collision || replacement) Assert.False(post.GetProperty("owned").GetBoolean()); + if (collision) + { + Assert.Equal(43, process.ExitCode); + Assert.Equal(collisionInvocation, post.GetProperty("invocation").GetString()); + } if (replacement) { Assert.NotEqual(ready!.Value.GetProperty("invocationId").GetString(), post.GetProperty("invocation").GetString()); @@ -203,7 +217,7 @@ private async Task RunCase(E2ESetupFixture fixture, string name) if (name == "real_cli") Assert.True(canonicalValid); if (name == "payload_capacity") Assert.True(capacityPreserved); _cases.Add(new { name, requestId = id, windowsPid = process.Id, readyMs, permitMs, resultMs, settledMs, - stdinHandoffVerified = ready.HasValue, resultSeen, resultReleased = settled && resultSeen && outcome == "completed", outcome, + stdinHandoffVerified = ready.HasValue, scriptInputNormalized = true, brokerStarted, resultSeen, resultReleased = settled && resultSeen && outcome == "completed", outcome, positiveSettlement = settled, retirementAllowed = settled, expectedUnknown, classification = settled ? "positive_settlement" : "UNKNOWN_BUSY_no_ack_no_retirement", canonicalValid, capacityPreserved, processCountBeforeLoss, setsidObserved, normalDetachedIdentity, @@ -216,7 +230,7 @@ private async Task RunCase(E2ESetupFixture fixture, string name) failure = ex.GetType().Name; _cases.Add(new { name, protocolCasePassed = false, failureType = failure, processExited = process.HasExited, exitCode = process.HasExited ? (int?)process.ExitCode : null, - readySeen = ready.HasValue, resultSeen, positiveSettlement = settled, retirementAllowed = false, + readySeen = ready.HasValue, brokerStarted = errors.IsCompletedSuccessfully && errors.Result, resultSeen, positiveSettlement = settled, retirementAllowed = false, prototypeOnly = true }); throw; } @@ -266,8 +280,18 @@ private static async Task ReadFrame(Stream stream, CancellationToke Assert.Matches("^[a-f0-9]{32}$", value.GetProperty("invocationId").GetString()!); return value.Clone(); } - private static async Task Drain(Stream stream) - { var bytes = new byte[1024]; var total = 0; int read; while ((read = await stream.ReadAsync(bytes)) != 0) if ((total += read) > 8192) throw new InvalidDataException(); } + internal static string NormalizeInput(string script) => script.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').TrimEnd('\n') + "\n"; + private static async Task Drain(Stream stream) + { + var bytes = new byte[1024]; var total = 0; var text = new StringBuilder(); int read; + while ((read = await stream.ReadAsync(bytes)) != 0) + { + if ((total += read) > 8192) throw new InvalidDataException(); + text.Append(Encoding.UTF8.GetString(bytes, 0, read)); + } + // Only this fixed marker leaves the private error drain. Never retain raw stderr. + return text.ToString().Split('\n').Contains("OC_PROTO_BROKER_STARTED", StringComparer.Ordinal); + } private static async Task Guest(E2ESetupFixture fixture, string script) { var result = await fixture.RunInWslAsync("set -euo pipefail\n" + script, TimeSpan.FromSeconds(10), inputViaStdin: true); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.cs new file mode 100644 index 000000000..d9e3cfbaa --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.cs @@ -0,0 +1,11 @@ +using OpenClaw.Connection; +namespace OpenClaw.E2ETests.Setup; +public sealed class BrowserPrototypeTransportTests +{ + [Fact] + public void StdinNormalizesWindowsRawStringLineEndings() + { + Assert.Equal("set -euo pipefail\nprintf ok\n", BrowserWslOwnerPrototypeTests.NormalizeInput("set -euo pipefail\r\nprintf ok\r\n")); + Assert.DoesNotContain("\r", BrowserWslOwnerPrototypeTests.NormalizeInput(BrowserBootstrapWslCommand.Script.Replace("\n", "\r\n"))); + } +} From 8c9dc44a5ba11564ea987bf776dcc2e82dd3d050 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:39:29 +0000 Subject: [PATCH 48/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: adeaf35e-c123-4c3e-befd-ea8bd1baadd0 From 657ea9a02b33707867e6ee396b156684a222c8e0 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:27:30 +0000 Subject: [PATCH 49/77] fix(test): bind WSL owner proof to the canonical CLI package Build the immutable reviewed consumer through its self-contained package helper and use the existing candidate-package setup interface. Verify source/version/hash and the required CLI option before testing real pairing. Preserve the failed release-CLI receipt and strengthen negative-case assertions. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../workflows/browser-wsl-owner-prototype.yml | 64 +++++++++++++++++++ .../Prototype/BrowserOwnerProtocolTests.cs | 12 ++++ 2 files changed, 76 insertions(+) diff --git a/.github/workflows/browser-wsl-owner-prototype.yml b/.github/workflows/browser-wsl-owner-prototype.yml index 5c6d47a54..b03f0b3b6 100644 --- a/.github/workflows/browser-wsl-owner-prototype.yml +++ b/.github/workflows/browser-wsl-owner-prototype.yml @@ -14,18 +14,82 @@ permissions: contents: read jobs: + canonical-consumer: + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + version: ${{ steps.identity.outputs.version }} + sha256: ${{ steps.identity.outputs.sha256 }} + steps: + - uses: actions/checkout@v7 + with: + repository: openclaw/openclaw + ref: 63f5d867fb67242ea6322f866b0e4f732d4e801c + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - uses: pnpm/action-setup@v4 + with: + version: '12.4.0' + run_install: false + - name: Build the immutable canonical CLI, not the published release CLI + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = 63f5d867fb67242ea6322f866b0e4f732d4e801c + pnpm install --frozen-lockfile + node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir "$RUNNER_TEMP/canonical-consumer" --output-name openclaw-current.tgz + git diff --exit-code + - name: Bind the candidate package bytes to their immutable source + id: identity + shell: bash + run: | + set -euo pipefail + node - <<'JS' + const fs=require('fs'),crypto=require('crypto'),path=require('path'); + const dir=path.join(process.env.RUNNER_TEMP,'canonical-consumer'); + const version=require('./package.json').version; + const sha256=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,'openclaw-current.tgz'))).digest('hex'); + fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'63f5d867fb67242ea6322f866b0e4f732d4e801c',version,sha256})); + fs.appendFileSync(process.env.GITHUB_OUTPUT,'version='+version+'\nsha256='+sha256+'\n'); + JS + - uses: actions/upload-artifact@v7 + with: + name: wsl-prototype-canonical-consumer + path: ${{ runner.temp }}/canonical-consumer/ + retention-days: 7 owner-prototype: + needs: canonical-consumer runs-on: windows-latest timeout-minutes: 40 env: OPENCLAW_REPO_ROOT: ${{ github.workspace }} OPENCLAW_RUN_E2E: '1' OPENCLAW_BROWSER_WSL_PROTOTYPE: '1' + OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: 63f5d867fb67242ea6322f866b0e4f732d4e801c + OPENCLAW_E2E_GATEWAY_VERSION: ${{ needs.canonical-consumer.outputs.version }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: wsl-prototype-canonical-consumer + path: ${{ runner.temp }}/canonical-consumer + - name: Verify the candidate for the existing fixture package interface + shell: pwsh + env: + EXPECTED_SHA256: ${{ needs.canonical-consumer.outputs.sha256 }} + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $env:RUNNER_TEMP 'canonical-consumer/openclaw-current.tgz' + $metadata = Get-Content (Join-Path $env:RUNNER_TEMP 'canonical-consumer/package-candidate.json') -Raw | ConvertFrom-Json + $actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SHA256 -or $metadata.sha256 -cne $actual -or $metadata.sourceSha -cne $env:OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA -or $metadata.version -cne $env:OPENCLAW_E2E_GATEWAY_VERSION) { throw 'Canonical package identity mismatch.' } + "OPENCLAW_E2E_GATEWAY_PACKAGE_TGZ=$package" >> $env:GITHUB_ENV + "OPENCLAW_WSL_PROTOTYPE_PACKAGE_SHA256=$actual" >> $env:GITHUB_ENV - name: Verify frozen production and accepted native proof shell: pwsh run: | diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs index f29c2263a..a127a3a68 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs @@ -25,6 +25,7 @@ public sealed class BrowserWslOwnerPrototypeTests private readonly List _errors = []; private string _source = "", _node = ""; private long _gatewayPid; + private bool _canonicalCapabilityVerified; [BrowserWslPrototypeFact] public async Task PrivateProtocolAndTransientScope_PrecedeProductionWiring() @@ -45,6 +46,9 @@ public async Task PrivateProtocolAndTransientScope_PrecedeProductionWiring() var lines = preflight.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); _node = lines[0]; _gatewayPid = long.Parse(lines[1]); Assert.StartsWith("/", _node); Assert.True(_gatewayPid > 1); + // The public release CLI can predate this consumer contract. Never substitute it silently. + var capability = await Guest(fixture, "export PATH=/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin; for cli in /home/openclaw/.openclaw/bin/openclaw /opt/openclaw/bin/openclaw /usr/local/bin/openclaw; do if test -x \"$cli\"; then if \"$cli\" browser extension pair --help 2>/dev/null | grep -F -- --local-gateway >/dev/null; then printf capability_verified; exit 0; fi; exit 1; fi; done; exit 1"); + Assert.Equal("capability_verified", capability.Trim()); _canonicalCapabilityVerified = true; foreach (var name in Cases) { try { await RunCase(fixture, name); } @@ -62,6 +66,10 @@ await File.WriteAllTextAsync(receipt, JsonSerializer.Serialize(new { schema = 1, sourceSha = Environment.GetEnvironmentVariable("GITHUB_SHA"), prototypeSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(_source))).ToLowerInvariant(), + consumerSha = Environment.GetEnvironmentVariable("OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA"), + candidatePackageSha256 = Environment.GetEnvironmentVariable("OPENCLAW_WSL_PROTOTYPE_PACKAGE_SHA256"), + candidateVersion = Environment.GetEnvironmentVariable("OPENCLAW_E2E_GATEWAY_VERSION"), + canonicalCapabilityVerified = _canonicalCapabilityVerified, productionUnchanged = true, publicProtocolUnchanged = true, scope = "private protocol/systemd prototype, not production owner repair or whole-Companion proof", cases = _cases, errors = _errors, gatewayUnchanged, ownedFixtureDisposed = removed, payloadLimitUtf16 = 16384, privateResultFrameBound = 90000, @@ -214,6 +222,10 @@ private async Task RunCase(E2ESetupFixture fixture, string name) var expectedUnknown = name.StartsWith("loss_", StringComparison.Ordinal) && name != "loss_after_settled" || name is "late_start_job" or "collision" or "manager_epoch_replaced"; Assert.Equal(!expectedUnknown, settled); if (settled) Assert.True(settledMs <= 17000, "Prototype exceeded unchanged request plus soft cleanup budget."); + if (name is "cancel" or "eof" or "deadline_setsid" or "wrong_epoch" or "duplicate_permit" or "oversized" or "out_of_order") + { + Assert.False(resultSeen); Assert.Equal("cancelled", outcome); + } if (name == "real_cli") Assert.True(canonicalValid); if (name == "payload_capacity") Assert.True(capacityPreserved); _cases.Add(new { name, requestId = id, windowsPid = process.Id, readyMs, permitMs, resultMs, settledMs, From f7dc5c58d4b2bb697d11e7f7d57bbe07459bf954 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:27:31 +0000 Subject: [PATCH 50/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: e6f61c00-e221-46e7-9e54-78b40467fbc6 From 16756d46a21ecb2709e2c65be0f5a72a4d1f105c Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:33:44 +0000 Subject: [PATCH 51/77] fix(ci): use the canonical consumer package-manager pin Remove the duplicate action version that rejected the consumer packageManager integrity-qualified pin before package build. Keep the immutable consumer and frozen install unchanged. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-prototype.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-prototype.yml b/.github/workflows/browser-wsl-owner-prototype.yml index b03f0b3b6..8a75a7043 100644 --- a/.github/workflows/browser-wsl-owner-prototype.yml +++ b/.github/workflows/browser-wsl-owner-prototype.yml @@ -31,7 +31,8 @@ jobs: node-version: '24.19.0' - uses: pnpm/action-setup@v4 with: - version: '12.4.0' + # The immutable consumer packageManager includes its integrity-qualified version. + # Do not provide a second, conflicting action version. run_install: false - name: Build the immutable canonical CLI, not the published release CLI shell: bash From edd93f4998528cc0362443067db95a1c822e4cf1 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:33:44 +0000 Subject: [PATCH 52/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 4b0039c9-267b-4970-9b6d-8191f5840a43 From c8738ea5821828610c22241eb9125fc0e887c670 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:15:16 +0000 Subject: [PATCH 53/77] fix(browser): retain WSL ownership until guest settlement Use a single-use private READY/PERMIT/RESULT/SETTLED exchange with request-owned transient user-systemd supervision. Revoke via EOF, require positive guest acknowledgment and Windows/drain settlement, and retain UNKNOWN/BUSY when acknowledgment is missing. Preserve existing public ABI, authority selectors, request and cleanup budgets. Add strict framing/cancellation regressions and a dedicated actual-owner hosted proof, including early EOF and missing-ack retirement retention. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 149 +++++++++++++ .../BrowserBootstrapWslBroker.cjs | 210 ++++++++++++++++++ .../BrowserBootstrapWslCommand.cs | 128 +++++++---- .../BrowserBootstrapWslExchange.cs | 118 ++++++++++ .../OpenClaw.Connection.csproj | 5 + .../BrowserBootstrapServiceTests.cs | 8 +- .../BrowserBootstrapWslExchangeTests.cs | 106 +++++++++ .../BrowserWslProductionOwnerTests.cs | 182 +++++++++++++++ 8 files changed, 860 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/browser-wsl-owner-proof.yml create mode 100644 src/OpenClaw.Connection/BrowserBootstrapWslBroker.cjs create mode 100644 src/OpenClaw.Connection/BrowserBootstrapWslExchange.cs create mode 100644 tests/OpenClaw.Connection.Tests/BrowserBootstrapWslExchangeTests.cs create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml new file mode 100644 index 000000000..7ef7c042e --- /dev/null +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -0,0 +1,149 @@ +name: Browser WSL production owner proof + +on: + push: + branches: [openclaw/automatic-chrome-extension-pairing] + paths: + - .github/workflows/browser-wsl-owner-proof.yml + - src/OpenClaw.Connection/BrowserBootstrapWsl* + - src/OpenClaw.Connection/OpenClaw.Connection.csproj + - tests/OpenClaw.Connection.Tests/BrowserBootstrap* + - tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs + +permissions: + contents: read + +jobs: + canonical-consumer: + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + version: ${{ steps.identity.outputs.version }} + sha256: ${{ steps.identity.outputs.sha256 }} + steps: + - uses: actions/checkout@v7 + with: + repository: openclaw/openclaw + ref: 63f5d867fb67242ea6322f866b0e4f732d4e801c + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - uses: pnpm/action-setup@v4 + with: + # The immutable consumer packageManager includes its integrity-qualified version. + # Do not provide a second, conflicting action version. + run_install: false + - name: Build the immutable canonical CLI, not the published release CLI + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = 63f5d867fb67242ea6322f866b0e4f732d4e801c + pnpm install --frozen-lockfile + node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir "$RUNNER_TEMP/canonical-consumer" --output-name openclaw-current.tgz + git diff --exit-code + - name: Bind the candidate package bytes to their immutable source + id: identity + shell: bash + run: | + set -euo pipefail + node - <<'JS' + const fs=require('fs'),crypto=require('crypto'),path=require('path'); + const dir=path.join(process.env.RUNNER_TEMP,'canonical-consumer'); + const version=require('./package.json').version; + const sha256=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,'openclaw-current.tgz'))).digest('hex'); + fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'63f5d867fb67242ea6322f866b0e4f732d4e801c',version,sha256})); + fs.appendFileSync(process.env.GITHUB_OUTPUT,'version='+version+'\nsha256='+sha256+'\n'); + JS + - uses: actions/upload-artifact@v7 + with: + name: wsl-owner-canonical-consumer + path: ${{ runner.temp }}/canonical-consumer/ + retention-days: 7 + owner-prototype: + needs: canonical-consumer + runs-on: windows-latest + timeout-minutes: 40 + env: + OPENCLAW_REPO_ROOT: ${{ github.workspace }} + OPENCLAW_RUN_E2E: '1' + OPENCLAW_BROWSER_WSL_OWNER_PROOF: '1' + OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: 63f5d867fb67242ea6322f866b0e4f732d4e801c + OPENCLAW_E2E_GATEWAY_VERSION: ${{ needs.canonical-consumer.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: wsl-owner-canonical-consumer + path: ${{ runner.temp }}/canonical-consumer + - name: Verify the candidate for the existing fixture package interface + shell: pwsh + env: + EXPECTED_SHA256: ${{ needs.canonical-consumer.outputs.sha256 }} + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $env:RUNNER_TEMP 'canonical-consumer/openclaw-current.tgz' + $metadata = Get-Content (Join-Path $env:RUNNER_TEMP 'canonical-consumer/package-candidate.json') -Raw | ConvertFrom-Json + $actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SHA256 -or $metadata.sha256 -cne $actual -or $metadata.sourceSha -cne $env:OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA -or $metadata.version -cne $env:OPENCLAW_E2E_GATEWAY_VERSION) { throw 'Canonical package identity mismatch.' } + "OPENCLAW_E2E_GATEWAY_PACKAGE_TGZ=$package" >> $env:GITHUB_ENV + "OPENCLAW_WSL_PROTOTYPE_PACKAGE_SHA256=$actual" >> $env:GITHUB_ENV + - name: Verify frozen production and accepted native proof + shell: pwsh + run: | + git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Frozen production/native proof changed.' } + "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - name: Check private protocol without running Linux service work + shell: pwsh + run: node --test scripts/BrowserWslOwnerPrototype.test.cjs + - name: Build existing managed WSL setup fixture + shell: pwsh + run: | + dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Tray restore failed.' } + dotnet restore tests/OpenClaw.E2ETests -r win-x64 + if ($LASTEXITCODE -ne 0) { throw 'Fixture restore failed.' } + dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Tray build failed.' } + dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Fixture build failed.' } + - name: Verify the actual stdin newline normalization + shell: pwsh + run: | + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserPrototypeTransportTests + if ($LASTEXITCODE -ne 0) { throw 'Prototype stdin transport regression failed.' } + - name: Exercise actual WSL owner and retirement retention in disposable WSL + shell: pwsh + run: | + # Existing setup diagnostics may include synthetic fixture credentials. + # Neither raw test output, TRX output sections nor fixture files are uploaded. + $privateLog = Join-Path $env:RUNNER_TEMP 'wsl-owner-private-test.log' + $privateResults = Join-Path $env:RUNNER_TEMP 'wsl-owner-private-results' + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserWslProductionOwnerTests --results-directory $privateResults --logger 'trx;LogFileName=trace.trx' *> $privateLog + $testExit = $LASTEXITCODE + if (!(Test-Path $env:OPENCLAW_WSL_OWNER_RECEIPT)) { throw 'No terminal component receipt. Setup/build capability must be diagnosed privately.' } + [xml]$trx = Get-Content (Join-Path $privateResults 'trace.trx') + $cases = @($trx.TestRun.Results.UnitTestResult) + if ($cases.Count -ne 1 -or $cases[0].outcome -ne 'Passed' -or $testExit -ne 0) { throw 'Component trace did not execute and finish all owned cleanup.' } + Write-Host 'Actual production-owner receipt collected; full native/consumer pairing remains separately required.' + - name: Retain only explicitly redacted component receipt + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-wsl-production-owner-receipt + path: ${{ env.OPENCLAW_WSL_OWNER_RECEIPT }} + if-no-files-found: warn + retention-days: 7 + - name: Evaluate actual owner proof + shell: pwsh + run: | + $r = Get-Content $env:OPENCLAW_WSL_OWNER_RECEIPT -Raw | ConvertFrom-Json + if ($r.status -ne 'production_owner_proof_passed' -or $r.cases.Count -ne 6) { throw 'Actual production-owner proof incomplete.' } + Write-Host 'Actual owner requires guest acknowledgment; missing acknowledgment must keep activation and retirement blocked.' diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslBroker.cjs b/src/OpenClaw.Connection/BrowserBootstrapWslBroker.cjs new file mode 100644 index 000000000..577ab20d3 --- /dev/null +++ b/src/OpenClaw.Connection/BrowserBootstrapWslBroker.cjs @@ -0,0 +1,210 @@ +'use strict'; +// Private request-owned WSL supervisor. No public endpoint or user configuration. +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { spawn, execFileSync } = require('node:child_process'); +const { TextDecoder } = require('node:util'); +const CONTROL = 2048, RESULT = 90000, CLI_BYTES = 65536, CLI_CHARS = 16384; +const decoder = new TextDecoder('utf-8', { fatal: true }); +const keys = { + permit: ['v','type','requestId','invocationId','challenge'], + cancel: ['v','type','requestId','invocationId'], + ready: ['v','type','requestId','invocationId','challenge','unit','bootId','pid','start','cgroup','environmentClean'], + result: ['v','type','requestId','invocationId','payload'], + settled: ['v','type','requestId','invocationId','outcome','startSealed','gateExited','cgroupEmpty','startJobSettled'] +}; +function parse(bytes) { + if(bytes.length>=3&&bytes[0]===0xef&&bytes[1]===0xbb&&bytes[2]===0xbf)throw Error("protocol_bom"); + const text = decoder.decode(bytes), value = JSON.parse(text); + if (!value || value.v !== 1 || !keys[value.type] || JSON.stringify(value) !== text || + Object.keys(value).sort().join() !== [...keys[value.type]].sort().join() || + !/^[a-f0-9]{32}$/.test(value.requestId) || !/^[a-f0-9]{32}$/.test(value.invocationId)) throw Error('protocol_schema'); + if (value.type === 'permit' && !/^[a-f0-9]{32}$/.test(value.challenge)) throw Error('protocol_challenge'); + return value; +} +function frame(value) { + const data = Buffer.from(JSON.stringify(value)); + if (data.length > (value.type === 'result' ? RESULT : CONTROL)) throw Error('protocol_bound'); + const h = Buffer.alloc(4); h.writeUInt32LE(data.length); return Buffer.concat([h,data]); +} +class Reader { + constructor(stream, onFrame, onGone) { + let data = Buffer.alloc(0), count = 0, failed = false; + const fail = () => { if (!failed) { failed = true; onGone('invalid'); } }; + stream.on('data', part => { + if (failed) return; + try { + if (data.length + part.length > RESULT + 4) throw Error('protocol_bound'); + data = Buffer.concat([data,part]); + while (data.length >= 4) { + const size = data.readUInt32LE(); + if (!size || size > RESULT) throw Error('protocol_bound'); + if (data.length < size + 4) break; + const message = parse(data.subarray(4, size + 4)); + if (size > (message.type === 'result' ? RESULT : CONTROL) || ++count > 4) throw Error('protocol_bound'); + data = data.subarray(size + 4); onFrame(message); + } + } catch { fail(); } + }); + stream.on('end', () => { if (!failed) { failed = true; onGone(data.length ? 'partial' : 'eof'); } }); + stream.on('error', fail); + } +} +function identity(pid) { + try { const a=fs.readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' '); return {pid,start:Number(a[19]),state:a[0]}; } + catch (e) { if(e.code==='ENOENT')return null; throw e; } +} +function show(unit) { + const text=execFileSync('/usr/bin/systemctl',['--user','show',unit,'-p','Id','-p','LoadState','-p','ActiveState','-p','SubState','-p','InvocationID','-p','ControlGroup','-p','MainPID','-p','Description'],{encoding:'utf8',timeout:2000,maxBuffer:8192,stdio:['ignore','pipe','pipe']}); + return Object.fromEntries(text.trim().split('\n').map(l=>{const p=l.indexOf('=');return [l.slice(0,p),l.slice(p+1)];})); +} +function empty(cgroup) { + if (!cgroup.startsWith('/user.slice/') || cgroup.includes('..')) throw Error('cgroup_binding'); + try { return /^populated 0$/m.test(fs.readFileSync('/sys/fs/cgroup'+cgroup+'/cgroup.events','utf8')); } + catch(e){if(e.code==='ENOENT')return true;throw e;} +} +function emit(value) { process.stdout.write(frame(value)); } +function binding(value, ready) { + if(value.requestId!==ready.requestId || value.invocationId!==ready.invocationId)throw Error('protocol_binding'); +} +function cliPayload(bytes) { + if(bytes.length>CLI_BYTES)throw Error('cli_bound'); + const text=decoder.decode(bytes); + if(text.length>CLI_CHARS)throw Error('cli_bound'); + return bytes.toString('base64'); +} +async function gate(s) { + const mine=identity(process.pid), unit=show(s.unit); + const cgroup=fs.readFileSync('/proc/self/cgroup','utf8').trim().split('\n').find(l=>l.startsWith('0::'))?.slice(3); + if(unit.Id!==s.unit || unit.Description!==s.description || Number(unit.MainPID)!==process.pid || unit.ControlGroup!==cgroup || !/^[a-f0-9]{32}$/.test(unit.InvocationID))throw Error('gate_binding'); + const clean=process.env.HOME==='/home/openclaw' && process.env.OPENCLAW_STATE_DIR==='/home/openclaw/.openclaw' && process.env.OPENCLAW_CONFIG_PATH==='/home/openclaw/.openclaw/openclaw.json' && + ['OPENCLAW_PROFILE','OPENCLAW_HOME','NODE_OPTIONS','NODE_PATH'].every(k=>!(k in process.env)); + if(!clean)throw Error('gate_environment'); + const ready={v:1,type:'ready',requestId:s.requestId,invocationId:unit.InvocationID,challenge:crypto.randomBytes(16).toString('hex'),unit:s.unit, + bootId:fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),pid:mine.pid,start:mine.start,cgroup,environmentClean:clean}; + let phase='waiting', child, stopped=false; + const revoke=()=>{ + if(stopped)return;stopped=true;phase='sealed'; + // A gate cannot wait for its own stop job. Queue only its positively bound unit. + try { const current=show(s.unit);if(current.InvocationID!==ready.invocationId)throw Error('epoch'); + execFileSync('/usr/bin/systemctl',['--user','stop','--no-block',s.unit],{timeout:2000,stdio:'ignore'}); + } catch { process.exitCode=42; } + }; + new Reader(process.stdin, value=>{ + binding(value,ready); + if(value.type==='cancel'){revoke();return;} + if(value.type!=='permit'||phase!=='waiting'||value.challenge!==ready.challenge)throw Error('permit_state'); + phase='running'; + child=spawn(s.command[0],s.command.slice(1),{stdio:['ignore','pipe','pipe'],env:process.env}); + let out=Buffer.alloc(0),errorBytes=0,failed=false; + child.stdout.on('data',part=>{if(out.length+part.length>CLI_BYTES){failed=true;revoke();}else out=Buffer.concat([out,part]);}); + child.stderr.on('data',part=>{errorBytes+=part.length;if(errorBytes>CLI_BYTES){failed=true;revoke();}}); + child.on('error',revoke); + child.on('close',code=>{ + if(stopped)return; + phase='sealed'; + if(code!==0||failed){revoke();return;} + try { emit({v:1,type:'result',requestId:s.requestId,invocationId:ready.invocationId,payload:cliPayload(out)}); } + catch { revoke();return; } + process.stdin.destroy();process.stdout.end(()=>process.exit(0)); + }); + },revoke); + emit(ready); +} +async function broker(s, source) { + if(!/^[a-f0-9]{32}$/.test(s.requestId))throw Error('request_id'); + // Single writer: each request uses a fresh name exactly once; no participant/recovery restarts it. + // Show/StopUnit is not an atomic invocation-conditional API. An actor controlling this same + // user-manager and trusted CLI/config could replace any unit between commands; that actor is + // outside the existing trusted-UID model. Refuse observed drift, never claim an atomic fence. + const unit='openclaw-browser-bootstrap-'+s.requestId+'.service'; + const description='OpenClaw browser bootstrap '+s.requestId; + const uid=process.getuid(),runtime='/run/user/'+uid; + const state={...s,mode:'gate',unit,description}; + const program='const settings='+JSON.stringify(state)+';const supervisorSource='+JSON.stringify(source)+';'+Buffer.from(source,'base64').toString(); + if(Buffer.byteLength(program)>100000)throw Error('inline_bound'); + const args=['--user','--quiet','--pipe','--service-type=exec','--unit='+unit,'--property=Description='+description, + '--property=ExitType=cgroup','--property=KillMode=control-group','--property=Restart=no','--property=RemainAfterExit=yes','--property=TimeoutStopSec=2s', + '/usr/bin/env','-i','HOME=/home/openclaw','PATH=/home/openclaw/.openclaw/tools/node/bin:/usr/local/bin:/usr/bin:/bin', + 'OPENCLAW_STATE_DIR=/home/openclaw/.openclaw','OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw/openclaw.json', + 'XDG_RUNTIME_DIR='+runtime,'DBUS_SESSION_BUS_ADDRESS=unix:path='+runtime+'/bus',s.node,'-e',program]; + let ready, result, cancelled=false, permit=false, runnerClosed=false, runnerCode, finished=false, stopping, stopFailed=false; + const runner=spawn('/usr/bin/systemd-run',args,{stdio:['pipe','pipe','pipe']}); + runner.stdin.on('error',()=>{}); + let stderr=0; + runner.stderr.on('data',p=>{stderr+=p.length;if(stderr>8192)cancelled=true;}); + const closed=new Promise(resolve=>{runner.once('error',()=>{runnerClosed=true;runnerCode=-1;resolve();});runner.once('close',code=>{runnerClosed=true;runnerCode=code;resolve();});}); + const stop=()=>{ + cancelled=true; + if(ready && !stopping) { + stopping=(async()=>{ + if(fs.readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim()!==ready.bootId)throw Error("stop_epoch"); + const now=show(unit); + if(now.LoadState==='not-found')return; // NOT a settlement decision; bound cgroup and runner must also finish. + if(now.InvocationID!==ready.invocationId||now.Description!==description)throw Error('stop_binding'); + await new Promise((resolve,reject)=>{ + const p=spawn('/usr/bin/systemctl',['--user','stop',unit],{stdio:'ignore'}); + p.on('error',reject);p.on('close',code=>code===0?resolve():reject(Error('stop_failed'))); + }); + })(); + stopping.catch(()=>{stopFailed=true;}); + } + }; + new Reader(process.stdin,value=>{ + if(!ready)throw Error('before_ready');binding(value,ready); + if(value.type==='cancel'){stop();return;} + if(value.type!=='permit'||permit||cancelled||value.challenge!==ready.challenge)throw Error('permit_state'); + permit=true;runner.stdin.write(frame(value)); + },stop); + new Reader(runner.stdout,value=>{ + if(value.type==='ready') { + if(ready||value.requestId!==s.requestId||value.unit!==unit||!value.environmentClean)throw Error('ready_state'); + const now=show(unit), actual=identity(value.pid); + if(now.InvocationID!==value.invocationId||now.ControlGroup!==value.cgroup||now.Description!==description||Number(now.MainPID)!==value.pid||actual?.start!==value.start)throw Error('ready_binding'); + ready=value; + // Even an already-revoked request needs this verified tuple to authenticate SETTLED. + // Forwarding READY grants no permission: the closed input can never deliver PERMIT. + emit(ready); + if(cancelled)stop(); + return; + } + if(!ready)throw Error('before_ready');binding(value,ready); + if(value.type!=='result'||result||!permit||cancelled)throw Error('result_state'); + const payload=Buffer.from(value.payload,'base64');if(payload.toString('base64')!==value.payload)throw Error('result_encoding');cliPayload(payload); + result=value; // Only the broker can forward RESULT, and it still cannot imply SETTLED. + },()=>{if(!result)stop();}); + let checking=false; + const interval=setInterval(async()=>{ + if(finished||checking)return; + checking=true; + try { + if(!ready){if(runnerClosed){finished=true;clearInterval(interval);process.exit(43);}return;} + if(stopFailed)throw Error("stop_refused"); + if(!result&&!cancelled)return; + if(!stopping){ + // Seal successful work too: detached descendants may outlive a successful CLI. + // Stop only the matched unit, THEN wait for its cgroup to empty. + if(fs.readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim()!==ready.bootId)throw Error("stop_epoch"); + const current=show(unit); + if(current.LoadState!=="not-found" && (current.InvocationID!==ready.invocationId||current.Description!==description))throw Error("stop_binding"); + stopping=(async()=>{await new Promise((resolve,reject)=>{ + const p=spawn('/usr/bin/systemctl',['--user','stop',unit],{stdio:'ignore'}); + p.on('error',reject);p.on('close',code=>code===0?resolve():reject(Error('stop_failed'))); + });})();stopping.catch(()=>{stopFailed=true;}); + } + if(!empty(ready.cgroup))return; + await stopping;await closed; + if(!empty(ready.cgroup)||!runnerClosed)throw Error('not_settled'); + finished=true;clearInterval(interval); + if(result&&!cancelled)emit(result); + emit({v:1,type:'settled',requestId:s.requestId,invocationId:ready.invocationId,outcome:cancelled?'cancelled':'completed',startSealed:true,gateExited:true,cgroupEmpty:true,startJobSettled:true}); + process.stdin.destroy();process.stdout.end(()=>process.exit(0)); + } catch { finished=true;clearInterval(interval);process.exit(44); } + finally { checking=false; } + },20); +} +module.exports={parse,frame,Reader,cliPayload,CONTROL,RESULT}; +if(typeof settings!=='undefined') { + process.stdout.on('error',()=>{process.exitCode=45;}); + (settings.mode==='gate'?gate(settings):broker(settings,supervisorSource)).catch(()=>{process.stderr.write('pairing_unavailable\n');process.exit(46);}); +} diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs index e9b607a0b..f4460605e 100644 --- a/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs +++ b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs @@ -26,93 +26,131 @@ unset OPENCLAW_PROFILE OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_HOME NOD done < "/proc/$pid/environ" export OPENCLAW_STATE_DIR=/home/openclaw/.openclaw export OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw/openclaw.json + cli_path= for cli in /home/openclaw/.openclaw/bin/openclaw /opt/openclaw/bin/openclaw /usr/local/bin/openclaw; do - if test -x "$cli"; then - exec "$cli" browser extension pair --json --local-gateway - fi + if test -x "$cli"; then cli_path="$cli"; break; fi done - exit 127 + test -n "$cli_path" + node=$(command -v node) """; internal static string NormalizeStandardInput(string script) => script.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').TrimEnd('\n') + "\n"; + private static readonly Lazy Broker = new(() => + { + using var stream = typeof(BrowserBootstrapWslCommand).Assembly.GetManifestResourceStream("OpenClaw.Connection.BrowserBootstrapWslBroker.cjs") + ?? throw new IOException("pairing_unavailable"); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true)); + return reader.ReadToEnd(); + }); + + internal static string BuildInput(string requestId) + { + if (requestId.Length != 32 || requestId.Any(c => c is not (>= 'a' and <= 'f' or >= '0' and <= '9'))) + throw new InvalidDataException("pairing_unavailable"); + var source = Broker.Value; + var source64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(source)); + // All code crosses WSL via stdin, never shell-variable-bearing wsl.exe argv. + // The command and runtime selectors remain fixed; no new public option is introduced. + var program = "const settings={mode:'broker',requestId:'" + requestId + "',node:process.execPath,command:[process.argv[1],'browser','extension','pair','--json','--local-gateway']};const supervisorSource='" + source64 + "';" + source; + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(program)); + return NormalizeStandardInput(Script + "\nexec \"$node\" -e \"$(printf '%s' '" + encoded + "' | base64 -d)\" \"$cli_path\"\n"); + } + public static async Task RunAsync(string distro, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + var requestId = Guid.NewGuid().ToString("N"); + var exchange = new BrowserBootstrapWslExchange(requestId, "openclaw-browser-bootstrap-"); + var input = Encoding.UTF8.GetBytes(BuildInput(requestId)); var start = new ProcessStartInfo { FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "wsl.exe"), WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardOutputEncoding = new UTF8Encoding(false, true), - StandardErrorEncoding = Encoding.UTF8 + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), + StandardOutputEncoding = new UTF8Encoding(false, true), StandardErrorEncoding = Encoding.UTF8 }; foreach (var arg in new[] { "--distribution", distro, "--exec", "/bin/bash", "--noprofile", "--norc", "-s" }) start.ArgumentList.Add(arg); - // Do not propagate Windows CLI profile/runtime overrides through WSLENV. start.Environment.Remove("WSLENV"); using var process = Process.Start(start) ?? throw new IOException("pairing_unavailable"); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(TimeSpan.FromSeconds(15)); - using var stop = deadline.Token.Register(() => + void Revoke() { - try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + exchange.Revoke(); + // EOF revokes permission in the guest. Killing wsl.exe is NOT guest settlement. + try { process.StandardInput.BaseStream.Close(); } + catch (IOException) { } catch (InvalidOperationException) { } - catch (System.ComponentModel.Win32Exception) { } - }); - var output = ReadBoundedAsync(process.StandardOutput, deadline.Token); - var error = ReadBoundedAsync(process.StandardError, deadline.Token); + } + using var stop = deadline.Token.Register(Revoke); + var output = exchange.ReadToEndAsync(process.StandardOutput.BaseStream); + var error = DrainErrorAsync(process.StandardError); + var exit = process.WaitForExitAsync(CancellationToken.None); + foreach (var task in new[] { output, error }) + _ = task.ContinueWith(t => { _ = t.Exception; Revoke(); }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); try { - await process.StandardInput.WriteAsync(NormalizeStandardInput(Script).AsMemory(), deadline.Token); - process.StandardInput.Close(); - // Await drains alongside exit so oversized output faults immediately rather than waiting for exit. - await Task.WhenAll(output, error, process.WaitForExitAsync(deadline.Token)); + await process.StandardInput.BaseStream.WriteAsync(input, deadline.Token); + await process.StandardInput.BaseStream.FlushAsync(deadline.Token); + await exchange.Ready.WaitAsync(deadline.Token); + deadline.Token.ThrowIfCancellationRequested(); + // Mark the single attempt before writing. A partial/failed write is never retried. + await process.StandardInput.BaseStream.WriteAsync(exchange.Permit(), deadline.Token); + await process.StandardInput.BaseStream.FlushAsync(deadline.Token); + await Task.WhenAll(output, error, exit).WaitAsync(deadline.Token); + deadline.Token.ThrowIfCancellationRequested(); if (process.ExitCode != 0) throw new IOException("pairing_unavailable"); - return await output; + return exchange.Result; } finally { + if (!exchange.Acknowledged) Revoke(); using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { - try + // The soft budget reports failure, never grants permission to retire. On lost + // submission/acknowledgment this deliberately stays pending (UNKNOWN/BUSY). + await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(exchange.Settlement, cleanup.Token); + } + finally + { + // This block cannot be reached through the pending settlement wait without a + // positive guest acknowledgment. Only then may Windows cleanup terminate a client. + if (exchange.Acknowledged) { - if (!process.HasExited) + try + { + if (!process.HasExited && (deadline.IsCancellationRequested || output.IsFaulted || error.IsFaulted || cleanup.IsCancellationRequested)) + { + try { process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + catch (System.ComponentModel.Win32Exception) { } + } + } + finally { - try { process.Kill(entireProcessTree: true); } - catch (InvalidOperationException) { } + try { await BrowserBootstrapProcessLifetime.JoinAsync(process, cleanup.Token); } + finally { await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(Task.WhenAll(output, error), cleanup.Token); } } } - finally { await BrowserBootstrapProcessLifetime.JoinAsync(process, cleanup.Token); } - } - finally - { - try { await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(Task.WhenAll(output, error), cleanup.Token); } - catch (OperationCanceledException) when (deadline.IsCancellationRequested && output.IsCompleted && error.IsCompleted) { } - // Windows process/drain settlement is not Linux guest settlement proof. - _ = output.ContinueWith(t => _ = t.Exception, CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - _ = error.ContinueWith(t => _ = t.Exception, CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } } - private static async Task ReadBoundedAsync(StreamReader reader, CancellationToken ct) + private static async Task DrainErrorAsync(StreamReader reader) { - var buffer = new char[1024]; - var result = new StringBuilder(); + var buffer = new char[1024]; var total = 0; for (;;) { - var count = await reader.ReadAsync(buffer.AsMemory(), ct); - if (count == 0) return result.ToString(); - if (result.Length + count > 16384) throw new IOException("pairing_unavailable"); - result.Append(buffer, 0, count); + var count = await reader.ReadAsync(buffer); + if (count == 0) return; + if ((total += count) > 16384) throw new IOException("pairing_unavailable"); } } } diff --git a/src/OpenClaw.Connection/BrowserBootstrapWslExchange.cs b/src/OpenClaw.Connection/BrowserBootstrapWslExchange.cs new file mode 100644 index 000000000..788b36257 --- /dev/null +++ b/src/OpenClaw.Connection/BrowserBootstrapWslExchange.cs @@ -0,0 +1,118 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Connection; + +// Private framing only. A missing terminal acknowledgment never releases request ownership. +internal sealed class BrowserBootstrapWslExchange(string requestId, string unitPrefix) +{ + internal const int ControlLimit = 2048, ResultLimit = 90000, WireLimit = ResultLimit + 2 * ControlLimit + 12; + private static readonly UTF8Encoding Utf8 = new(false, true); + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _settled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string? _invocation, _challenge, _result, _outcome; + private int _permitAttempted, _phase, _revoked; + internal void Revoke() { Volatile.Write(ref _revoked,1); _result = null; } + private bool _invalid; + internal Task Ready => _ready.Task; + // A transport failure must NOT complete this task. Missing acknowledgment retains ownership. + internal Task Settlement => _settled.Task; + internal bool Acknowledged => _settled.Task.IsCompletedSuccessfully; + internal string Result => Volatile.Read(ref _revoked) == 0 && !_invalid && Acknowledged && _outcome == "completed" && _result is not null + ? _result : throw Invalid(); + + internal byte[] Permit() + { + if (Volatile.Read(ref _revoked) != 0 || !_ready.Task.IsCompletedSuccessfully || _invalid || Interlocked.Exchange(ref _permitAttempted, 1) != 0) + throw Invalid(); + return Encode(new { v = 1, type = "permit", requestId, invocationId = _invocation, challenge = _challenge }); + } + internal byte[] Cancel() + { + if (!_ready.Task.IsCompletedSuccessfully || _invalid) throw Invalid(); + return Encode(new { v = 1, type = "cancel", requestId, invocationId = _invocation }); + } + internal async Task ReadToEndAsync(Stream stream) + { + var total = 0; var frames = 0; + try + { + for (;;) + { + var header = new byte[4]; + var count = await stream.ReadAsync(header); + if (count == 0) { if (!Acknowledged) throw Invalid(); return; } + if (count < 4) await stream.ReadExactlyAsync(header.AsMemory(count)); + var size = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (size is 0 or > ResultLimit || ++frames > 3 || (total += checked((int)size + 4)) > WireLimit) throw Invalid(); + var bytes = new byte[(int)size]; await stream.ReadExactlyAsync(bytes); + Accept(bytes); + } + } + catch { _invalid = true; _result = null; throw; } + } + internal void Accept(byte[] bytes) + { + try + { + if (_invalid || _phase == 3 || bytes.Length > ResultLimit || bytes.AsSpan().StartsWith(new byte[] { 0xef, 0xbb, 0xbf })) throw Invalid(); + _ = Utf8.GetString(bytes); + using var document = JsonDocument.Parse(bytes); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) throw Invalid(); + var properties = root.EnumerateObject().ToArray(); + if (properties.Select(p => p.Name).Distinct(StringComparer.Ordinal).Count() != properties.Length || + root.GetProperty("v").GetRawText() != "1" || Text(root, "requestId") != requestId || !Hex(requestId)) throw Invalid(); + var type = Text(root, "type"); + var expected = type switch + { + "ready" => "v,type,requestId,invocationId,challenge,unit,bootId,pid,start,cgroup,environmentClean", + "result" => "v,type,requestId,invocationId,payload", + "settled" => "v,type,requestId,invocationId,outcome,startSealed,gateExited,cgroupEmpty,startJobSettled", + _ => throw Invalid() + }; + if (!properties.Select(p => p.Name).Order(StringComparer.Ordinal).SequenceEqual(expected.Split(',').Order(StringComparer.Ordinal)) || + type != "result" && bytes.Length > ControlLimit) throw Invalid(); + var invocation = Text(root, "invocationId"); if (!Hex(invocation)) throw Invalid(); + if (type == "ready") + { + if (_phase != 0 || Text(root, "unit") != unitPrefix + requestId + ".service" || !True(root,"environmentClean") || + !root.GetProperty("pid").TryGetInt32(out var pid) || pid <= 1 || !root.GetProperty("start").TryGetInt64(out var start) || start <= 0) + throw Invalid(); + var boot = Text(root,"bootId"); + if (!Guid.TryParseExact(boot,"D",out var bootId) || bootId.ToString("D") != boot || bootId == Guid.Empty) throw Invalid(); + var group = Text(root,"cgroup"); + if (!group.StartsWith("/user.slice/",StringComparison.Ordinal) || group.Split('/').Contains("..") || + !group.EndsWith("/" + unitPrefix + requestId + ".service",StringComparison.Ordinal)) throw Invalid(); + _challenge = Text(root,"challenge"); if (!Hex(_challenge)) throw Invalid(); + _invocation = invocation; _phase = 1; _ready.TrySetResult(); return; + } + if (_phase is not (1 or 2) || invocation != _invocation) throw Invalid(); + if (type == "result") + { + if (_phase != 1 || Volatile.Read(ref _permitAttempted) != 1) throw Invalid(); + var encoded = Text(root,"payload"); var payload = Convert.FromBase64String(encoded); + if (payload.Length > 65536 || Convert.ToBase64String(payload) != encoded) throw Invalid(); + var result = Utf8.GetString(payload); if (result.Length > 16384) throw Invalid(); + _result = result; if (Volatile.Read(ref _revoked) != 0) _result = null; + _phase = 2; return; + } + foreach (var key in new[] { "startSealed", "gateExited", "cgroupEmpty", "startJobSettled" }) if (!True(root,key)) throw Invalid(); + _outcome = Text(root,"outcome"); + if (_outcome is not ("completed" or "cancelled" or "failed") || _outcome == "completed" && _phase != 2) throw Invalid(); + if (_outcome != "completed") _result = null; + _phase = 3; _settled.TrySetResult(); + } + catch { _invalid = true; _result = null; throw Invalid(); } + } + private static bool Hex(string value) => value.Length == 32 && value.All(c => c is >= 'a' and <= 'f' or >= '0' and <= '9'); + private static bool True(JsonElement value,string key) => value.GetProperty(key).ValueKind == JsonValueKind.True; + private static string Text(JsonElement value,string key) => value.GetProperty(key).ValueKind == JsonValueKind.String ? value.GetProperty(key).GetString()! : throw Invalid(); + private static InvalidDataException Invalid() => new("pairing_unavailable"); + private static byte[] Encode(object message) + { + var body = JsonSerializer.SerializeToUtf8Bytes(message); if (body.Length > ControlLimit) throw Invalid(); + var bytes = new byte[4 + body.Length]; BinaryPrimitives.WriteInt32LittleEndian(bytes,body.Length); body.CopyTo(bytes,4); return bytes; + } +} diff --git a/src/OpenClaw.Connection/OpenClaw.Connection.csproj b/src/OpenClaw.Connection/OpenClaw.Connection.csproj index baf18cf29..d5e5b6215 100644 --- a/src/OpenClaw.Connection/OpenClaw.Connection.csproj +++ b/src/OpenClaw.Connection/OpenClaw.Connection.csproj @@ -10,6 +10,7 @@ + @@ -18,6 +19,10 @@ + + + + diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs index 7db1a3c3b..46ac2377c 100644 --- a/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -134,7 +134,13 @@ public void WslInput_NormalizesWindowsCheckoutLineEndings(string input, string e [Fact] public void Command_UsesCanonicalPairingNotGatewaySecretsOrLifecycle() { - Assert.Contains("browser extension pair --json --local-gateway", BrowserBootstrapWslCommand.Script); + var script = BrowserBootstrapWslCommand.BuildInput(new string('a', 32)); + var encoded = System.Text.RegularExpressions.Regex.Match(script, "printf '%s' '([A-Za-z0-9+/=]+)'").Groups[1].Value; + var program = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + Assert.Contains("[process.argv[1],'browser','extension','pair','--json','--local-gateway']", program); + Assert.DoesNotContain("exec \"$cli\"", script); + Assert.DoesNotContain("\r", script); + Assert.DoesNotContain("DelayMs", program); Assert.Contains("unset OPENCLAW_PROFILE OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH", BrowserBootstrapWslCommand.Script); Assert.DoesNotContain("gateway start", BrowserBootstrapWslCommand.Script); Assert.DoesNotContain("auth.token", BrowserBootstrapWslCommand.Script); diff --git a/tests/OpenClaw.Connection.Tests/BrowserBootstrapWslExchangeTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapWslExchangeTests.cs new file mode 100644 index 000000000..c1fc946e8 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapWslExchangeTests.cs @@ -0,0 +1,106 @@ +using System.Text; +using System.Text.Json; +using System.Text.Encodings.Web; +using System.Buffers.Binary; + +namespace OpenClaw.Connection.Tests; +public sealed class BrowserBootstrapWslExchangeTests +{ + private const string Request = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Invocation = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Challenge = "cccccccccccccccccccccccccccccccc", Prefix = "openclaw-browser-bootstrap-"; + private static readonly JsonSerializerOptions Json = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static byte[] Bytes(object value) => JsonSerializer.SerializeToUtf8Bytes(value, Json); + private static byte[] Ready() => Bytes(new { v=1,type="ready",requestId=Request,invocationId=Invocation,challenge=Challenge,unit=Prefix+Request+".service",bootId="11111111-1111-4111-8111-111111111111",pid=99,start=10,cgroup="/user.slice/test/"+Prefix+Request+".service",environmentClean=true }); + private static byte[] Result(string text) => Bytes(new {v=1,type="result",requestId=Request,invocationId=Invocation,payload=Convert.ToBase64String(Encoding.UTF8.GetBytes(text))}); + private static byte[] Settled(string outcome="completed") => Bytes(new {v=1,type="settled",requestId=Request,invocationId=Invocation,outcome,startSealed=true,gateExited=true,cgroupEmpty=true,startJobSettled=true}); + private static BrowserBootstrapWslExchange Create() => new(Request,Prefix); + private static byte[] Frame(byte[] bytes) { var result=new byte[bytes.Length+4];BinaryPrimitives.WriteInt32LittleEndian(result,bytes.Length);bytes.CopyTo(result,4);return result; } + + [Theory] + [InlineData("x",16384)] + [InlineData("界",16384)] + [InlineData("😀",8192)] + public void PayloadCapacityAndResultRelease_RequirePositiveSettlement(string text,int count) + { + var value=string.Concat(Enumerable.Repeat(text,count));var state=Create();state.Accept(Ready());_ = state.Permit();state.Accept(Result(value)); + Assert.Throws(()=>state.Result);Assert.False(state.Acknowledged); + state.Accept(Settled());Assert.Equal(value,state.Result);Assert.True(state.Acknowledged); + } + [Theory] + [InlineData("beforePermit")] + [InlineData("afterResult")] + [InlineData("afterAck")] + public void CancellationNeverExposesBufferedPairing(string when) + { + var state=Create();state.Accept(Ready()); + if(when=="beforePermit") {state.Revoke();Assert.Throws(()=>state.Permit());return;} + _=state.Permit();state.Accept(Result("held"));if(when=="afterResult")state.Revoke();state.Accept(Settled());if(when=="afterAck")state.Revoke(); + Assert.Throws(()=>state.Result); + } + [Theory] + [InlineData("duplicate")] + [InlineData("extra")] + [InlineData("request")] + [InlineData("utf8")] + [InlineData("bom")] + [InlineData("version")] + public void InvalidReady_DoesNotAcknowledgeOwnership(string kind) + { + var text=Encoding.UTF8.GetString(Ready());byte[] bytes=kind switch + { + "duplicate"=>Encoding.UTF8.GetBytes(text.Replace("{","{\"v\":1,")), + "extra"=>Encoding.UTF8.GetBytes(text.Replace("}",",\"extra\":true}")), + "request"=>Encoding.UTF8.GetBytes(text.Replace(Request,new string('d',32))), + "utf8"=>[0xff],"bom"=>[0xef,0xbb,0xbf,..Ready()], + _=>Encoding.UTF8.GetBytes(text.Replace("\"v\":1","\"v\":1.0")) + }; + var state=Create();Assert.Throws(()=>state.Accept(bytes));Assert.False(state.Settlement.IsCompleted); + } + [Fact] + public void RevokeBeforeReady_StillAcceptsVerifiedCancelledSettlement() + { + var state=Create();state.Revoke();state.Accept(Ready()); + Assert.Throws(()=>state.Permit()); + state.Accept(Settled("cancelled"));Assert.True(state.Acknowledged); + Assert.Throws(()=>state.Result); + } + [Fact] + public void PermitCannotBeRetriedAndUnpermittedResultIsRejected() + { + var state=Create();state.Accept(Ready());_ = state.Permit();Assert.Throws(()=>state.Permit()); + var other=Create();other.Accept(Ready());Assert.Throws(()=>other.Accept(Result("invalid")));Assert.False(other.Acknowledged); + } + [Fact] + public void WrongInvocationDuplicateAndOversizedResultsFailClosed() + { + foreach(var kind in new[]{"invocation","duplicate","oversized"}) + { + var state=Create();state.Accept(Ready());_=state.Permit(); + if(kind=="duplicate")state.Accept(Result("first")); + var bytes=kind=="invocation"?Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(Result("x")).Replace(Invocation,new string('d',32))):Result(kind=="oversized"?new string('x',16385):"second"); + Assert.Throws(()=>state.Accept(bytes));Assert.False(state.Acknowledged); + } + } + [Fact] + public void CancelledSettlementDropsResultAndCompletedRequiresResult() + { + var state=Create();state.Accept(Ready());_=state.Permit();state.Accept(Result("held"));state.Accept(Settled("cancelled"));Assert.True(state.Acknowledged);Assert.Throws(()=>state.Result); + var other=Create();other.Accept(Ready());Assert.Throws(()=>other.Accept(Settled()));Assert.False(other.Acknowledged); + } + [Fact] + public async Task MissingAck_RemainsPendingBeyondCleanupBudget() + { + var state=Create();await Assert.ThrowsAsync(()=>state.ReadToEndAsync(new MemoryStream(Frame(Ready())))); + using var budget=new CancellationTokenSource();budget.Cancel(); + var held=OpenClaw.Shared.Browser.BrowserBootstrapProcessLifetime.AwaitOwnedAsync(state.Settlement,budget.Token); + await Task.Yield();Assert.False(held.IsCompleted);Assert.False(state.Acknowledged); + _=held.ContinueWith(t=>_=t.Exception,TaskContinuationOptions.OnlyOnFaulted); + } + [Fact] + public async Task StreamFramingRejectsOversizedAndPostSettlementBytes() + { + var header=new byte[4];BinaryPrimitives.WriteInt32LittleEndian(header,90001);var state=Create(); + await Assert.ThrowsAsync(()=>state.ReadToEndAsync(new MemoryStream(header)));Assert.False(state.Acknowledged); + var extra=Create();var bytes=Frame(Ready()).Concat(Frame(Settled("cancelled"))).Concat(new byte[]{1}).ToArray(); + await Assert.ThrowsAsync(()=>extra.ReadToEndAsync(new MemoryStream(bytes)));Assert.Throws(()=>extra.Result); + } +} diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs new file mode 100644 index 000000000..73639a6d9 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Security.Cryptography; +using OpenClaw.Connection; + +namespace OpenClaw.E2ETests.Setup; +public sealed class BrowserWslOwnerProofFactAttribute : FactAttribute +{ + public BrowserWslOwnerProofFactAttribute() + { + if (!E2ETestGate.IsEnabled || Environment.GetEnvironmentVariable("OPENCLAW_BROWSER_WSL_OWNER_PROOF") != "1") + Skip = "Dedicated actual-owner hosted proof only."; + } +} +public sealed class BrowserWslProductionOwnerTests +{ + private readonly List _cases=[]; + private readonly List _errors=[]; + private const string Cli="/home/openclaw/.openclaw/bin/openclaw"; + private string _root=""; + private long _gatewayPid; + [BrowserWslOwnerProofFact] + public async Task ActualOwner_DoesNotRetireWithoutGuestAcknowledgment() + { + Assert.True(OperatingSystem.IsWindows());Assert.Equal("github-hosted",Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")); + var receipt=Environment.GetEnvironmentVariable("OPENCLAW_WSL_OWNER_RECEIPT")!; + var fixture=new E2ESetupFixture();var stdout=Console.Out;var stderr=Console.Error; + bool installed=false,restored=false,disposed=false,gatewayUnchanged=false; + Console.SetOut(TextWriter.Null);Console.SetError(TextWriter.Null); + try + { + await fixture.InitializeAsync();Assert.Null(fixture.SetupError);await fixture.StopTrayAsync(); + _gatewayPid=long.Parse((await Guest(fixture,"systemctl --user show openclaw-gateway.service -p MainPID --value")).Trim()); + Assert.True(_gatewayPid>1); + _root="/home/openclaw/.openclaw/owner-proof-"+Guid.NewGuid().ToString("N"); + await EarlyEof(fixture); + await RunCase(fixture,"real_cli"); + installed=true; + await Guest(fixture,$"umask 077; mkdir '{_root}'; if test -e '{Cli}' || test -L '{Cli}'; then mv '{Cli}' '{_root}/original'; fi"); + foreach(var name in new[]{"normal_setsid","caller_cancel","deadline","forced_client_exit"}) + { + try{await RunCase(fixture,name);}catch(Exception e){_errors.Add(name+":"+e.GetType().Name);} + } + gatewayUnchanged=(await Guest(fixture,"systemctl --user show openclaw-gateway.service -p MainPID --value")).Trim()==_gatewayPid.ToString(); + } + catch(Exception e){_errors.Add("fixture:"+e.GetType().Name);} + finally + { + if(installed)try + { + await Guest(fixture,$"if test -f '{Cli}' && grep -Fq '{_root}/' '{Cli}'; then rm '{Cli}'; fi; if test -e '{_root}/original' || test -L '{_root}/original'; then test ! -e '{Cli}' && test ! -L '{Cli}'; mv '{_root}/original' '{Cli}'; fi; rm -rf '{_root}'");restored=true; + }catch(Exception e){_errors.Add("restore:"+e.GetType().Name);} + else restored=true; + try{await fixture.DisposeAsync();disposed=true;}catch(Exception e){_errors.Add("dispose:"+e.GetType().Name);} + Console.SetOut(stdout);Console.SetError(stderr); + var assembly=typeof(BrowserBootstrapWslCommand).Assembly; + using var broker=assembly.GetManifestResourceStream("OpenClaw.Connection.BrowserBootstrapWslBroker.cjs")!; + using var hash=SHA256.Create(); + await File.WriteAllTextAsync(receipt,JsonSerializer.Serialize(new + { + schema=1,sourceSha=Environment.GetEnvironmentVariable("GITHUB_SHA"),consumerSha=Environment.GetEnvironmentVariable("OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA"), + packageSha256=Environment.GetEnvironmentVariable("OPENCLAW_WSL_PROTOTYPE_PACKAGE_SHA256"), + productionAssemblySha256=Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(assembly.Location))).ToLowerInvariant(), + embeddedBrokerSha256=Convert.ToHexString(hash.ComputeHash(broker)).ToLowerInvariant(), + scope="actual BrowserBootstrapWslCommand plus unchanged source-linked registration owners; synthetic native inventory/real mutex, not shipping Chrome helper or registry proof", + cases=_cases,errors=_errors,gatewayUnchanged,originalCliRestored=restored,ownedFixtureDisposed=disposed, + status=_errors.Count==0&&_cases.Count==6&&restored&&disposed&&gatewayUnchanged?"production_owner_proof_passed":"production_owner_proof_failed" + },new JsonSerializerOptions{WriteIndented=true})); + } + Assert.Empty(_errors);Assert.Equal(6,_cases.Count);Assert.True(restored&&disposed&&gatewayUnchanged); + } + private async Task EarlyEof(E2ESetupFixture fixture) + { + var id=Guid.NewGuid().ToString("N");var state=new BrowserBootstrapWslExchange(id,"openclaw-browser-bootstrap-"); + var start=new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"wsl.exe")) + {UseShellExecute=false,CreateNoWindow=true,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var a in new[]{"--distribution",fixture.DistroName,"--exec","/bin/bash","--noprofile","--norc","-s"})start.ArgumentList.Add(a); + start.Environment.Remove("WSLENV");using var p=Process.Start(start)!; + var error=DrainBoundedError(p.StandardError); + try + { + await p.StandardInput.WriteAsync(BrowserBootstrapWslCommand.BuildInput(id));await p.StandardInput.FlushAsync();p.StandardInput.Close();state.Revoke(); + await state.ReadToEndAsync(p.StandardOutput.BaseStream).WaitAsync(TimeSpan.FromSeconds(17)); + await p.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2));await error; + Assert.True(state.Acknowledged);Assert.Throws(()=>state.Result);Assert.Equal(0,p.ExitCode); + _cases.Add(new{name="early_stdin_eof",actualProductionScript=true,noPermitSent=true,inputClosedBeforeRead=true,positiveSettlement=true,resultReturned=false}); + } + finally{if(!p.HasExited){p.Kill(entireProcessTree:true);await p.WaitForExitAsync();}} + } + private async Task RunCase(E2ESetupFixture fixture,string name) + { + var held=name is "caller_cancel" or "deadline" or "forced_client_exit"; + if(name!="real_cli") + { + var payload=JsonSerializer.Serialize(new{remote=false,relayPort=19444,pairingString=$"ws://127.0.0.1:{fixture.GatewayPort}/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A{fixture.GatewayPort}#"+new string('a',64)}); + var program="const p=require('node:child_process').spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{detached:true,stdio:'ignore'});p.unref();"+ + (held?"setTimeout(()=>{},60000);":"setTimeout(()=>process.stdout.write("+JsonSerializer.Serialize(payload)+"),1000);"); + var encoded=Convert.ToBase64String(Encoding.UTF8.GetBytes(program)); + await Guest(fixture,$"printf '%s' '{encoded}' | base64 -d > '{_root}/case.cjs'; printf '%s\\n' '#!/bin/sh' 'exec /home/openclaw/.openclaw/tools/node/bin/node {_root}/case.cjs' > '{Cli}'; chmod 700 '{Cli}'"); + } + using var caller=new CancellationTokenSource();var clock=Stopwatch.StartNew(); + // Unknown requests intentionally retain their source-linked activation until this test + // process exits. They are never disposed/unlocked on a timeout or negative guest query. + var ownership=new BrowserWslNativeOwnership(clock);long commandEnd=0;string? outcome=null;string? pairing=null; + var run=ownership.Run(async()=> + { + try{pairing=await BrowserBootstrapWslCommand.RunAsync(fixture.DistroName,caller.Token);outcome="returned";} + catch(OperationCanceledException){outcome="cancelled";} + catch(Exception e){outcome=e.GetType().Name;} + finally{Interlocked.Exchange(ref commandEnd,clock.ElapsedTicks);} + }); + _=run.ContinueWith(t=>_=t.Exception,TaskContinuationOptions.OnlyOnFaulted); + JsonElement unit=default;bool unknown=false; + try + { + unit=await WaitUnit(fixture,p=>p.GetProperty("present").GetBoolean()&&(!held||p.GetProperty("processes").GetArrayLength()>=3),TimeSpan.FromSeconds(12)); + var unitName=unit.GetProperty("unit").GetString()!; + Assert.StartsWith("openclaw-browser-bootstrap-",unitName); + var ids=unit.GetProperty("processes").EnumerateArray().Select(p=>new{pid=p.GetProperty("pid").GetInt32(),start=p.GetProperty("start").GetInt64(),session=p.GetProperty("session").GetInt32()}).ToArray(); + if(held)Assert.True(ids.Select(p=>p.session).Distinct().Count()>=2); + var retirement=ownership.Retire(); + Assert.Equal(0,Interlocked.Read(ref ownership.ManagementMutationTicks)); + if(name=="caller_cancel")caller.Cancel(); + if(name=="forced_client_exit") + { + var pid=FindProductionClient(fixture.DistroName); + using var process=Process.GetProcessById(pid);process.Kill();await process.WaitForExitAsync(); + await retirement.WaitAsync(TimeSpan.FromSeconds(40)); + Assert.Equal("busy",ownership.ManagementCode);Assert.False(run.IsCompleted);Assert.Equal(0,Interlocked.Read(ref commandEnd)); + Assert.Equal(0,ownership.ActivationReleasedTicks);Assert.Equal(0,ownership.RuntimeLeaseReleaseTicks);Assert.Equal(0,ownership.ManagementMutationTicks); + unknown=true; + } + else + { + await run.WaitAsync(TimeSpan.FromSeconds(20));await retirement.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("ok",ownership.ManagementCode);Assert.True(commandEnd>0&&commandEnd<=ownership.ActivationReleaseBeginTicks); + Assert.True(ownership.ActivationReleasedTicks<=ownership.ManagementMutationTicks); + if(held){Assert.Equal("cancelled",outcome);Assert.Null(pairing);}else{Assert.Equal("returned",outcome);_=BrowserBootstrapService.ParsePairing(pairing!,fixture.GatewayPort);} + if(name=="deadline")Assert.InRange(commandEnd*1000/Stopwatch.Frequency,14500,17000); + } + var empty=await WaitUnit(fixture,p=>!p.GetProperty("present").GetBoolean()||p.GetProperty("processes").GetArrayLength()==0,TimeSpan.FromSeconds(5),unitName); + _cases.Add(new{name,identities=ids,commandEndTicks=commandEnd,clockFrequency=Stopwatch.Frequency,outcome, + ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleaseBeginTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks,ownership.ManagementCode, + actualOwnerExecuted=true,guestAcknowledgmentRequiredByOwner=!unknown,unknownRetainsActivation=unknown,resultReturned=pairing is not null, + observedPostcheckEmpty=empty.GetProperty("processes").GetArrayLength()==0,canonicalPairingValidated=name=="real_cli",syntheticCli=name!="real_cli"}); + } + finally + { + caller.Cancel(); + if(!unknown&&run.IsCompleted)ownership.Dispose(); + // A failed case with unsettled work is NOT converted into successful retirement. + } + } + private static async Task DrainBoundedError(StreamReader reader) + { + var buffer=new char[1024];var total=0;int count; + while((count=await reader.ReadAsync(buffer))!=0)if((total+=count)>16384)throw new InvalidDataException("Bounded proof drain"); + } + private static int FindProductionClient(string distro) + { + var ps=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); + var script="$p=@(Get-CimInstance Win32_Process -Filter \"Name='wsl.exe'\" | Where-Object {$_.CommandLine -like '*--distribution "+distro+"*--exec /bin/bash --noprofile --norc -s*'});if($p.Count-ne 1){throw 'owner client identity'};[Console]::Out.Write($p[0].ProcessId)"; + var start=new ProcessStartInfo(ps){UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-EncodedCommand",Convert.ToBase64String(Encoding.Unicode.GetBytes(script))})start.ArgumentList.Add(a); + using var process=Process.Start(start)!;var output=process.StandardOutput.ReadToEnd();process.WaitForExit(5000);Assert.Equal(0,process.ExitCode);return int.Parse(output.Trim()); + } + private static async Task Guest(E2ESetupFixture f,string script) + {var r=await f.RunInWslAsync("set -euo pipefail\n"+script,TimeSpan.FromSeconds(15),inputViaStdin:true);Assert.False(r.TimedOut);Assert.Equal(0,r.ExitCode);return r.Stdout;} + private static async Task WaitUnit(E2ESetupFixture f,Func predicate,TimeSpan timeout,string? exact=null) + { + var clock=Stopwatch.StartNew(); + do{var p=await Probe(f,exact);if(predicate(p))return p;await Task.Delay(50);}while(clock.Elapsed Probe(E2ESetupFixture f,string? exact) + { + var selector=exact??"openclaw-browser-bootstrap-*.service"; + var code="import subprocess,pathlib,json\nnames=subprocess.run(['systemctl','--user','list-units','--all','--plain','--no-legend','"+selector+"'],capture_output=True,text=True).stdout.splitlines()\nnames=[l.split()[0] for l in names if l.strip()]\nassert len(names)<=1\nr={'present':False,'processes':[]}\nif names:\n u=names[0];v=dict(l.split('=',1) for l in subprocess.run(['systemctl','--user','show',u,'-p','ControlGroup','-p','InvocationID','-p','Description'],capture_output=True,text=True).stdout.splitlines() if '=' in l)\n assert v.get('Description','').startswith('OpenClaw browser bootstrap ')\n g=v.get('ControlGroup','');p=[]\n if g.startswith('/user.slice/') and '..' not in g:\n f=pathlib.Path('/sys/fs/cgroup'+g+'/cgroup.procs')\n if f.exists():\n for pid in f.read_text().split():\n try:\n a=pathlib.Path('/proc/'+pid+'/stat').read_text().rsplit(') ',1)[1].split();p.append({'pid':int(pid),'start':int(a[19]),'session':int(a[3])})\n except FileNotFoundError:pass\n r={'present':True,'unit':u,'invocation':v.get('InvocationID'),'processes':p}\nprint(json.dumps(r))"; + var b64=Convert.ToBase64String(Encoding.UTF8.GetBytes(code));using var doc=JsonDocument.Parse(await Guest(f,$"printf '%s' '{b64}' | base64 -d | /usr/bin/python3"));return doc.RootElement.Clone(); + } +} From 97e7aa7d3d79b9359995777ec07f3cee7d3f854a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:15:16 +0000 Subject: [PATCH 54/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: a44b98a1-def8-4950-9c4c-c9d028c41304 From b8aaccef5e97611c704f980ec05fb03bfb1ae989 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:53:57 +0000 Subject: [PATCH 55/77] fix(test): bind forced WSL loss to the exact owned client Use the parent-bound full owner command line, single-match enforcement, a pinned process handle and executable-image verification. Bound both lookup drains and process waits, and retain redacted failure-stage and retirement counters without weakening missing-ack BUSY assertions. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../BrowserWslProductionOwnerTests.cs | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs index 73639a6d9..d4981c130 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -111,7 +111,7 @@ private async Task RunCase(E2ESetupFixture fixture,string name) finally{Interlocked.Exchange(ref commandEnd,clock.ElapsedTicks);} }); _=run.ContinueWith(t=>_=t.Exception,TaskContinuationOptions.OnlyOnFaulted); - JsonElement unit=default;bool unknown=false; + JsonElement unit=default;bool unknown=false;string stage="unit_observation";int? clientPid=null;bool clientKilled=false;int queryExit=-1,queryMatches=-1; try { unit=await WaitUnit(fixture,p=>p.GetProperty("present").GetBoolean()&&(!held||p.GetProperty("processes").GetArrayLength()>=3),TimeSpan.FromSeconds(12)); @@ -119,14 +119,15 @@ private async Task RunCase(E2ESetupFixture fixture,string name) Assert.StartsWith("openclaw-browser-bootstrap-",unitName); var ids=unit.GetProperty("processes").EnumerateArray().Select(p=>new{pid=p.GetProperty("pid").GetInt32(),start=p.GetProperty("start").GetInt64(),session=p.GetProperty("session").GetInt32()}).ToArray(); if(held)Assert.True(ids.Select(p=>p.session).Distinct().Count()>=2); - var retirement=ownership.Retire(); + stage="retirement_started";var retirement=ownership.Retire(); Assert.Equal(0,Interlocked.Read(ref ownership.ManagementMutationTicks)); if(name=="caller_cancel")caller.Cancel(); if(name=="forced_client_exit") { - var pid=FindProductionClient(fixture.DistroName); - using var process=Process.GetProcessById(pid);process.Kill();await process.WaitForExitAsync(); - await retirement.WaitAsync(TimeSpan.FromSeconds(40)); + stage="exact_client_selection"; + using var process=await FindProductionClientAsync(fixture.DistroName,(exit,matches)=>{queryExit=exit;queryMatches=matches;});clientPid=process.Id; + stage="client_termination";process.Kill();await process.WaitForExitAsync();clientKilled=true; + stage="retirement_outcome";await retirement.WaitAsync(TimeSpan.FromSeconds(40)); Assert.Equal("busy",ownership.ManagementCode);Assert.False(run.IsCompleted);Assert.Equal(0,Interlocked.Read(ref commandEnd)); Assert.Equal(0,ownership.ActivationReleasedTicks);Assert.Equal(0,ownership.RuntimeLeaseReleaseTicks);Assert.Equal(0,ownership.ManagementMutationTicks); unknown=true; @@ -139,12 +140,19 @@ private async Task RunCase(E2ESetupFixture fixture,string name) if(held){Assert.Equal("cancelled",outcome);Assert.Null(pairing);}else{Assert.Equal("returned",outcome);_=BrowserBootstrapService.ParsePairing(pairing!,fixture.GatewayPort);} if(name=="deadline")Assert.InRange(commandEnd*1000/Stopwatch.Frequency,14500,17000); } - var empty=await WaitUnit(fixture,p=>!p.GetProperty("present").GetBoolean()||p.GetProperty("processes").GetArrayLength()==0,TimeSpan.FromSeconds(5),unitName); + stage="guest_postcheck";var empty=await WaitUnit(fixture,p=>!p.GetProperty("present").GetBoolean()||p.GetProperty("processes").GetArrayLength()==0,TimeSpan.FromSeconds(5),unitName); _cases.Add(new{name,identities=ids,commandEndTicks=commandEnd,clockFrequency=Stopwatch.Frequency,outcome, ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleaseBeginTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks,ownership.ManagementCode, - actualOwnerExecuted=true,guestAcknowledgmentRequiredByOwner=!unknown,unknownRetainsActivation=unknown,resultReturned=pairing is not null, + clientPid,clientKilled,queryExit,queryMatches,actualOwnerExecuted=true,guestAcknowledgmentRequiredByOwner=!unknown,unknownRetainsActivation=unknown,resultReturned=pairing is not null, observedPostcheckEmpty=empty.GetProperty("processes").GetArrayLength()==0,canonicalPairingValidated=name=="real_cli",syntheticCli=name!="real_cli"}); } + catch(Exception e) + { + _cases.Add(new{name,failed=true,stage,errorType=e.GetType().Name,clientPid,clientKilled,queryExit,queryMatches,outcome, + commandEndTicks=Interlocked.Read(ref commandEnd),runCompleted=run.IsCompleted,ownership.ManagementCode, + ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks}); + throw; + } finally { caller.Cancel(); @@ -157,13 +165,40 @@ private static async Task DrainBoundedError(StreamReader reader) var buffer=new char[1024];var total=0;int count; while((count=await reader.ReadAsync(buffer))!=0)if((total+=count)>16384)throw new InvalidDataException("Bounded proof drain"); } - private static int FindProductionClient(string distro) + private static async Task FindProductionClientAsync(string distro,Action report) { var ps=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); - var script="$p=@(Get-CimInstance Win32_Process -Filter \"Name='wsl.exe'\" | Where-Object {$_.CommandLine -like '*--distribution "+distro+"*--exec /bin/bash --noprofile --norc -s*'});if($p.Count-ne 1){throw 'owner client identity'};[Console]::Out.Write($p[0].ProcessId)"; + Assert.Matches("^OpenClawE2E-[a-f0-9]{8}$",distro); + var image=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"wsl.exe"); + var arguments="--distribution "+distro+" --exec /bin/bash --noprofile --norc -s"; + var expected=Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new {parent=Environment.ProcessId,quoted="\""+image+"\" "+arguments,unquoted=image+" "+arguments})); + var script="$e=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+expected+"'))|ConvertFrom-Json;Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$e.parent+' AND Name=\"wsl.exe\"') | Where-Object {$_.CommandLine-ceq$e.quoted-or$_.CommandLine-ceq$e.unquoted} | ForEach-Object {$_.ProcessId}"; var start=new ProcessStartInfo(ps){UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-EncodedCommand",Convert.ToBase64String(Encoding.Unicode.GetBytes(script))})start.ArgumentList.Add(a); - using var process=Process.Start(start)!;var output=process.StandardOutput.ReadToEnd();process.WaitForExit(5000);Assert.Equal(0,process.ExitCode);return int.Parse(output.Trim()); + using var process=Process.Start(start)!; + var output=ReadLookupOutput(process.StandardOutput);var error=DrainBoundedError(process.StandardError); + try + { + await Task.WhenAll(process.WaitForExitAsync(),output,error).WaitAsync(TimeSpan.FromSeconds(5)); + var matches=(await output).Split(['\r','\n'],StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); + report(process.ExitCode,matches.Length);Assert.Equal(0,process.ExitCode);Assert.Single(matches); + var owned=Process.GetProcessById(matches[0]);_=owned.Handle; + if(!string.Equals(owned.MainModule!.FileName,image,StringComparison.OrdinalIgnoreCase)){owned.Dispose();throw new InvalidDataException("Owned image changed");} + return owned; + } + finally + { + if(!process.HasExited)process.Kill(entireProcessTree:true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(output,error).WaitAsync(TimeSpan.FromSeconds(2)); + } + } + private static async Task ReadLookupOutput(StreamReader reader) + { + var result=new StringBuilder();var buffer=new char[128];int count; + while((count=await reader.ReadAsync(buffer))!=0) + {if(result.Length+count>4096)throw new InvalidDataException("Lookup output bound");result.Append(buffer,0,count);} + return result.ToString(); } private static async Task Guest(E2ESetupFixture f,string script) {var r=await f.RunInWslAsync("set -euo pipefail\n"+script,TimeSpan.FromSeconds(15),inputViaStdin:true);Assert.False(r.TimedOut);Assert.Equal(0,r.ExitCode);return r.Stdout;} From 2b3ca361857fcf387a748744457ce0917002fa57 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:53:57 +0000 Subject: [PATCH 56/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 29ce3e8f-5acf-4f2a-9adf-29d6f3dd4956 From dd747b1eded72dad1d90fd19c212109508a7fa8a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:08:40 +0000 Subject: [PATCH 57/77] test(browser): verify saved Windows profile with the final consumer Pin the published 57201e3e consumer, retain all fourteen native lifecycle cases, and exercise canonical CLI saved-work recovery, relay and pairing preservation, Store intent, fail-closed context and inventory cases, cancellation, retired selection, and verified owned cleanup. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../browser-native-composed-proof.yml | 5 +- .../Initialize-BrowserNativeComposition.ps1 | 2 +- scripts/Test-BrowserNativeComposition.mjs | 5 +- scripts/Test-BrowserNativeSavedProfile.mjs | 160 ++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 scripts/Test-BrowserNativeSavedProfile.mjs diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index 149116bec..fef40317a 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -6,6 +6,7 @@ on: paths: - .github/workflows/browser-native-composed-proof.yml - scripts/Test-BrowserNativeComposition.mjs + - scripts/Test-BrowserNativeSavedProfile.mjs - scripts/Initialize-BrowserNativeComposition.ps1 - scripts/BrowserNativePathAudit.cs - scripts/Control-BrowserNativeProofChild.ps1 @@ -33,7 +34,7 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: 57f78c49d47f445b85d4859f038e8447e08983ba + ref: 57201e3e26968ff686bae61d5de8e4e54308a6b6 path: consumer persist-credentials: false - uses: actions/setup-node@v4 @@ -77,7 +78,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - if ((git rev-parse HEAD).Trim() -cne '57f78c49d47f445b85d4859f038e8447e08983ba') { throw 'Consumer revision mismatch.' } + if ((git rev-parse HEAD).Trim() -cne '57201e3e26968ff686bae61d5de8e4e54308a6b6') { throw 'Consumer revision mismatch.' } pnpm install --frozen-lockfile --store-dir "$env:COMPOSED_PRIVATE_ROOT/store" --package-import-method=copy if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer dependency install failed.' } pnpm build:docker diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 index 5c0b06fa0..3fccaa32b 100644 --- a/scripts/Initialize-BrowserNativeComposition.ps1 +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -1,7 +1,7 @@ param([Parameter(Mandatory)][string]$Consumer, [Parameter(Mandatory)][string]$Receipt) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='57f78c49d47f445b85d4859f038e8447e08983ba' } +$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='57201e3e26968ff686bae61d5de8e4e54308a6b6' } try { $result.host = @{ windows=[bool]$IsWindows; githubActions=($env:GITHUB_ACTIONS -ceq 'true'); githubHosted=($env:RUNNER_ENVIRONMENT -ceq 'github-hosted') } if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 157dacced..490ab8298 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -6,9 +6,10 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { recordCompletion } from './BrowserNativeProofTiming.mjs'; +import { savedProfileAcceptance } from './Test-BrowserNativeSavedProfile.mjs'; const producerSha = process.env.GITHUB_SHA; -const consumerSha = '57f78c49d47f445b85d4859f038e8447e08983ba'; +const consumerSha = '57201e3e26968ff686bae61d5de8e4e54308a6b6'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension' } } } }; @@ -333,6 +334,8 @@ try { } assert.equal(await stateSnapshot(context.stateDir),eofBefore,'retired_variant_state_effect'); check('retired_valid_nonce_parser_variants_cannot_bypass_activation'); + stage='canonical-saved-profile-acceptance'; + await savedProfileAcceptance({fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt,withFrozenNative}); receipt.status='passed'; } } catch (error) { diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs new file mode 100644 index 000000000..59e306acb --- /dev/null +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -0,0 +1,160 @@ +// Disposable hosted Windows acceptance through the real canonical CLI and producer. +// Dependencies below are the existing real Windows proof helpers, not mocked adapters. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +export async function savedProfileAcceptance(h) { + const {fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt}=h; + assert.equal(process.platform,'win32');assert.equal(process.env.RUNNER_ENVIRONMENT,'github-hosted'); + const stateDir=await fs.realpath(await fs.mkdtemp(path.join(fixture,'saved-work-'))); + const configPath=path.join(stateDir,'openclaw.json'); + const config={gateway:{mode:'local',port:18789},browser:{enabled:true,profiles:{chrome:{driver:'extension'},work:{driver:'extension',cdpPort:19444}}}}; + const configBytes=JSON.stringify(config);await fs.writeFile(configPath,configBytes,{flag:'wx'}); + let current={...context,stateDir,configPath:await fs.realpath(configPath),browserProfile:'work'}; + const env=()=>({...baseEnv,OPENCLAW_STATE_DIR:current.stateDir,OPENCLAW_CONFIG_PATH:current.configPath,OPENCLAW_NO_RESPAWN:'1'}); + async function cli(args,overrides={}) { + const result=await exchange(overrides.node??current.nodePath,[current.cliPath,'browser','extension',...args,'--native-host-executable',executable,'--json'],Buffer.alloc(0),{end:true,env:{...env(),...overrides.env},onSpawn:overrides.onSpawn}); + let body;try{body=JSON.parse(result.out.toString('utf8'));}catch{} + return {code:result.code,body}; // Raw stderr and secret-bearing stdout never reach receipts. + } + async function descriptor() { + const manifest=JSON.parse(powershell("$k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Google\\Chrome\\NativeMessagingHosts\\ai.openclaw.browser_bootstrap');try{[Console]::Out.Write((ConvertTo-Json -Compress -InputObject ([string]$k.GetValue(''))))}finally{$k.Dispose()}")); + assert.ok(typeof manifest==='string'&&path.isAbsolute(manifest)); + const dir=path.dirname(manifest); + const binding=JSON.parse(await fs.readFile(path.join(dir,'OpenClaw.BrowserBootstrap.binding.json'),'utf8')); + assert.equal(binding.mode,'native-windows-cli'); + assert.equal(binding.nativeWindows.stateDir.toLowerCase(),stateDir.toLowerCase()); + assert.equal(binding.nativeWindows.configPath.toLowerCase(),current.configPath.toLowerCase()); + const request={v:1,action:'inspect',mode:'native-windows-cli',context:{...current,browserProfile:binding.nativeWindows.browserProfile},expectedOrigins:binding.expectedOrigins,store:'preserve'}; + const inspected=await manage(request);assert.equal(inspected.ok,true);assert.equal(inspected.registration,'owned'); + assert.equal(inspected.installation.manifestPath.toLowerCase(),manifest.toLowerCase()); + return {binding,request,inspected,root:path.dirname(dir)}; + } + const bootstrap=async d=>response(await exchange(d.inspected.installation.launcherPath,[origin],frame({v:1,op:'bootstrap',nonce}))); + let installed=false; + try { + // The seed uses the canonical CLI, so normal origin/runtime selection is preserved. + installed=true; + const seed=await cli(['install','--browser-profile','work','--no-store','--wait-ms','0']); + assert.ok(seed.body?.registrations.some(r=>r.state==='owned'&&r.browserProfile==='work')); + let active=await descriptor();assert.equal(active.inspected.store,'missing'); + const initialPair=await bootstrap(active);assert.equal(initialPair.ok,true); + assert.equal(new URL(initialPair.pairingString).port,'19444'); + for(const action of ['inspect','verify','install']) { + const before=await descriptor(),dirs=new Set(await fs.readdir(before.root)); + const result=await cli(['setup','--action',action,'--wait-ms','0']); + assert.equal(result.code,0);assert.equal(result.body.target.profile,'work');assert.equal(result.body.target.relayPort,19444); + assert.equal(result.body.installation.nativeHostRegistered,true);assert.equal(result.body.installation.installRequested,false); + active=await descriptor();assert.equal(active.binding.nativeWindows.browserProfile,'work');assert.equal(active.inspected.store,'missing'); + const added=(await fs.readdir(before.root)).filter(x=>!dirs.has(x)); + assert.equal(added.length,action==='install'?1:0); + assert.equal((await bootstrap(active)).pairingString,initialPair.pairingString); + } + check('canonical_cli_saved_work_profile_recovered_without_pairing_or_optout_changes'); + async function unchangedFailure(name,args,overrides={}) { + const before=await descriptor(),dirs=JSON.stringify((await fs.readdir(before.root)).sort()); + const result=await cli(args,overrides);assert.ok(result.code!==0||result.body?.phase==='blocked',name); + const after=await descriptor();assert.equal(after.inspected.installation.generation,before.inspected.installation.generation,name); + assert.equal(JSON.stringify((await fs.readdir(before.root)).sort()),dirs,name); + assert.equal((await bootstrap(after)).pairingString,initialPair.pairingString,name); + } + await unchangedFailure('explicit_other_profile',['setup','--action','install','--browser-profile','chrome','--wait-ms','0']); + const otherState=await fs.mkdtemp(path.join(fixture,'other-work-state-')); + await unchangedFailure('different_state',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_STATE_DIR:otherState}}); + const otherConfig=path.join(stateDir,'other-config.json');await fs.writeFile(otherConfig,configBytes); + await unchangedFailure('different_config',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_CONFIG_PATH:otherConfig}}); + const otherNode=path.join(fixture,'other-canonical-node.exe');await fs.copyFile(current.nodePath,otherNode,fs.constants.COPYFILE_EXCL); + await unchangedFailure('runtime_drift',['setup','--action','install','--wait-ms','0'],{node:otherNode}); + active=await descriptor();const wrongMode=await manage({...active.request,action:'install',mode:'companion-managed-wsl',context:null,expectedOrigins:[origin]}); + assert.equal(wrongMode.ok,false);assert.equal((await descriptor()).inspected.installation.generation,active.inspected.installation.generation); + check('saved_profile_explicit_context_mode_and_runtime_drift_fail_closed'); + const before=await descriptor(),dirs=JSON.stringify((await fs.readdir(before.root)).sort()); + try { + await fs.writeFile(configPath,JSON.stringify({...config,browser:{...config.browser,profiles:{chrome:config.browser.profiles.chrome}}})); + const noSaved=await cli(['setup','--action','install','--wait-ms','0']);assert.notEqual(noSaved.code,0); + assert.equal(JSON.stringify((await fs.readdir(before.root)).sort()),dirs); + } finally { await fs.writeFile(configPath,configBytes); } + assert.equal((await descriptor()).inspected.installation.generation,before.inspected.installation.generation); + check('absent_configured_saved_profile_does_not_fall_back_or_mutate'); + // Corrupt only the task-owned immutable binding and restore exact bytes; no production bypass. + active=await descriptor(); + const bindingPath=active.inspected.installation.bindingPath; + const bindingBytes=await fs.readFile(bindingPath),generationBefore=active.inspected.installation.generation; + const generationsBefore=JSON.stringify((await fs.readdir(active.root)).sort()); + try { + await fs.writeFile(bindingPath,Buffer.concat([bindingBytes,Buffer.from(' ')])); + const refused=await cli(['setup','--action','install','--wait-ms','0']); + assert.ok(refused.code!==0||refused.body?.phase==='blocked'); + assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); + } finally { await fs.writeFile(bindingPath,bindingBytes); } + assert.equal((await descriptor()).inspected.installation.generation,generationBefore); + assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); + check('unverified_binding_never_authorizes_automatic_repair'); + if(!h.withFrozenNative)throw Error('Actual native ownership fixture required for unknown/cancel proof'); + active=await descriptor(); + await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ + const blocked=await cli(['setup','--action','install','--wait-ms','0']); + assert.ok(blocked.code!==0||blocked.body?.phase==='blocked'); + assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); + await control.resume();assert.equal(response(await control.work).ok,true); + }); + assert.equal((await descriptor()).inspected.installation.generation,generationBefore); + check('unknown_busy_inventory_cannot_select_profile_or_mutate'); + active=await descriptor(); + let retained; + for(const name of await fs.readdir(active.root)) { + const file=path.join(active.root,name,'OpenClaw.BrowserBootstrap.binding.json'); + try { const b=JSON.parse(await fs.readFile(file,'utf8'));if(b.nativeWindows?.stateDir.toLowerCase()===stateDir.toLowerCase()&&b.manifestPath!==active.inspected.installation.manifestPath){retained=b.manifestPath;break;} } catch {} + } + assert.ok(retained,'retained owned generation fixture required'); + function replaceChromium(expected,next) { + const payload=Buffer.from(JSON.stringify({expected,next})).toString('base64'); + powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+payload+"'))|ConvertFrom-Json;$k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software/Chromium/NativeMessagingHosts/ai.openclaw.browser_bootstrap'.Replace('/',[IO.Path]::DirectorySeparatorChar),$true);try{if([string]$k.GetValue('')-cne $p.expected){throw 'fixture ownership changed'};$k.SetValue('',$p.next,[Microsoft.Win32.RegistryValueKind]::String)}finally{$k.Dispose()}"); + } + const currentManifest=active.inspected.installation.manifestPath; + try { + replaceChromium(currentManifest,retained); + const mixed=await cli(['setup','--action','install','--wait-ms','0']);assert.ok(mixed.code!==0||mixed.body?.phase==='blocked'); + assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); + } finally { replaceChromium(retained,currentManifest); } + assert.equal((await descriptor()).inspected.installation.generation,generationBefore); + check('mixed_registered_generations_fail_closed_without_automatic_mutation'); + await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ + const cancelled=await cli(['setup','--action','install','--wait-ms','0'],{onSpawn:async child=>{ + const payload=Buffer.from(JSON.stringify({pid:child.pid,node:current.nodePath,helper:executable})).toString('base64'); + powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+payload+"'))|ConvertFrom-Json;$parent=[Diagnostics.Process]::GetProcessById([int]$p.pid);$null=$parent.Handle;try{if($parent.MainModule.FileName-cne $p.node){throw 'CLI identity'};$end=[DateTime]::UtcNow.AddSeconds(12);$owned=$null;while($null-eq $owned){if($parent.HasExited-or[DateTime]::UtcNow-gt$end){throw 'management child not observed'};foreach($c in @(Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$parent.Id))){if($c.ExecutablePath-ceq$p.helper){$owned=[Diagnostics.Process]::GetProcessById([int]$c.ProcessId);$null=$owned.Handle;if($owned.StartTime.ToUniversalTime()-lt$parent.StartTime.ToUniversalTime()-or$owned.MainModule.FileName-cne$p.helper){throw 'child identity'};break}};if($null-eq$owned){Start-Sleep -Milliseconds 10}};try{& (Join-Path $env:SystemRoot 'System32/taskkill.exe') /PID $parent.Id /T /F >$null;if(!$parent.WaitForExit(5000)-or!$owned.WaitForExit(5000)){throw 'owned tree not joined'}}finally{$owned.Dispose()}}finally{$parent.Dispose()}"); + }}); + assert.notEqual(cancelled.code,0);assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); + await control.resume();assert.equal(response(await control.work).ok,true); + }); + assert.equal((await descriptor()).inspected.installation.generation,generationBefore); + check('cancelled_selector_free_setup_joins_owned_management_child_without_mutation'); + // Explicit Store request is separate from the opt-out case; repair must preserve it. + await cli(['install','--browser-profile','work','--wait-ms','0']); + assert.equal((await descriptor()).inspected.store,'requested'); + const preserve=await cli(['setup','--action','install','--wait-ms','0']); + assert.equal(preserve.body.target.profile,'work');assert.equal(preserve.body.installation.installRequested,true); + assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); + check('selector_free_repair_preserves_explicit_store_request'); + const retireWork=await cli(['uninstall-host','--browser-profile','work','--remove-store']); + assert.equal(retireWork.code,0);assert.ok(Array.isArray(retireWork.body?.refused));assert.equal(retireWork.body.refused.length,0); + current={...current,browserProfile:'chrome'}; + await cli(['install','--browser-profile','chrome','--no-store','--wait-ms','0']); + const currentChrome=await descriptor();assert.equal(currentChrome.binding.nativeWindows.browserProfile,'chrome'); + const select=await cli(['setup','--action','inspect','--wait-ms','0']); + assert.equal(select.body.target.profile,'chrome'); + assert.equal((await descriptor()).inspected.installation.generation,currentChrome.inspected.installation.generation); + check('retained_work_generations_never_override_current_chrome_selection'); + receipt.savedProfile={workRelay19444:true,pairingPreserved:true,optOutPreserved:true,requestedStorePreserved:true,retiredSelectionRefused:true}; + } finally { + if(installed) { + const cleanup=await cli(['uninstall-host','--browser-profile',current.browserProfile,'--remove-store']); + assert.equal(cleanup.code,0,'saved_profile_cleanup_exit'); + assert.ok(Array.isArray(cleanup.body?.refused),'saved_profile_cleanup_receipt'); + assert.equal(cleanup.body.refused.length,0,'saved_profile_owned_cleanup'); + const after=await manage({v:1,action:'inspect',mode:'native-windows-cli',context:current,expectedOrigins:[origin],store:'preserve'}); + assert.equal(after.ok,true);assert.equal(after.registration,'missing');assert.equal(after.store,'missing'); + receipt.savedProfileCleanup={registrationMissing:true,storeMissing:true}; + } + } +} From 0fc40589bd2c24432f6e861b3e2728c7e48139fa Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:08:40 +0000 Subject: [PATCH 58/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 41652768-6ae2-4ff4-a411-44d8d77c0c4d From 427802995001430bfcc05eefc2ee158ec493aa74 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:07:47 +0000 Subject: [PATCH 59/77] test(browser): correct saved-profile fixtures and isolate proof failures Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../browser-native-composed-proof.yml | 3 +- .github/workflows/browser-wsl-owner-proof.yml | 3 +- scripts/BrowserNativeSavedProfile.test.mjs | 23 +++++++ scripts/Test-BrowserNativeSavedProfile.mjs | 38 ++++++++++- .../BrowserWslLookupDiagnosticsTests.cs | 53 ++++++++++++++++ .../BrowserWslProductionOwnerTests.cs | 63 ++++++++++++++----- 6 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 scripts/BrowserNativeSavedProfile.test.mjs create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index fef40317a..a8e4880a6 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -12,6 +12,7 @@ on: - scripts/Control-BrowserNativeProofChild.ps1 - scripts/BrowserNativeProofTiming.mjs - scripts/BrowserNativeProofTiming.test.mjs + - scripts/BrowserNativeSavedProfile.test.mjs - src/OpenClaw.BrowserBootstrap/** - src/OpenClaw.BrowserBootstrap.Contracts/** - src/OpenClaw.Shared/Browser/** @@ -49,7 +50,7 @@ jobs: dotnet-version: '10.0.x' - name: Verify proof ordering instrumentation shell: pwsh - run: node --test producer/scripts/BrowserNativeProofTiming.test.mjs + run: node --test producer/scripts/BrowserNativeProofTiming.test.mjs producer/scripts/BrowserNativeSavedProfile.test.mjs - name: Build and identify the current reviewed producer shell: pwsh run: | diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 7ef7c042e..d1c0c9b71 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -9,6 +9,7 @@ on: - src/OpenClaw.Connection/OpenClaw.Connection.csproj - tests/OpenClaw.Connection.Tests/BrowserBootstrap* - tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs + - tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs permissions: contents: read @@ -117,7 +118,7 @@ jobs: - name: Verify the actual stdin newline normalization shell: pwsh run: | - dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter FullyQualifiedName~BrowserPrototypeTransportTests + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter 'FullyQualifiedName~BrowserPrototypeTransportTests|FullyQualifiedName~BrowserWslLookupDiagnosticsTests' if ($LASTEXITCODE -ne 0) { throw 'Prototype stdin transport regression failed.' } - name: Exercise actual WSL owner and retirement retention in disposable WSL shell: pwsh diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs new file mode 100644 index 000000000..fd5652758 --- /dev/null +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { savedProfileFailure } from './Test-BrowserNativeSavedProfile.mjs'; + +test('saved-profile diagnostics retain only assertion kind and fixture coordinates',()=>{ + const error=new assert.AssertionError({actual:'private-pairing',expected:'private-config',message:'private-message'}); + error.stack='AssertionError: private-message\n at savedProfileAcceptance (file:///private/root/Test-BrowserNativeSavedProfile.mjs:51:12)'; + assert.deepEqual(savedProfileFailure(error),{kind:'assertion_failed',line:51,column:12}); +}); +test('execution diagnostics never retain messages, child output or foreign stack paths',()=>{ + const error=Object.assign(new Error('private-message'),{stdout:'private-output',stderr:'private-error',code:'private-code'}); + error.stack='Error: private-message\n at file:///private/other.mjs:9:2'; + assert.deepEqual(savedProfileFailure(error),{kind:'execution_failed'}); +}); +test('numeric coordinates support Windows stack paths without publishing paths',()=>{ + const error=new Error('private-message'); + error.stack='Error: private-message\n at savedProfileAcceptance (D:\\private\\Test-BrowserNativeSavedProfile.mjs:173:5)'; + assert.deepEqual(savedProfileFailure(error),{kind:'execution_failed',line:173,column:5}); +}); +test('non-Error throws cannot add diagnostic fields',()=>{ + assert.deepEqual(savedProfileFailure({message:'private-message',stack:'private-stack',actual:'private-output'}),{kind:'execution_failed'}); + assert.deepEqual(savedProfileFailure(null),{kind:'execution_failed'}); +}); diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index 59e306acb..3e6b5d4e7 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -3,6 +3,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; +// Return only assertion class and fixture coordinates, never messages or actual/expected values. +export function savedProfileFailure(error) { + const location=error instanceof Error?error.stack?.match(/Test-BrowserNativeSavedProfile\.mjs:(\d+):(\d+)/):undefined; + return {kind:error?.code==='ERR_ASSERTION'?'assertion_failed':'execution_failed', + ...(location?{line:Number(location[1]),column:Number(location[2])}:{})}; +} export async function savedProfileAcceptance(h) { const {fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt}=h; assert.equal(process.platform,'win32');assert.equal(process.env.RUNNER_ENVIRONMENT,'github-hosted'); @@ -31,16 +37,22 @@ export async function savedProfileAcceptance(h) { return {binding,request,inspected,root:path.dirname(dir)}; } const bootstrap=async d=>response(await exchange(d.inspected.installation.launcherPath,[origin],frame({v:1,op:'bootstrap',nonce}))); - let installed=false; + let installed=false,primaryFailed=false; + let stage='seed'; try { // The seed uses the canonical CLI, so normal origin/runtime selection is preserved. installed=true; const seed=await cli(['install','--browser-profile','work','--no-store','--wait-ms','0']); assert.ok(seed.body?.registrations.some(r=>r.state==='owned'&&r.browserProfile==='work')); let active=await descriptor();assert.equal(active.inspected.store,'missing'); + stage='initial_pairing'; const initialPair=await bootstrap(active);assert.equal(initialPair.ok,true); - assert.equal(new URL(initialPair.pairingString).port,'19444'); + const pairingUrl=new URL(initialPair.pairingString); + assert.equal(pairingUrl.port,'18789'); + assert.equal(pairingUrl.pathname,'/browser/extension'); + assert.equal(pairingUrl.searchParams.get('profile'),'work'); for(const action of ['inspect','verify','install']) { + stage='selector_free_'+action; const before=await descriptor(),dirs=new Set(await fs.readdir(before.root)); const result=await cli(['setup','--action',action,'--wait-ms','0']); assert.equal(result.code,0);assert.equal(result.body.target.profile,'work');assert.equal(result.body.target.relayPort,19444); @@ -52,6 +64,7 @@ export async function savedProfileAcceptance(h) { } check('canonical_cli_saved_work_profile_recovered_without_pairing_or_optout_changes'); async function unchangedFailure(name,args,overrides={}) { + stage=name; const before=await descriptor(),dirs=JSON.stringify((await fs.readdir(before.root)).sort()); const result=await cli(args,overrides);assert.ok(result.code!==0||result.body?.phase==='blocked',name); const after=await descriptor();assert.equal(after.inspected.installation.generation,before.inspected.installation.generation,name); @@ -63,11 +76,14 @@ export async function savedProfileAcceptance(h) { await unchangedFailure('different_state',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_STATE_DIR:otherState}}); const otherConfig=path.join(stateDir,'other-config.json');await fs.writeFile(otherConfig,configBytes); await unchangedFailure('different_config',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_CONFIG_PATH:otherConfig}}); - const otherNode=path.join(fixture,'other-canonical-node.exe');await fs.copyFile(current.nodePath,otherNode,fs.constants.COPYFILE_EXCL); + const alternateRuntimeDirectory=await fs.mkdtemp(path.join(fixture,'alternate-runtime-')); + const otherNode=path.join(alternateRuntimeDirectory,'node.exe');await fs.copyFile(current.nodePath,otherNode,fs.constants.COPYFILE_EXCL); await unchangedFailure('runtime_drift',['setup','--action','install','--wait-ms','0'],{node:otherNode}); + stage='wrong_mode'; active=await descriptor();const wrongMode=await manage({...active.request,action:'install',mode:'companion-managed-wsl',context:null,expectedOrigins:[origin]}); assert.equal(wrongMode.ok,false);assert.equal((await descriptor()).inspected.installation.generation,active.inspected.installation.generation); check('saved_profile_explicit_context_mode_and_runtime_drift_fail_closed'); + stage='absent_saved_profile'; const before=await descriptor(),dirs=JSON.stringify((await fs.readdir(before.root)).sort()); try { await fs.writeFile(configPath,JSON.stringify({...config,browser:{...config.browser,profiles:{chrome:config.browser.profiles.chrome}}})); @@ -76,6 +92,7 @@ export async function savedProfileAcceptance(h) { } finally { await fs.writeFile(configPath,configBytes); } assert.equal((await descriptor()).inspected.installation.generation,before.inspected.installation.generation); check('absent_configured_saved_profile_does_not_fall_back_or_mutate'); + stage='unverified_binding'; // Corrupt only the task-owned immutable binding and restore exact bytes; no production bypass. active=await descriptor(); const bindingPath=active.inspected.installation.bindingPath; @@ -90,6 +107,7 @@ export async function savedProfileAcceptance(h) { assert.equal((await descriptor()).inspected.installation.generation,generationBefore); assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); check('unverified_binding_never_authorizes_automatic_repair'); + stage='unknown_busy_inventory'; if(!h.withFrozenNative)throw Error('Actual native ownership fixture required for unknown/cancel proof'); active=await descriptor(); await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ @@ -101,6 +119,7 @@ export async function savedProfileAcceptance(h) { assert.equal((await descriptor()).inspected.installation.generation,generationBefore); check('unknown_busy_inventory_cannot_select_profile_or_mutate'); active=await descriptor(); + stage='mixed_generations'; let retained; for(const name of await fs.readdir(active.root)) { const file=path.join(active.root,name,'OpenClaw.BrowserBootstrap.binding.json'); @@ -119,6 +138,7 @@ export async function savedProfileAcceptance(h) { } finally { replaceChromium(retained,currentManifest); } assert.equal((await descriptor()).inspected.installation.generation,generationBefore); check('mixed_registered_generations_fail_closed_without_automatic_mutation'); + stage='cancelled_setup'; await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ const cancelled=await cli(['setup','--action','install','--wait-ms','0'],{onSpawn:async child=>{ const payload=Buffer.from(JSON.stringify({pid:child.pid,node:current.nodePath,helper:executable})).toString('base64'); @@ -129,6 +149,7 @@ export async function savedProfileAcceptance(h) { }); assert.equal((await descriptor()).inspected.installation.generation,generationBefore); check('cancelled_selector_free_setup_joins_owned_management_child_without_mutation'); + stage='store_preservation'; // Explicit Store request is separate from the opt-out case; repair must preserve it. await cli(['install','--browser-profile','work','--wait-ms','0']); assert.equal((await descriptor()).inspected.store,'requested'); @@ -136,6 +157,7 @@ export async function savedProfileAcceptance(h) { assert.equal(preserve.body.target.profile,'work');assert.equal(preserve.body.installation.installRequested,true); assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); check('selector_free_repair_preserves_explicit_store_request'); + stage='retired_profile_selection'; const retireWork=await cli(['uninstall-host','--browser-profile','work','--remove-store']); assert.equal(retireWork.code,0);assert.ok(Array.isArray(retireWork.body?.refused));assert.equal(retireWork.body.refused.length,0); current={...current,browserProfile:'chrome'}; @@ -146,8 +168,13 @@ export async function savedProfileAcceptance(h) { assert.equal((await descriptor()).inspected.installation.generation,currentChrome.inspected.installation.generation); check('retained_work_generations_never_override_current_chrome_selection'); receipt.savedProfile={workRelay19444:true,pairingPreserved:true,optOutPreserved:true,requestedStorePreserved:true,retiredSelectionRefused:true}; + } catch(error) { + primaryFailed=true; + receipt.savedProfileFailure={stage,...savedProfileFailure(error)}; + throw error; } finally { if(installed) { + try { const cleanup=await cli(['uninstall-host','--browser-profile',current.browserProfile,'--remove-store']); assert.equal(cleanup.code,0,'saved_profile_cleanup_exit'); assert.ok(Array.isArray(cleanup.body?.refused),'saved_profile_cleanup_receipt'); @@ -155,6 +182,11 @@ export async function savedProfileAcceptance(h) { const after=await manage({v:1,action:'inspect',mode:'native-windows-cli',context:current,expectedOrigins:[origin],store:'preserve'}); assert.equal(after.ok,true);assert.equal(after.registration,'missing');assert.equal(after.store,'missing'); receipt.savedProfileCleanup={registrationMissing:true,storeMissing:true}; + } catch(error) { + receipt.savedProfileCleanup={failed:true,...savedProfileFailure(error)}; + // Preserve the initial failure when cleanup also fails; both remain in the receipt. + if(!primaryFailed)throw error; + } } } } diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs new file mode 100644 index 000000000..6fad62b6c --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class BrowserWslLookupDiagnosticsTests +{ + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public async Task LookupMarker_ReportsScriptEntryWithoutChangingPidOutput(string newline) + { + var output="LOOKUP_READY"+newline+"1234"+newline; + using var reader=new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(output))); + bool entered=false; + Assert.Equal(output,await BrowserWslProductionOwnerTests.ReadLookupOutput(reader,()=>entered=true)); + Assert.True(entered); + } + + [Fact] + public async Task AbsentMarker_DoesNotClaimScriptEntry() + { + using var reader=new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes("1234\n"))); + bool entered=false; + await BrowserWslProductionOwnerTests.ReadLookupOutput(reader,()=>entered=true); + Assert.False(entered); + } + + [Fact] + public async Task FaultedDrain_IsSettledWithoutInventingCleanupFailure() + { + var drain=Task.FromException(new InvalidDataException("Lookup output bound")); + await BrowserWslProductionOwnerTests.ObserveLookupDrainAsync(drain); + Assert.True(drain.IsFaulted); + } + + [Fact] + public async Task UnfinishedDrain_IsNotReportedSettled() + { + var drain=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var settlement=BrowserWslProductionOwnerTests.ObserveLookupDrainAsync(drain.Task); + Assert.False(settlement.IsCompleted); + drain.SetException(new InvalidDataException("Lookup output bound")); + await settlement; + Assert.True(drain.Task.IsFaulted); + } + + [Fact] + public async Task OutputBound_RemainsFailClosed() + { + using var reader=new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes("LOOKUP_READY\n"+new string('1',4096)))); + await Assert.ThrowsAsync(()=>BrowserWslProductionOwnerTests.ReadLookupOutput(reader,()=>{})); + } +} diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs index d4981c130..5b863e572 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -112,6 +112,7 @@ private async Task RunCase(E2ESetupFixture fixture,string name) }); _=run.ContinueWith(t=>_=t.Exception,TaskContinuationOptions.OnlyOnFaulted); JsonElement unit=default;bool unknown=false;string stage="unit_observation";int? clientPid=null;bool clientKilled=false;int queryExit=-1,queryMatches=-1; + ClientLookupObservation? lookup=null; try { unit=await WaitUnit(fixture,p=>p.GetProperty("present").GetBoolean()&&(!held||p.GetProperty("processes").GetArrayLength()>=3),TimeSpan.FromSeconds(12)); @@ -125,7 +126,7 @@ private async Task RunCase(E2ESetupFixture fixture,string name) if(name=="forced_client_exit") { stage="exact_client_selection"; - using var process=await FindProductionClientAsync(fixture.DistroName,(exit,matches)=>{queryExit=exit;queryMatches=matches;});clientPid=process.Id; + using var process=await FindProductionClientAsync(fixture.DistroName,observation=>{lookup=observation;queryExit=observation.ExitCode;queryMatches=observation.Matches;});clientPid=process.Id; stage="client_termination";process.Kill();await process.WaitForExitAsync();clientKilled=true; stage="retirement_outcome";await retirement.WaitAsync(TimeSpan.FromSeconds(40)); Assert.Equal("busy",ownership.ManagementCode);Assert.False(run.IsCompleted);Assert.Equal(0,Interlocked.Read(ref commandEnd)); @@ -143,12 +144,12 @@ private async Task RunCase(E2ESetupFixture fixture,string name) stage="guest_postcheck";var empty=await WaitUnit(fixture,p=>!p.GetProperty("present").GetBoolean()||p.GetProperty("processes").GetArrayLength()==0,TimeSpan.FromSeconds(5),unitName); _cases.Add(new{name,identities=ids,commandEndTicks=commandEnd,clockFrequency=Stopwatch.Frequency,outcome, ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleaseBeginTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks,ownership.ManagementCode, - clientPid,clientKilled,queryExit,queryMatches,actualOwnerExecuted=true,guestAcknowledgmentRequiredByOwner=!unknown,unknownRetainsActivation=unknown,resultReturned=pairing is not null, + clientPid,clientKilled,queryExit,queryMatches,lookup,actualOwnerExecuted=true,guestAcknowledgmentRequiredByOwner=!unknown,unknownRetainsActivation=unknown,resultReturned=pairing is not null, observedPostcheckEmpty=empty.GetProperty("processes").GetArrayLength()==0,canonicalPairingValidated=name=="real_cli",syntheticCli=name!="real_cli"}); } catch(Exception e) { - _cases.Add(new{name,failed=true,stage,errorType=e.GetType().Name,clientPid,clientKilled,queryExit,queryMatches,outcome, + _cases.Add(new{name,failed=true,stage,errorType=e.GetType().Name,clientPid,clientKilled,queryExit,queryMatches,lookup,outcome, commandEndTicks=Interlocked.Read(ref commandEnd),runCompleted=run.IsCompleted,ownership.ManagementCode, ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks}); throw; @@ -165,39 +166,73 @@ private static async Task DrainBoundedError(StreamReader reader) var buffer=new char[1024];var total=0;int count; while((count=await reader.ReadAsync(buffer))!=0)if((total+=count)>16384)throw new InvalidDataException("Bounded proof drain"); } - private static async Task FindProductionClientAsync(string distro,Action report) + private sealed record ClientLookupObservation(string Stage,long ElapsedMilliseconds,bool ScriptEntered, + bool ProcessExited,bool OutputCompleted,bool ErrorCompleted,int ExitCode,int Matches,bool CleanupFailed=false); + private static async Task FindProductionClientAsync(string distro,Action report) { var ps=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); Assert.Matches("^OpenClawE2E-[a-f0-9]{8}$",distro); var image=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"wsl.exe"); var arguments="--distribution "+distro+" --exec /bin/bash --noprofile --norc -s"; var expected=Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new {parent=Environment.ProcessId,quoted="\""+image+"\" "+arguments,unquoted=image+" "+arguments})); - var script="$e=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+expected+"'))|ConvertFrom-Json;Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$e.parent+' AND Name=\"wsl.exe\"') | Where-Object {$_.CommandLine-ceq$e.quoted-or$_.CommandLine-ceq$e.unquoted} | ForEach-Object {$_.ProcessId}"; + var script="[Console]::Out.WriteLine('LOOKUP_READY');[Console]::Out.Flush();$e=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+expected+"'))|ConvertFrom-Json;Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$e.parent+' AND Name=\"wsl.exe\"') | Where-Object {$_.CommandLine-ceq$e.quoted-or$_.CommandLine-ceq$e.unquoted} | ForEach-Object {$_.ProcessId}"; var start=new ProcessStartInfo(ps){UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-EncodedCommand",Convert.ToBase64String(Encoding.Unicode.GetBytes(script))})start.ArgumentList.Add(a); using var process=Process.Start(start)!; - var output=ReadLookupOutput(process.StandardOutput);var error=DrainBoundedError(process.StandardError); + var clock=Stopwatch.StartNew();bool scriptEntered=false,primaryFailed=false; + var output=ReadLookupOutput(process.StandardOutput,()=>Volatile.Write(ref scriptEntered,true));var error=DrainBoundedError(process.StandardError); + string stage="query_and_drains";int matchesCount=-1;ClientLookupObservation? failure=null; + ClientLookupObservation Snapshot()=>new(stage,clock.ElapsedMilliseconds,Volatile.Read(ref scriptEntered), + process.HasExited,output.IsCompleted,error.IsCompleted,process.HasExited?process.ExitCode:-1,matchesCount); try { await Task.WhenAll(process.WaitForExitAsync(),output,error).WaitAsync(TimeSpan.FromSeconds(5)); - var matches=(await output).Split(['\r','\n'],StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); - report(process.ExitCode,matches.Length);Assert.Equal(0,process.ExitCode);Assert.Single(matches); + stage="parse_exact_matches"; + var lines=(await output).Split(['\r','\n'],StringSplitOptions.RemoveEmptyEntries); + Assert.NotEmpty(lines);Assert.Equal("LOOKUP_READY",lines[0]); + var matches=lines.Skip(1).Select(int.Parse).ToArray();matchesCount=matches.Length; + report(Snapshot());Assert.Equal(0,process.ExitCode);Assert.Single(matches); + stage="bind_exact_image"; var owned=Process.GetProcessById(matches[0]);_=owned.Handle; if(!string.Equals(owned.MainModule!.FileName,image,StringComparison.OrdinalIgnoreCase)){owned.Dispose();throw new InvalidDataException("Owned image changed");} - return owned; + report(Snapshot());return owned; + } + catch + { + primaryFailed=true;failure=Snapshot();report(failure);throw; } finally { - if(!process.HasExited)process.Kill(entireProcessTree:true); - await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); - await Task.WhenAll(output,error).WaitAsync(TimeSpan.FromSeconds(2)); + try + { + if(!process.HasExited)process.Kill(entireProcessTree:true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(ObserveLookupDrainAsync(output),ObserveLookupDrainAsync(error)).WaitAsync(TimeSpan.FromSeconds(2)); + } + catch when(primaryFailed) + { + // Preserve the primary query failure and separately expose failed cleanup. + report(failure! with {CleanupFailed=true}); + _=Task.WhenAll(output,error).ContinueWith(t=>_=t.Exception,TaskContinuationOptions.OnlyOnFaulted); + } } } - private static async Task ReadLookupOutput(StreamReader reader) + internal static async Task ObserveLookupDrainAsync(Task drain) + { + // Cleanup needs settlement, not a second successful result from a failed drain. + // The primary query path already records/rethrows protocol and bound failures. + try{await drain.ConfigureAwait(false);}catch{_=drain.Exception;} + } + internal static async Task ReadLookupOutput(StreamReader reader,Action entered) { var result=new StringBuilder();var buffer=new char[128];int count; while((count=await reader.ReadAsync(buffer))!=0) - {if(result.Length+count>4096)throw new InvalidDataException("Lookup output bound");result.Append(buffer,0,count);} + { + if(result.Length+count>4096)throw new InvalidDataException("Lookup output bound"); + result.Append(buffer,0,count); + var text=result.ToString(); + if(text.StartsWith("LOOKUP_READY\r\n",StringComparison.Ordinal)||text.StartsWith("LOOKUP_READY\n",StringComparison.Ordinal))entered(); + } return result.ToString(); } private static async Task Guest(E2ESetupFixture f,string script) From bfcccf81b9d09569ff21d5b5187854d08b3fb9e6 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:08:50 +0000 Subject: [PATCH 60/77] test(browser): pin reviewed proof fixtures separately from native production Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index d1c0c9b71..223bac131 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -95,8 +95,11 @@ jobs: - name: Verify frozen production and accepted native proof shell: pwsh run: | - git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 - if ($LASTEXITCODE -ne 0) { throw 'Frozen production/native proof changed.' } + # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. + git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser + if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } + git diff --exit-code 427802995001430bfcc05eefc2ee158ec493aa74 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 with: From de288593ab32f0f91ecd2abb4726a73deed56a43 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:13:36 +0000 Subject: [PATCH 61/77] feat(browser): install and pair Chrome with Windows Companion OpenClaw-Publication: 4667b421-0ba1-42be-8c15-83136272b5be From 29772fc84ba673f3d0943357954df0364fe99538 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:42:28 +0000 Subject: [PATCH 62/77] test(browser): recover final consumer proof diagnostics Pin native and WSL acceptance to the landed canonical consumer. Preserve the exact native registration assertion while reporting closed-schema CLI projection facts, and distinguish WSL lookup entry/query/completion without changing identity guards or deadlines. Node diagnostics and protocol regressions pass 14/14 in a secretless network-isolated sandbox. Windows build and hosted acceptance remain pending. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../workflows/browser-native-composed-proof.yml | 8 ++++---- .github/workflows/browser-wsl-owner-proof.yml | 8 ++++---- scripts/BrowserNativeSavedProfile.test.mjs | 14 +++++++++++++- scripts/Test-BrowserNativeComposition.mjs | 2 +- scripts/Test-BrowserNativeSavedProfile.mjs | 16 +++++++++++++++- .../BrowserWslLookupDiagnosticsTests.cs | 14 ++++++++++++++ .../Prototype/BrowserWslProductionOwnerTests.cs | 17 +++++++++++------ 7 files changed, 62 insertions(+), 17 deletions(-) diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index a8e4880a6..55c7554b3 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -35,15 +35,15 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: 57201e3e26968ff686bae61d5de8e4e54308a6b6 + ref: bb2d479926adb23fb30737e2fd3e5af63969dc84 path: consumer persist-credentials: false - uses: actions/setup-node@v4 with: - node-version: '24.16.0' + node-version: '24.19.0' - uses: pnpm/action-setup@v4 with: - version: '12.4.0' + # The pinned consumer packageManager is the only version authority. run_install: false - uses: actions/setup-dotnet@v5 with: @@ -79,7 +79,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - if ((git rev-parse HEAD).Trim() -cne '57201e3e26968ff686bae61d5de8e4e54308a6b6') { throw 'Consumer revision mismatch.' } + if ((git rev-parse HEAD).Trim() -cne 'bb2d479926adb23fb30737e2fd3e5af63969dc84') { throw 'Consumer revision mismatch.' } pnpm install --frozen-lockfile --store-dir "$env:COMPOSED_PRIVATE_ROOT/store" --package-import-method=copy if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer dependency install failed.' } pnpm build:docker diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 223bac131..73da1afef 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: 63f5d867fb67242ea6322f866b0e4f732d4e801c + ref: bb2d479926adb23fb30737e2fd3e5af63969dc84 persist-credentials: false - uses: actions/setup-node@v4 with: @@ -39,7 +39,7 @@ jobs: shell: bash run: | set -euo pipefail - test "$(git rev-parse HEAD)" = 63f5d867fb67242ea6322f866b0e4f732d4e801c + test "$(git rev-parse HEAD)" = bb2d479926adb23fb30737e2fd3e5af63969dc84 pnpm install --frozen-lockfile node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir "$RUNNER_TEMP/canonical-consumer" --output-name openclaw-current.tgz git diff --exit-code @@ -53,7 +53,7 @@ jobs: const dir=path.join(process.env.RUNNER_TEMP,'canonical-consumer'); const version=require('./package.json').version; const sha256=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,'openclaw-current.tgz'))).digest('hex'); - fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'63f5d867fb67242ea6322f866b0e4f732d4e801c',version,sha256})); + fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'bb2d479926adb23fb30737e2fd3e5af63969dc84',version,sha256})); fs.appendFileSync(process.env.GITHUB_OUTPUT,'version='+version+'\nsha256='+sha256+'\n'); JS - uses: actions/upload-artifact@v7 @@ -69,7 +69,7 @@ jobs: OPENCLAW_REPO_ROOT: ${{ github.workspace }} OPENCLAW_RUN_E2E: '1' OPENCLAW_BROWSER_WSL_OWNER_PROOF: '1' - OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: 63f5d867fb67242ea6322f866b0e4f732d4e801c + OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: bb2d479926adb23fb30737e2fd3e5af63969dc84 OPENCLAW_E2E_GATEWAY_VERSION: ${{ needs.canonical-consumer.outputs.version }} steps: - uses: actions/checkout@v7 diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index fd5652758..f0512d4d2 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -1,7 +1,19 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { savedProfileFailure } from './Test-BrowserNativeSavedProfile.mjs'; +import { savedProfileFailure, savedProfileCliObservation } from './Test-BrowserNativeSavedProfile.mjs'; +test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ + for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { + const observed=savedProfileCliObservation({code:1,body}); + assert.equal(observed.ownedWorkProfile,false);assert.equal(observed.registrationsPresent,false); + assert.ok(!JSON.stringify(observed).includes('private-value')); + } +}); +test('CLI observation retains owned work selection and never raw registration fields',()=>{ + const observed=savedProfileCliObservation({code:1,body:{registrations:[{state:'owned',browserProfile:'work',path:'private-path'}],manualSetupRequired:true}}); + assert.equal(observed.ownedWorkProfile,true);assert.equal(observed.registrationCount,1); + assert.equal(observed.manualSetupRequired,true);assert.ok(!JSON.stringify(observed).includes('private-path')); +}); test('saved-profile diagnostics retain only assertion kind and fixture coordinates',()=>{ const error=new assert.AssertionError({actual:'private-pairing',expected:'private-config',message:'private-message'}); error.stack='AssertionError: private-message\n at savedProfileAcceptance (file:///private/root/Test-BrowserNativeSavedProfile.mjs:51:12)'; diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 490ab8298..7c9f49ad6 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -9,7 +9,7 @@ import { recordCompletion } from './BrowserNativeProofTiming.mjs'; import { savedProfileAcceptance } from './Test-BrowserNativeSavedProfile.mjs'; const producerSha = process.env.GITHUB_SHA; -const consumerSha = '57201e3e26968ff686bae61d5de8e4e54308a6b6'; +const consumerSha = 'bb2d479926adb23fb30737e2fd3e5af63969dc84'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension' } } } }; diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index 3e6b5d4e7..7c8e160ab 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -9,6 +9,19 @@ export function savedProfileFailure(error) { return {kind:error?.code==='ERR_ASSERTION'?'assertion_failed':'execution_failed', ...(location?{line:Number(location[1]),column:Number(location[2])}:{})}; } +// Closed-schema diagnostics distinguish a malformed CLI projection from registration refusal. +export function savedProfileCliObservation(result) { + const body=result.body; + return {exitCode:Number.isInteger(result.code)?result.code:null, + jsonObject:body!==null&&typeof body==='object'&&!Array.isArray(body), + registrationsPresent:Array.isArray(body?.registrations), + registrationCount:Array.isArray(body?.registrations)?body.registrations.length:0, + ownedWorkProfile:Array.isArray(body?.registrations)&&body.registrations.some(r=>r?.state==='owned'&&r.browserProfile==='work'), + setupProjection:body?.target?.kind==='local-host', + errorProjection:body!==null&&typeof body==='object'&&Object.hasOwn(body,'error'), + installedCopyProjection:body!==null&&typeof body==='object'&&Object.hasOwn(body,'installedCopy'), + manualSetupRequired:body?.manualSetupRequired===true}; +} export async function savedProfileAcceptance(h) { const {fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt}=h; assert.equal(process.platform,'win32');assert.equal(process.env.RUNNER_ENVIRONMENT,'github-hosted'); @@ -43,7 +56,8 @@ export async function savedProfileAcceptance(h) { // The seed uses the canonical CLI, so normal origin/runtime selection is preserved. installed=true; const seed=await cli(['install','--browser-profile','work','--no-store','--wait-ms','0']); - assert.ok(seed.body?.registrations.some(r=>r.state==='owned'&&r.browserProfile==='work')); + receipt.savedProfileSeed=savedProfileCliObservation(seed); + assert.equal(receipt.savedProfileSeed.ownedWorkProfile,true); let active=await descriptor();assert.equal(active.inspected.store,'missing'); stage='initial_pairing'; const initialPair=await bootstrap(active);assert.equal(initialPair.ok,true); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs index 6fad62b6c..7c8a1982e 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs @@ -16,6 +16,20 @@ public async Task LookupMarker_ReportsScriptEntryWithoutChangingPidOutput(string Assert.True(entered); } + [Theory] + [InlineData("LOOKUP_READY\n", "not_entered")] + [InlineData("LOOKUP_READY\nLOOKUP_QUERY", "not_entered")] + [InlineData("LOOKUP_READY\nLOOKUP_QUERY\n", "query")] + [InlineData("LOOKUP_READY\r\nLOOKUP_QUERY\r\nLOOKUP_COMPLETE\r\n1234\r\n", "completed")] + [InlineData("untrusted\nLOOKUP_QUERY\nLOOKUP_COMPLETE\n", "not_entered")] + public async Task QueryStage_RequiresExactOrderedCompleteMarkers(string output,string expected) + { + using var reader=new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(output))); + string stage="not_entered"; + Assert.Equal(output,await BrowserWslProductionOwnerTests.ReadLookupOutput(reader,()=>{},value=>stage=value)); + Assert.Equal(expected,stage); + } + [Fact] public async Task AbsentMarker_DoesNotClaimScriptEntry() { diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs index 5b863e572..59d3491b9 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -167,7 +167,7 @@ private static async Task DrainBoundedError(StreamReader reader) while((count=await reader.ReadAsync(buffer))!=0)if((total+=count)>16384)throw new InvalidDataException("Bounded proof drain"); } private sealed record ClientLookupObservation(string Stage,long ElapsedMilliseconds,bool ScriptEntered, - bool ProcessExited,bool OutputCompleted,bool ErrorCompleted,int ExitCode,int Matches,bool CleanupFailed=false); + bool ProcessExited,bool OutputCompleted,bool ErrorCompleted,int ExitCode,int Matches,bool CleanupFailed=false,string QueryStage="not_entered"); private static async Task FindProductionClientAsync(string distro,Action report) { var ps=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"WindowsPowerShell","v1.0","powershell.exe"); @@ -175,22 +175,24 @@ private static async Task FindProductionClientAsync(string distro,Actio var image=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"wsl.exe"); var arguments="--distribution "+distro+" --exec /bin/bash --noprofile --norc -s"; var expected=Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new {parent=Environment.ProcessId,quoted="\""+image+"\" "+arguments,unquoted=image+" "+arguments})); - var script="[Console]::Out.WriteLine('LOOKUP_READY');[Console]::Out.Flush();$e=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+expected+"'))|ConvertFrom-Json;Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$e.parent+' AND Name=\"wsl.exe\"') | Where-Object {$_.CommandLine-ceq$e.quoted-or$_.CommandLine-ceq$e.unquoted} | ForEach-Object {$_.ProcessId}"; + var script="[Console]::Out.WriteLine('LOOKUP_READY');[Console]::Out.Flush();$e=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+expected+"'))|ConvertFrom-Json;[Console]::Out.WriteLine('LOOKUP_QUERY');[Console]::Out.Flush();$c=@(Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$e.parent+' AND Name=\"wsl.exe\"') | Where-Object {$_.CommandLine-ceq$e.quoted-or$_.CommandLine-ceq$e.unquoted} | ForEach-Object {$_.ProcessId});[Console]::Out.WriteLine('LOOKUP_COMPLETE');[Console]::Out.Flush();$c"; var start=new ProcessStartInfo(ps){UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-NoLogo","-NoProfile","-NonInteractive","-EncodedCommand",Convert.ToBase64String(Encoding.Unicode.GetBytes(script))})start.ArgumentList.Add(a); using var process=Process.Start(start)!; var clock=Stopwatch.StartNew();bool scriptEntered=false,primaryFailed=false; - var output=ReadLookupOutput(process.StandardOutput,()=>Volatile.Write(ref scriptEntered,true));var error=DrainBoundedError(process.StandardError); + string queryStage="not_entered"; + var output=ReadLookupOutput(process.StandardOutput,()=>Volatile.Write(ref scriptEntered,true),value=>Volatile.Write(ref queryStage,value));var error=DrainBoundedError(process.StandardError); string stage="query_and_drains";int matchesCount=-1;ClientLookupObservation? failure=null; ClientLookupObservation Snapshot()=>new(stage,clock.ElapsedMilliseconds,Volatile.Read(ref scriptEntered), - process.HasExited,output.IsCompleted,error.IsCompleted,process.HasExited?process.ExitCode:-1,matchesCount); + process.HasExited,output.IsCompleted,error.IsCompleted,process.HasExited?process.ExitCode:-1,matchesCount,QueryStage:Volatile.Read(ref queryStage)); try { await Task.WhenAll(process.WaitForExitAsync(),output,error).WaitAsync(TimeSpan.FromSeconds(5)); stage="parse_exact_matches"; var lines=(await output).Split(['\r','\n'],StringSplitOptions.RemoveEmptyEntries); Assert.NotEmpty(lines);Assert.Equal("LOOKUP_READY",lines[0]); - var matches=lines.Skip(1).Select(int.Parse).ToArray();matchesCount=matches.Length; + Assert.True(lines.Length>=3);Assert.Equal("LOOKUP_QUERY",lines[1]);Assert.Equal("LOOKUP_COMPLETE",lines[2]); + var matches=lines.Skip(3).Select(int.Parse).ToArray();matchesCount=matches.Length; report(Snapshot());Assert.Equal(0,process.ExitCode);Assert.Single(matches); stage="bind_exact_image"; var owned=Process.GetProcessById(matches[0]);_=owned.Handle; @@ -223,7 +225,7 @@ internal static async Task ObserveLookupDrainAsync(Task drain) // The primary query path already records/rethrows protocol and bound failures. try{await drain.ConfigureAwait(false);}catch{_=drain.Exception;} } - internal static async Task ReadLookupOutput(StreamReader reader,Action entered) + internal static async Task ReadLookupOutput(StreamReader reader,Action entered,Action? stage=null) { var result=new StringBuilder();var buffer=new char[128];int count; while((count=await reader.ReadAsync(buffer))!=0) @@ -232,6 +234,9 @@ internal static async Task ReadLookupOutput(StreamReader reader,Action e result.Append(buffer,0,count); var text=result.ToString(); if(text.StartsWith("LOOKUP_READY\r\n",StringComparison.Ordinal)||text.StartsWith("LOOKUP_READY\n",StringComparison.Ordinal))entered(); + var lines=text.Split(['\r','\n'],StringSplitOptions.RemoveEmptyEntries); + if(lines.Length>=3&&lines[0]=="LOOKUP_READY"&&lines[1]=="LOOKUP_QUERY"&&lines[2]=="LOOKUP_COMPLETE"&&(text.EndsWith('\n')||lines.Length>3))stage?.Invoke("completed"); + else if(lines.Length>=2&&lines[0]=="LOOKUP_READY"&&lines[1]=="LOOKUP_QUERY"&&(text.EndsWith('\n')||lines.Length>2))stage?.Invoke("query"); } return result.ToString(); } From a5d16253d6c60856a86561c934e11d166c536376 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:43:10 +0000 Subject: [PATCH 63/77] test(browser): keep final consumer preflight pins coherent Align native initialization with the landed consumer and Node version and add a cross-file pin regression. Correct the bootstrap ownership documentation. Accept the concrete autoreview preflight mismatch; reject the claimed x64 ARM64 execution because build-arm64 runs on windows-11-arm. The historical observer readiness caller already has a five-second timeout; its EOF diagnostic refinement is not a production or current acceptance blocker. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- docs/BROWSER_EXTENSION_BOOTSTRAP.md | 31 +++++++++++++------ scripts/BrowserNativeSavedProfile.test.mjs | 15 +++++++++ .../Initialize-BrowserNativeComposition.ps1 | 4 +-- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/BROWSER_EXTENSION_BOOTSTRAP.md b/docs/BROWSER_EXTENSION_BOOTSTRAP.md index 7a22fa58e..92f1641d1 100644 --- a/docs/BROWSER_EXTENSION_BOOTSTRAP.md +++ b/docs/BROWSER_EXTENSION_BOOTSTRAP.md @@ -36,18 +36,24 @@ upgrade behavior require real Windows proof before claiming MSIX parity. ## Ownership and call graph -1. Chrome launches `tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe`. +1. Chrome launches an immutable generation copy of the packaged + `tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe`. `NativeTransport` and `BrowserNativeProtocol` validates the exact origin, strict v1 request schema, canonical 16-32-byte nonce, UTF-8, and four-byte little-endian framing. Requests are capped at 4 KiB. Raw standard streams are binary, not text writers. -2. `BrowserNativeRegistration` verifies the per-user manifest, exact executable - and origin. It inspects both registry views and machine/user locations before - creating the HKCU 32-bit native-host registration, preserving foreign entries. -3. The executable sends one bounded request through the Windows current-user-only - named pipe `OpenClawTray.BrowserBootstrap.v1`. Both pipe endpoints use - `PipeOptions.CurrentUserOnly`. The process does not read saved gateway tokens, - settings credentials, or a copied bearer secret. Same-user arbitrary code is - outside this boundary, as with Chrome's native messaging launch itself. +2. `RegistrationService` delegates generation admission and registry mutation to + `WindowsRegistrationPlatform` and `GenerationStore`. The four immutable + generation files, current SID, ACLs, hashes, exact runtime and origin must + validate. Both registry views and machine/user locations are inspected; + foreign or conflicting registrations are preserved. Installer, Companion and + canonical CLI use this same bounded management owner. +3. For `companion-managed-wsl`, the executable sends one bounded request through + the Windows current-user-only named pipe `OpenClawTray.BrowserBootstrap.v1`. + Both endpoints use `PipeOptions.CurrentUserOnly`. Explicit + `native-windows-cli` bindings instead invoke their admitted Windows Node/CLI; + neither backend falls back to the other. The helper does not read saved + gateway tokens, settings credentials, or a copied bearer secret. Same-user + arbitrary code is outside this boundary, as with Chrome native messaging. 4. `BrowserBootstrapHost` composes `GatewayRegistry`, `IGatewayConnectionManager`, `SettingsManager` and `ManagedLocalGatewayPortProvenanceService`. `BrowserBootstrapService` pins the @@ -62,6 +68,11 @@ upgrade behavior require real Windows proof before claiming MSIX parity. environment against the default installed user profile and refuses custom paths. stdout/stderr are bounded, secret-bearing stdout remains only in memory, and neither is passed through SetupEngine's command-output logger. + A request-owned transient user-systemd unit uses READY/PERMIT/RESULT/SETTLED + framing and stdin revocation. Positive guest settlement plus Windows process + and drain joins are required before ownership release. The request deadline + remains 15 seconds and soft cleanup budget 2 seconds. Lost acknowledgment is + UNKNOWN/BUSY, not permission to retire; Windows exit alone is not settlement. 6. The canonical TypeScript CLI owns relay-token generation and pairing encoding. Windows validates local topology, gateway port, loopback route and gateway hint before returning the nonce-bound native response. @@ -111,7 +122,7 @@ Baseline remains `./build.ps1`, full Shared tests and full Tray tests. Add: dotnet test tests/OpenClaw.Shared.Tests --filter "FullyQualifiedName~BrowserNativeProtocolTests|FullyQualifiedName~BrowserBootstrapPipeTests" dotnet test tests/OpenClaw.Connection.Tests --filter FullyQualifiedName~BrowserBootstrapServiceTests dotnet test tests/OpenClaw.Tray.Tests --filter FullyQualifiedName~BrowserBootstrapIntegrationContractTests -./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserNativeHost.exe +./scripts/Test-BrowserNativeHost.ps1 -ExecutablePath publish/tools/browser-bootstrap/OpenClaw.BrowserBootstrap.exe ``` The executable proof runs in the x64 and ARM64 publish jobs. It refuses existing diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index f0512d4d2..2e9702979 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -1,7 +1,22 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import fs from 'node:fs'; import { savedProfileFailure, savedProfileCliObservation } from './Test-BrowserNativeSavedProfile.mjs'; +test('native proof preflight and both final-consumer workflows use one immutable pin',()=>{ + const source=fs.readFileSync(new URL('./Test-BrowserNativeComposition.mjs',import.meta.url),'utf8'); + const initializer=fs.readFileSync(new URL('./Initialize-BrowserNativeComposition.ps1',import.meta.url),'utf8'); + const native=fs.readFileSync(new URL('../.github/workflows/browser-native-composed-proof.yml',import.meta.url),'utf8'); + const wsl=fs.readFileSync(new URL('../.github/workflows/browser-wsl-owner-proof.yml',import.meta.url),'utf8'); + const pin=source.match(/const consumerSha = '([a-f0-9]{40})'/)[1]; + assert.equal(initializer.match(/consumerSha='([a-f0-9]{40})'/)[1],pin); + for(const workflow of [native,wsl]) { + assert.equal(workflow.match(/ref: ([a-f0-9]{40})/)[1],pin); + assert.ok(workflow.includes(pin)); + } + const version=native.match(/node-version: '([^']+)'/)[1]; + assert.ok(initializer.includes("$result.nodeVersion -cne 'v"+version+"'")); +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 index 3fccaa32b..ed2b67fc7 100644 --- a/scripts/Initialize-BrowserNativeComposition.ps1 +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -1,7 +1,7 @@ param([Parameter(Mandatory)][string]$Consumer, [Parameter(Mandatory)][string]$Receipt) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='57201e3e26968ff686bae61d5de8e4e54308a6b6' } +$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='bb2d479926adb23fb30737e2fd3e5af63969dc84' } try { $result.host = @{ windows=[bool]$IsWindows; githubActions=($env:GITHUB_ACTIONS -ceq 'true'); githubHosted=($env:RUNNER_ENVIRONMENT -ceq 'github-hosted') } if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } @@ -15,7 +15,7 @@ try { $node = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))') if ($LASTEXITCODE -ne 0) { throw 'Node canonicalization failed' } $result.nodeVersion = (& $node --version) - if ($LASTEXITCODE -ne 0 -or $result.nodeVersion -cne 'v24.16.0') { throw 'Installed Node version mismatch' } + if ($LASTEXITCODE -ne 0 -or $result.nodeVersion -cne 'v24.19.0') { throw 'Installed Node version mismatch' } $result.stage = 'canonical-cli' $cli = (& $node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' (Join-Path $Consumer 'openclaw.mjs')) if ($LASTEXITCODE -ne 0) { throw 'CLI canonicalization failed' } From deff95a27e610bed167a556763ad2926a9d363e9 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:47:20 +0000 Subject: [PATCH 64/77] test(browser): freeze recovered native proof fixture Bind the WSL lane guard to a5d16253 after coherent consumer/preflight pins. Preserve the original edd93 native production baseline. Canonical autoreview d5cbe626 (Codex gpt-6-sol/high, P0-P2) completed both full-candidate passes scoped-clean with inherited authentication, proxy and isolation unchanged. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 73da1afef..548a52cda 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -98,7 +98,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code 427802995001430bfcc05eefc2ee158ec493aa74 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code a5d16253d6c60856a86561c934e11d166c536376 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 From 6bf6ffd66851cc978eb978b6b5e0a27ed3f73e68 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:48:18 +0000 Subject: [PATCH 65/77] test(installer): retain migration and browser preservation guards Reconcile the main migration contract with the existing browser-generation preservation branch. Keep the exact successful-cleanup gate and add negative cases for disabling the native-generation guard or removing its early exit. The merged source previously failed the positive assertion; all 11 focused cases now pass in a secretless network-none container. Canonical scoped P0-P2 autoreview is clean. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs index 276db0492..95607dcbf 100644 --- a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -299,6 +299,9 @@ public void Installer_ChecksCompletionBeforeChoiceAndNeverDeletesPreservedState( " LocalGatewayCleanupSucceeded := True;\n end;\n\n if Started and (ResultCode = 0) then")] [InlineData("if Started and (ResultCode = 0) then", "if ResultCode = 0 then")] [InlineData(" if not LocalGatewayCleanupSucceeded then\n Exit;", "")] + [InlineData("if DirExists(ExpandConstant('{localappdata}\\OpenClawTray\\browser-native')) then", "if False then")] + [InlineData("Log('Retained native generations require ownership-aware cleanup; preserving generated app state.');\n Exit;", + "Log('Retained native generations require ownership-aware cleanup; preserving generated app state.');")] public void Installer_PreservationContractsRejectUnsafeMutations(string original, string replacement) { var installer = Read("installer.iss").ReplaceLineEndings("\n"); @@ -356,6 +359,9 @@ private static void AssertPreservationGuards(string installer) Assert.Matches( @"procedure DeleteGeneratedAppState;\s+begin\s+" + @"if not LocalGatewayCleanupSucceeded then\s+Exit;\s+" + + @"(?:\{[^}]*\}\s+)?" + + @"if DirExists\(ExpandConstant\('\{localappdata\}\\OpenClawTray\\browser-native'\)\) then\s+begin\s+" + + @"Log\('[^']*'\);\s+Exit;\s+end;\s+" + @"if DelTree\(ExpandConstant\('\{app\}'\), True, True, True\) then", installer); } From c23905b78c8b45cbf7f4c7f76b367786005a3cff Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:54:24 +0000 Subject: [PATCH 66/77] test(browser): resolve pnpm from the nested consumer checkout Native proof 36367399725 stopped before producer build because pnpm/action-setup looked for package.json at the empty workspace root. Select consumer/package.json through the documented action input and keep its immutable packageManager as the sole version owner. The new regression reproduces the missing-selector failure and all 16 Node checks pass in secretless network-none isolation. No product or acceptance assertion changed. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-native-composed-proof.yml | 1 + scripts/BrowserNativeSavedProfile.test.mjs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index 55c7554b3..5c82bdfc2 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -44,6 +44,7 @@ jobs: - uses: pnpm/action-setup@v4 with: # The pinned consumer packageManager is the only version authority. + package_json_file: consumer/package.json run_install: false - uses: actions/setup-dotnet@v5 with: diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index 2e9702979..bf5f75b2d 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -17,6 +17,12 @@ test('native proof preflight and both final-consumer workflows use one immutable const version=native.match(/node-version: '([^']+)'/)[1]; assert.ok(initializer.includes("$result.nodeVersion -cne 'v"+version+"'")); }); +test('native proof selects packageManager from the nested consumer checkout',()=>{ + const native=fs.readFileSync(new URL('../.github/workflows/browser-native-composed-proof.yml',import.meta.url),'utf8'); + const setup=native.split('- uses: pnpm/action-setup@v4')[1].split('- uses:')[0]; + assert.match(setup,/package_json_file: consumer\/package\.json/); + assert.doesNotMatch(setup,/^\s+version:/m); +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); From 58f0eb8c597e41af40e2974c406354b8050332c9 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:55:40 +0000 Subject: [PATCH 67/77] test(browser): bind nested-checkout fixture correction Advance only the native proof fixture guard to c23905b7 after the packageManager path correction. Canonical scoped P0-P2 autoreview passed with no findings; production baseline and runtime guards remain unchanged. Existing 6bf6ffd6 WSL and CI runs are still being collected before the next publication. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 548a52cda..1d3a3c7cc 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -98,7 +98,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code a5d16253d6c60856a86561c934e11d166c536376 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code c23905b78c8b45cbf7f4c7f76b367786005a3cff HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 From 11827e21270e41bd19ef51692b8dae016186c86b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:38:11 +0000 Subject: [PATCH 68/77] test(browser): use accepted canonical setup discovery waits The saved-profile fixture passed --wait-ms 0, but the canonical CLI rejects values outside 1000-120000 before installation. Reproduce exact CLI error on the immutable bb2 consumer package in a secretless network-none Node24.19 sandbox; 1000 reaches registration-status output. Correct all 15 fixture invocations to the canonical minimum and add a red/green range regression. All 17 Node checks pass. Production 15s/2s bounds and all acceptance/ownership assertions are unchanged; actual Windows acceptance remains required. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- scripts/BrowserNativeSavedProfile.test.mjs | 7 +++++ scripts/Test-BrowserNativeSavedProfile.mjs | 32 ++++++++++++---------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index bf5f75b2d..00a3ecaf5 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -23,6 +23,13 @@ test('native proof selects packageManager from the nested consumer checkout',()= assert.match(setup,/package_json_file: consumer\/package\.json/); assert.doesNotMatch(setup,/^\s+version:/m); }); +test('every saved-profile CLI wait respects the canonical 1000-120000 ms range',()=>{ + const source=fs.readFileSync(new URL('./Test-BrowserNativeSavedProfile.mjs',import.meta.url),'utf8'); + const waits=[...source.matchAll(/'--wait-ms','([0-9]+)'/g)].map(match=>Number(match[1])); + assert.ok(waits.length>0); + assert.equal(waits.length,[...source.matchAll(/'--wait-ms'/g)].length); + assert.ok(waits.every(wait=>wait>=1000&&wait<=120000)); +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index 7c8e160ab..e5573bae9 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -53,9 +53,11 @@ export async function savedProfileAcceptance(h) { let installed=false,primaryFailed=false; let stage='seed'; try { + // The real CLI accepts 1000-120000 ms. Zero rejects before any registration work. + // Keep its minimum discovery wait without changing production request/cleanup deadlines. // The seed uses the canonical CLI, so normal origin/runtime selection is preserved. installed=true; - const seed=await cli(['install','--browser-profile','work','--no-store','--wait-ms','0']); + const seed=await cli(['install','--browser-profile','work','--no-store','--wait-ms','1000']); receipt.savedProfileSeed=savedProfileCliObservation(seed); assert.equal(receipt.savedProfileSeed.ownedWorkProfile,true); let active=await descriptor();assert.equal(active.inspected.store,'missing'); @@ -68,7 +70,7 @@ export async function savedProfileAcceptance(h) { for(const action of ['inspect','verify','install']) { stage='selector_free_'+action; const before=await descriptor(),dirs=new Set(await fs.readdir(before.root)); - const result=await cli(['setup','--action',action,'--wait-ms','0']); + const result=await cli(['setup','--action',action,'--wait-ms','1000']); assert.equal(result.code,0);assert.equal(result.body.target.profile,'work');assert.equal(result.body.target.relayPort,19444); assert.equal(result.body.installation.nativeHostRegistered,true);assert.equal(result.body.installation.installRequested,false); active=await descriptor();assert.equal(active.binding.nativeWindows.browserProfile,'work');assert.equal(active.inspected.store,'missing'); @@ -85,14 +87,14 @@ export async function savedProfileAcceptance(h) { assert.equal(JSON.stringify((await fs.readdir(before.root)).sort()),dirs,name); assert.equal((await bootstrap(after)).pairingString,initialPair.pairingString,name); } - await unchangedFailure('explicit_other_profile',['setup','--action','install','--browser-profile','chrome','--wait-ms','0']); + await unchangedFailure('explicit_other_profile',['setup','--action','install','--browser-profile','chrome','--wait-ms','1000']); const otherState=await fs.mkdtemp(path.join(fixture,'other-work-state-')); - await unchangedFailure('different_state',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_STATE_DIR:otherState}}); + await unchangedFailure('different_state',['setup','--action','install','--wait-ms','1000'],{env:{OPENCLAW_STATE_DIR:otherState}}); const otherConfig=path.join(stateDir,'other-config.json');await fs.writeFile(otherConfig,configBytes); - await unchangedFailure('different_config',['setup','--action','install','--wait-ms','0'],{env:{OPENCLAW_CONFIG_PATH:otherConfig}}); + await unchangedFailure('different_config',['setup','--action','install','--wait-ms','1000'],{env:{OPENCLAW_CONFIG_PATH:otherConfig}}); const alternateRuntimeDirectory=await fs.mkdtemp(path.join(fixture,'alternate-runtime-')); const otherNode=path.join(alternateRuntimeDirectory,'node.exe');await fs.copyFile(current.nodePath,otherNode,fs.constants.COPYFILE_EXCL); - await unchangedFailure('runtime_drift',['setup','--action','install','--wait-ms','0'],{node:otherNode}); + await unchangedFailure('runtime_drift',['setup','--action','install','--wait-ms','1000'],{node:otherNode}); stage='wrong_mode'; active=await descriptor();const wrongMode=await manage({...active.request,action:'install',mode:'companion-managed-wsl',context:null,expectedOrigins:[origin]}); assert.equal(wrongMode.ok,false);assert.equal((await descriptor()).inspected.installation.generation,active.inspected.installation.generation); @@ -101,7 +103,7 @@ export async function savedProfileAcceptance(h) { const before=await descriptor(),dirs=JSON.stringify((await fs.readdir(before.root)).sort()); try { await fs.writeFile(configPath,JSON.stringify({...config,browser:{...config.browser,profiles:{chrome:config.browser.profiles.chrome}}})); - const noSaved=await cli(['setup','--action','install','--wait-ms','0']);assert.notEqual(noSaved.code,0); + const noSaved=await cli(['setup','--action','install','--wait-ms','1000']);assert.notEqual(noSaved.code,0); assert.equal(JSON.stringify((await fs.readdir(before.root)).sort()),dirs); } finally { await fs.writeFile(configPath,configBytes); } assert.equal((await descriptor()).inspected.installation.generation,before.inspected.installation.generation); @@ -114,7 +116,7 @@ export async function savedProfileAcceptance(h) { const generationsBefore=JSON.stringify((await fs.readdir(active.root)).sort()); try { await fs.writeFile(bindingPath,Buffer.concat([bindingBytes,Buffer.from(' ')])); - const refused=await cli(['setup','--action','install','--wait-ms','0']); + const refused=await cli(['setup','--action','install','--wait-ms','1000']); assert.ok(refused.code!==0||refused.body?.phase==='blocked'); assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); } finally { await fs.writeFile(bindingPath,bindingBytes); } @@ -125,7 +127,7 @@ export async function savedProfileAcceptance(h) { if(!h.withFrozenNative)throw Error('Actual native ownership fixture required for unknown/cancel proof'); active=await descriptor(); await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ - const blocked=await cli(['setup','--action','install','--wait-ms','0']); + const blocked=await cli(['setup','--action','install','--wait-ms','1000']); assert.ok(blocked.code!==0||blocked.body?.phase==='blocked'); assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); await control.resume();assert.equal(response(await control.work).ok,true); @@ -147,14 +149,14 @@ export async function savedProfileAcceptance(h) { const currentManifest=active.inspected.installation.manifestPath; try { replaceChromium(currentManifest,retained); - const mixed=await cli(['setup','--action','install','--wait-ms','0']);assert.ok(mixed.code!==0||mixed.body?.phase==='blocked'); + const mixed=await cli(['setup','--action','install','--wait-ms','1000']);assert.ok(mixed.code!==0||mixed.body?.phase==='blocked'); assert.equal(JSON.stringify((await fs.readdir(active.root)).sort()),generationsBefore); } finally { replaceChromium(retained,currentManifest); } assert.equal((await descriptor()).inspected.installation.generation,generationBefore); check('mixed_registered_generations_fail_closed_without_automatic_mutation'); stage='cancelled_setup'; await h.withFrozenNative(active.inspected.installation,frame({v:1,op:'bootstrap',nonce}),fixture,async control=>{ - const cancelled=await cli(['setup','--action','install','--wait-ms','0'],{onSpawn:async child=>{ + const cancelled=await cli(['setup','--action','install','--wait-ms','1000'],{onSpawn:async child=>{ const payload=Buffer.from(JSON.stringify({pid:child.pid,node:current.nodePath,helper:executable})).toString('base64'); powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+payload+"'))|ConvertFrom-Json;$parent=[Diagnostics.Process]::GetProcessById([int]$p.pid);$null=$parent.Handle;try{if($parent.MainModule.FileName-cne $p.node){throw 'CLI identity'};$end=[DateTime]::UtcNow.AddSeconds(12);$owned=$null;while($null-eq $owned){if($parent.HasExited-or[DateTime]::UtcNow-gt$end){throw 'management child not observed'};foreach($c in @(Get-CimInstance Win32_Process -Filter ('ParentProcessId='+$parent.Id))){if($c.ExecutablePath-ceq$p.helper){$owned=[Diagnostics.Process]::GetProcessById([int]$c.ProcessId);$null=$owned.Handle;if($owned.StartTime.ToUniversalTime()-lt$parent.StartTime.ToUniversalTime()-or$owned.MainModule.FileName-cne$p.helper){throw 'child identity'};break}};if($null-eq$owned){Start-Sleep -Milliseconds 10}};try{& (Join-Path $env:SystemRoot 'System32/taskkill.exe') /PID $parent.Id /T /F >$null;if(!$parent.WaitForExit(5000)-or!$owned.WaitForExit(5000)){throw 'owned tree not joined'}}finally{$owned.Dispose()}}finally{$parent.Dispose()}"); }}); @@ -165,9 +167,9 @@ export async function savedProfileAcceptance(h) { check('cancelled_selector_free_setup_joins_owned_management_child_without_mutation'); stage='store_preservation'; // Explicit Store request is separate from the opt-out case; repair must preserve it. - await cli(['install','--browser-profile','work','--wait-ms','0']); + await cli(['install','--browser-profile','work','--wait-ms','1000']); assert.equal((await descriptor()).inspected.store,'requested'); - const preserve=await cli(['setup','--action','install','--wait-ms','0']); + const preserve=await cli(['setup','--action','install','--wait-ms','1000']); assert.equal(preserve.body.target.profile,'work');assert.equal(preserve.body.installation.installRequested,true); assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); check('selector_free_repair_preserves_explicit_store_request'); @@ -175,9 +177,9 @@ export async function savedProfileAcceptance(h) { const retireWork=await cli(['uninstall-host','--browser-profile','work','--remove-store']); assert.equal(retireWork.code,0);assert.ok(Array.isArray(retireWork.body?.refused));assert.equal(retireWork.body.refused.length,0); current={...current,browserProfile:'chrome'}; - await cli(['install','--browser-profile','chrome','--no-store','--wait-ms','0']); + await cli(['install','--browser-profile','chrome','--no-store','--wait-ms','1000']); const currentChrome=await descriptor();assert.equal(currentChrome.binding.nativeWindows.browserProfile,'chrome'); - const select=await cli(['setup','--action','inspect','--wait-ms','0']); + const select=await cli(['setup','--action','inspect','--wait-ms','1000']); assert.equal(select.body.target.profile,'chrome'); assert.equal((await descriptor()).inspected.installation.generation,currentChrome.inspected.installation.generation); check('retained_work_generations_never_override_current_chrome_selection'); From a14dc452fdbaf17d61006f246aa496e641bfa935 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:40:23 +0000 Subject: [PATCH 69/77] test(browser): freeze valid saved-profile CLI acceptance inputs Advance the native-fixture guard to 11827e21 after replacing all invalid zero discovery waits with the canonical 1000ms minimum. Canonical scoped P0-P2 review passed; the exact CLI red/green reproduction and 17 Node tests passed. Preserve edd93 production baseline and every native/WSL ownership, settlement, identity and acceptance guard. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 1d3a3c7cc..7cc70657f 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -98,7 +98,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code c23905b78c8b45cbf7f4c7f76b367786005a3cff HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code 11827e21270e41bd19ef51692b8dae016186c86b HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 From bef49fdccbd411a27f5563dd116c994fe2ce697b Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:24:17 +0000 Subject: [PATCH 70/77] test(browser): respect generation reuse and diagnose setup admission The a14 native seed and saved-profile selection now pass, but the fixture incorrectly demanded a new generation for idempotent install. Require unchanged generation/directory state and seed retained history through explicit same-owner origin transitions, restoring canonical CLI origins before pairing. Preserve all 23 acceptance cases and immutable generation rules. The a14 WSL run failed before any owner case; add bounded closed-schema fixture-phase/setup JSONL diagnostics without exposing logs, paths, argv or credentials. Node18 and CSharp22 focused tests pass in secretless network-none isolation. No product, ABI, ownership or deadline change; actual Windows proof and CI remain gates. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 3 +- scripts/BrowserNativeSavedProfile.test.mjs | 7 +- scripts/Test-BrowserNativeSavedProfile.mjs | 36 ++++- .../BrowserWslProductionOwnerTests.cs | 25 +++- .../Prototype/BrowserWslSetupDiagnostics.cs | 113 ++++++++++++++++ .../BrowserWslSetupDiagnosticsTests.cs | 128 ++++++++++++++++++ 6 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs create mode 100644 tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 7cc70657f..e5ed26722 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -10,6 +10,7 @@ on: - tests/OpenClaw.Connection.Tests/BrowserBootstrap* - tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs - tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs + - tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics*.cs permissions: contents: read @@ -121,7 +122,7 @@ jobs: - name: Verify the actual stdin newline normalization shell: pwsh run: | - dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter 'FullyQualifiedName~BrowserPrototypeTransportTests|FullyQualifiedName~BrowserWslLookupDiagnosticsTests' + dotnet test tests/OpenClaw.E2ETests --no-build -c Debug -r win-x64 --filter 'FullyQualifiedName~BrowserPrototypeTransportTests|FullyQualifiedName~BrowserWslLookupDiagnosticsTests|FullyQualifiedName~BrowserWslSetupDiagnosticsTests' if ($LASTEXITCODE -ne 0) { throw 'Prototype stdin transport regression failed.' } - name: Exercise actual WSL owner and retirement retention in disposable WSL shell: pwsh diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index 00a3ecaf5..7d5e5de7f 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import fs from 'node:fs'; -import { savedProfileFailure, savedProfileCliObservation } from './Test-BrowserNativeSavedProfile.mjs'; +import { savedProfileFailure, savedProfileCliObservation, assertSavedProfileGenerationUnchanged } from './Test-BrowserNativeSavedProfile.mjs'; test('native proof preflight and both final-consumer workflows use one immutable pin',()=>{ const source=fs.readFileSync(new URL('./Test-BrowserNativeComposition.mjs',import.meta.url),'utf8'); @@ -30,6 +30,11 @@ test('every saved-profile CLI wait respects the canonical 1000-120000 ms range', assert.equal(waits.length,[...source.matchAll(/'--wait-ms'/g)].length); assert.ok(waits.every(wait=>wait>=1000&&wait<=120000)); }); +test('idempotent setup keeps the admitted generation and creates no directory',()=>{ + assert.doesNotThrow(()=>assertSavedProfileGenerationUnchanged('generation-a','generation-a',[])); + assert.throws(()=>assertSavedProfileGenerationUnchanged('generation-a','generation-b',[])); + assert.throws(()=>assertSavedProfileGenerationUnchanged('generation-a','generation-a',['new-generation'])); +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index e5573bae9..812076f30 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -22,6 +22,11 @@ export function savedProfileCliObservation(result) { installedCopyProjection:body!==null&&typeof body==='object'&&Object.hasOwn(body,'installedCopy'), manualSetupRequired:body?.manualSetupRequired===true}; } +// Matching binding and producer bytes are reused by the C# generation owner. +export function assertSavedProfileGenerationUnchanged(before,after,added) { + assert.equal(added.length,0); + assert.equal(after,before); +} export async function savedProfileAcceptance(h) { const {fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt}=h; assert.equal(process.platform,'win32');assert.equal(process.env.RUNNER_ENVIRONMENT,'github-hosted'); @@ -61,6 +66,24 @@ export async function savedProfileAcceptance(h) { receipt.savedProfileSeed=savedProfileCliObservation(seed); assert.equal(receipt.savedProfileSeed.ownedWorkProfile,true); let active=await descriptor();assert.equal(active.inspected.store,'missing'); + // Seed retained history through the sole owner, not by requiring a no-op install to rotate. + // Restore normal canonical origins before any pairing or selector-free acceptance checks. + const retainedWorkManifest=active.inspected.installation.manifestPath; + const retainedWorkGeneration=active.inspected.installation.generation; + const canonicalOrigins=[...active.binding.expectedOrigins]; + assert.ok(canonicalOrigins.length>1&&canonicalOrigins.includes(origin)); + stage='history_narrow'; + const narrowed=await manage({...active.request,action:'install',expectedOrigins:[origin]}); + receipt.savedProfileHistory={narrowed:narrowed.ok===true,canonicalRestored:false}; + assert.equal(narrowed.ok,true);assert.notEqual(narrowed.installation.generation,retainedWorkGeneration); + stage='history_restore'; + const restored=await cli(['install','--browser-profile','work','--no-store','--wait-ms','1000']); + assert.equal(savedProfileCliObservation(restored).ownedWorkProfile,true); + active=await descriptor();assert.equal(active.inspected.store,'missing'); + assert.notEqual(active.inspected.installation.generation,retainedWorkGeneration); + assert.notEqual(active.inspected.installation.generation,narrowed.installation.generation); + assert.deepEqual(active.binding.expectedOrigins,canonicalOrigins); + receipt.savedProfileHistory.canonicalRestored=true; stage='initial_pairing'; const initialPair=await bootstrap(active);assert.equal(initialPair.ok,true); const pairingUrl=new URL(initialPair.pairingString); @@ -75,7 +98,7 @@ export async function savedProfileAcceptance(h) { assert.equal(result.body.installation.nativeHostRegistered,true);assert.equal(result.body.installation.installRequested,false); active=await descriptor();assert.equal(active.binding.nativeWindows.browserProfile,'work');assert.equal(active.inspected.store,'missing'); const added=(await fs.readdir(before.root)).filter(x=>!dirs.has(x)); - assert.equal(added.length,action==='install'?1:0); + assertSavedProfileGenerationUnchanged(before.inspected.installation.generation,active.inspected.installation.generation,added); assert.equal((await bootstrap(active)).pairingString,initialPair.pairingString); } check('canonical_cli_saved_work_profile_recovered_without_pairing_or_optout_changes'); @@ -136,12 +159,11 @@ export async function savedProfileAcceptance(h) { check('unknown_busy_inventory_cannot_select_profile_or_mutate'); active=await descriptor(); stage='mixed_generations'; - let retained; - for(const name of await fs.readdir(active.root)) { - const file=path.join(active.root,name,'OpenClaw.BrowserBootstrap.binding.json'); - try { const b=JSON.parse(await fs.readFile(file,'utf8'));if(b.nativeWindows?.stateDir.toLowerCase()===stateDir.toLowerCase()&&b.manifestPath!==active.inspected.installation.manifestPath){retained=b.manifestPath;break;} } catch {} - } - assert.ok(retained,'retained owned generation fixture required'); + const retained=retainedWorkManifest; + assert.notEqual(retained,active.inspected.installation.manifestPath); + const retainedBinding=JSON.parse(await fs.readFile(path.join(path.dirname(retained),'OpenClaw.BrowserBootstrap.binding.json'),'utf8')); + assert.deepEqual(retainedBinding.nativeWindows,active.binding.nativeWindows); + assert.deepEqual(retainedBinding.expectedOrigins,active.binding.expectedOrigins); function replaceChromium(expected,next) { const payload=Buffer.from(JSON.stringify({expected,next})).toString('base64'); powershell("$ErrorActionPreference='Stop';$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+payload+"'))|ConvertFrom-Json;$k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software/Chromium/NativeMessagingHosts/ai.openclaw.browser_bootstrap'.Replace('/',[IO.Path]::DirectorySeparatorChar),$true);try{if([string]$k.GetValue('')-cne $p.expected){throw 'fixture ownership changed'};$k.SetValue('',$p.next,[Microsoft.Win32.RegistryValueKind]::String)}finally{$k.Dispose()}"); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs index 59d3491b9..dcb714f01 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -27,24 +27,37 @@ public async Task ActualOwner_DoesNotRetireWithoutGuestAcknowledgment() var receipt=Environment.GetEnvironmentVariable("OPENCLAW_WSL_OWNER_RECEIPT")!; var fixture=new E2ESetupFixture();var stdout=Console.Out;var stderr=Console.Error; bool installed=false,restored=false,disposed=false,gatewayUnchanged=false; + string fixtureStage="initialize"; + object? fixtureFailure=null; + BrowserWslSetupFailure? setupFailure=null; Console.SetOut(TextWriter.Null);Console.SetError(TextWriter.Null); try { - await fixture.InitializeAsync();Assert.Null(fixture.SetupError);await fixture.StopTrayAsync(); + await fixture.InitializeAsync();Assert.Null(fixture.SetupError); + fixtureStage="stop_tray";await fixture.StopTrayAsync(); + fixtureStage="gateway_identity"; _gatewayPid=long.Parse((await Guest(fixture,"systemctl --user show openclaw-gateway.service -p MainPID --value")).Trim()); Assert.True(_gatewayPid>1); _root="/home/openclaw/.openclaw/owner-proof-"+Guid.NewGuid().ToString("N"); - await EarlyEof(fixture); - await RunCase(fixture,"real_cli"); - installed=true; + fixtureStage="early_stdin_eof";await EarlyEof(fixture); + fixtureStage="real_cli";await RunCase(fixture,"real_cli"); + fixtureStage="install_synthetic_cli";installed=true; await Guest(fixture,$"umask 077; mkdir '{_root}'; if test -e '{Cli}' || test -L '{Cli}'; then mv '{Cli}' '{_root}/original'; fi"); + fixtureStage="synthetic_cases"; foreach(var name in new[]{"normal_setsid","caller_cancel","deadline","forced_client_exit"}) { try{await RunCase(fixture,name);}catch(Exception e){_errors.Add(name+":"+e.GetType().Name);} } + fixtureStage="gateway_postcheck"; gatewayUnchanged=(await Guest(fixture,"systemctl --user show openclaw-gateway.service -p MainPID --value")).Trim()==_gatewayPid.ToString(); } - catch(Exception e){_errors.Add("fixture:"+e.GetType().Name);} + catch(Exception e) + { + _errors.Add("fixture:"+e.GetType().Name); + fixtureFailure=new{stage=fixtureStage,setupReportedError=fixture.SetupError is not null}; + if(fixtureStage=="initialize") + setupFailure=BrowserWslSetupDiagnostics.Read(Path.Combine(fixture.ArtifactDir,"setup-engine.jsonl")); + } finally { if(installed)try @@ -64,7 +77,7 @@ await File.WriteAllTextAsync(receipt,JsonSerializer.Serialize(new productionAssemblySha256=Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(assembly.Location))).ToLowerInvariant(), embeddedBrokerSha256=Convert.ToHexString(hash.ComputeHash(broker)).ToLowerInvariant(), scope="actual BrowserBootstrapWslCommand plus unchanged source-linked registration owners; synthetic native inventory/real mutex, not shipping Chrome helper or registry proof", - cases=_cases,errors=_errors,gatewayUnchanged,originalCliRestored=restored,ownedFixtureDisposed=disposed, + cases=_cases,errors=_errors,fixtureFailure,setupFailure,gatewayUnchanged,originalCliRestored=restored,ownedFixtureDisposed=disposed, status=_errors.Count==0&&_cases.Count==6&&restored&&disposed&&gatewayUnchanged?"production_owner_proof_passed":"production_owner_proof_failed" },new JsonSerializerOptions{WriteIndented=true})); } diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs new file mode 100644 index 000000000..54d5224d6 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs @@ -0,0 +1,113 @@ +using System.Text; +using System.Text.Json; + +namespace OpenClaw.E2ETests.Setup; + +internal sealed record BrowserWslSetupFailure(string ReadState, bool Truncated, int Records, + string? FailedStep, string? LastStep, string? LastOutcome, int? CommandExit, + bool? CommandTimedOut, double? CommandElapsedMs, string[] ObservedCodes); + +// Proof-only projection of the fixture's own JSONL. Never return messages, paths, argv or streams. +internal static class BrowserWslSetupDiagnostics +{ + internal const int MaxBytes = 262144; + private const int MaxRecords = 1024; + private static readonly HashSet Steps = new(StringComparer.Ordinal) + { + "validate-distro-path", "preflight-os", "preflight-wsl", "preflight-port", + "ensure-wsl-platform", "cleanup-distro", "cleanup-gateway", "wsl-create", + "wsl-configure", "validate-wsl-lockdown", "install-cli", "configure-gateway", + "install-service", "start-gateway", "restart-gateway", "mint-token", + "pair-operator", "pair-node", "verify-e2e", "run-wizard", + "windows-node-context", "start-keepalive" + }; + private static readonly (string Needle, string Code)[] Codes = + [ + ("StateDatabaseCoordinatorContentionError", "state_coordinator_contention"), + ("GatewayRestartPreparationError", "gateway_restart_preparation"), + ("Cannot record restart intent for the serving Gateway", "restart_intent_refused"), + ("Cannot verify a live serving Gateway owner", "serving_owner_unverified"), + ("ECONNREFUSED", "connection_refused"), + ("ETIMEDOUT", "connection_timeout") + ]; + private sealed record Command(string? Step, int Exit, bool? TimedOut, double? ElapsedMs, string[] Codes); + + internal static BrowserWslSetupFailure Read(string file) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var length = stream.Length; + var count = (int)Math.Min(length, MaxBytes); + var truncated = length > count; + stream.Position = length - count; + var bytes = new byte[count]; + stream.ReadExactly(bytes); + // Starting at an arbitrary byte can split UTF-8 or a JSON record. Drop that first fragment. + var offset = truncated ? Array.IndexOf(bytes, (byte)'\n') + 1 : 0; + if (truncated && offset == 0) return Empty("partial", true); + return Project(new UTF8Encoding(false, true).GetString(bytes, offset, count - offset), truncated); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException) + { + return Empty("unavailable", false); + } + } + + internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = false) + { + if (Encoding.UTF8.GetByteCount(jsonl) > MaxBytes) return Empty("bounded", true); + string? step = null, outcome = null, failedStep = null, failedRawStep = null; + Command? lastCommand = null, failedCommand = null; + var codes = new HashSet(StringComparer.Ordinal); + int records = 0; bool partial = false; + using var reader = new StringReader(jsonl); + while (reader.ReadLine() is { } line) + { + if (++records > MaxRecords) { records = MaxRecords; truncated = true; partial = true; break; } + try + { + using var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) continue; + var message = String(root, "msg"); + var rawStep = String(data, "step_id"); + if (rawStep is not null && (message?.StartsWith("step.started:", StringComparison.Ordinal) == true || message?.StartsWith("step.completed:", StringComparison.Ordinal) == true)) + { + step = Steps.Contains(rawStep) ? rawStep : "other"; + var value = String(data, "outcome"); + outcome = value is "Success" or "Failed" or "Skipped" or "Cancelled" ? value : value is null ? null : "other"; + if (value == "Failed" && failedStep is null) + { + failedStep = step; failedRawStep = rawStep; + failedCommand = lastCommand?.Step == rawStep ? lastCommand : null; + if (failedCommand is not null) codes.UnionWith(failedCommand.Codes); + codes.UnionWith(Classify(data)); + } + } + // Freeze the command preceding the primary failed step, not later rollback commands. + if (failedStep is null && message?.StartsWith("cmd.done:", StringComparison.Ordinal) == true && data.TryGetProperty("exit_code", out var exit) && exit.ValueKind == JsonValueKind.Number && exit.TryGetInt32(out var exitCode)) + { + bool? timedOut = data.TryGetProperty("timed_out", out var timed) && timed.ValueKind is JsonValueKind.True or JsonValueKind.False ? timed.GetBoolean() : null; + double? elapsed = data.TryGetProperty("elapsed_ms", out var ms) && ms.ValueKind == JsonValueKind.Number && ms.TryGetDouble(out var number) && double.IsFinite(number) && number is >= 0 and <= 3600000 ? number : null; + // Only closed step names are retained, including for unknown producer IDs. + lastCommand = new(step, exitCode, timedOut, elapsed, exitCode == 0 ? [] : Classify(data)); + } + if (failedRawStep is not null && rawStep == failedRawStep && message?.StartsWith("step.exception:", StringComparison.Ordinal) == true) codes.UnionWith(Classify(data)); + } + catch (JsonException) { partial = true; } + } + return new(partial ? "partial" : records == 0 ? "empty" : "observed", truncated, records, + failedStep, step, outcome, failedCommand?.Exit, failedCommand?.TimedOut, + failedCommand?.ElapsedMs, codes.Order(StringComparer.Ordinal).ToArray()); + } + + private static string? String(JsonElement value, string name) => + value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; + private static string[] Classify(JsonElement data) => Codes.Where(pair => + new[] { "message", "exception", "exception_type", "stdout", "stderr" } + .Any(field => String(data, field)?.Contains(pair.Needle, StringComparison.Ordinal) == true)) + .Select(pair => pair.Code).ToArray(); + private static BrowserWslSetupFailure Empty(string state, bool truncated) => + new(state, truncated, 0, null, null, null, null, null, null, []); +} diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs new file mode 100644 index 000000000..cd74db142 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs @@ -0,0 +1,128 @@ +using System.Text.Json; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class BrowserWslSetupDiagnosticsTests +{ + private static string Entry(string message, object data) => JsonSerializer.Serialize(new { msg = message, data }) + "\n"; + + [Fact] + public void FailedStep_ProjectsOnlyClosedStageCodesAndNumbers() + { + var log = Entry("step.started: private title", new { step_id = "run-wizard" }) + + Entry("cmd.done: private command", new { exit_code = 1, timed_out = false, elapsed_ms = 5010.5, stderr = "StateDatabaseCoordinatorContentionError private-value" }) + + Entry("step.completed: private title", new { step_id = "run-wizard", outcome = "Failed", message = "GatewayRestartPreparationError private-value" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal("observed", value.ReadState); + Assert.Equal("run-wizard", value.FailedStep); + Assert.Equal(1, value.CommandExit); + Assert.False(value.CommandTimedOut); + Assert.Equal(5010.5, value.CommandElapsedMs); + Assert.Equal(new[] { "gateway_restart_preparation", "state_coordinator_contention" }, value.ObservedCodes); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + + [Fact] + public void RollbackCommand_CannotReplaceThePrimaryFailure() + { + var log = Entry("step.started: wizard", new { step_id = "run-wizard" }) + + Entry("cmd.done: original", new { exit_code = 1, timed_out = false, elapsed_ms = 5000 }) + + Entry("step.completed: wizard", new { step_id = "run-wizard", outcome = "Failed" }) + + Entry("cmd.done: rollback", new { exit_code = 42, timed_out = true, elapsed_ms = 10000, stderr = "ECONNREFUSED private-value" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal(1, value.CommandExit); + Assert.False(value.CommandTimedOut); + Assert.Equal(5000, value.CommandElapsedMs); + Assert.Empty(value.ObservedCodes); + } + + [Fact] + public void FailedException_AddsOnlyAllowlistedCategories() + { + var log = Entry("step.completed: wizard", new { step_id = "run-wizard", outcome = "Failed" }) + + Entry("step.exception: private", new { step_id = "run-wizard", exception = "Cannot record restart intent for the serving Gateway. private-value" }); + Assert.Equal(new[] { "restart_intent_refused" }, BrowserWslSetupDiagnostics.Project(log).ObservedCodes); + } + + [Fact] + public void UnknownProducerValues_AreNotExported() + { + var log = Entry("step.started: private title", new { step_id = "private-step" }) + + Entry("step.completed: private title", new { step_id = "private-step", outcome = "Failed", message = "private-value" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal("other", value.FailedStep); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + + [Fact] + public void AHandledEarlierCommand_IsNotAttributedToAnotherFailedStep() + { + var log = Entry("step.started: install", new { step_id = "install-cli" }) + + Entry("cmd.done: handled", new { exit_code = 1, stderr = "ECONNREFUSED private-value" }) + + Entry("step.completed: install", new { step_id = "install-cli", outcome = "Success" }) + + Entry("step.started: wizard", new { step_id = "run-wizard" }) + + Entry("step.completed: wizard", new { step_id = "run-wizard", outcome = "Failed" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Null(value.CommandExit); + Assert.Empty(value.ObservedCodes); + } + + [Fact] + public void ARecoveredCommand_DoesNotBecomeTheReportedFailingCommand() + { + var log = Entry("step.started: wizard", new { step_id = "run-wizard" }) + + Entry("cmd.done: handled", new { exit_code = 1, stderr = "ECONNREFUSED private-value" }) + + Entry("cmd.done: recovered", new { exit_code = 0 }) + + Entry("step.completed: wizard", new { step_id = "run-wizard", outcome = "Failed" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal(0, value.CommandExit); + Assert.Empty(value.ObservedCodes); + } + + [Fact] + public void MalformedAndWronglyTypedRecords_DoNotLeakOrThrow() + { + var value = BrowserWslSetupDiagnostics.Project("private malformed\n" + + Entry("cmd.done: private", new { exit_code = "private", timed_out = "private", elapsed_ms = "private" })); + Assert.Equal("partial", value.ReadState); + Assert.Null(value.CommandExit); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + + [Fact] + public void InputAndRecordLimits_AreExplicit() + { + var tooLarge = BrowserWslSetupDiagnostics.Project(new string('x', BrowserWslSetupDiagnostics.MaxBytes + 1)); + Assert.Equal("bounded", tooLarge.ReadState); + Assert.True(tooLarge.Truncated); + var many = BrowserWslSetupDiagnostics.Project(string.Concat(Enumerable.Repeat("{}\n", 1025))); + Assert.Equal("partial", many.ReadState); + Assert.True(many.Truncated); + Assert.Equal(1024, many.Records); + } + + [Fact] + public void TailRead_SkipsThePartialPrefixAndRetainsClosedFailureFacts() + { + var file = Path.GetTempFileName(); + try + { + File.WriteAllText(file, new string('x', BrowserWslSetupDiagnostics.MaxBytes + 8) + "\n" + + Entry("step.completed: private", new { step_id = "run-wizard", outcome = "Failed", message = "private-value" })); + var value = BrowserWslSetupDiagnostics.Read(file); + Assert.True(value.Truncated); + Assert.Equal("run-wizard", value.FailedStep); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + finally { File.Delete(file); } + } + + [Fact] + public void MissingFile_IsUnknownNotASetupSuccess() + { + var value = BrowserWslSetupDiagnostics.Read(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + Assert.Equal("unavailable", value.ReadState); + Assert.Null(value.FailedStep); + Assert.Null(value.CommandExit); + } +} From b926e7ab6a13157cc664db08332ccd2d410b40d4 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:32:25 +0000 Subject: [PATCH 71/77] test(browser): retain latest setup failure records within bounds Accept the scoped review finding that a record cap must retain the newest records, not discard the final failure. The new regression reproduces the prior loss and all 23 CSharp diagnostic/transport tests pass in secretless isolation. Clarify that native generation reuse sees only currently registered manifests, not retained directories; the proposed restore-reuse finding does not apply to this owner. No production or acceptance guard changes. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- scripts/Test-BrowserNativeSavedProfile.mjs | 2 ++ .../Prototype/BrowserWslSetupDiagnostics.cs | 14 +++++++++++--- .../Prototype/BrowserWslSetupDiagnosticsTests.cs | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index e5ed26722..5282fe243 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -99,7 +99,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code 11827e21270e41bd19ef51692b8dae016186c86b HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code bef49fdccbd411a27f5563dd116c994fe2ce697b HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index 812076f30..205ba4fdf 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -76,6 +76,8 @@ export async function savedProfileAcceptance(h) { const narrowed=await manage({...active.request,action:'install',expectedOrigins:[origin]}); receipt.savedProfileHistory={narrowed:narrowed.ok===true,canonicalRestored:false}; assert.equal(narrowed.ok,true);assert.notEqual(narrowed.installation.generation,retainedWorkGeneration); + // Prepare considers only registered manifests, not retained directories. + // Successful narrow publication leaves only that generation in the reuse inventory. stage='history_restore'; const restored=await cli(['install','--browser-profile','work','--no-store','--wait-ms','1000']); assert.equal(savedProfileCliObservation(restored).ownedWorkProfile,true); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs index 54d5224d6..c173b502f 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs @@ -61,10 +61,18 @@ internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = fa Command? lastCommand = null, failedCommand = null; var codes = new HashSet(StringComparer.Ordinal); int records = 0; bool partial = false; - using var reader = new StringReader(jsonl); - while (reader.ReadLine() is { } line) + var recent = new Queue(MaxRecords); + using (var reader = new StringReader(jsonl)) { - if (++records > MaxRecords) { records = MaxRecords; truncated = true; partial = true; break; } + while (reader.ReadLine() is { } line) + { + if (recent.Count == MaxRecords) { recent.Dequeue(); truncated = true; partial = true; } + recent.Enqueue(line); + } + } + foreach (var line in recent) + { + records++; try { using var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs index cd74db142..690a3fb7b 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs @@ -101,6 +101,20 @@ public void InputAndRecordLimits_AreExplicit() Assert.Equal(1024, many.Records); } + [Fact] + public void RecordLimit_RetainsTheLatestFailure() + { + var log = string.Concat(Enumerable.Repeat("{}\n", 1025)) + + Entry("step.started: wizard", new { step_id = "run-wizard" }) + + Entry("cmd.done: failed", new { exit_code = 1 }) + + Entry("step.completed: wizard", new { step_id = "run-wizard", outcome = "Failed" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.True(value.Truncated); + Assert.Equal(1024, value.Records); + Assert.Equal("run-wizard", value.FailedStep); + Assert.Equal(1, value.CommandExit); + } + [Fact] public void TailRead_SkipsThePartialPrefixAndRetainsClosedFailureFacts() { From 92afaa430ee2bb5fb16e455ee8c9e41f246565f1 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:34:29 +0000 Subject: [PATCH 72/77] test(browser): freeze idempotent saved-profile proof inputs Bind the native fixture guard to b926e7ab after the idempotent-generation history correction and bounded newest-record diagnostics. Canonical corrected P0-P2 autoreview completed scoped-clean; Node18 and CSharp23 focused checks pass. Original production source and ownership/deadline/ABI guards remain unchanged. All required exact-head Windows proofs and CI are still required before landing. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 5282fe243..36a7f06a0 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -99,7 +99,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code bef49fdccbd411a27f5563dd116c994fe2ce697b HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code b926e7ab6a13157cc664db08332ccd2d410b40d4 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 From 37256b7e0cc1c99faabb23ec81bc13c6911e64a8 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:57:50 +0000 Subject: [PATCH 73/77] test: retain setup and migration failure boundaries Recover only allowlisted setup completion stages when the logger redacts step identifiers. Correlate commands and exceptions by step-start boundaries and cover the real logger path, unknown text, and cross-step isolation. Capture pre-cleanup process and drain completion in migration timeout diagnostics without changing deadlines, cleanup or preservation assertions. These are diagnostic corrections, not a waiver or claimed runtime repair. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../MigrationRecordTests.cs | 11 +++- .../Prototype/BrowserWslSetupDiagnostics.cs | 35 ++++++++--- .../BrowserWslSetupDiagnosticsTests.cs | 63 +++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs b/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs index 7db17b793..3d31ee2db 100644 --- a/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs +++ b/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs @@ -492,6 +492,7 @@ public async Task ScriptTimeout_TerminatesProcessTreeAndPreservesFailureDiagnost await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(child.HasExited); Assert.IsAssignableFrom(error.InnerException); + Assert.Contains("Before cleanup: rootExited=False", error.Message); Assert.Contains("timeout stdout", error.Message); Assert.Contains("timeout stderr", error.Message); Assert.Contains(lockLog ? "Script log unavailable: IOException" : "cleanup checkpoint", error.Message); @@ -525,11 +526,17 @@ private static async Task AssertScriptExitAsync(Process process, int expectedExi } catch (OperationCanceledException exception) when (timeout.IsCancellationRequested) { + // Capture before killing: joined cleanup alone cannot distinguish a live + // PowerShell stall from a process-exit/drain notification race in the harness. + bool? rootExitedBeforeCleanup = null; + var stdoutCompletedBeforeCleanup = stdout.IsCompleted; + var stderrCompletedBeforeCleanup = stderr.IsCompleted; // Stop cleanup before the caller releases its lock or deletes the fixture. var cleanup = "Root process exit confirmed."; try { - if (!process.HasExited) + rootExitedBeforeCleanup = process.HasExited; + if (!rootExitedBeforeCleanup.Value) process.Kill(entireProcessTree: true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } @@ -551,6 +558,8 @@ private static async Task AssertScriptExitAsync(Process process, int expectedExi } throw new TimeoutException( $"Migration script PID {process.Id} exceeded {limit.TotalSeconds:g} seconds.\n" + + $"Before cleanup: rootExited={rootExitedBeforeCleanup?.ToString() ?? "unknown"}; " + + $"stdoutCompleted={stdoutCompletedBeforeCleanup}; stderrCompleted={stderrCompletedBeforeCleanup}.\n" + $"{cleanup}\nStandard output:\n{output[0]}\nStandard error:\n{output[1]}\nScript log:\n{log}", exception); } diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs index c173b502f..924fb0bbb 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs @@ -30,7 +30,7 @@ private static readonly (string Needle, string Code)[] Codes = ("ECONNREFUSED", "connection_refused"), ("ETIMEDOUT", "connection_timeout") ]; - private sealed record Command(string? Step, int Exit, bool? TimedOut, double? ElapsedMs, string[] Codes); + private sealed record Command(int StepSequence, int Exit, bool? TimedOut, double? ElapsedMs, string[] Codes); internal static BrowserWslSetupFailure Read(string file) { @@ -60,7 +60,9 @@ internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = fa string? step = null, outcome = null, failedStep = null, failedRawStep = null; Command? lastCommand = null, failedCommand = null; var codes = new HashSet(StringComparer.Ordinal); - int records = 0; bool partial = false; + int records = 0, stepSequence = 0; + int? failedSequence = null; + bool partial = false; var recent = new Queue(MaxRecords); using (var reader = new StringReader(jsonl)) { @@ -82,13 +84,18 @@ internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = fa var rawStep = String(data, "step_id"); if (rawStep is not null && (message?.StartsWith("step.started:", StringComparison.Ordinal) == true || message?.StartsWith("step.completed:", StringComparison.Ordinal) == true)) { - step = Steps.Contains(rawStep) ? rawStep : "other"; + if (message.StartsWith("step.started:", StringComparison.Ordinal)) + { + stepSequence++; + lastCommand = null; + } + step = ClosedStep(rawStep, message); var value = String(data, "outcome"); outcome = value is "Success" or "Failed" or "Skipped" or "Cancelled" ? value : value is null ? null : "other"; if (value == "Failed" && failedStep is null) { - failedStep = step; failedRawStep = rawStep; - failedCommand = lastCommand?.Step == rawStep ? lastCommand : null; + failedStep = step; failedRawStep = rawStep; failedSequence = stepSequence; + failedCommand = lastCommand?.StepSequence == stepSequence ? lastCommand : null; if (failedCommand is not null) codes.UnionWith(failedCommand.Codes); codes.UnionWith(Classify(data)); } @@ -98,10 +105,10 @@ internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = fa { bool? timedOut = data.TryGetProperty("timed_out", out var timed) && timed.ValueKind is JsonValueKind.True or JsonValueKind.False ? timed.GetBoolean() : null; double? elapsed = data.TryGetProperty("elapsed_ms", out var ms) && ms.ValueKind == JsonValueKind.Number && ms.TryGetDouble(out var number) && double.IsFinite(number) && number is >= 0 and <= 3600000 ? number : null; - // Only closed step names are retained, including for unknown producer IDs. - lastCommand = new(step, exitCode, timedOut, elapsed, exitCode == 0 ? [] : Classify(data)); + // Correlate by the step-start boundary because all sanitized IDs may be identical. + lastCommand = new(stepSequence, exitCode, timedOut, elapsed, exitCode == 0 ? [] : Classify(data)); } - if (failedRawStep is not null && rawStep == failedRawStep && message?.StartsWith("step.exception:", StringComparison.Ordinal) == true) codes.UnionWith(Classify(data)); + if (failedRawStep is not null && failedSequence == stepSequence && rawStep == failedRawStep && message?.StartsWith("step.exception:", StringComparison.Ordinal) == true) codes.UnionWith(Classify(data)); } catch (JsonException) { partial = true; } } @@ -110,6 +117,18 @@ internal static BrowserWslSetupFailure Project(string jsonl, bool truncated = fa failedCommand?.ElapsedMs, codes.Order(StringComparer.Ordinal).ToArray()); } + private static string ClosedStep(string rawStep, string message) + { + if (Steps.Contains(rawStep)) return rawStep; + // SetupLogger intentionally redacts *_id metadata. Its completion message has + // the public step ID. Match a closed producer prefix, never export arbitrary text. + if (rawStep == "[REDACTED]") + foreach (var known in Steps) + if (message.StartsWith($"step.completed: {known} → ", StringComparison.Ordinal)) + return known; + return "other"; + } + private static string? String(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; private static string[] Classify(JsonElement data) => Codes.Where(pair => diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs index 690a3fb7b..ba08f5c10 100644 --- a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs @@ -6,6 +6,69 @@ public sealed class BrowserWslSetupDiagnosticsTests { private static string Entry(string message, object data) => JsonSerializer.Serialize(new { msg = message, data }) + "\n"; + [Fact] + public void RealSetupLogger_RedactedStepIdsKeepClosedFailureAndCommandFacts() + { + var file = Path.GetTempFileName(); + try + { + using (var logger = new OpenClaw.SetupEngine.SetupLogger(file)) + { + logger.StepStarted("install-cli", "Install CLI"); + logger.CommandCompleted("private-executable", new(42, "", "ECONNREFUSED private-value", TimeSpan.Zero, false), TimeSpan.Zero); + logger.StepCompleted("install-cli", OpenClaw.SetupEngine.StepResult.Ok("handled"), TimeSpan.Zero); + logger.StepStarted("run-wizard", "Gateway wizard"); + logger.CommandCompleted("private-executable", new(1, "", "StateDatabaseCoordinatorContentionError private-value", TimeSpan.Zero, false), TimeSpan.FromMilliseconds(5010.5)); + logger.StepCompleted("run-wizard", OpenClaw.SetupEngine.StepResult.Fail("GatewayRestartPreparationError private-value"), TimeSpan.Zero); + } + // Exercise the real producer, whose general sanitizer intentionally redacts *_id. + using var first = JsonDocument.Parse(File.ReadLines(file).First()); + Assert.Equal("[REDACTED]", first.RootElement.GetProperty("data").GetProperty("step_id").GetString()); + var value = BrowserWslSetupDiagnostics.Read(file); + Assert.Equal("run-wizard", value.FailedStep); + Assert.Equal(1, value.CommandExit); + Assert.False(value.CommandTimedOut); + Assert.Equal(5010.5, value.CommandElapsedMs); + Assert.Equal(new[] { "gateway_restart_preparation", "state_coordinator_contention" }, value.ObservedCodes); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + finally { File.Delete(file); } + } + + [Fact] + public void RedactedStepIds_DoNotJoinAnEarlierStepsCommand() + { + var log = Entry("step.started: install", new { step_id = "[REDACTED]" }) + + Entry("cmd.done: handled", new { exit_code = 42, stderr = "ECONNREFUSED private-value" }) + + Entry("step.completed: install-cli → Success", new { step_id = "[REDACTED]", outcome = "Success" }) + + Entry("step.started: wizard", new { step_id = "[REDACTED]" }) + + Entry("step.completed: run-wizard → Failed", new { step_id = "[REDACTED]", outcome = "Failed" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal("run-wizard", value.FailedStep); + Assert.Null(value.CommandExit); + Assert.Empty(value.ObservedCodes); + } + + [Fact] + public void RedactedIds_DoNotJoinLaterStepExceptions() + { + var log = Entry("step.completed: run-wizard → Failed", new { step_id = "[REDACTED]", outcome = "Failed" }) + + Entry("step.started: rollback", new { step_id = "[REDACTED]" }) + + Entry("step.exception: private", new { step_id = "[REDACTED]", exception = "ECONNREFUSED private-value" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal("run-wizard", value.FailedStep); + Assert.Empty(value.ObservedCodes); + } + + [Fact] + public void UnknownCompletionMessages_NeverBecomeExportedSteps() + { + var log = Entry("step.completed: private-value → Failed", new { step_id = "[REDACTED]", outcome = "Failed" }); + var value = BrowserWslSetupDiagnostics.Project(log); + Assert.Equal("other", value.FailedStep); + Assert.DoesNotContain("private", JsonSerializer.Serialize(value)); + } + [Fact] public void FailedStep_ProjectsOnlyClosedStageCodesAndNumbers() { From 9e650dd14d7cd50626129b39005b77929b7c49cc Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:08:02 +0000 Subject: [PATCH 74/77] test(browser): verify reviewed saved-profile Store recovery Pin native and WSL composition to reviewed OpenClaw consumer 6cff5472. Retain every prior native case and verify saved Work Store preservation for inspect, verify and install through the actual canonical CLI. Construct a real foreign Chrome Store with a matching Work native host through the sole C# owner. Require typed ownership refusal, failed CLI verify/install, unchanged state hashes and native generations, and exact owned cleanup. No product guard, timeout or ABI change. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../browser-native-composed-proof.yml | 4 +- .github/workflows/browser-wsl-owner-proof.yml | 8 +- scripts/BrowserNativeSavedProfile.test.mjs | 10 ++- .../Initialize-BrowserNativeComposition.ps1 | 2 +- scripts/Test-BrowserNativeComposition.mjs | 4 +- scripts/Test-BrowserNativeSavedProfile.mjs | 73 ++++++++++++++++++- 6 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index 5c82bdfc2..c805f08f8 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: bb2d479926adb23fb30737e2fd3e5af63969dc84 + ref: 6cff547216bd3229446d7cc32a7dae667182ef51 path: consumer persist-credentials: false - uses: actions/setup-node@v4 @@ -80,7 +80,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - if ((git rev-parse HEAD).Trim() -cne 'bb2d479926adb23fb30737e2fd3e5af63969dc84') { throw 'Consumer revision mismatch.' } + if ((git rev-parse HEAD).Trim() -cne '6cff547216bd3229446d7cc32a7dae667182ef51') { throw 'Consumer revision mismatch.' } pnpm install --frozen-lockfile --store-dir "$env:COMPOSED_PRIVATE_ROOT/store" --package-import-method=copy if ($LASTEXITCODE -ne 0) { throw 'Pinned consumer dependency install failed.' } pnpm build:docker diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 36a7f06a0..959c4ba48 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: bb2d479926adb23fb30737e2fd3e5af63969dc84 + ref: 6cff547216bd3229446d7cc32a7dae667182ef51 persist-credentials: false - uses: actions/setup-node@v4 with: @@ -40,7 +40,7 @@ jobs: shell: bash run: | set -euo pipefail - test "$(git rev-parse HEAD)" = bb2d479926adb23fb30737e2fd3e5af63969dc84 + test "$(git rev-parse HEAD)" = 6cff547216bd3229446d7cc32a7dae667182ef51 pnpm install --frozen-lockfile node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir "$RUNNER_TEMP/canonical-consumer" --output-name openclaw-current.tgz git diff --exit-code @@ -54,7 +54,7 @@ jobs: const dir=path.join(process.env.RUNNER_TEMP,'canonical-consumer'); const version=require('./package.json').version; const sha256=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,'openclaw-current.tgz'))).digest('hex'); - fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'bb2d479926adb23fb30737e2fd3e5af63969dc84',version,sha256})); + fs.writeFileSync(path.join(dir,'package-candidate.json'),JSON.stringify({sourceSha:'6cff547216bd3229446d7cc32a7dae667182ef51',version,sha256})); fs.appendFileSync(process.env.GITHUB_OUTPUT,'version='+version+'\nsha256='+sha256+'\n'); JS - uses: actions/upload-artifact@v7 @@ -70,7 +70,7 @@ jobs: OPENCLAW_REPO_ROOT: ${{ github.workspace }} OPENCLAW_RUN_E2E: '1' OPENCLAW_BROWSER_WSL_OWNER_PROOF: '1' - OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: bb2d479926adb23fb30737e2fd3e5af63969dc84 + OPENCLAW_WSL_PROTOTYPE_CONSUMER_SHA: 6cff547216bd3229446d7cc32a7dae667182ef51 OPENCLAW_E2E_GATEWAY_VERSION: ${{ needs.canonical-consumer.outputs.version }} steps: - uses: actions/checkout@v7 diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index 7d5e5de7f..c83c8b008 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import fs from 'node:fs'; -import { savedProfileFailure, savedProfileCliObservation, assertSavedProfileGenerationUnchanged } from './Test-BrowserNativeSavedProfile.mjs'; +import { savedProfileFailure, savedProfileCliObservation, assertSavedProfileGenerationUnchanged, assertMatchedForeignStore } from './Test-BrowserNativeSavedProfile.mjs'; test('native proof preflight and both final-consumer workflows use one immutable pin',()=>{ const source=fs.readFileSync(new URL('./Test-BrowserNativeComposition.mjs',import.meta.url),'utf8'); @@ -35,6 +35,14 @@ test('idempotent setup keeps the admitted generation and creates no directory',( assert.throws(()=>assertSavedProfileGenerationUnchanged('generation-a','generation-b',[])); assert.throws(()=>assertSavedProfileGenerationUnchanged('generation-a','generation-a',['new-generation'])); }); +test('foreign Store proof requires a matching live native descriptor and typed refusal',()=>{ + const matching={ok:false,code:'foreign_registration',registration:'owned',mode:'native-windows-cli',store:'foreign',installation:{generation:'expected'}}; + assert.doesNotThrow(()=>assertMatchedForeignStore(matching,'expected')); + for(const patch of [{ok:true},{code:'context_conflict'},{registration:'foreign'},{mode:'companion-managed-wsl'},{store:'requested'},{installation:null},{installation:{generation:'other'}}]) { + assert.throws(()=>assertMatchedForeignStore({...matching,...patch},'expected')); + } + assert.throws(()=>assertMatchedForeignStore({...matching,installation:null},undefined)); +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); diff --git a/scripts/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 index ed2b67fc7..fc9c5fcdf 100644 --- a/scripts/Initialize-BrowserNativeComposition.ps1 +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -1,7 +1,7 @@ param([Parameter(Mandatory)][string]$Consumer, [Parameter(Mandatory)][string]$Receipt) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='bb2d479926adb23fb30737e2fd3e5af63969dc84' } +$result = [ordered]@{ status='failed'; stage='preflight'; consumerSha='6cff547216bd3229446d7cc32a7dae667182ef51' } try { $result.host = @{ windows=[bool]$IsWindows; githubActions=($env:GITHUB_ACTIONS -ceq 'true'); githubHosted=($env:RUNNER_ENVIRONMENT -ceq 'github-hosted') } if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or !$IsWindows) { throw 'Disposable Windows required' } diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 7c9f49ad6..48b30bb49 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -9,7 +9,7 @@ import { recordCompletion } from './BrowserNativeProofTiming.mjs'; import { savedProfileAcceptance } from './Test-BrowserNativeSavedProfile.mjs'; const producerSha = process.env.GITHUB_SHA; -const consumerSha = 'bb2d479926adb23fb30737e2fd3e5af63969dc84'; +const consumerSha = '6cff547216bd3229446d7cc32a7dae667182ef51'; const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; const fixtureConfig = { gateway: { mode: 'local', port: 18789 }, browser: { enabled: true, profiles: { chrome: { driver: 'extension' } } } }; @@ -335,7 +335,7 @@ try { assert.equal(await stateSnapshot(context.stateDir),eofBefore,'retired_variant_state_effect'); check('retired_valid_nonce_parser_variants_cannot_bypass_activation'); stage='canonical-saved-profile-acceptance'; - await savedProfileAcceptance({fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt,withFrozenNative}); + await savedProfileAcceptance({fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt,withFrozenNative,stateSnapshot}); receipt.status='passed'; } } catch (error) { diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index 205ba4fdf..a3c7c8840 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; +import crypto from 'node:crypto'; // Return only assertion class and fixture coordinates, never messages or actual/expected values. export function savedProfileFailure(error) { const location=error instanceof Error?error.stack?.match(/Test-BrowserNativeSavedProfile\.mjs:(\d+):(\d+)/):undefined; @@ -27,6 +28,12 @@ export function assertSavedProfileGenerationUnchanged(before,after,added) { assert.equal(added.length,0); assert.equal(after,before); } +export function assertMatchedForeignStore(body,generation) { + assert.ok(typeof generation==='string'&&generation.length>0); + assert.equal(body.ok,false);assert.equal(body.code,'foreign_registration'); + assert.equal(body.registration,'owned');assert.equal(body.mode,'native-windows-cli'); + assert.equal(body.store,'foreign');assert.equal(body.installation?.generation,generation); +} export async function savedProfileAcceptance(h) { const {fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt}=h; assert.equal(process.platform,'win32');assert.equal(process.env.RUNNER_ENVIRONMENT,'github-hosted'); @@ -193,9 +200,15 @@ export async function savedProfileAcceptance(h) { // Explicit Store request is separate from the opt-out case; repair must preserve it. await cli(['install','--browser-profile','work','--wait-ms','1000']); assert.equal((await descriptor()).inspected.store,'requested'); - const preserve=await cli(['setup','--action','install','--wait-ms','1000']); - assert.equal(preserve.body.target.profile,'work');assert.equal(preserve.body.installation.installRequested,true); - assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); + receipt.savedProfileStoreRecovery=[]; + for(const action of ['inspect','verify','install']) { + const preserve=await cli(['setup','--action',action,'--wait-ms','1000']); + assert.equal(preserve.code,0);assert.equal(preserve.body.target.profile,'work'); + assert.equal(preserve.body.target.relayPort,19444);assert.equal(preserve.body.installation.installRequested,true); + assert.equal((await descriptor()).inspected.store,'requested'); + assert.equal((await bootstrap(await descriptor())).pairingString,initialPair.pairingString); + receipt.savedProfileStoreRecovery.push({action,exitCode:preserve.code,workSelected:true,storePreserved:true,pairingPreserved:true}); + } check('selector_free_repair_preserves_explicit_store_request'); stage='retired_profile_selection'; const retireWork=await cli(['uninstall-host','--browser-profile','work','--remove-store']); @@ -207,7 +220,59 @@ export async function savedProfileAcceptance(h) { assert.equal(select.body.target.profile,'chrome'); assert.equal((await descriptor()).inspected.installation.generation,currentChrome.inspected.installation.generation); check('retained_work_generations_never_override_current_chrome_selection'); - receipt.savedProfile={workRelay19444:true,pairingPreserved:true,optOutPreserved:true,requestedStorePreserved:true,retiredSelectionRefused:true}; + stage='true_foreign_store'; + assert.ok(h.stateSnapshot,'bounded state snapshot helper required'); + assert.equal((await manage({...currentChrome.request,action:'uninstall'})).ok,true); + const foreignState=await fs.realpath(await fs.mkdtemp(path.join(fixture,'foreign-store-'))); + const foreignConfig=path.join(foreignState,'openclaw.json');await fs.writeFile(foreignConfig,configBytes,{flag:'wx'}); + const foreignChrome={...current,stateDir:foreignState,configPath:await fs.realpath(foreignConfig),browserProfile:'chrome'}; + const foreignWork={...foreignChrome,browserProfile:'work'}; + const foreignRequest=context=>({v:1,action:'inspect',mode:'native-windows-cli',context,expectedOrigins:canonicalOrigins,store:'preserve'}); + let cleanupContext=foreignChrome,foreignFailure; + try { + // Create both legitimate owners through C#, never forge a Store marker or relax a guard. + const chromeStore=await manage({...foreignRequest(foreignChrome),action:'install',store:'request'}); + assert.equal(chromeStore.ok,true);assert.equal(chromeStore.store,'requested'); + // Complete ordinary CLI config initialization with a read-only matching observation + // before taking the no-effects baseline for the foreign Store transition. + const warmup=await cli(['setup','--action','inspect','--browser-profile','chrome','--wait-ms','1000'],{env:{OPENCLAW_STATE_DIR:foreignState,OPENCLAW_CONFIG_PATH:foreignConfig}}); + assert.equal(warmup.code,0);assert.equal(warmup.body.target.profile,'chrome'); + assert.equal(warmup.body.installation.installRequested,true); + assert.equal((await manage({...foreignRequest(foreignChrome),action:'uninstall'})).ok,true); + cleanupContext=foreignWork; + const workNative=await manage({...foreignRequest(foreignWork),action:'install'}); + assert.equal(workNative.ok,true);assert.equal(workNative.store,'foreign'); + const generation=workNative.installation.generation; + assertMatchedForeignStore(await manage(foreignRequest(foreignWork)),generation); + const beforeState=await h.stateSnapshot(foreignState); + const beforeDirectories=JSON.stringify((await fs.readdir(currentChrome.root)).sort()); + receipt.trueForeignStore={matchedNativeDescriptor:true,store:'foreign',refusals:[], + beforeStateSha256:crypto.createHash('sha256').update(beforeState).digest('hex')}; + for(const action of ['verify','install']) { + const result=await cli(['setup','--action',action,'--wait-ms','1000'],{env:{OPENCLAW_STATE_DIR:foreignState,OPENCLAW_CONFIG_PATH:foreignConfig}}); + assert.equal(result.code,1);assert.equal(result.body?.target,undefined); + assert.equal(result.body?.installation,undefined); + assertMatchedForeignStore(await manage(foreignRequest(foreignWork)),generation); + assert.equal(JSON.stringify((await fs.readdir(currentChrome.root)).sort()),beforeDirectories); + assert.equal(await h.stateSnapshot(foreignState),beforeState); + receipt.trueForeignStore.refusals.push({action,exitCode:result.code,setupProjectionAbsent:true, + nativeGenerationPreserved:true,foreignStorePreserved:true,statePreserved:true}); + } + receipt.trueForeignStore.afterStateSha256=crypto.createHash('sha256').update(await h.stateSnapshot(foreignState)).digest('hex'); + check('true_foreign_store_blocks_saved_profile_verify_and_install_without_state_effects'); + } catch(error) { foreignFailure=error;throw error; } + finally { + try { + assert.equal((await manage({...foreignRequest(cleanupContext),action:'uninstall'})).ok,true); + const removed=await manage({...foreignRequest(foreignChrome),action:'uninstall',store:'remove'}); + assert.equal(removed.ok,true);assert.equal(removed.registration,'missing');assert.equal(removed.store,'missing'); + receipt.trueForeignStoreCleanup={registrationMissing:true,storeMissing:true}; + } catch(error) { + receipt.trueForeignStoreCleanup={failed:true,...savedProfileFailure(error)}; + if(!foreignFailure)throw error; + } + } + receipt.savedProfile={workRelay19444:true,pairingPreserved:true,optOutPreserved:true,requestedStorePreserved:true,retiredSelectionRefused:true,trueForeignStoreRefused:true}; } catch(error) { primaryFailed=true; receipt.savedProfileFailure={stage,...savedProfileFailure(error)}; From 9c604eaad44e151ec437a75d80fc034572356efa Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:09:21 +0000 Subject: [PATCH 75/77] test(browser): freeze reviewed corrected-consumer fixture Advance only the native fixture pin to reviewed source 9e650dd1. Keep the original native production baseline and all fail-closed guards. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 959c4ba48..78ad2f59a 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -99,7 +99,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code b926e7ab6a13157cc664db08332ccd2d410b40d4 HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code 9e650dd14d7cd50626129b39005b77929b7c49cc HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 From aa6cc7f828e778f95fa2eb745b80bce4993f362c Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:09:15 +0000 Subject: [PATCH 76/77] test(browser): snapshot named browser mutation state incrementally Canonical 6cff CLI inspection initializes a 1454080-byte general state database, reproducing the old whole-tree 1 MiB bound with just three entries. Snapshot config and atomic siblings, credentials and browser installation state instead, with 64 KiB streaming hashes and unchanged 1 MiB/256-entry acceptance budgets. Detect absence, mutation, links and unsupported files. Keep prior native whole-state checks and foreign Store/generation checks. Record bounded role-only legacy size/count evidence before the browser snapshot; never expose file names, contents, pairing material or raw errors. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .../browser-native-composed-proof.yml | 4 +- .github/workflows/browser-wsl-owner-proof.yml | 2 +- scripts/BrowserNativeSavedProfile.test.mjs | 12 +++ scripts/BrowserNativeStateSnapshot.mjs | 91 +++++++++++++++++++ scripts/BrowserNativeStateSnapshot.test.mjs | 62 +++++++++++++ scripts/Test-BrowserNativeComposition.mjs | 2 +- scripts/Test-BrowserNativeSavedProfile.mjs | 17 ++-- 7 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 scripts/BrowserNativeStateSnapshot.mjs create mode 100644 scripts/BrowserNativeStateSnapshot.test.mjs diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml index c805f08f8..40715e39b 100644 --- a/.github/workflows/browser-native-composed-proof.yml +++ b/.github/workflows/browser-native-composed-proof.yml @@ -13,6 +13,8 @@ on: - scripts/BrowserNativeProofTiming.mjs - scripts/BrowserNativeProofTiming.test.mjs - scripts/BrowserNativeSavedProfile.test.mjs + - scripts/BrowserNativeStateSnapshot.mjs + - scripts/BrowserNativeStateSnapshot.test.mjs - src/OpenClaw.BrowserBootstrap/** - src/OpenClaw.BrowserBootstrap.Contracts/** - src/OpenClaw.Shared/Browser/** @@ -51,7 +53,7 @@ jobs: dotnet-version: '10.0.x' - name: Verify proof ordering instrumentation shell: pwsh - run: node --test producer/scripts/BrowserNativeProofTiming.test.mjs producer/scripts/BrowserNativeSavedProfile.test.mjs + run: node --test producer/scripts/BrowserNativeProofTiming.test.mjs producer/scripts/BrowserNativeSavedProfile.test.mjs producer/scripts/BrowserNativeStateSnapshot.test.mjs - name: Build and identify the current reviewed producer shell: pwsh run: | diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 78ad2f59a..32f69da39 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -99,7 +99,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code 9e650dd14d7cd50626129b39005b77929b7c49cc HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code 9e650dd14d7cd50626129b39005b77929b7c49cc HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeStateSnapshot.mjs scripts/BrowserNativeStateSnapshot.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6 diff --git a/scripts/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs index c83c8b008..fcfa7496d 100644 --- a/scripts/BrowserNativeSavedProfile.test.mjs +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -43,6 +43,18 @@ test('foreign Store proof requires a matching live native descriptor and typed r } assert.throws(()=>assertMatchedForeignStore({...matching,installation:null},undefined)); }); +test('snapshot failures retain only an allowlisted bound code',()=>{ + const error=new Error('private-value');error.code='snapshot_byte_bound'; + assert.deepEqual(savedProfileFailure(error),{kind:'execution_failed',snapshotCode:'snapshot_byte_bound'}); + error.code='private-value';assert.deepEqual(savedProfileFailure(error),{kind:'execution_failed'}); +}); +test('snapshot helper and regressions remain in the frozen native proof graph',()=>{ + for(const file of ['BrowserNativeStateSnapshot.mjs','BrowserNativeStateSnapshot.test.mjs']) { + const native=fs.readFileSync(new URL('../.github/workflows/browser-native-composed-proof.yml',import.meta.url),'utf8'); + const wsl=fs.readFileSync(new URL('../.github/workflows/browser-wsl-owner-proof.yml',import.meta.url),'utf8'); + assert.ok(native.includes('scripts/'+file));assert.ok(wsl.includes('scripts/'+file)); + } +}); test('CLI projection diagnostics reject missing registrations without masking the boundary',()=>{ for(const body of [undefined,null,{},[],{error:'private-value'},{target:{kind:'local-host'}}]) { const observed=savedProfileCliObservation({code:1,body}); diff --git a/scripts/BrowserNativeStateSnapshot.mjs b/scripts/BrowserNativeStateSnapshot.mjs new file mode 100644 index 000000000..3513ae392 --- /dev/null +++ b/scripts/BrowserNativeStateSnapshot.mjs @@ -0,0 +1,91 @@ +// Proof-only browser mutation inventory. Generic CLI database/log/cache churn is not browser state. +import fs from 'node:fs/promises'; +import crypto from 'node:crypto'; +import path from 'node:path'; + +export const browserStateScope='config-credentials-browser'; +const maxEntries=256,maxBytes=1048576,chunkBytes=65536; +const fail=code=>{const error=new Error(code);error.code=code;throw error;}; +const same=(a,b)=>a.dev===b.dev&&a.ino===b.ino&&a.size===b.size&&a.mtimeMs===b.mtimeMs&&a.ctimeMs===b.ctimeMs; + +export async function snapshotBrowserState(root) { + const entries=[];let bytes=0; + const add=value=>{if(entries.length>=maxEntries)fail('snapshot_entry_bound');entries.push(value);}; + async function visit(relative,missingAllowed=false) { + const file=path.join(root,relative);let before; + try {before=await fs.lstat(file);} catch(error) { + if(missingAllowed&&error.code==='ENOENT'){add([relative,'missing']);return;} + throw error; + } + if(before.isSymbolicLink())fail('snapshot_link'); + if(before.isDirectory()) { + add([relative,'directory']); + const names=[]; + for await(const entry of await fs.opendir(file)) { + if(names.length>=maxEntries)fail('snapshot_entry_bound'); + names.push(entry.name); + } + for(const name of names.sort())await visit(path.join(relative,name)); + const after=await fs.lstat(file); + if(after.isSymbolicLink()||!after.isDirectory()||!same(before,after))fail('snapshot_changed'); + return; + } + if(!before.isFile()||before.nlink!==1)fail('snapshot_file_type'); + if(before.size>maxBytes-bytes)fail('snapshot_byte_bound'); + const handle=await fs.open(file,'r'); + try { + if(!same(before,await handle.stat()))fail('snapshot_changed'); + const hash=crypto.createHash('sha256'),buffer=Buffer.alloc(chunkBytes);let count=0; + for(;;) { + const read=await handle.read(buffer,0,buffer.length,null); + if(read.bytesRead===0)break; + count+=read.bytesRead;bytes+=read.bytesRead; + if(bytes>maxBytes)fail('snapshot_byte_bound'); + hash.update(buffer.subarray(0,read.bytesRead)); + } + const after=await fs.lstat(file); + if(after.isSymbolicLink()||!same(before,after)||!same(before,await handle.stat())||count!==before.size)fail('snapshot_changed'); + add([relative,hash.digest('hex')]); + } finally {await handle.close();} + } + const rootStat=await fs.lstat(root); + if(rootStat.isSymbolicLink()||!rootStat.isDirectory())fail('snapshot_link'); + // The immutable consumer persists relay material in credentials/, installs assets in + // browser/, and consumes this exact config plus its atomic-write/backup siblings. + // Keep absence in the baseline so first-time creation is detected, not silently ignored. + const configs=new Set(['openclaw.json']);let scanned=0; + for await(const entry of await fs.opendir(root)) { + if(++scanned>4096)fail('snapshot_root_inventory_bound'); + if(entry.name.startsWith('openclaw.json.')||entry.name.startsWith('.openclaw.json.'))configs.add(entry.name); + } + for(const config of [...configs].sort())await visit(config,true); + await visit('credentials',true);await visit('browser',true); + return {value:JSON.stringify(entries),entries:entries.length,bytes,scope:browserStateScope}; +} + +// Explain the old whole-tree oracle safely: metadata only, no file names or contents +// in the result. A truncated diagnostic is not a successful mutation snapshot. +export async function describeLegacySnapshotBounds(root) { + let entries=0,maxFileBytes=0,fileBytesExceeded=false,truncated=false; + const roles=new Set(); + const role=relative=>relative==='openclaw.json'||relative.startsWith('openclaw.json.')||relative.startsWith('.openclaw.json.')?'config': + relative.startsWith('credentials/')?'credentials':relative.startsWith('browser/')?'browser': + ['state/openclaw.sqlite','state/openclaw.sqlite-wal','state/openclaw.sqlite-shm'].includes(relative)?'runtime_database': + relative.startsWith('logs/')?'logs':'other'; + async function walk(dir) { + for await(const entry of await fs.opendir(dir)) { + if(entries>=1024){truncated=true;return;} + entries++; + const file=path.join(dir,entry.name),stat=await fs.lstat(file); + if(stat.isSymbolicLink())continue; + if(stat.isDirectory())await walk(file); + else if(stat.isFile()) { + maxFileBytes=Math.max(maxFileBytes,stat.size); + if(stat.size>maxBytes){fileBytesExceeded=true;roles.add(role(path.relative(root,file).split(path.sep).join('/')));} + } + if(truncated)return; + } + } + await walk(root); + return {entries,maxFileBytes,fileBytesExceeded,entryCountExceeded:entries>maxEntries,truncated,oversizedRoles:[...roles].sort()}; +} diff --git a/scripts/BrowserNativeStateSnapshot.test.mjs b/scripts/BrowserNativeStateSnapshot.test.mjs new file mode 100644 index 000000000..a0b1a1b07 --- /dev/null +++ b/scripts/BrowserNativeStateSnapshot.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {snapshotBrowserState,describeLegacySnapshotBounds} from './BrowserNativeStateSnapshot.mjs'; + +async function fixture(t) { + const root=await fs.mkdtemp(path.join(os.tmpdir(),'browser-state-proof-')); + t.after(()=>fs.rm(root,{recursive:true,force:true})); + await fs.writeFile(path.join(root,'openclaw.json'),'{}');return root; +} +test('large generic runtime database explains old byte bound without weakening browser bounds',async t=>{ + const root=await fixture(t);await fs.mkdir(path.join(root,'state')); + const db=await fs.open(path.join(root,'state','openclaw.sqlite'),'w');await db.truncate(2*1048576);await db.close(); + const old=await describeLegacySnapshotBounds(root); + assert.equal(old.fileBytesExceeded,true);assert.equal(old.entryCountExceeded,false); + assert.deepEqual(old.oversizedRoles,['runtime_database']);assert.equal(old.maxFileBytes,2*1048576); + const before=await snapshotBrowserState(root); + await fs.mkdir(path.join(root,'logs'));await fs.writeFile(path.join(root,'logs','runtime.log'),'unrelated runtime event'); + assert.equal((await snapshotBrowserState(root)).value,before.value); + assert.equal(before.scope,'config-credentials-browser');assert.equal(before.bytes,2); +}); +test('credential creation, mutation, removal and config backups remain observable',async t=>{ + const root=await fixture(t);const empty=await snapshotBrowserState(root); + await fs.mkdir(path.join(root,'credentials'));const file=path.join(root,'credentials','browser-extension-relay.secret'); + await fs.writeFile(file,'first');const first=await snapshotBrowserState(root);assert.notEqual(first.value,empty.value); + await fs.writeFile(file,'second');const second=await snapshotBrowserState(root);assert.notEqual(second.value,first.value); + await fs.rm(file);assert.notEqual((await snapshotBrowserState(root)).value,second.value); + const prior=await snapshotBrowserState(root);await fs.writeFile(path.join(root,'openclaw.json.bak'),'{}'); + assert.notEqual((await snapshotBrowserState(root)).value,prior.value); + const withBackup=await snapshotBrowserState(root);await fs.writeFile(path.join(root,'.openclaw.json.pending'),'{}'); + assert.notEqual((await snapshotBrowserState(root)).value,withBackup.value); +}); +test('browser files are streamed and content hashes remain exact',async t=>{ + const root=await fixture(t);await fs.mkdir(path.join(root,'browser'));const bytes=Buffer.alloc(524288,7); + await fs.writeFile(path.join(root,'browser','asset.bin'),bytes);const before=await snapshotBrowserState(root); + assert.equal(before.bytes,524290);assert.ok(before.value.includes(crypto.createHash('sha256').update(bytes).digest('hex'))); + bytes[200000]=8;await fs.writeFile(path.join(root,'browser','asset.bin'),bytes); + assert.notEqual((await snapshotBrowserState(root)).value,before.value); +}); +test('intended files still fail closed on byte and entry overflow',async t=>{ + const root=await fixture(t);await fs.mkdir(path.join(root,'credentials')); + await fs.writeFile(path.join(root,'credentials','oversized'),Buffer.alloc(1048576)); + await assert.rejects(snapshotBrowserState(root),{code:'snapshot_byte_bound'}); + await fs.rm(path.join(root,'credentials','oversized')); + for(let n=0;n<256;n++)await fs.writeFile(path.join(root,'credentials',String(n)),''); + await assert.rejects(snapshotBrowserState(root),{code:'snapshot_entry_bound'}); +}); +test('symlinked authority roots are rejected without reading their target',async t=>{ + const root=await fixture(t),outside=await fixture(t); + await fs.symlink(outside,path.join(root,'credentials'),process.platform==='win32'?'junction':'dir'); + await assert.rejects(snapshotBrowserState(root),{code:'snapshot_link'}); +}); +test('legacy count diagnostic does not export filenames or secret content',async t=>{ + const root=await fixture(t);await fs.mkdir(path.join(root,'logs')); + for(let n=0;n<257;n++)await fs.writeFile(path.join(root,'logs','private-'+n),'private-value'); + const value=await describeLegacySnapshotBounds(root); + assert.equal(value.entryCountExceeded,true);assert.equal(value.fileBytesExceeded,false); + assert.equal(JSON.stringify(value).includes('private'),false); +}); diff --git a/scripts/Test-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs index 48b30bb49..590b00dc1 100644 --- a/scripts/Test-BrowserNativeComposition.mjs +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -335,7 +335,7 @@ try { assert.equal(await stateSnapshot(context.stateDir),eofBefore,'retired_variant_state_effect'); check('retired_valid_nonce_parser_variants_cannot_bypass_activation'); stage='canonical-saved-profile-acceptance'; - await savedProfileAcceptance({fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt,withFrozenNative,stateSnapshot}); + await savedProfileAcceptance({fixture,context,executable,baseEnv,exchange,manage,powershell,frame,response,origin,nonce,check,receipt,withFrozenNative}); receipt.status='passed'; } } catch (error) { diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs index a3c7c8840..6f8066fc2 100644 --- a/scripts/Test-BrowserNativeSavedProfile.mjs +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -4,11 +4,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; +import {snapshotBrowserState,describeLegacySnapshotBounds,browserStateScope} from './BrowserNativeStateSnapshot.mjs'; // Return only assertion class and fixture coordinates, never messages or actual/expected values. export function savedProfileFailure(error) { const location=error instanceof Error?error.stack?.match(/Test-BrowserNativeSavedProfile\.mjs:(\d+):(\d+)/):undefined; + const snapshotCode=['snapshot_entry_bound','snapshot_byte_bound','snapshot_link','snapshot_file_type','snapshot_changed','snapshot_root_inventory_bound'].includes(error?.code)?error.code:undefined; return {kind:error?.code==='ERR_ASSERTION'?'assertion_failed':'execution_failed', - ...(location?{line:Number(location[1]),column:Number(location[2])}:{})}; + ...(snapshotCode?{snapshotCode}:{}),...(location?{line:Number(location[1]),column:Number(location[2])}:{})}; } // Closed-schema diagnostics distinguish a malformed CLI projection from registration refusal. export function savedProfileCliObservation(result) { @@ -221,7 +223,6 @@ export async function savedProfileAcceptance(h) { assert.equal((await descriptor()).inspected.installation.generation,currentChrome.inspected.installation.generation); check('retained_work_generations_never_override_current_chrome_selection'); stage='true_foreign_store'; - assert.ok(h.stateSnapshot,'bounded state snapshot helper required'); assert.equal((await manage({...currentChrome.request,action:'uninstall'})).ok,true); const foreignState=await fs.realpath(await fs.mkdtemp(path.join(fixture,'foreign-store-'))); const foreignConfig=path.join(foreignState,'openclaw.json');await fs.writeFile(foreignConfig,configBytes,{flag:'wx'}); @@ -244,21 +245,23 @@ export async function savedProfileAcceptance(h) { assert.equal(workNative.ok,true);assert.equal(workNative.store,'foreign'); const generation=workNative.installation.generation; assertMatchedForeignStore(await manage(foreignRequest(foreignWork)),generation); - const beforeState=await h.stateSnapshot(foreignState); + receipt.trueForeignStore={matchedNativeDescriptor:true,store:'foreign',refusals:[],snapshotScope:browserStateScope}; + receipt.trueForeignStore.legacySnapshotBounds=await describeLegacySnapshotBounds(foreignState); + const snapshot=await snapshotBrowserState(foreignState),beforeState=snapshot.value; const beforeDirectories=JSON.stringify((await fs.readdir(currentChrome.root)).sort()); - receipt.trueForeignStore={matchedNativeDescriptor:true,store:'foreign',refusals:[], - beforeStateSha256:crypto.createHash('sha256').update(beforeState).digest('hex')}; + Object.assign(receipt.trueForeignStore,{snapshotEntries:snapshot.entries,snapshotBytes:snapshot.bytes, + beforeStateSha256:crypto.createHash('sha256').update(beforeState).digest('hex')}); for(const action of ['verify','install']) { const result=await cli(['setup','--action',action,'--wait-ms','1000'],{env:{OPENCLAW_STATE_DIR:foreignState,OPENCLAW_CONFIG_PATH:foreignConfig}}); assert.equal(result.code,1);assert.equal(result.body?.target,undefined); assert.equal(result.body?.installation,undefined); assertMatchedForeignStore(await manage(foreignRequest(foreignWork)),generation); assert.equal(JSON.stringify((await fs.readdir(currentChrome.root)).sort()),beforeDirectories); - assert.equal(await h.stateSnapshot(foreignState),beforeState); + assert.equal((await snapshotBrowserState(foreignState)).value,beforeState); receipt.trueForeignStore.refusals.push({action,exitCode:result.code,setupProjectionAbsent:true, nativeGenerationPreserved:true,foreignStorePreserved:true,statePreserved:true}); } - receipt.trueForeignStore.afterStateSha256=crypto.createHash('sha256').update(await h.stateSnapshot(foreignState)).digest('hex'); + receipt.trueForeignStore.afterStateSha256=crypto.createHash('sha256').update((await snapshotBrowserState(foreignState)).value).digest('hex'); check('true_foreign_store_blocks_saved_profile_verify_and_install_without_state_effects'); } catch(error) { foreignFailure=error;throw error; } finally { From 88958a82fab7063eea3a44ffc92fa4868b325db3 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:16:38 +0000 Subject: [PATCH 77/77] test(browser): freeze bounded native snapshot fixture Bind WSL composition to reviewed snapshot source aa6cc7f8. Keep original native production baseline and all refusal/settlement guards. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- .github/workflows/browser-wsl-owner-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml index 32f69da39..844f648fd 100644 --- a/.github/workflows/browser-wsl-owner-proof.yml +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -99,7 +99,7 @@ jobs: # Production remains on its original reviewed baseline; fixture evolution has its own reviewed pin. git diff --exit-code edd93f4998528cc0362443067db95a1c822e4cf1 HEAD -- src/OpenClaw.BrowserBootstrap src/OpenClaw.BrowserBootstrap.Contracts src/OpenClaw.Shared/Browser if ($LASTEXITCODE -ne 0) { throw 'Frozen native production changed.' } - git diff --exit-code 9e650dd14d7cd50626129b39005b77929b7c49cc HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeStateSnapshot.mjs scripts/BrowserNativeStateSnapshot.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs + git diff --exit-code aa6cc7f828e778f95fa2eb745b80bce4993f362c HEAD -- .github/workflows/browser-native-composed-proof.yml scripts/Test-BrowserNativeComposition.mjs scripts/Initialize-BrowserNativeComposition.ps1 scripts/Test-BrowserNativeSavedProfile.mjs scripts/BrowserNativeSavedProfile.test.mjs scripts/BrowserNativeStateSnapshot.mjs scripts/BrowserNativeStateSnapshot.test.mjs scripts/BrowserNativeProofTiming.mjs scripts/BrowserNativeProofTiming.test.mjs scripts/Control-BrowserNativeProofChild.ps1 scripts/BrowserNativePathAudit.cs if ($LASTEXITCODE -ne 0) { throw 'Reviewed native proof fixture changed.' } "OPENCLAW_WSL_OWNER_RECEIPT=$env:RUNNER_TEMP/browser-wsl-production-owner.json" >> $env:GITHUB_ENV - uses: actions/setup-dotnet@v6