diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index 0fbefff05562..8cdeaefdc545 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -22,6 +22,10 @@ If the Gateway is running on the same computer, open:
If the page fails to load, start the Gateway first: `openclaw gateway`.
+
+On native Windows LAN binds, Windows Firewall or organization-managed Group Policy can still block the advertised LAN URL even when `127.0.0.1` works on the Gateway host. Run `openclaw gateway status --deep` on the Windows host; it reports likely blocked ports, profile mismatches, and local firewall rules that policy may ignore.
+
+
Auth is supplied during the WebSocket handshake via:
- `connect.params.auth.token`
diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts
index 69d748f2910a..4e06470446cf 100644
--- a/src/cli/daemon-cli/status.gather.test.ts
+++ b/src/cli/daemon-cli/status.gather.test.ts
@@ -59,6 +59,13 @@ const loadInstalledPluginIndexInstallRecords = vi.fn<
const readGatewayRestartHandoffSync = vi.fn<
(_env?: NodeJS.ProcessEnv) => GatewayRestartHandoff | null
>(() => null);
+const inspectWindowsGatewayFirewall = vi.fn<(opts?: unknown) => Promise>(async () => ({
+ applies: false,
+ severity: "info" as const,
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+}));
const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined);
const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true);
const serviceReadRuntime = vi.fn<
@@ -224,6 +231,10 @@ vi.mock("../../infra/tls/gateway.js", () => ({
loadGatewayTlsRuntime: (cfg: unknown) => loadGatewayTlsRuntime(cfg),
}));
+vi.mock("../../infra/windows-gateway-firewall-diagnostics.js", () => ({
+ inspectWindowsGatewayFirewall: (opts: unknown) => inspectWindowsGatewayFirewall(opts),
+}));
+
vi.mock("./probe.js", () => ({
probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts),
}));
@@ -281,6 +292,14 @@ describe("gatherDaemonStatus", () => {
loadGatewayTlsRuntime.mockClear();
inspectGatewayRestart.mockClear();
inspectPortConnections.mockClear();
+ inspectWindowsGatewayFirewall.mockClear();
+ inspectWindowsGatewayFirewall.mockResolvedValue({
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ });
readGatewayRestartHandoffSync.mockClear();
readConfigFileSnapshotCalls.mockClear();
loadConfigCalls.mockClear();
@@ -335,6 +354,31 @@ describe("gatherDaemonStatus", () => {
expect(status.cli?.entrypoint).toBe(process.argv[1]);
}
expect(inspectGatewayRestart).not.toHaveBeenCalled();
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ });
+
+ it("includes Windows firewall diagnostics during deep LAN gateway status", async () => {
+ inspectWindowsGatewayFirewall.mockResolvedValueOnce({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
+ details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
+ });
+
+ const status = await gatherDaemonStatus({
+ rpc: {},
+ probe: false,
+ deep: true,
+ });
+
+ expect(inspectWindowsGatewayFirewall).toHaveBeenCalledWith(
+ expect.objectContaining({ bind: "lan", mode: "quick", port: 19001 }),
+ );
+ expect(status.gateway?.windowsFirewall).toMatchObject({
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ });
});
it("falls back to probe version when server metadata is unavailable", async () => {
@@ -653,6 +697,7 @@ describe("gatherDaemonStatus", () => {
daemonLoadedConfig = {
gateway: {
mode: "remote",
+ bind: "lan",
remote: { url: "wss://gateway.example" },
},
};
@@ -664,6 +709,7 @@ describe("gatherDaemonStatus", () => {
});
expect(inspectPortConnections).not.toHaveBeenCalled();
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
expect(loadInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(status.connections).toBeUndefined();
expect(status.pluginVersionDrift).toBeUndefined();
diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts
index b838175f411b..266379d7da30 100644
--- a/src/cli/daemon-cli/status.gather.ts
+++ b/src/cli/daemon-cli/status.gather.ts
@@ -46,6 +46,10 @@ import {
readGatewayRestartHandoffSync,
type GatewayRestartHandoff,
} from "../../infra/restart-handoff.js";
+import {
+ inspectWindowsGatewayFirewall,
+ type WindowsGatewayFirewallDiagnostic,
+} from "../../infra/windows-gateway-firewall-diagnostics.js";
import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js";
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-record-reader.js";
import {
@@ -77,6 +81,7 @@ type GatewayStatusSummary = {
controlUiLinks?: { httpUrl: string; wsUrl: string };
probeNote?: string;
version?: string | null;
+ windowsFirewall?: WindowsGatewayFirewallDiagnostic;
};
type PortStatusSummary = {
@@ -599,6 +604,16 @@ export async function gatherDaemonStatus(
commandProgramArguments: command?.programArguments,
rpcUrlOverride: opts.rpc.url,
});
+ const shouldInspectLocalGateway = daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride;
+ const windowsFirewall =
+ opts.deep === true && shouldInspectLocalGateway
+ ? await inspectWindowsGatewayFirewall({
+ bind: gateway.bindMode,
+ mode: "quick",
+ port: daemonPort,
+ platform: process.platform,
+ })
+ : undefined;
const { portStatus, portCliStatus } = await inspectDaemonPortStatuses({
daemonPort,
cliPort,
@@ -731,7 +746,7 @@ export async function gatherDaemonStatus(
// diagnostics instead.
// Best-effort: unreadable install records omit this advisory report.
let pluginVersionDrift: PluginVersionDriftReport | undefined;
- if (daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride) {
+ if (shouldInspectLocalGateway) {
try {
const installRecords = await loadInstalledPluginIndexInstallRecords({
env: mergedDaemonEnv as NodeJS.ProcessEnv,
@@ -767,6 +782,7 @@ export async function gatherDaemonStatus(
},
gateway: {
...gateway,
+ ...(windowsFirewall?.applies ? { windowsFirewall } : {}),
...(opts.probe
? {
version: gatewayVersion,
diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts
index aa88dd11793a..cad4b41efad2 100644
--- a/src/cli/daemon-cli/status.print.test.ts
+++ b/src/cli/daemon-cli/status.print.test.ts
@@ -183,6 +183,40 @@ describe("printDaemonStatus", () => {
expectMockLineContains(runtime.log, "protocol mismatch after rollback");
});
+ it("prints Windows firewall diagnostics in gateway status output", () => {
+ printDaemonStatus(
+ {
+ service: {
+ label: "LaunchAgent",
+ loaded: true,
+ loadedText: "loaded",
+ notLoadedText: "not loaded",
+ runtime: { status: "running", pid: 8000 },
+ },
+ gateway: {
+ bindMode: "lan",
+ bindHost: "0.0.0.0",
+ port: 18789,
+ portSource: "env/config",
+ probeUrl: "ws://127.0.0.1:18789",
+ windowsFirewall: {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ message:
+ "Windows Firewall may ignore local Gateway allow rules for this network profile.",
+ details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
+ },
+ },
+ extraServices: [],
+ },
+ { json: false, deep: true },
+ );
+
+ expectMockLineContains(runtime.error, "Windows firewall: Windows Firewall may ignore");
+ expectMockLineContains(runtime.error, "GPO-store only");
+ });
+
it("uses service command env for WSL systemd unavailable hints", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux" });
diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts
index 863ded1b1098..4e6b63add03f 100644
--- a/src/cli/daemon-cli/status.print.ts
+++ b/src/cli/daemon-cli/status.print.ts
@@ -223,6 +223,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
if (status.gateway.probeNote) {
defaultRuntime.log(`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`);
}
+ if (status.gateway.windowsFirewall?.severity === "warning") {
+ defaultRuntime.error(warnText(`Windows firewall: ${status.gateway.windowsFirewall.message}`));
+ for (const detail of status.gateway.windowsFirewall.details) {
+ defaultRuntime.error(warnText(` ${detail}`));
+ }
+ }
spacer();
}
diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts
index dacfce271662..a1cd57f4840a 100644
--- a/src/commands/configure.wizard.test.ts
+++ b/src/commands/configure.wizard.test.ts
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
resolveAdvertisedControlUiLinks: vi.fn(),
resolveControlUiLinks: vi.fn(),
resolveLocalControlUiProbeLinks: vi.fn(),
+ inspectWindowsGatewayFirewall: vi.fn(),
summarizeExistingConfig: vi.fn(),
promptAuthConfig: vi.fn(),
promptGatewayConfig: vi.fn(),
@@ -91,6 +92,16 @@ vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
}));
+vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
+ inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall,
+ formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) =>
+ params.bind === "lan"
+ ? [
+ "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
+ ]
+ : [],
+}));
+
vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
@@ -232,6 +243,13 @@ function setupBaseWizardState(config: OpenClawConfig = {}) {
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
+ mocks.inspectWindowsGatewayFirewall.mockResolvedValue({
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({
intro: vi.fn(async () => {}),
@@ -409,6 +427,24 @@ describe("runConfigureWizard", () => {
);
});
+ it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => {
+ setupBaseWizardState({
+ gateway: {
+ mode: "local",
+ bind: "lan",
+ auth: { token: "token" },
+ },
+ });
+
+ await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
+
+ expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ expect(mocks.note).toHaveBeenCalledWith(
+ expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"),
+ "Control UI",
+ );
+ });
+
it("exits with code 1 when configure wizard is cancelled", async () => {
const runtime = createRuntime();
setupBaseWizardState();
diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts
index 80e2593f3048..ea26e5402a22 100644
--- a/src/commands/configure.wizard.ts
+++ b/src/commands/configure.wizard.ts
@@ -18,6 +18,7 @@ import { logConfigUpdated } from "../config/logging.js";
import { ConfigMutationConflictError } from "../config/mutate.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
+import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -884,12 +885,14 @@ export async function runConfigureWizard(
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
+ const windowsFirewallLines = formatWindowsGatewayFirewallGuidance({ bind });
note(
[
`Web UI: ${displayLinks.httpUrl}`,
`Gateway WS: ${displayLinks.wsUrl}`,
gatewayStatusLine,
+ ...windowsFirewallLines,
"Docs: https://docs.openclaw.ai/web/control-ui",
].join("\n"),
"Control UI",
diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts
index a0c524abaf57..2a2187324058 100644
--- a/src/commands/gateway-status.test.ts
+++ b/src/commands/gateway-status.test.ts
@@ -47,6 +47,13 @@ const mocks = vi.hoisted(() => {
fingerprintSha256: "sha256:local-fingerprint",
}),
),
+ inspectWindowsGatewayFirewall: vi.fn<() => Promise>(async () => ({
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ })),
probeGateway: vi.fn(async (opts: { url: string }): Promise => {
const { url } = opts;
if (url.includes("127.0.0.1")) {
@@ -153,6 +160,7 @@ const {
resolveSshConfig,
startSshPortForward,
loadGatewayTlsRuntime,
+ inspectWindowsGatewayFirewall,
probeGateway,
} = mocks;
@@ -215,6 +223,10 @@ vi.mock("../infra/tls/gateway.js", () => ({
loadGatewayTlsRuntime: mocks.loadGatewayTlsRuntime,
}));
+vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
+ inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall,
+}));
+
vi.mock("../gateway/probe.js", async (importOriginal) => ({
...(await importOriginal()),
probeGateway: mocks.probeGateway,
@@ -301,6 +313,7 @@ async function runGatewayStatus(
timeout: string;
json?: boolean;
port?: unknown;
+ url?: string;
ssh?: string;
sshAuto?: boolean;
sshIdentity?: string;
@@ -376,6 +389,74 @@ describe("gateway-status command", () => {
requireRecord(firstTarget.summary, "first target summary");
});
+ it("does not run Windows LAN firewall diagnostics during fast gateway status", async () => {
+ readBestEffortConfig.mockResolvedValueOnce({
+ gateway: {
+ mode: "local",
+ bind: "lan",
+ auth: { token: "ltok" },
+ },
+ } as never);
+ const { runtime, runtimeLogs } = createRuntimeCapture();
+
+ await runGatewayStatus(runtime, { timeout: "1000", json: true });
+
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ const parsed = JSON.parse(runtimeLogs.join("\n")) as {
+ warnings: Array<{ code?: string }>;
+ };
+ expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
+ false,
+ );
+ });
+
+ it("skips local Windows firewall diagnostics for remote Gateway mode", async () => {
+ readBestEffortConfig.mockResolvedValueOnce({
+ gateway: {
+ mode: "remote",
+ bind: "lan",
+ remote: { url: "wss://remote.example:18789", token: "rtok" },
+ auth: { token: "ltok" },
+ },
+ } as never);
+ const { runtime, runtimeLogs } = createRuntimeCapture();
+
+ await runGatewayStatus(runtime, { timeout: "1000", json: true });
+
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ const parsed = JSON.parse(runtimeLogs.join("\n")) as {
+ warnings: Array<{ code?: string }>;
+ };
+ expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
+ false,
+ );
+ });
+
+ it("skips local Windows firewall diagnostics for explicit Gateway URLs", async () => {
+ readBestEffortConfig.mockResolvedValueOnce({
+ gateway: {
+ mode: "local",
+ bind: "lan",
+ auth: { token: "ltok" },
+ },
+ } as never);
+ const { runtime, runtimeLogs } = createRuntimeCapture();
+
+ await runGatewayStatus(runtime, {
+ timeout: "1000",
+ json: true,
+ url: "wss://remote.example:18789",
+ });
+
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ const parsed = JSON.parse(runtimeLogs.join("\n")) as {
+ warnings: Array<{ code?: string }>;
+ };
+ expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
+ false,
+ );
+ });
+
it("surfaces degraded model-pricing health as a warning", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const defaultProbeGateway = probeGateway.getMockImplementation();
diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts
index 5ef928274172..cac458025a29 100644
--- a/src/commands/gateway-status/output.ts
+++ b/src/commands/gateway-status/output.ts
@@ -14,9 +14,10 @@ import {
import type { GatewayStatusProbedTarget } from "./probe-run.js";
/** Warning emitted when gateway status finds degraded or surprising probe state. */
-type GatewayStatusWarning = {
+export type GatewayStatusWarning = {
code: string;
message: string;
+ details?: string[];
targetIds?: string[];
};
@@ -260,6 +261,9 @@ export function writeGatewayStatusText(params: {
params.runtime.log(colorize(params.rich, theme.warn, "Warning:"));
for (const warning of params.warnings) {
params.runtime.log(`- ${warning.message}`);
+ for (const detail of warning.details ?? []) {
+ params.runtime.log(` ${detail}`);
+ }
}
}
diff --git a/src/infra/windows-gateway-firewall-diagnostics.test.ts b/src/infra/windows-gateway-firewall-diagnostics.test.ts
new file mode 100644
index 000000000000..ea06c439f0e5
--- /dev/null
+++ b/src/infra/windows-gateway-firewall-diagnostics.test.ts
@@ -0,0 +1,671 @@
+// Windows Gateway firewall diagnostics classify LAN reachability risks.
+import { describe, expect, it, vi } from "vitest";
+import {
+ DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
+ QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
+ inspectWindowsGatewayFirewall,
+ parseWindowsGatewayFirewallState,
+ classifyWindowsGatewayFirewallState,
+ type WindowsGatewayFirewallCommandRunner,
+} from "./windows-gateway-firewall-diagnostics.js";
+import { getWindowsPowerShellExePath, getWindowsSystem32ExePath } from "./windows-install-roots.js";
+
+function stateJson(params?: {
+ networkCategory?: string;
+ defaultInboundAction?: string;
+ allowInboundRules?: string;
+ activeAllowLocalRules?: string;
+ localAllowRules?: string;
+}) {
+ return JSON.stringify({
+ ConnectionProfiles: [
+ {
+ InterfaceAlias: "Ethernet",
+ NetworkCategory: params?.networkCategory ?? "Public",
+ },
+ ],
+ ActiveFirewallProfiles: [
+ {
+ Name: "Public",
+ Enabled: "True",
+ DefaultInboundAction: params?.defaultInboundAction ?? "Block",
+ AllowInboundRules: params?.allowInboundRules ?? "True",
+ AllowLocalFirewallRules: params?.activeAllowLocalRules ?? "True",
+ },
+ ],
+ LocalFirewallProfiles: [
+ {
+ Name: "Public",
+ Enabled: "True",
+ DefaultInboundAction: "NotConfigured",
+ AllowInboundRules: "NotConfigured",
+ AllowLocalFirewallRules: params?.localAllowRules ?? "NotConfigured",
+ },
+ ],
+ });
+}
+
+function multiProfileStateJson() {
+ return JSON.stringify({
+ ConnectionProfiles: [
+ {
+ InterfaceAlias: "Ethernet",
+ NetworkCategory: "Public",
+ },
+ {
+ InterfaceAlias: "Wi-Fi",
+ NetworkCategory: "Private",
+ },
+ ],
+ ActiveFirewallProfiles: [
+ {
+ Name: "Public",
+ Enabled: "True",
+ DefaultInboundAction: "Block",
+ AllowInboundRules: "True",
+ AllowLocalFirewallRules: "False",
+ },
+ {
+ Name: "Private",
+ Enabled: "True",
+ DefaultInboundAction: "Block",
+ AllowInboundRules: "True",
+ AllowLocalFirewallRules: "True",
+ },
+ ],
+ LocalFirewallProfiles: [
+ {
+ Name: "Public",
+ Enabled: "True",
+ DefaultInboundAction: "NotConfigured",
+ AllowInboundRules: "NotConfigured",
+ AllowLocalFirewallRules: "NotConfigured",
+ },
+ {
+ Name: "Private",
+ Enabled: "True",
+ DefaultInboundAction: "NotConfigured",
+ AllowInboundRules: "NotConfigured",
+ AllowLocalFirewallRules: "NotConfigured",
+ },
+ ],
+ });
+}
+
+function ruleJson(params?: {
+ displayName?: string;
+ profile?: string;
+ policyStoreSource?: string;
+ policyStoreSourceType?: string;
+ program?: string;
+ localAddress?: string;
+ remoteAddress?: string;
+}) {
+ return JSON.stringify([ruleRow(params)]);
+}
+
+function quickPayloadJson(params?: {
+ state?: string;
+ activeRules?: Array>;
+ localRules?: Array>;
+}) {
+ return JSON.stringify({
+ State: JSON.parse(params?.state ?? stateJson({ localAllowRules: "True" })),
+ ActiveRules: params?.activeRules ?? [],
+ LocalRules: params?.localRules ?? [ruleRow()],
+ });
+}
+
+function rulesPayloadJson(params: { active?: unknown[]; local?: unknown[] }) {
+ return JSON.stringify({
+ ActiveRules: params.active ?? [],
+ LocalRules: params.local ?? [],
+ });
+}
+
+function ruleRow(params?: {
+ displayName?: string;
+ profile?: string;
+ policyStoreSource?: string;
+ policyStoreSourceType?: string;
+ program?: string;
+ localAddress?: string;
+ remoteAddress?: string;
+}) {
+ return {
+ DisplayName: params?.displayName ?? "OpenClaw Gateway",
+ Profile: params?.profile ?? "Any",
+ PolicyStoreSource: params?.policyStoreSource ?? "PersistentStore",
+ PolicyStoreSourceType: params?.policyStoreSourceType ?? "Local",
+ Program: params?.program ?? "Any",
+ LocalAddress: params?.localAddress ?? "Any",
+ RemoteAddress: params?.remoteAddress ?? "Any",
+ };
+}
+
+function classify(params: { stateJson: string; rulesJson: string; netshOutput?: string }) {
+ const state = parseWindowsGatewayFirewallState(params);
+ if (!state) {
+ throw new Error("expected parsed firewall state");
+ }
+ return classifyWindowsGatewayFirewallState(state);
+}
+
+describe("Windows Gateway firewall diagnostics", () => {
+ it("does not run commands outside Windows LAN binding", async () => {
+ const runner = vi.fn();
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "loopback",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ applies: false,
+ code: "windows_firewall_not_applicable",
+ });
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "darwin",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ applies: false,
+ code: "windows_firewall_not_applicable",
+ });
+ expect(runner).not.toHaveBeenCalled();
+ });
+
+ it("detects managed Windows policy that ignores local Gateway allow rules", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({
+ activeAllowLocalRules: "False",
+ localAllowRules: "NotConfigured",
+ }),
+ rulesJson: ruleJson(),
+ netshOutput: "LocalFirewallRules N/A (GPO-store only)",
+ });
+
+ expect(diagnostic).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ });
+ expect(diagnostic.details.join("\n")).toContain("GPO-store only");
+ });
+
+ it("detects ignored local rules even when they are absent from ActiveStore", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({
+ activeAllowLocalRules: "False",
+ localAllowRules: "NotConfigured",
+ }),
+ rulesJson: rulesPayloadJson({
+ active: [],
+ local: [ruleRow()],
+ }),
+ netshOutput: "LocalFirewallRules N/A (GPO-store only)",
+ });
+
+ expect(diagnostic).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ });
+ expect(diagnostic.details.join("\n")).toContain("OpenClaw Gateway");
+ });
+
+ it("requires every active profile to allow local firewall rules", () => {
+ expect(
+ classify({
+ stateJson: multiProfileStateJson(),
+ rulesJson: rulesPayloadJson({
+ active: [],
+ local: [ruleRow()],
+ }),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ });
+ });
+
+ it("does not treat NotConfigured local-rule policy as blocked", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ localAllowRules: "NotConfigured" }),
+ rulesJson: ruleJson(),
+ netshOutput: "LocalFirewallRules N/A (GPO-store only)",
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ });
+
+ it("accepts a local allow rule when local rules are enabled for the active profile", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: ruleJson(),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ });
+
+ it("rejects allow rules when the active profile blocks inbound rules globally", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ allowInboundRules: "False", localAllowRules: "True" }),
+ rulesJson: ruleJson(),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inbound_rules_disabled",
+ });
+ });
+
+ it("does not treat program-scoped rules as sufficient Gateway allow rules", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: ruleJson({ program: "C:\\Other\\server.exe" }),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_program_scoped_rule_unverified",
+ });
+ });
+
+ it("does not treat address-scoped rules as sufficient Gateway allow rules", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: ruleJson({ remoteAddress: "192.168.1.20" }),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_address_scoped_rule_unverified",
+ });
+ });
+
+ it("detects a Gateway allow rule on the wrong Windows network profile", () => {
+ expect(
+ classify({
+ stateJson: stateJson({ networkCategory: "Public" }),
+ rulesJson: ruleJson({ profile: "Private" }),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_rule_profile_mismatch",
+ });
+ });
+
+ it("prefers managed rule profile mismatch over local-rule-disabled fallback", () => {
+ expect(
+ classify({
+ stateJson: stateJson({
+ networkCategory: "Public",
+ activeAllowLocalRules: "False",
+ }),
+ rulesJson: rulesPayloadJson({
+ active: [
+ ruleRow({
+ displayName: "Managed private allow",
+ profile: "Private",
+ policyStoreSource: "Intune",
+ policyStoreSourceType: "MDM",
+ }),
+ ],
+ local: [],
+ }),
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_rule_profile_mismatch",
+ });
+ });
+
+ it("detects a blocking profile with no inbound allow rule for the Gateway port", () => {
+ expect(
+ classify({
+ stateJson: stateJson(),
+ rulesJson: "[]",
+ }),
+ ).toMatchObject({
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_no_allow_rule",
+ });
+ });
+
+ it("classifies empty successful rule output as no allow rule", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ if (command.includes("Get-NetConnectionProfile")) {
+ return { code: 0, stdout: stateJson() };
+ }
+ if (command.includes("HNetCfg.FwPolicy2")) {
+ return { code: 0, stdout: "" };
+ }
+ if (command.includes("advfirewall")) {
+ return { code: 0, stdout: "" };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_no_allow_rule",
+ });
+ });
+
+ it("fails closed when firewall rule output is truncated", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ if (command.includes("Get-NetConnectionProfile")) {
+ return { code: 0, stdout: stateJson() };
+ }
+ if (command.includes("HNetCfg.FwPolicy2")) {
+ return { code: 0, stdout: ruleJson(), stdoutTruncatedBytes: 1 };
+ }
+ if (command.includes("advfirewall")) {
+ return { code: 0, stdout: "" };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_inspection_failed",
+ });
+ });
+
+ it("reports local-rule policy when the persistent detail probe is unavailable", async () => {
+ const runner = vi.fn(async (argv, opts) => {
+ const command = argv.join(" ");
+ if (command.includes("Get-NetConnectionProfile")) {
+ return { code: 0, stdout: stateJson({ activeAllowLocalRules: "False" }) };
+ }
+ if (command.includes("HNetCfg.FwPolicy2")) {
+ return { code: 0, stdout: "" };
+ }
+ if (command.includes("PolicyStore ActiveStore")) {
+ return { code: 0, stdout: "" };
+ }
+ if (command.includes("PolicyStore PersistentStore")) {
+ expect(opts.timeoutMs).toBeGreaterThanOrEqual(10_000);
+ return { code: null, stdout: "" };
+ }
+ if (command.includes("advfirewall")) {
+ return { code: 0, stdout: "LocalFirewallRules N/A (GPO-store only)" };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_local_rules_ignored",
+ });
+ });
+
+ it("preserves managed ActiveStore allow rules when local rules are disabled", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ if (command.includes("Get-NetConnectionProfile")) {
+ return { code: 0, stdout: stateJson({ activeAllowLocalRules: "False" }) };
+ }
+ if (command.includes("HNetCfg.FwPolicy2")) {
+ return { code: 0, stdout: ruleJson({ displayName: "Ignored local allow" }) };
+ }
+ if (command.includes("PolicyStore ActiveStore")) {
+ expect(command).toContain("requestedPolicyStoreSourceTypes");
+ expect(command).toContain("-ieq");
+ expect(command).toContain("GroupPolicy");
+ expect(command).toContain("MDM");
+ return {
+ code: 0,
+ stdout: ruleJson({
+ displayName: "MDM-managed Gateway allow",
+ policyStoreSource: "Intune",
+ policyStoreSourceType: "MDM",
+ }),
+ };
+ }
+ if (command.includes("advfirewall")) {
+ return { code: 0, stdout: "" };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ expect(
+ runner.mock.calls.some(([argv]) => argv.join(" ").includes("PolicyStore PersistentStore")),
+ ).toBe(false);
+ });
+
+ it("keeps broad any-port rules from structured Windows rule output", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: rulesPayloadJson({ active: [ruleRow({ displayName: "Broad TCP allow" })] }),
+ });
+
+ expect(diagnostic).toMatchObject({
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ });
+
+ it("treats COM wildcard addresses as address-agnostic", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: rulesPayloadJson({
+ active: [ruleRow({ localAddress: "*", remoteAddress: "*" })],
+ }),
+ });
+
+ expect(diagnostic).toMatchObject({
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ });
+
+ it("does not treat app-scoped any-port rules as sufficient Gateway allow rules", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: rulesPayloadJson({
+ active: [ruleRow({ displayName: "Microsoft Teams", program: "Microsoft Teams" })],
+ }),
+ });
+
+ expect(diagnostic).toMatchObject({
+ severity: "warning",
+ code: "windows_firewall_program_scoped_rule_unverified",
+ });
+ });
+
+ it("does not treat service-scoped explicit port rules as sufficient Gateway allow rules", () => {
+ const diagnostic = classify({
+ stateJson: stateJson({ localAllowRules: "True" }),
+ rulesJson: rulesPayloadJson({
+ active: [ruleRow({ displayName: "Service rule", program: "SomeService" })],
+ }),
+ });
+
+ expect(diagnostic).toMatchObject({
+ severity: "warning",
+ code: "windows_firewall_program_scoped_rule_unverified",
+ });
+ });
+
+ it("runs a quick bounded Windows probe without netsh or follow-up commands", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ expect(command).toContain("Get-NetConnectionProfile");
+ expect(command).toContain("HNetCfg.FwPolicy2");
+ expect(command).toContain("Get-NetFirewallRule");
+ expect(command).toContain("PolicyStore ActiveStore");
+ expect(command).toContain("foreach ($entry in @($value))");
+ expect(command).not.toContain("advfirewall");
+ expect(command).not.toContain("PolicyStore PersistentStore");
+ return { code: 0, stdout: quickPayloadJson() };
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ mode: "quick",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_rule_present",
+ });
+ expect(runner).toHaveBeenCalledTimes(1);
+ expect(runner.mock.calls[0]?.[0][0]).toBe(getWindowsPowerShellExePath());
+ expect(runner.mock.calls[0]?.[1]).toMatchObject({
+ timeoutMs: QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
+ });
+ });
+
+ it("preserves managed ActiveStore allow rules during quick inspection", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ expect(command).toContain("Get-NetFirewallRule");
+ expect(command).toContain("GroupPolicy");
+ expect(command).toContain("MDM");
+ return {
+ code: 0,
+ stdout: quickPayloadJson({
+ state: stateJson({ activeAllowLocalRules: "False" }),
+ activeRules: [
+ ruleRow({
+ displayName: "MDM-managed Gateway allow",
+ policyStoreSource: "Intune",
+ policyStoreSourceType: "MDM",
+ }),
+ ],
+ localRules: [ruleRow({ displayName: "Ignored local allow" })],
+ }),
+ };
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ mode: "quick",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ });
+ expect(runner).toHaveBeenCalledTimes(1);
+ });
+
+ it("runs bounded read-only full Windows probes for LAN binding", async () => {
+ const runner = vi.fn(async (argv) => {
+ const command = argv.join(" ");
+ if (command.includes("Get-NetConnectionProfile")) {
+ return { code: 0, stdout: stateJson({ localAllowRules: "True" }) };
+ }
+ if (command.includes("HNetCfg.FwPolicy2")) {
+ expect(command).toContain("$targetPort = 18789");
+ expect(command).not.toContain("Grouping");
+ expect(command).not.toContain("Description");
+ expect(command).toContain("System.Collections.ArrayList");
+ expect(command).toContain("$matchingRules.Add");
+ expect(command).toContain("[string]$rule.LocalAddresses");
+ expect(command).toContain("[string]$rule.RemoteAddresses");
+ return { code: 0, stdout: ruleJson() };
+ }
+ if (command.includes("advfirewall")) {
+ return { code: 0, stdout: "" };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ timeoutMs: 1234,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_rule_present",
+ });
+ expect(runner).toHaveBeenCalledTimes(3);
+ expect(runner.mock.calls.map(([argv]) => argv[0])).toEqual(
+ expect.arrayContaining([
+ getWindowsPowerShellExePath(),
+ getWindowsSystem32ExePath("netsh.exe"),
+ ]),
+ );
+ for (const [, opts] of runner.mock.calls) {
+ expect(opts).toMatchObject({ timeoutMs: 1234 });
+ }
+
+ runner.mockClear();
+ await expect(
+ inspectWindowsGatewayFirewall({
+ bind: "lan",
+ port: 18789,
+ platform: "win32",
+ runCommandWithTimeout: runner,
+ }),
+ ).resolves.toMatchObject({
+ code: "windows_firewall_rule_present",
+ });
+ expect(runner).toHaveBeenCalledTimes(3);
+ for (const [, opts] of runner.mock.calls) {
+ expect(opts).toMatchObject({ timeoutMs: DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS });
+ }
+ });
+});
diff --git a/src/infra/windows-gateway-firewall-diagnostics.ts b/src/infra/windows-gateway-firewall-diagnostics.ts
new file mode 100644
index 000000000000..da9f078ed840
--- /dev/null
+++ b/src/infra/windows-gateway-firewall-diagnostics.ts
@@ -0,0 +1,1040 @@
+// Read-only diagnostics for Windows LAN Gateway reachability.
+import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js";
+import { getWindowsPowerShellExePath, getWindowsSystem32ExePath } from "./windows-install-roots.js";
+
+export const DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS = 5_000;
+export const QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS = 5_000;
+const DEFAULT_OUTPUT_BYTES = 2 * 1024 * 1024;
+const WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES = [
+ "GroupPolicy",
+ "Dynamic",
+ "Generated",
+ "Hardcoded",
+ "MDM",
+ "HostFirewallGroupPolicy",
+ "HostFirewallDynamic",
+ "HostFirewallMDM",
+];
+
+const WINDOWS_FIREWALL_STATE_COMMAND = [
+ "$ErrorActionPreference = 'Stop'",
+ "$connections = Get-NetConnectionProfile | Select-Object InterfaceAlias, @{Name='NetworkCategory';Expression={$_.NetworkCategory.ToString()}}",
+ "$activeProfiles = Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}",
+ "$localProfiles = Get-NetFirewallProfile -PolicyStore localhost | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}",
+ "[pscustomobject]@{ConnectionProfiles = $connections; ActiveFirewallProfiles = $activeProfiles; LocalFirewallProfiles = $localProfiles} | ConvertTo-Json -Depth 4 -Compress",
+].join("\n");
+
+function buildWindowsNetSecurityFirewallRulesCommand(
+ port: number,
+ policyStore: "ActiveStore" | "PersistentStore",
+ policyStoreSourceTypes?: readonly string[],
+): string {
+ const sourceTypeNames = policyStoreSourceTypes?.map((name) => `'${name}'`).join(", ");
+ const sourceTypeSetup = sourceTypeNames
+ ? `
+$policyStoreSourceType = (Get-Command Get-NetFirewallRule).Parameters['PolicyStoreSourceType'].ParameterType.GetElementType()
+$requestedPolicyStoreSourceTypes = @(${sourceTypeNames})
+$supportedPolicyStoreSourceTypes = [enum]::GetNames($policyStoreSourceType)
+$policyStoreSourceTypes = @(
+ foreach ($requestedPolicyStoreSourceType in $requestedPolicyStoreSourceTypes) {
+ $supportedPolicyStoreSourceTypes | Where-Object { $_ -ieq $requestedPolicyStoreSourceType } | Select-Object -First 1
+ }
+)
+`
+ : "";
+ const ruleQuery = sourceTypeNames
+ ? `
+$rules = if ($policyStoreSourceTypes.Count -gt 0) {
+ @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ${policyStore} -PolicyStoreSourceType $policyStoreSourceTypes -ErrorAction SilentlyContinue)
+} else {
+ @()
+}
+`
+ : `
+$rules = @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ${policyStore})
+`;
+ return `
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$targetPort = ${port}
+${sourceTypeSetup}
+function Test-OpenClawPortMatch($value) {
+ foreach ($entry in @($value)) {
+ $text = ([string]$entry).Trim()
+ if ($text -eq 'Any') { return $true }
+ foreach ($part in $text -split ',') {
+ $range = $part.Trim()
+ if ($range -eq ([string]$targetPort)) { return $true }
+ if ($range -match '^(\\d+)-(\\d+)$') {
+ $start = [int]$Matches[1]
+ $end = [int]$Matches[2]
+ if ($start -le $targetPort -and $targetPort -le $end) { return $true }
+ }
+ }
+ }
+ return $false
+}
+${ruleQuery}
+$matchingRules = New-Object System.Collections.ArrayList
+foreach ($rule in $rules) {
+ foreach ($portFilter in @($rule | Get-NetFirewallPortFilter)) {
+ $protocol = $portFilter.Protocol.ToString()
+ if (($protocol -eq 'Any' -or $protocol -eq 'TCP') -and (Test-OpenClawPortMatch $portFilter.LocalPort)) {
+ $appFilter = $rule | Get-NetFirewallApplicationFilter
+ $addressFilter = $rule | Get-NetFirewallAddressFilter
+ [void]$matchingRules.Add([pscustomobject]@{
+ DisplayName = [string]$rule.DisplayName
+ Name = [string]$rule.Name
+ Profile = [string]$rule.Profile
+ PolicyStoreSource = [string]$rule.PolicyStoreSource
+ PolicyStoreSourceType = $rule.PolicyStoreSourceType.ToString()
+ Program = [string]$appFilter.Program
+ LocalAddress = [string]$addressFilter.LocalAddress
+ RemoteAddress = [string]$addressFilter.RemoteAddress
+ })
+ }
+ }
+}
+$matchingRules | ConvertTo-Json -Depth 4 -Compress
+`.trim();
+}
+
+function buildWindowsPersistentFirewallRulesCommand(port: number): string {
+ return buildWindowsNetSecurityFirewallRulesCommand(port, "PersistentStore");
+}
+
+function buildWindowsManagedActiveFirewallRulesCommand(port: number): string {
+ return buildWindowsNetSecurityFirewallRulesCommand(
+ port,
+ "ActiveStore",
+ WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES,
+ );
+}
+
+function buildWindowsFirewallRulesCommand(port: number): string {
+ return `
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$targetPort = ${port}
+function Test-OpenClawPortMatch($value) {
+ $text = ([string]$value).Trim()
+ if ($text -eq '' -or $text -eq '*') { return $true }
+ foreach ($part in $text -split ',') {
+ $range = $part.Trim()
+ if ($range -eq ([string]$targetPort)) { return $true }
+ if ($range -match '^(\\d+)-(\\d+)$') {
+ $start = [int]$Matches[1]
+ $end = [int]$Matches[2]
+ if ($start -le $targetPort -and $targetPort -le $end) { return $true }
+ }
+ }
+ return $false
+}
+function Resolve-OpenClawProgramScope($rule) {
+ $program = ([string]$rule.ApplicationName).Trim()
+ if ($program) { return $program }
+ foreach ($field in @('serviceName', 'LocalAppPackageId', 'LocalUserOwner')) {
+ $value = ([string]$rule.$field).Trim()
+ if ($value) { return $value }
+ }
+ $ports = ([string]$rule.LocalPorts).Trim()
+ if ($ports -ne '' -and $ports -ne '*') { return 'Any' }
+ return 'Any'
+}
+$policy = New-Object -ComObject HNetCfg.FwPolicy2
+$matchingRules = New-Object System.Collections.ArrayList
+foreach ($rule in $policy.Rules) {
+ if (-not $rule.Enabled -or $rule.Direction -ne 1 -or $rule.Action -ne 1) { continue }
+ $protocol = if ($rule.Protocol -eq 6) { 'TCP' } elseif ($rule.Protocol -eq 256) { 'Any' } else { [string]$rule.Protocol }
+ if (($protocol -ne 'TCP' -and $protocol -ne 'Any') -or -not (Test-OpenClawPortMatch $rule.LocalPorts)) { continue }
+ [void]$matchingRules.Add([pscustomobject]@{
+ DisplayName = [string]$rule.Name
+ Name = [string]$rule.Name
+ Profile = [string]$rule.Profiles
+ PolicyStoreSource = 'PersistentStore'
+ PolicyStoreSourceType = 'Local'
+ Program = (Resolve-OpenClawProgramScope $rule)
+ LocalAddress = [string]$rule.LocalAddresses
+ RemoteAddress = [string]$rule.RemoteAddresses
+ })
+}
+$matchingRules | ConvertTo-Json -Depth 4 -Compress
+`.trim();
+}
+
+function buildWindowsQuickFirewallCommand(port: number): string {
+ const sourceTypeNames = WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES.map(
+ (name) => `'${name}'`,
+ ).join(", ");
+ return `
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$targetPort = ${port}
+function Test-OpenClawPortMatch($value) {
+ foreach ($entry in @($value)) {
+ $text = ([string]$entry).Trim()
+ if ($text -eq '' -or $text -eq '*' -or $text -eq 'Any') { return $true }
+ foreach ($part in $text -split ',') {
+ $range = $part.Trim()
+ if ($range -eq ([string]$targetPort)) { return $true }
+ if ($range -match '^(\\d+)-(\\d+)$') {
+ $start = [int]$Matches[1]
+ $end = [int]$Matches[2]
+ if ($start -le $targetPort -and $targetPort -le $end) { return $true }
+ }
+ }
+ }
+ return $false
+}
+function Resolve-OpenClawProgramScope($rule) {
+ $program = ([string]$rule.ApplicationName).Trim()
+ if ($program) { return $program }
+ foreach ($field in @('serviceName', 'LocalAppPackageId', 'LocalUserOwner')) {
+ $value = ([string]$rule.$field).Trim()
+ if ($value) { return $value }
+ }
+ $ports = ([string]$rule.LocalPorts).Trim()
+ if ($ports -ne '' -and $ports -ne '*') { return 'Any' }
+ return 'Any'
+}
+function Get-OpenClawManagedRules {
+ try {
+ $getRule = Get-Command Get-NetFirewallRule -ErrorAction Stop
+ $sourceTypeParameter = $getRule.Parameters['PolicyStoreSourceType']
+ if ($null -eq $sourceTypeParameter) { return @() }
+ $sourceType = $sourceTypeParameter.ParameterType
+ if ($sourceType.IsArray) { $sourceType = $sourceType.GetElementType() }
+ $requestedPolicyStoreSourceTypes = @(${sourceTypeNames})
+ $supportedPolicyStoreSourceTypes = [enum]::GetNames($sourceType)
+ $policyStoreSourceTypes = @(
+ foreach ($requestedPolicyStoreSourceType in $requestedPolicyStoreSourceTypes) {
+ $supportedPolicyStoreSourceTypes | Where-Object { $_ -ieq $requestedPolicyStoreSourceType } | Select-Object -First 1
+ }
+ )
+ if ($policyStoreSourceTypes.Count -eq 0) { return @() }
+ $rules = @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ActiveStore -PolicyStoreSourceType $policyStoreSourceTypes -ErrorAction SilentlyContinue)
+ $matchingRules = New-Object System.Collections.ArrayList
+ foreach ($rule in $rules) {
+ foreach ($portFilter in @($rule | Get-NetFirewallPortFilter)) {
+ $protocol = $portFilter.Protocol.ToString()
+ if (($protocol -eq 'Any' -or $protocol -eq 'TCP') -and (Test-OpenClawPortMatch $portFilter.LocalPort)) {
+ $appFilter = $rule | Get-NetFirewallApplicationFilter
+ $addressFilter = $rule | Get-NetFirewallAddressFilter
+ [void]$matchingRules.Add([pscustomobject]@{
+ DisplayName = [string]$rule.DisplayName
+ Name = [string]$rule.Name
+ Profile = [string]$rule.Profile
+ PolicyStoreSource = [string]$rule.PolicyStoreSource
+ PolicyStoreSourceType = $rule.PolicyStoreSourceType.ToString()
+ Program = [string]$appFilter.Program
+ LocalAddress = [string]$addressFilter.LocalAddress
+ RemoteAddress = [string]$addressFilter.RemoteAddress
+ })
+ }
+ }
+ }
+ return $matchingRules
+ } catch {
+ return @()
+ }
+}
+$connections = Get-NetConnectionProfile | Select-Object InterfaceAlias, @{Name='NetworkCategory';Expression={$_.NetworkCategory.ToString()}}
+$activeProfiles = Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}
+$localProfiles = Get-NetFirewallProfile -PolicyStore localhost | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}
+$managedMatchingRules = @(Get-OpenClawManagedRules)
+$policy = New-Object -ComObject HNetCfg.FwPolicy2
+$matchingRules = New-Object System.Collections.ArrayList
+foreach ($rule in $policy.Rules) {
+ if (-not $rule.Enabled -or $rule.Direction -ne 1 -or $rule.Action -ne 1) { continue }
+ $protocol = if ($rule.Protocol -eq 6) { 'TCP' } elseif ($rule.Protocol -eq 256) { 'Any' } else { [string]$rule.Protocol }
+ if (($protocol -ne 'TCP' -and $protocol -ne 'Any') -or -not (Test-OpenClawPortMatch $rule.LocalPorts)) { continue }
+ [void]$matchingRules.Add([pscustomobject]@{
+ DisplayName = [string]$rule.Name
+ Name = [string]$rule.Name
+ Profile = [string]$rule.Profiles
+ PolicyStoreSource = 'PersistentStore'
+ PolicyStoreSourceType = 'Local'
+ Program = (Resolve-OpenClawProgramScope $rule)
+ LocalAddress = [string]$rule.LocalAddresses
+ RemoteAddress = [string]$rule.RemoteAddresses
+ })
+}
+[pscustomobject]@{
+ State = [pscustomobject]@{
+ ConnectionProfiles = $connections
+ ActiveFirewallProfiles = $activeProfiles
+ LocalFirewallProfiles = $localProfiles
+ }
+ ActiveRules = $managedMatchingRules
+ LocalRules = $matchingRules
+} | ConvertTo-Json -Depth 5 -Compress
+`.trim();
+}
+
+export type WindowsGatewayFirewallDiagnosticCode =
+ | "windows_firewall_not_applicable"
+ | "windows_firewall_unrestricted"
+ | "windows_firewall_rule_present"
+ | "windows_firewall_rule_profile_mismatch"
+ | "windows_firewall_program_scoped_rule_unverified"
+ | "windows_firewall_address_scoped_rule_unverified"
+ | "windows_firewall_inbound_rules_disabled"
+ | "windows_firewall_local_rules_ignored"
+ | "windows_firewall_no_allow_rule"
+ | "windows_firewall_inspection_failed";
+
+export type WindowsGatewayFirewallDiagnostic = {
+ applies: boolean;
+ severity: "info" | "warning";
+ code: WindowsGatewayFirewallDiagnosticCode;
+ message: string;
+ details: string[];
+};
+
+export type WindowsGatewayFirewallCommandResult = {
+ code: number | null;
+ stdout: string;
+ stderr?: string;
+ stdoutTruncatedBytes?: number;
+ stderrTruncatedBytes?: number;
+};
+
+export type WindowsGatewayFirewallCommandRunner = (
+ argv: string[],
+ opts: { timeoutMs: number; maxOutputBytes?: number },
+) => Promise;
+
+export type InspectWindowsGatewayFirewallParams = {
+ bind: string | undefined;
+ port: number;
+ mode?: "quick" | "full";
+ platform?: NodeJS.Platform;
+ runCommandWithTimeout?: WindowsGatewayFirewallCommandRunner;
+ timeoutMs?: number;
+};
+
+type FirewallStatePayload = {
+ ConnectionProfiles?: unknown;
+ ActiveFirewallProfiles?: unknown;
+ LocalFirewallProfiles?: unknown;
+};
+
+type FirewallProfile = {
+ name: string;
+ enabled: string;
+ defaultInboundAction: string;
+ allowInboundRules: string;
+ allowLocalFirewallRules: string;
+};
+
+type FirewallRule = {
+ displayName: string;
+ profile: string;
+ policyStoreSource: string;
+ policyStoreSourceType: string;
+ program: string;
+ localAddress: string;
+ remoteAddress: string;
+};
+
+type ClassifiedFirewallState = {
+ activeProfileNames: string[];
+ activeProfiles: FirewallProfile[];
+ localProfiles: FirewallProfile[];
+ matchingRules: FirewallRule[];
+ localMatchingRules: FirewallRule[];
+ netshOutput: string;
+};
+
+type QuickFirewallPayload = {
+ State?: unknown;
+ ActiveRules?: unknown;
+ LocalRules?: unknown;
+};
+
+function powershell(command: string): string[] {
+ return [
+ getWindowsPowerShellExePath(),
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ command,
+ ];
+}
+
+async function runBestEffortCommand(
+ runCommandWithTimeout: WindowsGatewayFirewallCommandRunner,
+ argv: string[],
+ timeoutMs: number,
+): Promise {
+ try {
+ const result = await runCommandWithTimeout(argv, {
+ timeoutMs,
+ maxOutputBytes: DEFAULT_OUTPUT_BYTES,
+ });
+ if ((result.stdoutTruncatedBytes ?? 0) > 0 || (result.stderrTruncatedBytes ?? 0) > 0) {
+ return null;
+ }
+ return result.code === 0 ? result.stdout : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseJsonRows(value: unknown): unknown[] {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return value && typeof value === "object" ? [value] : [];
+}
+
+function parseJsonPayload(stdout: string): unknown {
+ const trimmed = stdout.trim();
+ if (!trimmed) {
+ return null;
+ }
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return null;
+ }
+}
+
+function stringField(row: Record, key: string): string {
+ const value = row[key];
+ if (typeof value === "string") {
+ return value.trim();
+ }
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
+ return String(value).trim();
+ }
+ return "";
+}
+
+function normalizeProfileName(value: string): string {
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "domainauthenticated") {
+ return "domain";
+ }
+ return normalized;
+}
+
+function parseFirewallProfiles(value: unknown): FirewallProfile[] {
+ return parseJsonRows(value)
+ .filter((row): row is Record => Boolean(row) && typeof row === "object")
+ .map((row) => ({
+ name: normalizeProfileName(stringField(row, "Name")),
+ enabled: stringField(row, "Enabled").toLowerCase(),
+ defaultInboundAction: stringField(row, "DefaultInboundAction").toLowerCase(),
+ allowInboundRules: stringField(row, "AllowInboundRules").toLowerCase(),
+ allowLocalFirewallRules: stringField(row, "AllowLocalFirewallRules").toLowerCase(),
+ }))
+ .filter((profile) => profile.name.length > 0);
+}
+
+function parseConnectionProfileNames(value: unknown): string[] {
+ const names = parseJsonRows(value)
+ .filter((row): row is Record => Boolean(row) && typeof row === "object")
+ .map((row) => normalizeProfileName(stringField(row, "NetworkCategory")))
+ .filter(Boolean);
+ return [...new Set(names)];
+}
+
+function parseFirewallRules(value: unknown): FirewallRule[] {
+ return parseJsonRows(value)
+ .filter((row): row is Record => Boolean(row) && typeof row === "object")
+ .map((row) => ({
+ displayName:
+ stringField(row, "DisplayName") ||
+ stringField(row, "displayName") ||
+ stringField(row, "Name") ||
+ "unnamed rule",
+ profile: (stringField(row, "Profile") || stringField(row, "profile")).toLowerCase(),
+ policyStoreSource: (
+ stringField(row, "PolicyStoreSource") || stringField(row, "policyStoreSource")
+ ).toLowerCase(),
+ policyStoreSourceType: (
+ stringField(row, "PolicyStoreSourceType") || stringField(row, "policyStoreSourceType")
+ ).toLowerCase(),
+ program: (stringField(row, "Program") || stringField(row, "program")).toLowerCase(),
+ localAddress: (
+ stringField(row, "LocalAddress") || stringField(row, "localAddress")
+ ).toLowerCase(),
+ remoteAddress: (
+ stringField(row, "RemoteAddress") || stringField(row, "remoteAddress")
+ ).toLowerCase(),
+ }));
+}
+
+function isTruthyFirewallValue(value: string): boolean {
+ return value === "true" || value === "allow" || value === "1";
+}
+
+function isBlockingInbound(profile: FirewallProfile): boolean {
+ return profile.enabled !== "false" && profile.defaultInboundAction !== "allow";
+}
+
+function inboundRulesAreAllowed(profiles: FirewallProfile[]): boolean {
+ return profiles.every((profile) => profile.allowInboundRules !== "false");
+}
+
+function findProfileSettings(
+ profiles: FirewallProfile[],
+ activeProfileNames: string[],
+): FirewallProfile[] {
+ if (activeProfileNames.length === 0) {
+ return profiles;
+ }
+ return profiles.filter((profile) => activeProfileNames.includes(profile.name));
+}
+
+function profileMaskMatches(value: number, activeProfileNames: string[]): boolean {
+ const masks: Record = {
+ domain: 1,
+ private: 2,
+ public: 4,
+ };
+ return activeProfileNames.some((name) => (value & (masks[name] ?? 0)) !== 0);
+}
+
+function ruleMatchesActiveProfile(rule: FirewallRule, activeProfileNames: string[]): boolean {
+ if (activeProfileNames.length === 0) {
+ return true;
+ }
+ const profile = rule.profile;
+ if (!profile || profile === "any" || profile === "all") {
+ return true;
+ }
+ const numeric = Number.parseInt(profile, 10);
+ if (Number.isFinite(numeric)) {
+ return profileMaskMatches(numeric, activeProfileNames);
+ }
+ return activeProfileNames.some((name) => profile.includes(name));
+}
+
+function isLocalRule(rule: FirewallRule): boolean {
+ return (
+ !rule.policyStoreSourceType ||
+ rule.policyStoreSourceType === "local" ||
+ rule.policyStoreSourceType === "persistentstore" ||
+ rule.policyStoreSource === "persistentstore"
+ );
+}
+
+function isProgramAgnosticRule(rule: FirewallRule): boolean {
+ return !rule.program || rule.program === "any";
+}
+
+function isAnyAddress(value: string): boolean {
+ return !value || value === "any" || value === "*";
+}
+
+function isAddressAgnosticRule(rule: FirewallRule): boolean {
+ return isAnyAddress(rule.localAddress) && isAnyAddress(rule.remoteAddress);
+}
+
+function localRulesAreAllowed(params: {
+ activeProfileNames: string[];
+ activeProfiles: FirewallProfile[];
+ localProfiles: FirewallProfile[];
+}): boolean {
+ const activeProfiles = findProfileSettings(params.activeProfiles, params.activeProfileNames);
+ const explicitActiveProfiles = activeProfiles.filter(
+ (profile) =>
+ profile.allowLocalFirewallRules && profile.allowLocalFirewallRules !== "notconfigured",
+ );
+ if (explicitActiveProfiles.length > 0) {
+ return explicitActiveProfiles.every((profile) =>
+ isTruthyFirewallValue(profile.allowLocalFirewallRules),
+ );
+ }
+
+ const localProfiles = findProfileSettings(params.localProfiles, params.activeProfileNames);
+ const explicitLocalProfiles = localProfiles.filter(
+ (profile) =>
+ profile.allowLocalFirewallRules && profile.allowLocalFirewallRules !== "notconfigured",
+ );
+ if (explicitLocalProfiles.length > 0) {
+ return explicitLocalProfiles.every((profile) =>
+ isTruthyFirewallValue(profile.allowLocalFirewallRules),
+ );
+ }
+
+ return true;
+}
+
+function formatProfiles(activeProfileNames: string[]): string {
+ return activeProfileNames.length > 0 ? activeProfileNames.join(", ") : "unknown";
+}
+
+function formatRuleNames(rules: FirewallRule[]): string {
+ return rules
+ .map((rule) => rule.displayName)
+ .filter(Boolean)
+ .join(", ");
+}
+
+export function classifyWindowsGatewayFirewallState(
+ state: ClassifiedFirewallState,
+): WindowsGatewayFirewallDiagnostic {
+ const activeProfiles = findProfileSettings(state.activeProfiles, state.activeProfileNames);
+ const blockingProfiles = activeProfiles.filter(isBlockingInbound);
+ const matchingActiveRules = state.matchingRules.filter((rule) =>
+ ruleMatchesActiveProfile(rule, state.activeProfileNames),
+ );
+ const programAgnosticMatchingRules = matchingActiveRules.filter(
+ (rule) => isProgramAgnosticRule(rule) && isAddressAgnosticRule(rule),
+ );
+ const programScopedMatchingRules = matchingActiveRules.filter(
+ (rule) => !isProgramAgnosticRule(rule),
+ );
+ const addressScopedMatchingRules = matchingActiveRules.filter(
+ (rule) => isProgramAgnosticRule(rule) && !isAddressAgnosticRule(rule),
+ );
+ const localMatchingRules = state.localMatchingRules.filter((rule) =>
+ ruleMatchesActiveProfile(rule, state.activeProfileNames),
+ );
+ const programAgnosticLocalRules = localMatchingRules.filter(
+ (rule) => isProgramAgnosticRule(rule) && isAddressAgnosticRule(rule),
+ );
+ const mismatchedRules = state.matchingRules.filter(
+ (rule) => !ruleMatchesActiveProfile(rule, state.activeProfileNames),
+ );
+ const activeProfileText = formatProfiles(state.activeProfileNames);
+
+ if (activeProfiles.length > 0 && blockingProfiles.length === 0) {
+ return {
+ applies: true,
+ severity: "info",
+ code: "windows_firewall_unrestricted",
+ message:
+ "Windows Firewall is not blocking unsolicited inbound traffic on the active profile.",
+ details: [`Active network profile: ${activeProfileText}.`],
+ };
+ }
+
+ if (programAgnosticMatchingRules.length > 0) {
+ if (!inboundRulesAreAllowed(activeProfiles)) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inbound_rules_disabled",
+ message:
+ "Windows Firewall is configured to block inbound connections even when allow rules exist.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Matching allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`,
+ "Enable inbound rules for the active Windows Firewall profile, or use loopback, Tailscale, or an SSH tunnel instead of LAN binding.",
+ ],
+ };
+ }
+ const localRules = programAgnosticMatchingRules.filter(isLocalRule);
+ const onlyLocalRules = localRules.length === programAgnosticMatchingRules.length;
+ if (onlyLocalRules && !localRulesAreAllowed(state)) {
+ const policyDetail = /gpo-store only/i.test(state.netshOutput)
+ ? "Windows reports LocalFirewallRules as N/A (GPO-store only)."
+ : "Local firewall rules are not explicitly enabled for the active profile.";
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Matching local allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`,
+ policyDetail,
+ "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.",
+ ],
+ };
+ }
+ return {
+ applies: true,
+ severity: "info",
+ code: "windows_firewall_rule_present",
+ message:
+ "Windows Firewall has an inbound TCP allow rule for the Gateway port on the active profile.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Matching allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`,
+ "If another device still cannot connect, verify the advertised LAN URL from that device.",
+ ],
+ };
+ }
+
+ if (programScopedMatchingRules.length > 0) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_program_scoped_rule_unverified",
+ message:
+ "Windows Firewall has a matching port allow rule, but it is scoped to a specific program.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Program-scoped allow rule(s): ${formatRuleNames(programScopedMatchingRules)}.`,
+ "Create an inbound TCP allow rule for the Gateway port that is not scoped to another executable, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+
+ if (addressScopedMatchingRules.length > 0) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_address_scoped_rule_unverified",
+ message:
+ "Windows Firewall has a matching port allow rule, but it is scoped to specific addresses.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Address-scoped allow rule(s): ${formatRuleNames(addressScopedMatchingRules)}.`,
+ "Create an inbound TCP allow rule for the Gateway port that covers LAN clients, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+
+ if (programAgnosticLocalRules.length > 0 && !localRulesAreAllowed(state)) {
+ const policyDetail = /gpo-store only/i.test(state.netshOutput)
+ ? "Windows reports LocalFirewallRules as N/A (GPO-store only)."
+ : "Local firewall rules are disabled for the active profile.";
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Matching local allow rule(s): ${formatRuleNames(programAgnosticLocalRules)}.`,
+ policyDetail,
+ "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.",
+ ],
+ };
+ }
+
+ if (mismatchedRules.length > 0) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_rule_profile_mismatch",
+ message: "Windows Firewall has a Gateway allow rule, but not for the active network profile.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ `Mismatched allow rule(s): ${formatRuleNames(mismatchedRules)}.`,
+ "Create or update an inbound TCP allow rule for the active profile, or change the Windows network profile intentionally.",
+ ],
+ };
+ }
+
+ if (!localRulesAreAllowed(state) && state.localMatchingRules.length === 0) {
+ const policyDetail = /gpo-store only/i.test(state.netshOutput)
+ ? "Windows reports LocalFirewallRules as N/A (GPO-store only)."
+ : "Local firewall rules are disabled for the active profile.";
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_local_rules_ignored",
+ message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ "No active inbound TCP allow rule for the Gateway port was found.",
+ policyDetail,
+ "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.",
+ ],
+ };
+ }
+
+ if (blockingProfiles.length > 0 || activeProfiles.length === 0) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_no_allow_rule",
+ message: "Windows Firewall is likely blocking LAN devices from reaching the Gateway port.",
+ details: [
+ `Active network profile: ${activeProfileText}.`,
+ "No enabled inbound TCP allow rule for the Gateway port was found in the active firewall policy.",
+ "Allow the Gateway port in Windows Firewall, or use loopback, Tailscale, or an SSH tunnel instead of LAN binding.",
+ ],
+ };
+ }
+
+ return {
+ applies: true,
+ severity: "info",
+ code: "windows_firewall_unrestricted",
+ message: "Windows Firewall did not show a blocking active profile for the Gateway port.",
+ details: [`Active network profile: ${activeProfileText}.`],
+ };
+}
+
+function buildClassifiedState(
+ stateJson: string,
+ netshOutput: string,
+ activeRules: FirewallRule[],
+ localRules: FirewallRule[],
+): ClassifiedFirewallState | null {
+ return parseWindowsGatewayFirewallState({
+ stateJson,
+ rulesJson: JSON.stringify({
+ ActiveRules: activeRules,
+ LocalRules: localRules,
+ }),
+ netshOutput,
+ });
+}
+
+function shouldProbeManagedActiveRules(diagnostic: WindowsGatewayFirewallDiagnostic): boolean {
+ return (
+ diagnostic.severity === "warning" &&
+ diagnostic.code !== "windows_firewall_inbound_rules_disabled"
+ );
+}
+
+export function parseWindowsGatewayFirewallState(params: {
+ stateJson: string;
+ rulesJson: string;
+ netshOutput?: string | null;
+}): ClassifiedFirewallState | null {
+ const state = parseJsonPayload(params.stateJson) as FirewallStatePayload | null;
+ const rules = parseJsonPayload(params.rulesJson);
+ if (!state) {
+ return null;
+ }
+ const rulePayload =
+ rules && typeof rules === "object" && !Array.isArray(rules)
+ ? (rules as { ActiveRules?: unknown; LocalRules?: unknown })
+ : null;
+ return {
+ activeProfileNames: parseConnectionProfileNames(state.ConnectionProfiles),
+ activeProfiles: parseFirewallProfiles(state.ActiveFirewallProfiles),
+ localProfiles: parseFirewallProfiles(state.LocalFirewallProfiles),
+ matchingRules: parseFirewallRules(rulePayload ? rulePayload.ActiveRules : rules),
+ localMatchingRules: parseFirewallRules(rulePayload?.LocalRules),
+ netshOutput: params.netshOutput ?? "",
+ };
+}
+
+export async function inspectWindowsGatewayFirewall(
+ params: InspectWindowsGatewayFirewallParams,
+): Promise {
+ const platform = params.platform ?? process.platform;
+ if (platform !== "win32" || params.bind !== "lan") {
+ return {
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ };
+ }
+
+ const runCommandWithTimeout = params.runCommandWithTimeout ?? defaultRunCommandWithTimeout;
+ const mode = params.mode ?? "full";
+ const timeoutMs =
+ params.timeoutMs ??
+ (mode === "quick"
+ ? QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS
+ : DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS);
+ if (mode === "quick") {
+ const quickJson = await runBestEffortCommand(
+ runCommandWithTimeout,
+ powershell(buildWindowsQuickFirewallCommand(params.port)),
+ timeoutMs,
+ );
+ if (quickJson === null) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not quickly inspect Windows Firewall LAN Gateway policy.",
+ details: [
+ "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ const quickPayload = parseJsonPayload(quickJson) as QuickFirewallPayload | null;
+ if (!quickPayload || typeof quickPayload !== "object" || Array.isArray(quickPayload)) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.",
+ details: [
+ "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ const managedActiveRules = parseFirewallRules(quickPayload.ActiveRules);
+ const localRules = parseFirewallRules(quickPayload.LocalRules);
+ const stateJson = JSON.stringify(quickPayload.State ?? null);
+ const policyState = parseWindowsGatewayFirewallState({
+ stateJson,
+ rulesJson: JSON.stringify({
+ ActiveRules: [],
+ LocalRules: [],
+ }),
+ });
+ if (!policyState) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.",
+ details: [
+ "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ const activeRules = [
+ ...managedActiveRules,
+ ...(localRulesAreAllowed(policyState) ? localRules : []),
+ ];
+ const state = buildClassifiedState(stateJson, "", activeRules, localRules);
+ return state
+ ? classifyWindowsGatewayFirewallState(state)
+ : {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.",
+ details: [
+ "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ const [stateJson, rulesJson, netshOutput] = await Promise.all([
+ runBestEffortCommand(
+ runCommandWithTimeout,
+ powershell(WINDOWS_FIREWALL_STATE_COMMAND),
+ timeoutMs,
+ ),
+ runBestEffortCommand(
+ runCommandWithTimeout,
+ powershell(buildWindowsFirewallRulesCommand(params.port)),
+ timeoutMs,
+ ),
+ runBestEffortCommand(
+ runCommandWithTimeout,
+ [getWindowsSystem32ExePath("netsh.exe"), "advfirewall", "show", "allprofiles"],
+ timeoutMs,
+ ),
+ ]);
+
+ if (stateJson === null || rulesJson === null) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not inspect Windows Firewall policy for LAN Gateway reachability.",
+ details: [
+ "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ const firewallPolicyText = netshOutput ?? "";
+ const localRules = parseFirewallRules(parseJsonPayload(rulesJson));
+ const policyState = parseWindowsGatewayFirewallState({
+ stateJson,
+ rulesJson: JSON.stringify({
+ ActiveRules: [],
+ LocalRules: [],
+ }),
+ netshOutput: firewallPolicyText,
+ });
+ if (!policyState) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.",
+ details: [
+ "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ let activeRules = localRulesAreAllowed(policyState) ? localRules : [];
+ let state = buildClassifiedState(stateJson, firewallPolicyText, activeRules, localRules);
+ if (!state) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.",
+ details: [
+ "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+
+ const initialDiagnostic = classifyWindowsGatewayFirewallState(state);
+ if (shouldProbeManagedActiveRules(initialDiagnostic)) {
+ const managedRulesJson = await runBestEffortCommand(
+ runCommandWithTimeout,
+ powershell(buildWindowsManagedActiveFirewallRulesCommand(params.port)),
+ timeoutMs,
+ );
+ if (managedRulesJson !== null) {
+ activeRules = [...activeRules, ...parseFirewallRules(parseJsonPayload(managedRulesJson))];
+ state = buildClassifiedState(stateJson, firewallPolicyText, activeRules, localRules);
+ if (!state) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.",
+ details: [
+ "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.",
+ ],
+ };
+ }
+ } else if (!localRulesAreAllowed(state)) {
+ return {
+ applies: true,
+ severity: "warning",
+ code: "windows_firewall_inspection_failed",
+ message:
+ "OpenClaw could not inspect managed Windows Firewall rules for LAN Gateway reachability.",
+ details: [
+ "Run `openclaw gateway status --deep` from a normal PowerShell session and verify Group Policy or administrator-managed allow rules for the Gateway port.",
+ ],
+ };
+ }
+ }
+
+ const diagnosticBeforeLocalDetail = classifyWindowsGatewayFirewallState(state);
+ if (!localRulesAreAllowed(state) && diagnosticBeforeLocalDetail.severity !== "info") {
+ const localRulesJson = await runBestEffortCommand(
+ runCommandWithTimeout,
+ powershell(buildWindowsPersistentFirewallRulesCommand(params.port)),
+ Math.max(timeoutMs, 10_000),
+ );
+ if (localRulesJson !== null) {
+ state.localMatchingRules = parseFirewallRules(parseJsonPayload(localRulesJson));
+ }
+ }
+
+ return classifyWindowsGatewayFirewallState(state);
+}
+
+export function formatWindowsGatewayFirewallGuidance(params: {
+ bind: string | undefined;
+ platform?: NodeJS.Platform;
+}): string[] {
+ const platform = params.platform ?? process.platform;
+ if (platform !== "win32" || params.bind !== "lan") {
+ return [];
+ }
+ return [
+ "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
+ ];
+}
+
+export function formatWindowsGatewayFirewallDiagnostic(
+ diagnostic: WindowsGatewayFirewallDiagnostic,
+): string[] {
+ if (!diagnostic.applies || diagnostic.severity !== "warning") {
+ return [];
+ }
+ return [
+ `Windows firewall: ${diagnostic.message}`,
+ ...diagnostic.details.map((line) => ` ${line}`),
+ ];
+}
diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts
index 75972eb53681..64c6483f0407 100644
--- a/src/wizard/setup.finalize.test.ts
+++ b/src/wizard/setup.finalize.test.ts
@@ -86,6 +86,15 @@ const startGatewayServer = vi.hoisted(() =>
close: vi.fn(async () => {}),
})),
);
+const inspectWindowsGatewayFirewall = vi.hoisted(() =>
+ vi.fn<() => Promise>(async () => ({
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ })),
+);
vi.mock("../commands/onboard-helpers.js", () => ({
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
@@ -101,6 +110,16 @@ vi.mock("../commands/onboard-helpers.js", () => ({
waitForGatewayReachable,
}));
+vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
+ inspectWindowsGatewayFirewall,
+ formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) =>
+ params.bind === "lan"
+ ? [
+ "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
+ ]
+ : [],
+}));
+
vi.mock("../commands/daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint: vi.fn(() => "hint"),
@@ -379,6 +398,14 @@ describe("finalizeSetupWizard", () => {
isContainerEnvironment.mockReturnValue(false);
startGatewayServer.mockReset();
startGatewayServer.mockResolvedValue({ close: vi.fn(async () => {}) });
+ inspectWindowsGatewayFirewall.mockReset();
+ inspectWindowsGatewayFirewall.mockResolvedValue({
+ applies: false,
+ severity: "info",
+ code: "windows_firewall_not_applicable",
+ message: "Windows LAN firewall diagnostics do not apply.",
+ details: [],
+ });
});
it("resolves gateway password SecretRef for probe but omits auth from TUI hatch", async () => {
@@ -506,6 +533,38 @@ describe("finalizeSetupWizard", () => {
expectNoteContains(prompter, "ws://10.211.55.3:18789", "Control UI");
});
+ it("shows static Windows Firewall guidance for LAN Control UI links without inspection", async () => {
+ const prompter = createLaterPrompter();
+ const args = createAdvancedFinalizeArgs({
+ nextConfig: {
+ gateway: {
+ bind: "lan",
+ },
+ },
+ prompter,
+ });
+
+ await finalizeSetupWizard({
+ ...args,
+ opts: {
+ ...args.opts,
+ skipHealth: false,
+ skipUi: false,
+ },
+ settings: {
+ ...args.settings,
+ bind: "lan",
+ },
+ });
+
+ expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
+ expectNoteContains(
+ prompter,
+ "Windows firewall: if another device cannot connect to the LAN URL",
+ "Control UI",
+ );
+ });
+
it("bounds the bootstrap hatch TUI run timeout", async () => {
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
const select = vi.fn(async (params: { message: string }) => {
diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts
index fd6bac7b7e1f..b1a7fa7cfca3 100644
--- a/src/wizard/setup.finalize.ts
+++ b/src/wizard/setup.finalize.ts
@@ -35,6 +35,7 @@ import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { isContainerEnvironment } from "../infra/container-environment.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { formatErrorMessage } from "../infra/errors.js";
+import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
import type { RuntimeEnv } from "../runtime.js";
import { launchTuiCli } from "../tui/tui-launch.js";
import { resolveUserPath } from "../utils.js";
@@ -556,6 +557,9 @@ export async function finalizeSetupWizard(
: t("wizard.finalize.gatewayNotDetectedStatus", {
detail: gatewayProbe.detail ? ` (${gatewayProbe.detail})` : "",
});
+ const windowsFirewallLines = formatWindowsGatewayFirewallGuidance({
+ bind: settings.bind,
+ });
const bootstrapPath = path.join(
resolveUserPath(options.workspaceDir),
DEFAULT_BOOTSTRAP_FILENAME,
@@ -574,6 +578,7 @@ export async function finalizeSetupWizard(
: undefined,
t("wizard.finalize.gatewayWsUrl", { url: displayLinks.wsUrl }),
gatewayStatusLine,
+ ...windowsFirewallLines,
t("wizard.finalize.controlUiDocs"),
]
.filter(Boolean)