Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/gateway-msix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
265 changes: 265 additions & 0 deletions plugins/gateway-isolation/index.js
Original file line number Diff line number Diff line change
@@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gateway Isolation</title>
<style>
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #ffffff;
--card: #f6f8fa;
--border: #d0d7de;
--text: #1f2328;
--muted: #59636e;
--ok-bg: #dafbe1;
--ok-text: #116329;
--warn-bg: #fff8c5;
--warn-text: #7d4e00;
--button-bg: #f6f8fa;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--card: #161b22;
--border: #30363d;
--text: #f0f6fc;
--muted: #8b949e;
--ok-bg: #12261e;
--ok-text: #56d364;
--warn-bg: #2e240d;
--warn-text: #e3b341;
--button-bg: #21262d;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
main {
max-width: 880px;
margin: 0 auto;
padding: 24px;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 650;
}
.intro {
margin: 0 0 20px;
color: var(--muted);
line-height: 1.5;
}
.settings-section {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
}
.settings-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.3fr);
gap: 20px;
align-items: center;
padding: 18px;
}
.settings-row + .settings-row { border-top: 1px solid var(--border); }
.settings-row--stacked { align-items: start; }
.settings-row__title { font-weight: 600; }
.settings-row__description {
margin-top: 5px;
color: var(--muted);
line-height: 1.45;
}
.settings-row__control { justify-self: end; min-width: 0; }
.status {
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 999px;
padding: 5px 10px;
font-weight: 600;
}
.status::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
content: "";
}
.status--ok { color: var(--ok-text); background: var(--ok-bg); }
.status--warn { color: var(--warn-text); background: var(--warn-bg); }
.command {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 9px;
background: var(--bg);
}
code {
min-width: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 13px;
}
button {
flex: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 9px;
background: var(--button-bg);
color: var(--text);
cursor: pointer;
font: inherit;
}
.copy-status {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
button:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
@media (max-width: 620px) {
main { padding: 16px; }
.settings-row { grid-template-columns: 1fr; gap: 12px; }
.settings-row__control { justify-self: stretch; }
.status { width: fit-content; }
}
</style>
</head>
<body>
<main>
<h1>Gateway Isolation</h1>
<p class="intro">Diagnostic launch mode reported by the Windows launcher.</p>
<section class="settings-section" aria-label="Gateway Isolation">
<div class="settings-row">
<div class="settings-row__title">Reported Gateway Isolation</div>
<div class="settings-row__control">
<span class="status status--${tone}">${status}</span>
</div>
</div>
<div class="settings-row settings-row--stacked">
<div>
<div class="settings-row__title">Change with CLI</div>
<div class="settings-row__description">Run from the signed-in user session on the Gateway host.</div>
</div>
<div class="settings-row__control command">
<code id="isolation-command">${command}</code>
<button id="copy-command" type="button" aria-label="Copy command">Copy</button>
</div>
<div id="copy-status" class="copy-status" role="status" aria-live="polite"></div>
</div>
</section>
</main>
<script>
const button = document.getElementById("copy-command");
const command = document.getElementById("isolation-command");
const status = document.getElementById("copy-status");
button.addEventListener("click", async () => {
const value = command.textContent;
let copied = false;
try {
await navigator.clipboard.writeText(value);
copied = true;
} catch {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(command);
selection.removeAllRanges();
selection.addRange(range);
copied = document.execCommand("copy");
if (copied) {
selection.removeAllRanges();
}
}
if (copied) {
button.textContent = "Copied";
status.textContent = "";
} else {
button.textContent = "Selected";
status.textContent = "Copy the selected command manually.";
}
});
</script>
</body>
</html>`;
}

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,
"<!doctype html><title>Gateway Isolation unavailable</title><p>The Windows launcher did not provide a valid Gateway isolation mode.</p>",
);
return true;
}
writeHtmlResponse(response, 200, renderGatewayIsolationPage(launchMode));
return true;
},
});
},
};
}

export default createGatewayIsolationPlugin();
Loading