diff --git a/.github/workflows/browser-native-composed-proof.yml b/.github/workflows/browser-native-composed-proof.yml new file mode 100644 index 000000000..40715e39b --- /dev/null +++ b/.github/workflows/browser-native-composed-proof.yml @@ -0,0 +1,106 @@ +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 + - scripts/Test-BrowserNativeSavedProfile.mjs + - scripts/Initialize-BrowserNativeComposition.ps1 + - scripts/BrowserNativePathAudit.cs + - scripts/Control-BrowserNativeProofChild.ps1 + - 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/** + - tests/OpenClaw.BrowserBootstrap.Tests/** + +permissions: + contents: read + actions: read + +jobs: + composed-native: + runs-on: windows-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v7 + with: + path: producer + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@v7 + with: + repository: openclaw/openclaw + ref: 6cff547216bd3229446d7cc32a7dae667182ef51 + path: consumer + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - 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: + 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 producer/scripts/BrowserNativeStateSnapshot.test.mjs + - name: Build and identify the current reviewed producer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + 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: + 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).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 + working-directory: ${{ env.COMPOSED_PRIVATE_CORE }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + 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 + 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: | + & $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 + ${{ runner.temp }}/composed-path-audit.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/browser-wsl-guest-trace.yml b/.github/workflows/browser-wsl-guest-trace.yml new file mode 100644 index 000000000..55487c35f --- /dev/null +++ b/.github/workflows/browser-wsl-guest-trace.yml @@ -0,0 +1,76 @@ +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/BrowserWsl*.cs + - tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj + +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' + 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_TRACE_RECEIPT=$env:RUNNER_TEMP/browser-wsl-guest-receipt.json" >> $env:GITHUB_ENV + - 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 'Persistent pidfd observer receipt is authoritative. Exit-notification precedence is not a reaping or whole-Companion claim.' diff --git a/.github/workflows/browser-wsl-owner-proof.yml b/.github/workflows/browser-wsl-owner-proof.yml new file mode 100644 index 000000000..844f648fd --- /dev/null +++ b/.github/workflows/browser-wsl-owner-proof.yml @@ -0,0 +1,154 @@ +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 + - tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs + - tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics*.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: 6cff547216bd3229446d7cc32a7dae667182ef51 + 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)" = 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 + - 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:'6cff547216bd3229446d7cc32a7dae667182ef51',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: 6cff547216bd3229446d7cc32a7dae667182ef51 + 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: | + # 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 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 + 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|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 + 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/.github/workflows/browser-wsl-owner-prototype.yml b/.github/workflows/browser-wsl-owner-prototype.yml new file mode 100644 index 000000000..8a75a7043 --- /dev/null +++ b/.github/workflows/browser-wsl-owner-prototype.yml @@ -0,0 +1,149 @@ +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 + - tests/OpenClaw.E2ETests/Prototype/BrowserPrototypeTransportTests.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-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: | + 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: 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: | + # 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index cace93457..835d24e81 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 @@ -321,6 +325,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', @@ -336,6 +345,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' }} @@ -346,6 +356,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: @@ -363,6 +374,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 @@ -791,6 +809,21 @@ 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.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 run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime @@ -847,6 +880,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.BrowserBootstrap.exe + - name: Verify ARM64 Native Runtime Payload shell: pwsh # -SkipNativeLoadProbe keeps parity with the prior release matrix. Presence @@ -1146,12 +1183,15 @@ 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.BrowserBootstrap.exe")) { + 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 @@ -1164,12 +1204,15 @@ 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.BrowserBootstrap.exe")) { + 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/.gitignore b/.gitignore index 97d6a0cbc..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 @@ -361,3 +363,6 @@ visual-test-output/ msix-validation-evidence/ packaging-test-output/ uninstall-validation-output/ + +# Local agent validation SDKs, logs and proof scratch +.openclaw/tmp/ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5acb83a9f..358f53d5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -262,6 +262,8 @@ 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 | - | +| 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 | | inno-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | migration record parsing and completed-handoff admission | MigrationRecordCodec + InnoMigrationStartupGuard | App invokes the guard before ordinary startup; explicit destructive CLI uninstall remains separate | completed migration prevents normal unpackaged startup before settings or activation; development and packaged apps are unaffected | InnoMigrationContractTests.CompletedMigrationGuard_PrecedesSettingsAndActivation | source-shape | when packaged/unpackaged migration startup is fully hosted outside App | | store-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | Inno discovery, pending-record inspection, and Store migration admission policy | InnoInstallationDetector + MigrationStartupRecordReader + StoreMigrationStartupCoordinator + StoreMigrationStartupGuard | App calls the compile-time-gated adapter before instance forwarding and normal services | disabled builds perform no migration inspection; preview admission never mutates source state or enables normal startup for pending migration | StoreMigrationStartupCoordinatorTests.Disabled_DoesNotInspectInstallationOrRecords | behavioral | - | | store-migration-startup-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | direct registry discovery and migration startup policy | StoreMigrationStartupGuard + StoreMigrationStartupCoordinator | one adapter invocation after explicit CLI uninstall and before protocol processing and instance forwarding | App does not regain installation discovery, record parsing, or admission decisions | InnoMigrationContractTests.StorePreviewGuard_PrecedesInstanceForwardingAndNormalServices | source-shape | when packaged migration bootstrap is hosted outside App | @@ -282,4 +284,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/docs/BROWSER_EXTENSION_BOOTSTRAP.md b/docs/BROWSER_EXTENSION_BOOTSTRAP.md new file mode 100644 index 000000000..92f1641d1 --- /dev/null +++ b/docs/BROWSER_EXTENSION_BOOTSTRAP.md @@ -0,0 +1,142 @@ +# 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. +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 +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 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. `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 + 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. + 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. + +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 +{ + "remote": false, + "relayPort": 18792, + "pairingString": "ws://127.0.0.1:18789/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#" +} +``` + +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. + +## 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.BrowserBootstrap.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, 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 +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 1312af3b9..0d986fc09 100644 --- a/installer.iss +++ b/installer.iss @@ -64,6 +64,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. @@ -101,6 +102,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 @@ -236,6 +240,8 @@ begin end; end; +#include "scripts\BrowserBootstrapManagement.iss" + #if vcRedist != "" procedure InstallVCRuntime; var @@ -591,6 +597,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 @@ -624,10 +637,36 @@ begin Log('{#MyStartupTaskName} startup task already absent or unavailable.'); end; +procedure RegisterBrowserIntegration; +var + StoreAction: String; +begin +#ifndef DevBuild + 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 + if CurStep = ssPostInstall then RegisterBrowserIntegration; +end; + +procedure UnregisterBrowserNativeHost; +begin +#ifndef DevBuild + if not RunBrowserManagement('uninstall', 'remove') then + Log('Browser cleanup was refused or its outcome is uncertain. Retained generations must not be deleted.'); +#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 0b1ccdde8..518dff64c 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -5,6 +5,7 @@ + @@ -44,4 +45,6 @@ + + diff --git a/scripts/BrowserBootstrapManagement.iss b/scripts/BrowserBootstrapManagement.iss new file mode 100644 index 000000000..ca1106a4e --- /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: INT_PTR; SA: AnsiString; Size: Cardinal): Boolean; + external 'CreatePipe@kernel32.dll stdcall'; +function BBHandleFlags(H: INT_PTR; Mask, Flags: Cardinal): Boolean; + external 'SetHandleInformation@kernel32.dll stdcall'; +function BBClose(H: INT_PTR): Boolean; + external 'CloseHandle@kernel32.dll stdcall'; +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: INT_PTR; Data: AnsiString; Count: Cardinal; var Written: Cardinal; Overlapped: INT_PTR): Boolean; + external 'WriteFile@kernel32.dll stdcall'; +function BBRead(H: INT_PTR; Data: AnsiString; Count: Cardinal; var ReadCount: Cardinal; Overlapped: INT_PTR): Boolean; + external 'ReadFile@kernel32.dll stdcall'; +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: INT_PTR; Milliseconds: Cardinal): Cardinal; + external 'WaitForSingleObject@kernel32.dll stdcall'; +function BBExit(H: INT_PTR; var Code: Cardinal): Boolean; + external 'GetExitCodeProcess@kernel32.dll stdcall'; +function BBResume(H: INT_PTR): Cardinal; + external 'ResumeThread@kernel32.dll stdcall'; +function BBTerminate(H: INT_PTR; Code: Cardinal): Boolean; + external 'TerminateProcess@kernel32.dll stdcall'; +function BBJob(Security, Name: INT_PTR): INT_PTR; + external 'CreateJobObjectW@kernel32.dll stdcall'; +function BBJobLimits(Job: INT_PTR; InfoClass: Integer; Info: AnsiString; Size: Cardinal): Boolean; + external 'SetInformationJobObject@kernel32.dll stdcall'; +function BBAssign(Job, Process: INT_PTR): 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: INT_PTR); +begin + if H <> 0 then begin BBClose(H); H := 0; end; +end; + +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; + 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: INT_PTR; + 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/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/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/BrowserNativeSavedProfile.test.mjs b/scripts/BrowserNativeSavedProfile.test.mjs new file mode 100644 index 000000000..fcfa7496d --- /dev/null +++ b/scripts/BrowserNativeSavedProfile.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +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'); + 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('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('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('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('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('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}); + 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)'; + 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/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/BrowserWslGuestTrace.py b/scripts/BrowserWslGuestTrace.py new file mode 100644 index 000000000..64433a433 --- /dev/null +++ b/scripts/BrowserWslGuestTrace.py @@ -0,0 +1,196 @@ +#!/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 selectors +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() + + +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"): + run(mode) + elif mode == "observe": + observe() + elif mode == "cleanup": + cleanup() + elif mode == "monitor": + monitor() + else: + raise RuntimeError("invalid fixture operation") diff --git a/scripts/BrowserWslOwnerPrototype.cjs b/scripts/BrowserWslOwnerPrototype.cjs new file mode 100644 index 000000000..3c5641a97 --- /dev/null +++ b/scripts/BrowserWslOwnerPrototype.cjs @@ -0,0 +1,213 @@ +'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'); + 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 + // 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/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/Initialize-BrowserNativeComposition.ps1 b/scripts/Initialize-BrowserNativeComposition.ps1 new file mode 100644 index 000000000..fc9c5fcdf --- /dev/null +++ b/scripts/Initialize-BrowserNativeComposition.ps1 @@ -0,0 +1,69 @@ +param([Parameter(Mandatory)][string]$Consumer, [Parameter(Mandatory)][string]$Receipt) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$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' } + $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 = (& $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.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' } + $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' + $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. + $result | ConvertTo-Json -Depth 8 -Compress | Write-Output +} +if ($result.status -ne 'passed') { exit 1 } 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 +} 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-BrowserNativeComposition.mjs b/scripts/Test-BrowserNativeComposition.mjs new file mode 100644 index 000000000..590b00dc1 --- /dev/null +++ b/scripts/Test-BrowserNativeComposition.mjs @@ -0,0 +1,358 @@ +// 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 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 = '6cff547216bd3229446d7cc32a7dae667182ef51'; +const origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/'; +const nonce = 'AAAAAAAAAAAAAAAAAAAAAA'; +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'; +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("$ProgressPreference='SilentlyContinue';" + script, 'utf16le').toString('base64')], + { 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) { + 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, 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, 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. + 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(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', () => {}); + }); +} +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'); + 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'); } + +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 && process.env.COMPOSED_PRIVATE_ROOT, 'explicit_private_artifact_core_receipt_required'); + 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. + 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}))")); + 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-'))); + 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(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'); + 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 = {}; + 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 = '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 = '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'); + 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)); + 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'); + 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: 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'); + 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'); + // 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'); + 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'); + 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) { + // 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 { + 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)); +} diff --git a/scripts/Test-BrowserNativeHost.ps1 b/scripts/Test-BrowserNativeHost.ps1 new file mode 100644 index 000000000..4771e1e05 --- /dev/null +++ b/scripts/Test-BrowserNativeHost.ps1 @@ -0,0 +1,160 @@ +<# 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 +$source = (Resolve-Path -LiteralPath $ExecutablePath).Path +$origin = 'chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/' +$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() } + } +} +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 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 { + $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) + 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 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 -or $Process.StandardOutput.BaseStream.ReadByte() -ne -1 -or $Process.StandardError.BaseStream.ReadByte() -ne -1) { throw 'Native output or exit invalid.' } +} +# 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 { + & (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 { + $connected=$pipe.WaitForConnectionAsync() + $native=Start-Native $nativeExe @($origin,'--parent-window=0') + try { + 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"}')) + # 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 + } 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 { + $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 ($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. +} +$global:LASTEXITCODE=0 diff --git a/scripts/Test-BrowserNativeSavedProfile.mjs b/scripts/Test-BrowserNativeSavedProfile.mjs new file mode 100644 index 000000000..6f8066fc2 --- /dev/null +++ b/scripts/Test-BrowserNativeSavedProfile.mjs @@ -0,0 +1,300 @@ +// 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'; +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', + ...(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) { + 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}; +} +// 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 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'); + 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,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','1000']); + 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); + // 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); + 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); + 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','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'); + const added=(await fs.readdir(before.root)).filter(x=>!dirs.has(x)); + 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'); + 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); + 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','1000']); + const otherState=await fs.mkdtemp(path.join(fixture,'other-work-state-')); + 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','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','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); + 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}}})); + 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); + 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; + 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','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); } + 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=>{ + 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); + }); + assert.equal((await descriptor()).inspected.installation.generation,generationBefore); + check('unknown_busy_inventory_cannot_select_profile_or_mutate'); + active=await descriptor(); + stage='mixed_generations'; + 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()}"); + } + const currentManifest=active.inspected.installation.manifestPath; + try { + replaceChromium(currentManifest,retained); + 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','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()}"); + }}); + 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'); + 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','1000']); + assert.equal((await descriptor()).inspected.store,'requested'); + 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']); + 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','1000']); + const currentChrome=await descriptor();assert.equal(currentChrome.binding.nativeWindows.browserProfile,'chrome'); + 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'); + stage='true_foreign_store'; + 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); + 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()); + 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 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 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 { + 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)}; + 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'); + 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}; + } 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/scripts/Test-ReleaseExecutableSignatures.ps1 b/scripts/Test-ReleaseExecutableSignatures.ps1 index 357e2c3b1..080c5ac7e 100644 --- a/scripts/Test-ReleaseExecutableSignatures.ps1 +++ b/scripts/Test-ReleaseExecutableSignatures.ps1 @@ -52,7 +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\.BrowserBootstrap\.exe$' { return "OpenClawOwned" } '(^|\\)createdump\.exe$' { return "ThirdPartyExcluded" } '(^|\\)RestartAgent\.exe$' { return "ThirdPartyExcluded" } '^tools\\mxc\\[^\\]+\\wxc-exec\.exe$' { return "ThirdPartyExcluded" } @@ -127,7 +129,9 @@ foreach ($binary in $binaries) { "OpenClaw.SetupEngine.UI.dll", "OpenClaw.SetupEngine.dll", "OpenClaw.Shared.dll", - "OpenClawTray.FunctionalUI.dll" + "OpenClaw.BrowserBootstrap.Contracts.dll", + "OpenClawTray.FunctionalUI.dll", + "tools\browser-bootstrap\OpenClaw.BrowserBootstrap.exe" ) | ForEach-Object { $requiredBinary = $_ if (-not ($binaries | Where-Object RelativePath -eq $requiredBinary)) { @@ -149,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/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' diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 804c5f23e..f1d448166 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." 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..216c1932b --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/GenerationStore.cs @@ -0,0 +1,178 @@ +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"); + 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 + { + 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"); + 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 + { + 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 new file mode 100644 index 000000000..caf0c12ae --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/NativeTransport.cs @@ -0,0 +1,141 @@ +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, NativeRegistrationRuntime activation, 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(); + 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(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 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]; + 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); + 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 + { + 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 + { + 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){} } + 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..9e0897325 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/OpenClaw.BrowserBootstrap.csproj @@ -0,0 +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 new file mode 100644 index 000000000..ca3b7ce72 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/Program.cs @@ -0,0 +1,74 @@ +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 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)); + 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,activation,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) + { + // 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/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.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..36c20e7b3 --- /dev/null +++ b/src/OpenClaw.BrowserBootstrap/WindowsRegistrationPlatform.cs @@ -0,0 +1,231 @@ +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, 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"; + 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 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); + 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) + { + 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;iPairs 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(); + // 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!; + // 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(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(destination, 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("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()!; + 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/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 new file mode 100644 index 000000000..f4460605e --- /dev/null +++ b/src/OpenClaw.Connection/BrowserBootstrapWslCommand.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using System.Text; +using OpenClaw.Shared.Browser; + +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 + cli_path= + for cli in /home/openclaw/.openclaw/bin/openclaw /opt/openclaw/bin/openclaw /usr/local/bin/openclaw; do + if test -x "$cli"; then cli_path="$cli"; break; fi + done + 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, + 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); + 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)); + void Revoke() + { + exchange.Revoke(); + // EOF revokes permission in the guest. Killing wsl.exe is NOT guest settlement. + try { process.StandardInput.BaseStream.Close(); } + catch (IOException) { } + catch (InvalidOperationException) { } + } + 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.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 exchange.Result; + } + finally + { + if (!exchange.Acknowledged) Revoke(); + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + 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) + { + 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 { await BrowserBootstrapProcessLifetime.JoinAsync(process, cleanup.Token); } + finally { await BrowserBootstrapProcessLifetime.AwaitOwnedAsync(Task.WhenAll(output, error), cleanup.Token); } + } + } + } + } + } + + private static async Task DrainErrorAsync(StreamReader reader) + { + var buffer = new char[1024]; var total = 0; + for (;;) + { + 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 038ec87d8..90cce582b 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/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 new file mode 100644 index 000000000..2324cf193 --- /dev/null +++ b/src/OpenClaw.Shared/Browser/BrowserBootstrapPipeServer.cs @@ -0,0 +1,78 @@ +using System.IO.Pipes; + +namespace OpenClaw.Shared.Browser; + +/// 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); + await pipe.WaitForConnectionAsync(_stop.Token); + await HandleConnectionAsync(pipe); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { return; } + catch (Exception) when (!_stop.IsCancellationRequested) + { + // Requests, responses and exception messages may contain pairing material. Never log them. + await Task.Delay(250, _stop.Token); + } + } + } + private async Task HandleConnectionAsync(Stream pipe) + { + 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 + { + await lifetime.CancelAsync(); + if (disconnected is not null) + { + try { await disconnected; } + catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { } + } + } + } + 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/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/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/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/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index f8a295ae7..9c22ebfbc 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/App.AppShutdownCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs index a673662f6..1330b0a4d 100644 --- a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs @@ -84,6 +84,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 19400cb73..ac88b7dfa 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; @@ -839,6 +840,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/BrowserBootstrap.targets b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets new file mode 100644 index 000000000..da08de220 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/BrowserBootstrap.targets @@ -0,0 +1,31 @@ + + + $(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/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index c1e158f2b..ba3deb1f5 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -469,4 +469,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..3088fc453 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/BrowserBootstrapHost.cs @@ -0,0 +1,72 @@ +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; + private readonly CancellationTokenSource _stop = new(); + private Task? _registration; + + 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 + { + _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. */ } + } + _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(); + await _stop.CancelAsync(); + if (_registration is not null) await _registration; + _stop.Dispose(); + _registry.Changed -= OnRegistryChanged; + _manager.StateChanged -= OnStateChanged; + _settings.Saved -= OnSettingsSaved; + await _pipe.DisposeAsync(); + } +} 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..df216788f --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/ManagementContractTests.cs @@ -0,0 +1,122 @@ +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 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)); + 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..5c4773dee --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/NativeCancellationTests.cs @@ -0,0 +1,64 @@ +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 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(),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)); + Assert.Equal(0,output.Length); + } + [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"),new(platform),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 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(),new(platform),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 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/OpenClaw.BrowserBootstrap.Tests.csproj b/tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj new file mode 100644 index 000000000..0d1846aa3 --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/OpenClaw.BrowserBootstrap.Tests.csproj @@ -0,0 +1 @@ + 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.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..91492c54b --- /dev/null +++ b/tests/OpenClaw.BrowserBootstrap.Tests/RegistryTransactionTests.cs @@ -0,0 +1,88 @@ +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 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; + 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.Connection.Tests/BrowserBootstrapServiceTests.cs b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs new file mode 100644 index 000000000..46ac2377c --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/BrowserBootstrapServiceTests.cs @@ -0,0 +1,148 @@ +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, bool remote = false) => JsonSerializer.Serialize(new + { + 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() + { + 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()); }); + 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?unexpected=1" })); + } + + [Fact] + public void PairingOutput_MustMatchLocalGatewayTopologyAndPort() + { + Assert.NotEmpty(BrowserBootstrapService.ParsePairing(PairingJson(), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(18790), 18789)); + Assert.Throws(() => BrowserBootstrapService.ParsePairing(PairingJson(remote: true), 18789)); + 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() + { + 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.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/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/Prototype/BrowserOwnerProtocolTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs new file mode 100644 index 000000000..a127a3a68 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserOwnerProtocolTests.cs @@ -0,0 +1,326 @@ +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; + private bool _canonicalCapabilityVerified; + + [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); + // 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); } + 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(), + 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, + 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; + 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) + { + 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); + 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()); + 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 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, + 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, + 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, brokerStarted = errors.IsCompletedSuccessfully && errors.Result, 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(); + } + 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); + 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(); + } +} 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"))); + } +} diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs new file mode 100644 index 000000000..7c8a1982e --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslLookupDiagnosticsTests.cs @@ -0,0 +1,67 @@ +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); + } + + [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() + { + 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 new file mode 100644 index 000000000..dcb714f01 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslProductionOwnerTests.cs @@ -0,0 +1,270 @@ +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; + 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); + 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"); + 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); + 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 + { + 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,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})); + } + 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;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)); + 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); + 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") + { + stage="exact_client_selection"; + 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)); + 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); + } + 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,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,lookup,outcome, + commandEndTicks=Interlocked.Read(ref commandEnd),runCompleted=run.IsCompleted,ownership.ManagementCode, + ownership.RuntimeLeaseReleaseTicks,ownership.ActivationReleasedTicks,ownership.ManagementMutationTicks,ownership.ManagementCompletionTicks}); + throw; + } + 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 sealed record ClientLookupObservation(string Stage,long ElapsedMilliseconds,bool ScriptEntered, + 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"); + 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="[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; + 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,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]); + 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; + if(!string.Equals(owned.MainModule!.FileName,image,StringComparison.OrdinalIgnoreCase)){owned.Dispose();throw new InvalidDataException("Owned image changed");} + report(Snapshot());return owned; + } + catch + { + primaryFailed=true;failure=Snapshot();report(failure);throw; + } + finally + { + 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); + } + } + } + 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,Action? stage=null) + { + 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); + 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(); + } + 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(); + } +} diff --git a/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs new file mode 100644 index 000000000..924fb0bbb --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnostics.cs @@ -0,0 +1,140 @@ +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(int StepSequence, 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, stepSequence = 0; + int? failedSequence = null; + bool partial = false; + var recent = new Queue(MaxRecords); + using (var reader = new StringReader(jsonl)) + { + 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 }); + 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)) + { + 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; failedSequence = stepSequence; + failedCommand = lastCommand?.StepSequence == stepSequence ? 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; + // 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 && failedSequence == stepSequence && 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 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 => + 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..ba08f5c10 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Prototype/BrowserWslSetupDiagnosticsTests.cs @@ -0,0 +1,205 @@ +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 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() + { + 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 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() + { + 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); + } +} diff --git a/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs new file mode 100644 index 000000000..903bcb280 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/BrowserWslGuestTraceTests.cs @@ -0,0 +1,430 @@ +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 = 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", + 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(); + 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); + var command = BrowserBootstrapWslCommand.RunAsync(fixture.DistroName, ct); + _ = command.ContinueWith(_ => + { + 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; } + return BrowserNativeProtocol.Failure("pairing_unavailable"); + } + 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); + BrowserWslPersistentObserver? observer = null; + Task? client = null, pipeRetirement = null, management = null; + bool cleanupSettled = false; + try + { + await pipe.ConnectAsync(budget.Token); + client = ownership.Run(ObserveClient); + JsonElement initial; + var readyDeadline = DateTime.UtcNow.AddSeconds(10); + do + { + 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 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(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 "deadline": break; + default: throw new InvalidOperationException("Unknown trace case."); + } + 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 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()); + } + 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, 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 + { + // 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 + { + 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 (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 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 + { + 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; } + } + } + + 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; + } +} 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(); } + } + } +} 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 new file mode 100644 index 000000000..921657ee5 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/BrowserBootstrapPipeTests.cs @@ -0,0 +1,83 @@ +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 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() + { + 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/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();} + } +} 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..ffc1ad4ef --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapIntegrationContractTests.cs @@ -0,0 +1,65 @@ +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("RegisterBrowserIntegration;", installer); + Assert.Contains("UsePreviousTasks=yes", 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); + 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", "BrowserBootstrap.targets"); + Assert.Contains("SelfContained=true", targets); + Assert.Contains("win-x64", targets); + Assert.Contains("win-arm64", 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.DoesNotContain("OpenClaw.BrowserNativeHost.dll", workflow); + Assert.Contains("OpenClaw.BrowserBootstrap.Contracts.dll", workflow); + Assert.Contains("Test-BrowserNativeHost.ps1", workflow); + 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..8e368876c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/BrowserBootstrapPublishTests.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.Xml.Linq; +using OpenClaw.TestSupport; + +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) + { + 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/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); } 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); } diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3812cd5a2..0784d5b02 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -224,4 +224,7 @@ + + +