Gateway Isolation
+Diagnostic launch mode reported by the Windows launcher.
+${command}
+
+ diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 15d1530d..336937ef 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -48,6 +48,11 @@ jobs: cache: true cache-dependency-path: Directory.Packages.props + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Restore run: dotnet restore .\OpenClaw.Gateway.MSIX.slnx @@ -67,6 +72,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test Gateway isolation plugin + shell: pwsh + run: > + .\scripts\Test-GatewayIsolationPlugin.Tests.ps1 + build-package: name: Build OpenClaw npm package runs-on: ubuntu-latest diff --git a/README.md b/README.md index f5d4df6d..11b263b7 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,18 @@ copy, repair, or otherwise change package files at runtime. Every OpenClaw child process runs with `OPENCLAW_SUPERVISOR_MODE=external`, `OPENCLAW_SERVICE_REPAIR_POLICY=external`, and -`OPENCLAW_NO_AUTO_UPDATE=1`. These declare external lifecycle ownership, -prevent doctor-owned service repair, and disable configured background -auto-updates. The pinned OpenClaw `v2026.8.2` release honors external supervisor -mode by refusing native service mutation and OpenClaw self-update with guidance -to use the external supervisor's workflow. This behavior belongs to upstream -OpenClaw; the launcher does not reserve, reject, or rewrite upstream command -arguments. +`OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the selected Windows Gateway +session mode through the process-stable +`CLAWCTL_GATEWAY_ISOLATION=enabled|disabled` environment variable. The current +interactive-session launch path reports `disabled`; the future isolated-session +launch path will select `enabled` when that session switch is implemented. +These values declare external lifecycle ownership, prevent doctor-owned service +repair, disable configured background auto-updates, and expose diagnostic +isolation status without claiming independent attestation. The pinned OpenClaw +`v2026.8.2` release honors external supervisor mode by refusing native service +mutation and OpenClaw self-update with guidance to use the external supervisor's +workflow. This behavior belongs to upstream OpenClaw; the launcher does not +reserve, reject, or rewrite upstream command arguments. OpenClaw inherits the terminal's working directory; the launcher does not make the read-only application directory the workspace. @@ -100,7 +105,10 @@ both: Changing only the workflow-dispatch default does not change automatic builds. For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in -`openclaw_ref`. +`openclaw_ref`. Payload composition validates that the selected OpenClaw +runtime can discover and load the packaging-owned Gateway Isolation plugin +with its required read-only route shape; incompatible older refs fail instead +of producing a package without status UI. The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. @@ -125,9 +133,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` copies that -tree into package content, rejects any Node.js executable or runtime archive, -creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. +architecture-specific application tree and provisions the packaging-owned, +enabled-by-default Gateway Isolation plugin into OpenClaw's bundled plugin +directory. The plugin adds a read-only **Gateway Isolation** tab to the Control +group and serves it through an authenticated, sandboxed plugin route. It reads +only the launch-time `CLAWCTL_GATEWAY_ISOLATION` value and registers no mutation +RPC or process control. `scripts\Build-MSIX.ps1` copies that tree into package +content, rejects any Node.js executable or runtime archive, creates a per-file +inventory, and then creates an unsigned NativeAOT MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a local payload directory. The Node.js used by the payload build jobs is build infrastructure only and is not copied into the MSIX. diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js new file mode 100644 index 00000000..7690e828 --- /dev/null +++ b/plugins/gateway-isolation/index.js @@ -0,0 +1,265 @@ +const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; +const STATUS_PATH = "/plugins/gateway-isolation/status"; + +export function readGatewayIsolationMode(env) { + const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; + return value === "enabled" || value === "disabled" ? value : null; +} + +export function renderGatewayIsolationPage(mode) { + if (mode !== "enabled" && mode !== "disabled") { + throw new TypeError("Gateway isolation mode must be enabled or disabled."); + } + + const enabled = mode === "enabled"; + const status = enabled ? "Enabled" : "Disabled"; + const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; + const tone = enabled ? "ok" : "warn"; + + return ` + +
+ + +Diagnostic launch mode reported by the Windows launcher.
+${command}
+
+ The Windows launcher did not provide a valid Gateway isolation mode.
", + ); + return true; + } + writeHtmlResponse(response, 200, renderGatewayIsolationPage(launchMode)); + return true; + }, + }); + }, + }; +} + +export default createGatewayIsolationPlugin(); diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js new file mode 100644 index 00000000..2e5c09dd --- /dev/null +++ b/plugins/gateway-isolation/index.test.js @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createGatewayIsolationPlugin, + readGatewayIsolationMode, + renderGatewayIsolationPage, +} from "./index.js"; + +function registerPlugin(mode) { + const descriptors = []; + const routes = []; + const plugin = createGatewayIsolationPlugin({ + CLAWCTL_GATEWAY_ISOLATION: mode, + }); + plugin.register({ + session: { + controls: { + registerControlUiDescriptor(descriptor) { + descriptors.push(descriptor); + }, + }, + }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + assert.equal(descriptors.length, 1); + assert.equal(routes.length, 1); + return { descriptors, routes }; +} + +function invokeRoute(route) { + const result = { + body: "", + headers: {}, + statusCode: 0, + }; + const handled = route.handler( + {}, + { + writeHead(statusCode, headers) { + result.statusCode = statusCode; + result.headers = headers; + }, + end(body) { + result.body = body; + }, + }, + ); + assert.equal(handled, true); + return result; +} + +test("accepts only the exact launcher isolation values", () => { + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "enabled" }), "enabled"); + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "disabled" }), "disabled"); + for (const value of [undefined, "", "ENABLED", "unknown"]) { + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: value }), null); + } +}); + +for (const expected of [ + { + mode: "enabled", + status: "Enabled", + command: "clawctl gateway-isolation disable", + tone: "status--ok", + }, + { + mode: "disabled", + status: "Disabled", + command: "clawctl gateway-isolation enable", + tone: "status--warn", + }, +]) { + test(`renders the exact ${expected.mode} read-only status`, () => { + const html = renderGatewayIsolationPage(expected.mode); + assert.match(html, /Gateway Isolation/); + assert.match(html, /Reported Gateway Isolation/); + assert.match(html, new RegExp(`>${expected.status}<`)); + assert.match(html, /Change with CLI/); + assert.match(html, /Run from the signed-in user session on the Gateway host\./); + assert.match(html, new RegExp(expected.command)); + assert.match(html, new RegExp(expected.tone)); + assert.match(html, /aria-label="Copy command"/); + assert.match(html, /Copy the selected command manually\./); + assert.match(html, /copied = document\.execCommand\("copy"\)/); + assert.doesNotMatch(html, /next manual Gateway restart/i); + }); +} + +test("registers one read-only Control tab and one authenticated sandbox route", () => { + const { descriptors, routes } = registerPlugin("enabled"); + assert.deepEqual(descriptors[0], { + surface: "tab", + id: "gateway-isolation", + label: "Gateway Isolation", + description: "Read-only Windows Gateway isolation status.", + icon: "shield-check", + group: "control", + order: 20, + path: "/plugins/gateway-isolation/status", + requiredScopes: ["operator.read"], + }); + assert.equal(routes[0].path, descriptors[0].path); + assert.equal(routes[0].auth, "gateway"); + assert.equal(routes[0].match, "exact"); + + const response = invokeRoute(routes[0]); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["Cache-Control"], "no-store"); + assert.match(response.headers["Content-Security-Policy"], /frame-ancestors 'self'/); + assert.match(response.body, />Enabled); +}); + +test("keeps the launch mode stable for the plugin process", () => { + const env = { CLAWCTL_GATEWAY_ISOLATION: "enabled" }; + const plugin = createGatewayIsolationPlugin(env); + env.CLAWCTL_GATEWAY_ISOLATION = "disabled"; + const routes = []; + plugin.register({ + session: { + controls: { + registerControlUiDescriptor() {}, + }, + }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + + const response = invokeRoute(routes[0]); + assert.match(response.body, />Enabled); + assert.doesNotMatch(response.body, />Disabled); +}); + +test("fails closed when the launcher value is missing or invalid", () => { + const { routes } = registerPlugin("invalid"); + const response = invokeRoute(routes[0]); + assert.equal(response.statusCode, 503); + assert.match(response.body, /did not provide a valid Gateway isolation mode/); +}); diff --git a/plugins/gateway-isolation/openclaw.plugin.json b/plugins/gateway-isolation/openclaw.plugin.json new file mode 100644 index 00000000..b07da303 --- /dev/null +++ b/plugins/gateway-isolation/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "gateway-isolation", + "name": "Gateway Isolation", + "description": "Reports the Windows launch mode selected for the running Gateway.", + "enabledByDefault": true, + "enabledByDefaultOnPlatforms": [ + "win32" + ], + "activation": { + "onStartup": true + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/plugins/gateway-isolation/package.json b/plugins/gateway-isolation/package.json new file mode 100644 index 00000000..ced9c931 --- /dev/null +++ b/plugins/gateway-isolation/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openclaw/gateway-isolation", + "version": "2026.8.2", + "private": true, + "description": "Read-only Windows Gateway isolation status", + "type": "module", + "scripts": { + "test": "node --test index.test.js" + }, + "peerDependencies": { + "openclaw": ">=2026.8.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.js" + ] + } +} diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index a823b9da..42d110a0 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -12,6 +12,7 @@ param( ) $ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent $package = @(Get-ChildItem -Path $PackageDirectory -Filter '*.tgz' -File) if ($package.Count -ne 1) { @@ -61,6 +62,101 @@ foreach ($requiredPath in @('package.json', 'openclaw.mjs', 'dist')) { } } +$pluginSource = Join-Path $repositoryRoot 'plugins\gateway-isolation' +$pluginFiles = @('package.json', 'openclaw.plugin.json', 'index.js') +foreach ($pluginFile in $pluginFiles) { + $sourcePath = Join-Path $pluginSource $pluginFile + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Gateway isolation plugin is missing required file: $sourcePath" + } +} + +$pluginManifest = Get-Content ` + -LiteralPath (Join-Path $pluginSource 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json +if ( + $pluginManifest.id -ne 'gateway-isolation' -or + $pluginManifest.enabledByDefault -ne $true -or + $pluginManifest.activation.onStartup -ne $true +) { + throw 'Gateway isolation plugin manifest is not enabled for Gateway startup.' +} + +$bundledPluginsDirectory = Join-Path $installedPackage 'dist\extensions' +if (-not (Test-Path -LiteralPath $bundledPluginsDirectory -PathType Container)) { + throw ( + 'The pinned OpenClaw payload does not expose the supported bundled ' + + "plugin directory: $bundledPluginsDirectory" + ) +} +$pluginTarget = Join-Path $bundledPluginsDirectory 'gateway-isolation' +if (Test-Path -LiteralPath $pluginTarget) { + throw ( + 'The OpenClaw payload already contains a gateway-isolation plugin; ' + + 'refusing to replace upstream content.' + ) +} +New-Item -Path $pluginTarget -ItemType Directory | Out-Null +foreach ($pluginFile in $pluginFiles) { + Copy-Item ` + -LiteralPath (Join-Path $pluginSource $pluginFile) ` + -Destination (Join-Path $pluginTarget $pluginFile) +} + +$previousStateDirectory = $env:OPENCLAW_STATE_DIR +$previousIsolationMode = $env:CLAWCTL_GATEWAY_ISOLATION +try { + $env:OPENCLAW_STATE_DIR = Join-Path ` + $stagingDirectory ` + 'gateway-isolation-validation' + $env:CLAWCTL_GATEWAY_ISOLATION = 'disabled' + Push-Location $installedPackage + try { + $inspectionOutput = ( + & node ` + .\openclaw.mjs ` + plugins inspect gateway-isolation ` + --runtime ` + --json + ) | Out-String + if ($LASTEXITCODE -ne 0) { + throw ( + 'The selected OpenClaw payload cannot load the Gateway ' + + "isolation plugin. Exit code: $LASTEXITCODE." + ) + } + } + finally { + Pop-Location + } + + $inspection = $inspectionOutput | ConvertFrom-Json + if ( + $inspection.plugin.id -ne 'gateway-isolation' -or + $inspection.plugin.origin -ne 'bundled' -or + $inspection.plugin.enabled -ne $true -or + $inspection.plugin.activated -ne $true -or + $inspection.plugin.status -ne 'loaded' -or + $inspection.plugin.imported -ne $true -or + $inspection.plugin.httpRoutes -ne 1 -or + $inspection.httpRouteCount -ne 1 -or + @($inspection.gatewayMethods).Count -ne 0 -or + @($inspection.tools).Count -ne 0 -or + @($inspection.services).Count -ne 0 -or + @($inspection.diagnostics).Count -ne 0 + ) { + throw ( + 'The selected OpenClaw payload did not load the Gateway isolation ' + + 'plugin with the required read-only runtime shape.' + ) + } +} +finally { + $env:OPENCLAW_STATE_DIR = $previousStateDirectory + $env:CLAWCTL_GATEWAY_ISOLATION = $previousIsolationMode +} + $bundledNodeFiles = @( Get-ChildItem -LiteralPath $installedPackage -File -Recurse | Where-Object { diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 new file mode 100644 index 00000000..5b3b3ee6 --- /dev/null +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -0,0 +1,161 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$pluginDirectory = Join-Path $repositoryRoot 'plugins\gateway-isolation' +$testRoot = Join-Path $env:TEMP ( + "openclaw-gateway-isolation-$([guid]::NewGuid().ToString('N'))" +) +$packageSource = Join-Path $testRoot 'package-source' +$packageDirectory = Join-Path $testRoot 'package' +$payloadDirectory = Join-Path $testRoot 'payload' + +function Assert-Path { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Expected file was not found: $Path" + } +} + +try { + $manifest = Get-Content ` + -LiteralPath (Join-Path $pluginDirectory 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json + if ( + $manifest.id -ne 'gateway-isolation' -or + $manifest.enabledByDefault -ne $true -or + $manifest.activation.onStartup -ne $true -or + $manifest.configSchema.additionalProperties -ne $false + ) { + throw 'Gateway isolation plugin manifest is not valid for automatic startup.' + } + + $package = Get-Content ` + -LiteralPath (Join-Path $pluginDirectory 'package.json') ` + -Raw | + ConvertFrom-Json + if ($package.openclaw.extensions.Count -ne 1 -or + $package.openclaw.extensions[0] -ne './index.js') { + throw 'Gateway isolation plugin package has an invalid runtime entry.' + } + + & node --test (Join-Path $pluginDirectory 'index.test.js') + if ($LASTEXITCODE -ne 0) { + throw "Gateway isolation plugin tests failed with exit code $LASTEXITCODE." + } + + New-Item ` + -Path ( + Join-Path $packageSource 'dist\extensions\fixture' + ), $packageDirectory ` + -ItemType Directory ` + -Force | + Out-Null + Set-Content ` + -LiteralPath (Join-Path $packageSource 'openclaw.mjs') ` + -Value @' +const inspection = { + plugin: { + id: "gateway-isolation", + origin: "bundled", + enabled: true, + activated: true, + status: "loaded", + imported: true, + httpRoutes: 1 + }, + httpRouteCount: 1, + gatewayMethods: [], + tools: [], + services: [], + diagnostics: [] +}; +console.log(JSON.stringify(inspection)); +'@ ` + -Encoding utf8 + Set-Content ` + -LiteralPath ( + Join-Path $packageSource 'dist\extensions\fixture\package.json' + ) ` + -Value '{"name":"@openclaw/fixture","version":"1.0.0"}' ` + -Encoding utf8 + [ordered]@{ + name = 'openclaw' + version = '2026.8.2' + type = 'module' + } | + ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $packageSource 'package.json') ` + -Encoding utf8 + + Push-Location $packageSource + try { + & npm pack --pack-destination $packageDirectory --silent + if ($LASTEXITCODE -ne 0) { + throw "npm pack failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } + + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw' + requestedRef = '0965053fe6b9341776df147a6934b7485c60b5ca' + resolvedCommit = '0965053fe6b9341776df147a6934b7485c60b5ca' + packageVersion = '2026.8.2' + } | + ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $packageDirectory 'source.json') ` + -Encoding utf8 + + $previousRunnerTemp = $env:RUNNER_TEMP + try { + $env:RUNNER_TEMP = $testRoot + & (Join-Path $PSScriptRoot 'Build-Payload.ps1') ` + -PackageDirectory $packageDirectory ` + -Architecture arm64 ` + -OutputDirectory $payloadDirectory + } + finally { + $env:RUNNER_TEMP = $previousRunnerTemp + } + + $packagedPlugin = Join-Path ` + $payloadDirectory ` + 'app\dist\extensions\gateway-isolation' + foreach ($pluginFile in @('package.json', 'openclaw.plugin.json', 'index.js')) { + Assert-Path -Path (Join-Path $packagedPlugin $pluginFile) + } + if (Test-Path -LiteralPath (Join-Path $packagedPlugin 'index.test.js')) { + throw 'Plugin test sources must not be shipped in the MSIX payload.' + } + + $packagedManifest = Get-Content ` + -LiteralPath (Join-Path $packagedPlugin 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json + if ( + $packagedManifest.id -ne 'gateway-isolation' -or + $packagedManifest.enabledByDefault -ne $true + ) { + throw 'Packaged Gateway isolation plugin manifest changed during provisioning.' + } + + Write-Host 'Gateway isolation plugin tests passed.' +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/src/OpenClaw.Launcher/GatewayIsolationMode.cs b/src/OpenClaw.Launcher/GatewayIsolationMode.cs new file mode 100644 index 00000000..ff8e2c36 --- /dev/null +++ b/src/OpenClaw.Launcher/GatewayIsolationMode.cs @@ -0,0 +1,18 @@ +namespace OpenClaw.Launcher; + +public enum GatewayIsolationMode +{ + Disabled, + Enabled +} + +internal static class GatewayIsolationModeExtensions +{ + public static string ToEnvironmentValue(this GatewayIsolationMode mode) => + mode switch + { + GatewayIsolationMode.Disabled => "disabled", + GatewayIsolationMode.Enabled => "enabled", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; +} diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index 9f4f0fd1..32a16fe9 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -8,13 +8,15 @@ public static async Task