From 9aa1df286c2b6fd59b4c101201ddae0ae6000324 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 10 Sep 2026 18:04:34 -0700 Subject: [PATCH] Add Gateway isolation status plugin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 10 + README.md | 35 ++- plugins/gateway-isolation/index.js | 265 ++++++++++++++++++ plugins/gateway-isolation/index.test.js | 142 ++++++++++ .../gateway-isolation/openclaw.plugin.json | 17 ++ plugins/gateway-isolation/package.json | 23 ++ scripts/Build-Payload.ps1 | 96 +++++++ scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 161 +++++++++++ src/OpenClaw.Launcher/GatewayIsolationMode.cs | 18 ++ src/OpenClaw.Launcher/GatewayLauncher.cs | 10 +- src/OpenClaw.Launcher/Program.cs | 2 + .../GatewayLauncherTests.cs | 24 ++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 11 +- 13 files changed, 800 insertions(+), 14 deletions(-) create mode 100644 plugins/gateway-isolation/index.js create mode 100644 plugins/gateway-isolation/index.test.js create mode 100644 plugins/gateway-isolation/openclaw.plugin.json create mode 100644 plugins/gateway-isolation/package.json create mode 100644 scripts/Test-GatewayIsolationPlugin.Tests.ps1 create mode 100644 src/OpenClaw.Launcher/GatewayIsolationMode.cs 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 ` + + + + + Gateway Isolation + + + +
+

Gateway Isolation

+

Diagnostic launch mode reported by the Windows launcher.

+
+
+
Reported Gateway Isolation
+
+ ${status} +
+
+
+
+
Change with CLI
+
Run from the signed-in user session on the Gateway host.
+
+
+ ${command} + +
+
+
+
+
+ + +`; +} + +function writeHtmlResponse(response, statusCode, html) { + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'self'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); + response.end(html); +} + +export function createGatewayIsolationPlugin(env = process.env) { + const launchMode = readGatewayIsolationMode(env); + + return { + id: "gateway-isolation", + name: "Gateway Isolation", + description: "Reports the Windows launch mode selected for the running Gateway.", + register(api) { + api.session.controls.registerControlUiDescriptor({ + surface: "tab", + id: "gateway-isolation", + label: "Gateway Isolation", + description: "Read-only Windows Gateway isolation status.", + icon: "shield-check", + group: "control", + order: 20, + path: STATUS_PATH, + requiredScopes: ["operator.read"], + }); + api.registerHttpRoute({ + path: STATUS_PATH, + auth: "gateway", + match: "exact", + handler(_request, response) { + if (!launchMode) { + writeHtmlResponse( + response, + 503, + "Gateway Isolation unavailable

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 { + 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, />EnabledDisabled { + 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 RunAsync( string nodePath, string payloadDirectory, IReadOnlyList openClawArguments, + GatewayIsolationMode gatewayIsolationMode, CancellationToken cancellationToken, Action? log = null) { ProcessStartInfo startInfo = CreateStartInfo( nodePath, payloadDirectory, - openClawArguments); + openClawArguments, + gatewayIsolationMode: gatewayIsolationMode); log?.Invoke("Launching OpenClaw with forwarded command arguments."); using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); using Process process = job.StartProcess(startInfo); @@ -42,7 +44,9 @@ public static ProcessStartInfo CreateStartInfo( string nodePath, string applicationDirectory, IReadOnlyList openClawArguments, - string? workingDirectory = null) + string? workingDirectory = null, + GatewayIsolationMode gatewayIsolationMode = + GatewayIsolationMode.Disabled) { string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); if (!File.Exists(entryPoint)) @@ -67,6 +71,8 @@ public static ProcessStartInfo CreateStartInfo( startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"] = "external"; startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"] = "external"; startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"] = "1"; + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"] = + gatewayIsolationMode.ToEnvironmentValue(); startInfo.ArgumentList.Add(entryPoint); foreach (string argument in openClawArguments) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index a3402c4d..3fa45336 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -142,6 +142,7 @@ internal static async Task RunAgentAsync( nodeRuntime.ExecutablePath, applicationDirectory, options.OpenClawArguments, + GatewayIsolationMode.Disabled, CancellationToken.None, log); } @@ -198,6 +199,7 @@ internal delegate Task LaunchOpenClawAsync( string nodePath, string applicationDirectory, IReadOnlyList openClawArguments, + GatewayIsolationMode gatewayIsolationMode, CancellationToken cancellationToken, Action? log); diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index 34bd1dbf..e8c9b23f 100644 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs @@ -31,6 +31,9 @@ public void CreateStartInfoForwardsNoArgumentsUnchanged() "external", startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + Assert.Equal( + "disabled", + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); Assert.Equal( [Path.Combine(_payloadDirectory, "openclaw.mjs")], startInfo.ArgumentList); @@ -67,11 +70,32 @@ public void CreateStartInfoPreservesExplicitArguments() "external", startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + Assert.Equal( + "disabled", + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); Assert.Equal( [Path.Combine(_payloadDirectory, "openclaw.mjs"), .. arguments], startInfo.ArgumentList); } + [Theory] + [InlineData(GatewayIsolationMode.Disabled, "disabled")] + [InlineData(GatewayIsolationMode.Enabled, "enabled")] + public void CreateStartInfoReportsExactGatewayIsolationMode( + GatewayIsolationMode mode, + string expected) + { + var startInfo = GatewayLauncher.CreateStartInfo( + "node", + _payloadDirectory, + [], + gatewayIsolationMode: mode); + + Assert.Equal( + expected, + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); + } + [Theory] [InlineData("update", "--yes")] [InlineData("--update")] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index a32bb2ad..df55ec1a 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -29,12 +29,21 @@ await File.WriteAllTextAsync( nodeResolutionAttempted = true; return Task.FromResult(nodeRuntime); }, - (nodePath, appDirectory, forwardedArguments, _, _) => + ( + nodePath, + appDirectory, + forwardedArguments, + gatewayIsolationMode, + _, + _) => { launchAttempted = true; Assert.Equal(nodeRuntime.ExecutablePath, nodePath); Assert.Equal(applicationDirectory, appDirectory); Assert.Equal(arguments, forwardedArguments); + Assert.Equal( + GatewayIsolationMode.Disabled, + gatewayIsolationMode); return Task.FromResult(23); });