From 81a8c2ba802a230b710eda3c0bd45c3c1a7f4598 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:58:46 +0800 Subject: [PATCH 01/24] Speed up MXC startup attestation Keep remembered MXC startup on one locked Gateway generation, gate the bundled node on authenticated RPC readiness, and hot-promote the exact agent policy with verified rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/env.d.ts | 1 + .../src/components/GatewayLoading.test.ts | 17 + .../src/components/GatewayLoading.vue | 1 + desktop/renderer/src/i18n/en-US.ts | 2 + desktop/renderer/src/i18n/zh-CN.ts | 1 + desktop/renderer/src/views/SettingsView.vue | 1 + desktop/src/constants.test.ts | 12 + desktop/src/constants.ts | 10 + desktop/src/gateway-http-readiness.test.ts | 65 ++++ desktop/src/gateway-http-readiness.ts | 31 ++ desktop/src/gateway-rpc-readiness.test.ts | 106 +++++++ desktop/src/gateway-rpc-readiness.ts | 86 +++++ desktop/src/main.ts | 186 ++++++++--- .../windows-node-mxc-auto-transition.test.ts | 20 +- .../src/windows-node-mxc-auto-transition.ts | 10 +- .../windows-node-mxc-folder-transaction.ts | 1 + .../windows-node-mxc-policy-promotion.test.ts | 219 +++++++++++++ .../src/windows-node-mxc-policy-promotion.ts | 294 ++++++++++++++++++ 18 files changed, 1012 insertions(+), 51 deletions(-) create mode 100644 desktop/src/gateway-http-readiness.test.ts create mode 100644 desktop/src/gateway-http-readiness.ts create mode 100644 desktop/src/gateway-rpc-readiness.test.ts create mode 100644 desktop/src/gateway-rpc-readiness.ts create mode 100644 desktop/src/windows-node-mxc-policy-promotion.test.ts create mode 100644 desktop/src/windows-node-mxc-policy-promotion.ts diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index e45a64e..e6f290f 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -359,6 +359,7 @@ interface OpenClawAPI { | "starting-locked" | "smoking-locked" | "starting-active" + | "promoting-active" | "smoking-active" | "verifying-active" | "starting-standard" diff --git a/desktop/renderer/src/components/GatewayLoading.test.ts b/desktop/renderer/src/components/GatewayLoading.test.ts index 148ef7a..7c253c3 100644 --- a/desktop/renderer/src/components/GatewayLoading.test.ts +++ b/desktop/renderer/src/components/GatewayLoading.test.ts @@ -32,6 +32,23 @@ describe("GatewayLoading MXC lifecycle", () => { expect(wrapper.text()).not.toMatch(/approve|permission|allow once/i); }); + it("shows in-process policy promotion as a guarded startup phase", () => { + const wrapper = shallowMount(GatewayLoading, { + props: { + status: "running", + connected: true, + warming: false, + mxcDesired: true, + mxcPhase: "promoting-active", + }, + }); + + expect(wrapper.find(".loading-status").text()).toContain( + "Promoting verified policy in the current generation", + ); + expect(wrapper.find(".loading-status").text()).not.toContain("Ready"); + }); + it("shows fail-closed recovery without labeling MXC failure as a Gateway outage", async () => { const wrapper = shallowMount(GatewayLoading, { props: { diff --git a/desktop/renderer/src/components/GatewayLoading.vue b/desktop/renderer/src/components/GatewayLoading.vue index c790f44..da09bd9 100644 --- a/desktop/renderer/src/components/GatewayLoading.vue +++ b/desktop/renderer/src/components/GatewayLoading.vue @@ -207,6 +207,7 @@ const targetProgress = computed(() => { "smoking-locked": 80, "starting-active": 86, "smoking-active": 92, + "promoting-active": 92, "verifying-active": 97, "starting-standard": 88, }; diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index b8bc5d1..00db17a 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -344,6 +344,8 @@ export default { "settings.windowsNodeMxcLifecyclePhase.starting-active": "Starting active generation", "settings.windowsNodeMxcLifecyclePhase.smoking-active": "Running internal probes in active generation", + "settings.windowsNodeMxcLifecyclePhase.promoting-active": + "Promoting verified policy in the current generation", "settings.windowsNodeMxcLifecyclePhase.verifying-active": "Verifying active route", "settings.windowsNodeMxcLifecyclePhase.starting-standard": "Starting standard security mode", "settings.windowsNodeMxcLifecyclePhase.active": "Active", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 2039ece..b84dd21 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -324,6 +324,7 @@ export default { "settings.windowsNodeMxcLifecyclePhase.smoking-locked": "正在锁定代际中运行内部探针", "settings.windowsNodeMxcLifecyclePhase.starting-active": "正在启动活动代际", "settings.windowsNodeMxcLifecyclePhase.smoking-active": "正在活动代际中运行内部探针", + "settings.windowsNodeMxcLifecyclePhase.promoting-active": "正在当前代际中提升已验证策略", "settings.windowsNodeMxcLifecyclePhase.verifying-active": "正在验证活动路由", "settings.windowsNodeMxcLifecyclePhase.starting-standard": "正在启动标准安全模式", "settings.windowsNodeMxcLifecyclePhase.active": "活动", diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index e84244b..10c953d 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -1469,6 +1469,7 @@ const windowsNodeMxcTransitionPhases = new Set([ "smoking-locked", "starting-active", "smoking-active", + "promoting-active", "verifying-active", "starting-standard", ]); diff --git a/desktop/src/constants.test.ts b/desktop/src/constants.test.ts index f26f071..2658f2d 100644 --- a/desktop/src/constants.test.ts +++ b/desktop/src/constants.test.ts @@ -7,6 +7,10 @@ import { HEALTH_CHECK_HTTP_TIMEOUT_MS, HEALTH_CHECK_BUSY_GRACE_MS, GATEWAY_READY_TIMEOUT_MS, + MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + MXC_GATEWAY_RPC_READY_TIMEOUT_MS, PORT_WAIT_TIMEOUT_MS, MODEL_CONNECTION_TEST_TIMEOUT_MS, POST_SPAWN_CHANNEL_READY_TIMEOUT_MS, @@ -58,6 +62,14 @@ describe("constants", () => { expect(GATEWAY_READY_TIMEOUT_MS).toBeGreaterThanOrEqual(HEALTH_CHECK_BUSY_GRACE_MS); }); + it("gates bundled-node startup on a bounded long-running authenticated RPC", () => { + expect(MXC_GATEWAY_RPC_READY_TIMEOUT_MS).toBeLessThanOrEqual(GATEWAY_READY_TIMEOUT_MS); + expect(MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS).toBeLessThan(MXC_GATEWAY_RPC_READY_TIMEOUT_MS); + expect(MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS).toBeLessThanOrEqual( + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + ); + }); + it("model connection test has a finite timeout", () => { expect(MODEL_CONNECTION_TEST_TIMEOUT_MS).toBeGreaterThan(0); expect(MODEL_CONNECTION_TEST_TIMEOUT_MS).toBeLessThanOrEqual(30_000); diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index 2b84e96..0732722 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -54,6 +54,16 @@ export const HEALTH_CHECK_BUSY_GRACE_MS = 240_000; * than two minutes before the Gateway binds its health endpoint. */ export const GATEWAY_READY_TIMEOUT_MS = 300_000; +/** Total authenticated RPC readiness budget before the bundled Windows node may start. */ +export const MXC_GATEWAY_RPC_READY_TIMEOUT_MS = 180_000; + +/** Per-attempt budget for the representative authenticated config.get readiness RPC. */ +export const MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS = 120_000; + +/** Initial and maximum retry delays for authenticated Gateway readiness. */ +export const MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS = 500; +export const MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS = 5_000; + /** Max time to wait for the gateway port to become free (ms). */ export const PORT_WAIT_TIMEOUT_MS = 30_000; diff --git a/desktop/src/gateway-http-readiness.test.ts b/desktop/src/gateway-http-readiness.test.ts new file mode 100644 index 0000000..8a7813f --- /dev/null +++ b/desktop/src/gateway-http-readiness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { waitForGatewayHttpReadiness } from "./gateway-http-readiness"; + +function clock() { + let value = 0; + return { + now: () => value, + sleep: vi.fn(async (milliseconds: number) => { + value += milliseconds; + }), + }; +} + +describe("Gateway HTTP readiness", () => { + it("waits through delayed health responses", async () => { + const time = clock(); + const probe = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + await expect( + waitForGatewayHttpReadiness({ + probe, + isProcessAlive: () => true, + timeoutMs: 5_000, + pollIntervalMs: 500, + ...time, + }), + ).resolves.toBe(true); + + expect(probe).toHaveBeenCalledTimes(3); + expect(time.sleep).toHaveBeenCalledTimes(2); + }); + + it("stops immediately when the managed process exits", async () => { + const probe = vi.fn(); + + await expect( + waitForGatewayHttpReadiness({ + probe, + isProcessAlive: () => false, + timeoutMs: 5_000, + pollIntervalMs: 500, + }), + ).resolves.toBe(false); + + expect(probe).not.toHaveBeenCalled(); + }); + + it("returns false at the bounded deadline", async () => { + const time = clock(); + + await expect( + waitForGatewayHttpReadiness({ + probe: vi.fn().mockResolvedValue(false), + isProcessAlive: () => true, + timeoutMs: 1_200, + pollIntervalMs: 500, + ...time, + }), + ).resolves.toBe(false); + }); +}); diff --git a/desktop/src/gateway-http-readiness.ts b/desktop/src/gateway-http-readiness.ts new file mode 100644 index 0000000..dfe4e3b --- /dev/null +++ b/desktop/src/gateway-http-readiness.ts @@ -0,0 +1,31 @@ +export type GatewayHttpReadinessOptions = { + probe: () => Promise; + isProcessAlive: () => boolean; + timeoutMs: number; + pollIntervalMs: number; + sleep?: (milliseconds: number) => Promise; + now?: () => number; +}; + +export async function waitForGatewayHttpReadiness( + options: GatewayHttpReadinessOptions, +): Promise { + if (options.timeoutMs <= 0 || options.pollIntervalMs <= 0) { + throw new Error("Gateway HTTP readiness timings must be positive"); + } + + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const deadline = now() + options.timeoutMs; + + while (now() < deadline) { + if (!options.isProcessAlive()) return false; + if (await options.probe()) return true; + const remainingMs = deadline - now(); + if (remainingMs <= 0) break; + await sleep(Math.min(options.pollIntervalMs, remainingMs)); + } + return false; +} diff --git a/desktop/src/gateway-rpc-readiness.test.ts b/desktop/src/gateway-rpc-readiness.test.ts new file mode 100644 index 0000000..90c12c9 --- /dev/null +++ b/desktop/src/gateway-rpc-readiness.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GatewayRpcReadinessCancelledError, + startAfterGatewayRpcReady, + waitForGatewayRpcReadiness, +} from "./gateway-rpc-readiness"; + +function clock() { + let value = 0; + return { + now: () => value, + sleep: vi.fn(async (milliseconds: number) => { + value += milliseconds; + }), + }; +} + +describe("Gateway authenticated RPC readiness", () => { + it("retries HTTP-ready but RPC-delayed generations before succeeding", async () => { + const time = clock(); + const probe = vi + .fn<(timeoutMs: number) => Promise>() + .mockRejectedValueOnce(new Error("dispatcher busy")) + .mockRejectedValueOnce(new Error("compatibility modules loading")) + .mockResolvedValue(undefined); + + await expect( + waitForGatewayRpcReadiness({ + probe, + isCurrent: () => true, + timeoutMs: 10_000, + attemptTimeoutMs: 2_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + ...time, + }), + ).resolves.toEqual({ attempts: 3, elapsedMs: 750 }); + + expect(probe).toHaveBeenCalledTimes(3); + }); + + it("never starts the bundled node until authenticated RPC responds", async () => { + let releaseProbe!: () => void; + const probe = new Promise((resolve) => { + releaseProbe = resolve; + }); + const start = vi.fn().mockResolvedValue(undefined); + const startup = startAfterGatewayRpcReady({ + probe: () => probe, + start, + isCurrent: () => true, + timeoutMs: 10_000, + attemptTimeoutMs: 5_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + }); + + await Promise.resolve(); + expect(start).not.toHaveBeenCalled(); + releaseProbe(); + await startup; + expect(start).toHaveBeenCalledOnce(); + }); + + it("times out without starting the bundled node", async () => { + const time = clock(); + const start = vi.fn(); + + await expect( + startAfterGatewayRpcReady({ + probe: vi.fn(async () => { + await time.sleep(1_000); + throw new Error("RPC unavailable"); + }), + start, + isCurrent: () => true, + timeoutMs: 2_500, + attemptTimeoutMs: 1_000, + initialBackoffMs: 250, + maxBackoffMs: 500, + ...time, + }), + ).rejects.toThrow(/authenticated RPC readiness timed out/); + expect(start).not.toHaveBeenCalled(); + }); + + it("cancels stale generations before node launch", async () => { + let current = true; + const start = vi.fn(); + + await expect( + startAfterGatewayRpcReady({ + probe: async () => { + current = false; + }, + start, + isCurrent: () => current, + timeoutMs: 10_000, + attemptTimeoutMs: 5_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + }), + ).rejects.toBeInstanceOf(GatewayRpcReadinessCancelledError); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/gateway-rpc-readiness.ts b/desktop/src/gateway-rpc-readiness.ts new file mode 100644 index 0000000..3e6ad6f --- /dev/null +++ b/desktop/src/gateway-rpc-readiness.ts @@ -0,0 +1,86 @@ +export type GatewayRpcReadinessResult = { + attempts: number; + elapsedMs: number; +}; + +export type GatewayRpcReadinessOptions = { + probe: (timeoutMs: number) => Promise; + isCurrent: () => boolean; + timeoutMs: number; + attemptTimeoutMs: number; + initialBackoffMs: number; + maxBackoffMs: number; + sleep?: (milliseconds: number) => Promise; + now?: () => number; + onAttemptFailure?: (error: unknown, attempt: number) => void; +}; + +export class GatewayRpcReadinessCancelledError extends Error { + constructor() { + super("Gateway RPC readiness was cancelled because the managed generation changed"); + this.name = "GatewayRpcReadinessCancelledError"; + } +} + +export async function waitForGatewayRpcReadiness( + options: GatewayRpcReadinessOptions, +): Promise { + if (options.timeoutMs <= 0 || options.attemptTimeoutMs <= 0) { + throw new Error("Gateway RPC readiness timeouts must be positive"); + } + if (options.initialBackoffMs <= 0 || options.maxBackoffMs < options.initialBackoffMs) { + throw new Error("Gateway RPC readiness backoff is invalid"); + } + + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const startedAt = now(); + const deadline = startedAt + options.timeoutMs; + let attempts = 0; + let backoffMs = options.initialBackoffMs; + let lastError: unknown = new Error("Gateway RPC probe has not completed"); + + while (now() < deadline) { + assertCurrent(options.isCurrent); + attempts += 1; + const remainingMs = Math.max(1, deadline - now()); + try { + await options.probe(Math.min(options.attemptTimeoutMs, remainingMs)); + assertCurrent(options.isCurrent); + return { attempts, elapsedMs: now() - startedAt }; + } catch (error) { + assertCurrent(options.isCurrent); + lastError = error; + options.onAttemptFailure?.(error, attempts); + } + + const remainingMsAfterProbe = deadline - now(); + if (remainingMsAfterProbe <= 0) break; + await sleep(Math.min(backoffMs, remainingMsAfterProbe)); + backoffMs = Math.min(backoffMs * 2, options.maxBackoffMs); + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `Gateway authenticated RPC readiness timed out after ${options.timeoutMs}ms (${attempts} attempts): ${detail}`, + ); +} + +export async function startAfterGatewayRpcReady( + options: GatewayRpcReadinessOptions & { + start: () => Promise; + onReady?: (result: GatewayRpcReadinessResult) => void; + }, +): Promise { + const result = await waitForGatewayRpcReadiness(options); + assertCurrent(options.isCurrent); + options.onReady?.(result); + await options.start(); + return result; +} + +function assertCurrent(isCurrent: () => boolean): void { + if (!isCurrent()) throw new GatewayRpcReadinessCancelledError(); +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts index a07f14b..93585f2 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -67,6 +67,10 @@ import { DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, GATEWAY_READY_TIMEOUT_MS, + MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + MXC_GATEWAY_RPC_READY_TIMEOUT_MS, HEALTH_CHECK_INTERVAL_MS, HEALTH_CHECK_HTTP_TIMEOUT_MS, HEALTH_CHECK_FAILURE_THRESHOLD, @@ -175,6 +179,13 @@ import { type WindowsNodeMxcFolderTransactionPhase, } from "./windows-node-mxc-folder-transaction"; import { runWindowsNodeMxcAutomaticTransition } from "./windows-node-mxc-auto-transition"; +import { + attestWindowsNodeMxcGatewaySnapshot, + promoteWindowsNodeMxcPolicy, + type GatewayConfigSnapshot, +} from "./windows-node-mxc-policy-promotion"; +import { startAfterGatewayRpcReady } from "./gateway-rpc-readiness"; +import { waitForGatewayHttpReadiness } from "./gateway-http-readiness"; import { WindowsNodeMxcDurableApprovalStore, consumeExactDurableApproval, @@ -467,6 +478,7 @@ const bundledWindowsNodeHost = new BundledWindowsNodeHost(); let bundledWindowsNodeStartup: Promise | null = null; let bundledWindowsNodeGeneration = 0; let gatewayGenerationId = ""; +let gatewayHttpReadyGeneration: string | null = null; let windowsNodeMxcApprovalProofContext: BundledWindowsNodeApprovalProofContext | null = null; let windowsNodeMxcIngressGeneration: string | null = null; let windowsNodeMxcActivationInProgress = false; @@ -482,6 +494,11 @@ let pendingWindowsNodeMxcGatewayApproval: { gatewayGeneration: string; } | null = null; let windowsNodeMxcFailClosedPromise: Promise | null = null; +let windowsNodeMxcStartupTiming: { + startedAt: number; + previousAt: number; + generation: string | null; +} | null = null; let windowsNodeMxcDurableApprovalStore: WindowsNodeMxcDurableApprovalStore | null = null; let windowsNodeMxcDurableApprovalOperation: Promise = Promise.resolve(); let windowsNodeMxcApprovalResolutionInProgress = false; @@ -584,6 +601,22 @@ function logStartupTiming(phase: string): void { console.log(`[startup-timing] ${phase} +${Date.now() - appStartupStartedAt}ms`); } +function beginWindowsNodeMxcStartupTiming(): void { + const now = Date.now(); + windowsNodeMxcStartupTiming = { startedAt: now, previousAt: now, generation: null }; +} + +function markWindowsNodeMxcStartupTiming(phase: string, detail = ""): void { + const timing = windowsNodeMxcStartupTiming; + if (!timing) return; + const now = Date.now(); + if (gatewayGenerationId) timing.generation = gatewayGenerationId; + console.log( + `[windows-node-mxc][startup-timing] phase=${phase} durationMs=${now - timing.previousAt} totalMs=${now - timing.startedAt} generation=${timing.generation ?? "pending"}${detail ? ` ${detail}` : ""}`, + ); + timing.previousAt = now; +} + function setGatewayStatus(status: GatewayStatus): void { gatewayStatus = status; updateTrayMenu(status, resolveSupportedLocale(settingsStore.get("language") ?? "en-US")); @@ -1489,6 +1522,7 @@ async function runAutomaticWindowsNodeMxcReadiness(): Promise { + beginWindowsNodeMxcStartupTiming(); windowsNodeMxcActivationInProgress = true; windowsNodeMxcIngressGeneration = null; bundledWindowsNodeHost.revokeActivationLease(); @@ -1500,38 +1534,49 @@ async function runAutomaticWindowsNodeMxcReadiness(): Promise { assertWindowsNodeMxcSmokePassed(await runCurrentWindowsNodeMxcSmoke(lockedStatus), "Locked"); + markWindowsNodeMxcStartupTiming("probes"); }, - startActiveGeneration: async () => { - const config = readConfig(); - if (!config || typeof config !== "object" || Array.isArray(config)) { - throw new Error("OpenClaw configuration is unavailable"); - } - const configuredPort = config?.gateway?.port || gatewayPort || DEFAULT_PORT; + promoteActivePolicy: async () => { + const generation = gatewayGenerationId; + const gateway = gwClient; + const gatewayProcessAtPromotion = gatewayProcess; const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); - const activePolicy = applyWindowsNodeMxcGatewayPolicy( - config, + if (!gateway || !gatewayProcessAtPromotion) { + throw new Error("Managed Gateway is unavailable for MXC policy promotion"); + } + const promoted = await promoteWindowsNodeMxcPolicy({ + gateway, nodeId, - settingsStore.get("windowsNodeMxcToolBackups"), - "active", - ); - await stopGatewayForSecurityTransition(configuredPort); - settingsStore.delete("windowsNodeMxcSmoke"); - settingsStore.set("windowsNodeMxcToolBackups", activePolicy.backups); - writeConfigTextAtomically(JSON.stringify(activePolicy.config, null, 2)); - await startGatewayWithWindowsNodeMxcPolicy("active"); - activeGeneration = gatewayGenerationId; - await waitForBundledWindowsNodeGeneration(activeGeneration); - return waitForWindowsNodeMxcBaseReady(activeGeneration, "active", false); + backups: settingsStore.get("windowsNodeMxcToolBackups"), + expectedGeneration: generation, + currentGeneration: () => gatewayGenerationId, + isCurrent: () => + gwClient === gateway && + gateway.connected && + gatewayProcess === gatewayProcessAtPromotion && + isManagedGatewayProcessAlive(), + requestTimeoutMs: MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + }); + settingsStore.set("windowsNodeMxcToolBackups", promoted.backups); + activeGeneration = generation; + const status = await waitForWindowsNodeMxcBaseReady(generation, "active", false); + markWindowsNodeMxcStartupTiming("policy-promoted"); + return status; }, - smokeActiveGeneration: async (activeStatus) => { + smokeActivePolicy: async (activeStatus) => { assertWindowsNodeMxcSmokePassed(await runCurrentWindowsNodeMxcSmoke(activeStatus), "Active"); + await waitForWindowsNodeMxcBaseReady(activeGeneration, "active", true); + markWindowsNodeMxcStartupTiming("active-probes"); }, mintActivationLease: async () => { await bundledWindowsNodeHost.setActivationLease("active", 120_000); + markWindowsNodeMxcStartupTiming("activation-lease"); }, verifyActiveGeneration: async () => { const finalStatus = await getWindowsNodeMxcStatus(); @@ -1543,14 +1588,18 @@ async function runAutomaticWindowsNodeMxcReadiness(): Promise { windowsNodeMxcIngressGeneration = activeGeneration; windowsNodeMxcActivationInProgress = false; + markWindowsNodeMxcStartupTiming("ingress-release"); + windowsNodeMxcStartupTiming = null; }, lockAfterFailure: async (error) => { windowsNodeMxcActivationInProgress = false; + windowsNodeMxcStartupTiming = null; await failClosedWindowsNodeMxc( `Automatic readiness failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -2771,19 +2820,18 @@ async function waitForGatewayReady( timeoutMs = GATEWAY_READY_TIMEOUT_MS, isProcessAlive: () => boolean = () => true, ): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (!isProcessAlive()) return false; - const ok = await checkExistingGateway(port); - if (ok) return true; - await new Promise((r) => setTimeout(r, 500)); - } - return false; + return waitForGatewayHttpReadiness({ + probe: () => checkExistingGateway(port), + isProcessAlive, + timeoutMs, + pollIntervalMs: 500, + }); } /** Kill the managed gateway and any listener still holding gatewayPort. */ function stopGatewayProcess(): void { stopBundledWindowsNodeHost(); + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; const knownPid = gatewayProcess?.pid; gatewayProcess = null; @@ -2898,6 +2946,7 @@ async function stopGatewayForSecurityTransition(port: number): Promise { pendingWindowsNodeMxcGatewayApproval = null; sendToWindow(mainWindow, "windows-node-mxc:approval-request", null); stopBundledWindowsNodeHost(true); + gatewayHttpReadyGeneration = null; const managedPid = gatewaySpawnedByUs && isManagedGatewayProcessAlive() ? gatewayProcess?.pid : undefined; const listenerPids = getGatewayListenerPids(port); @@ -3898,6 +3947,7 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { gatewayProcess = child; gatewayGenerationId = nextGatewayGenerationId; + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = nextApprovalProofContext; windowsNodeMxcIngressGeneration = null; gatewaySpawnedByUs = true; @@ -3940,6 +3990,7 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { if (gatewayProcess === child) { if (!postInstallReadySignaled) signalPostInstallFailure(err); windowsNodeMxcIngressGeneration = null; + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); @@ -3960,6 +4011,7 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { ); } windowsNodeMxcIngressGeneration = null; + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); @@ -4279,6 +4331,10 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { if (ready) { try { logStartupTiming("gateway-ready"); + gatewayHttpReadyGeneration = nextGatewayGenerationId; + if (windowsNodeMxcActivationInProgress) { + markWindowsNodeMxcStartupTiming("http-ready"); + } commitStartupAgentSkills(); setGatewayStatus("running"); } catch (error) { @@ -4431,6 +4487,16 @@ function connectGatewayWs(): void { return; } const hostGeneration = ++bundledWindowsNodeGeneration; + const expectedGatewayPolicy = getWindowsNodeMxcGatewayPolicyState( + readConfig(), + approvalProof.nodeId, + ); + if (expectedGatewayPolicy === "drift") { + void failClosedWindowsNodeMxc( + "Gateway policy drifted before authenticated RPC readiness", + ); + return; + } const startOptions = { gatewayUrl: `ws://127.0.0.1:${gatewayPort}`, gatewayToken, @@ -4473,6 +4539,7 @@ function connectGatewayWs(): void { bundledWindowsNodeGeneration !== hostGeneration || gwClient !== gateway || !gateway.connected || + gatewayHttpReadyGeneration !== approvalProof.gatewayGeneration || !gatewayGeneration || gatewayProcess !== gatewayGeneration || gatewayGeneration.exitCode !== null || @@ -4481,15 +4548,55 @@ function connectGatewayWs(): void { throw new Error("Managed Gateway generation changed before Windows node startup"); } }; - const startup = Promise.resolve() - .then(() => { + const startup = startAfterGatewayRpcReady({ + probe: async (timeoutMs) => { assertCurrentGatewayGeneration(); - return bundledWindowsNodeHost.start(startOptions); - }) - .then(() => { + const snapshot = await gateway.request( + "config.get", + {}, + timeoutMs, + ); assertCurrentGatewayGeneration(); - return bundledWindowsNodeHost.ensurePaired(gateway); - }) + attestWindowsNodeMxcGatewaySnapshot( + snapshot, + approvalProof.nodeId, + expectedGatewayPolicy, + ); + }, + isCurrent: () => { + try { + assertCurrentGatewayGeneration(); + return true; + } catch { + return false; + } + }, + timeoutMs: MXC_GATEWAY_RPC_READY_TIMEOUT_MS, + attemptTimeoutMs: MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + initialBackoffMs: MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + maxBackoffMs: MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + onAttemptFailure: (error, attempt) => { + console.warn( + `[bundled-windows-node] authenticated RPC readiness attempt ${attempt} failed:`, + error, + ); + }, + onReady: (result) => { + markWindowsNodeMxcStartupTiming( + "rpc-ready", + `attempts=${result.attempts} probeElapsedMs=${result.elapsedMs}`, + ); + }, + start: async () => { + assertCurrentGatewayGeneration(); + await bundledWindowsNodeHost.start(startOptions); + assertCurrentGatewayGeneration(); + await bundledWindowsNodeHost.ensurePaired(gateway); + assertCurrentGatewayGeneration(); + markWindowsNodeMxcStartupTiming("node-connected"); + }, + }) + .then(() => undefined) .catch((error) => { if (bundledWindowsNodeGeneration === hostGeneration) { bundledWindowsNodeHost.stop(); @@ -4502,6 +4609,13 @@ function connectGatewayWs(): void { bundledWindowsNodeStartup = startup; void startup.catch((error) => { console.error("[bundled-windows-node] start failed:", error); + if (!windowsNodeMxcSecurityTransitionInProgress) { + void failClosedWindowsNodeMxc( + `Bundled node startup failed during authenticated RPC readiness: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } }); } } diff --git a/desktop/src/windows-node-mxc-auto-transition.test.ts b/desktop/src/windows-node-mxc-auto-transition.test.ts index 04c70e0..94f5543 100644 --- a/desktop/src/windows-node-mxc-auto-transition.test.ts +++ b/desktop/src/windows-node-mxc-auto-transition.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { runWindowsNodeMxcAutomaticTransition } from "./windows-node-mxc-auto-transition"; describe("automatic Windows Node + MXC transition", () => { - it("keeps ingress closed until both internally probed generations verify", async () => { + it("keeps one generation locked until its policy is promoted and verified", async () => { const calls: string[] = []; const status = await runWindowsNodeMxcAutomaticTransition({ setPhase: (phase) => calls.push(`phase:${phase}`), @@ -16,11 +16,11 @@ describe("automatic Windows Node + MXC transition", () => { smokeLockedGeneration: async () => { calls.push("smoke-locked"); }, - startActiveGeneration: async () => { - calls.push("start-active"); + promoteActivePolicy: async () => { + calls.push("promote-active"); return "active"; }, - smokeActiveGeneration: async () => { + smokeActivePolicy: async () => { calls.push("smoke-active"); }, mintActivationLease: async () => { @@ -43,8 +43,8 @@ describe("automatic Windows Node + MXC transition", () => { "start-locked", "phase:smoking-locked", "smoke-locked", - "phase:starting-active", - "start-active", + "phase:promoting-active", + "promote-active", "phase:smoking-active", "smoke-active", "lease", @@ -55,7 +55,7 @@ describe("automatic Windows Node + MXC transition", () => { ]); }); - it.each(["start-locked", "smoke-locked", "start-active", "smoke-active", "lease", "verify"])( + it.each(["start-locked", "smoke-locked", "promote-active", "smoke-active", "lease", "verify"])( "fails locked without releasing ingress when %s fails", async (failure) => { const releaseIngress = vi.fn(); @@ -74,11 +74,11 @@ describe("automatic Windows Node + MXC transition", () => { return "locked"; }, smokeLockedGeneration: async () => fail("smoke-locked"), - startActiveGeneration: async () => { - fail("start-active"); + promoteActivePolicy: async () => { + fail("promote-active"); return "active"; }, - smokeActiveGeneration: async () => fail("smoke-active"), + smokeActivePolicy: async () => fail("smoke-active"), mintActivationLease: async () => fail("lease"), verifyActiveGeneration: async () => { fail("verify"); diff --git a/desktop/src/windows-node-mxc-auto-transition.ts b/desktop/src/windows-node-mxc-auto-transition.ts index 5da0fca..7f0a58f 100644 --- a/desktop/src/windows-node-mxc-auto-transition.ts +++ b/desktop/src/windows-node-mxc-auto-transition.ts @@ -5,8 +5,8 @@ export interface WindowsNodeMxcAutomaticTransitionHooks { closeIngress(): Promise | void; startLockedGeneration(): Promise; smokeLockedGeneration(status: TStatus): Promise; - startActiveGeneration(): Promise; - smokeActiveGeneration(status: TStatus): Promise; + promoteActivePolicy(status: TStatus): Promise; + smokeActivePolicy(status: TStatus): Promise; mintActivationLease(): Promise; verifyActiveGeneration(): Promise; releaseIngress(status: TStatus): Promise | void; @@ -25,11 +25,11 @@ export async function runWindowsNodeMxcAutomaticTransition( hooks.setPhase("smoking-locked"); await hooks.smokeLockedGeneration(locked); - hooks.setPhase("starting-active"); - const active = await hooks.startActiveGeneration(); + hooks.setPhase("promoting-active"); + const active = await hooks.promoteActivePolicy(locked); hooks.setPhase("smoking-active"); - await hooks.smokeActiveGeneration(active); + await hooks.smokeActivePolicy(active); await hooks.mintActivationLease(); hooks.setPhase("verifying-active"); diff --git a/desktop/src/windows-node-mxc-folder-transaction.ts b/desktop/src/windows-node-mxc-folder-transaction.ts index 10bad16..eb0278c 100644 --- a/desktop/src/windows-node-mxc-folder-transaction.ts +++ b/desktop/src/windows-node-mxc-folder-transaction.ts @@ -7,6 +7,7 @@ export type WindowsNodeMxcFolderTransactionPhase = | "smoking-locked" | "starting-active" | "smoking-active" + | "promoting-active" | "verifying-active" | "starting-standard" | "active" diff --git a/desktop/src/windows-node-mxc-policy-promotion.test.ts b/desktop/src/windows-node-mxc-policy-promotion.test.ts new file mode 100644 index 0000000..b6dcf6d --- /dev/null +++ b/desktop/src/windows-node-mxc-policy-promotion.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildWindowsNodeMxcLockedToolPolicy, + buildWindowsNodeMxcToolPolicy, +} from "./windows-node-mxc"; +import { promoteWindowsNodeMxcPolicy } from "./windows-node-mxc-policy-promotion"; + +const nodeId = "a".repeat(64); +const generation = "generation-1"; + +function config(state: "locked" | "active") { + return { + agents: { + ownership: "explicit", + entries: { + main: { + tools: + state === "locked" + ? buildWindowsNodeMxcLockedToolPolicy(nodeId) + : buildWindowsNodeMxcToolPolicy(nodeId), + }, + }, + }, + gateway: { + nodes: { + commands: { + allow: [ + "system.run", + "system.run.readiness", + "system.run.prepare", + "system.which", + "system.run.cwd-policy", + ], + deny: [], + }, + }, + }, + channels: {}, + cron: { enabled: false }, + hooks: { enabled: false, internal: { enabled: false } }, + plugins: { enabled: false, entries: {} }, + }; +} + +function snapshot(state: "locked" | "active", hash: string) { + return { + config: config(state), + hash, + configRevisionHash: `${hash}-applied`, + appliedConfigHash: `${hash}-applied`, + }; +} + +function legacyConfig(state: "locked" | "active") { + const base = config(state); + return { + ...base, + agents: { + list: [ + { + id: "main", + tools: + state === "locked" + ? buildWindowsNodeMxcLockedToolPolicy(nodeId) + : buildWindowsNodeMxcToolPolicy(nodeId), + }, + ], + }, + }; +} + +function patchAck(hash: string) { + return { + ok: true, + hash, + changedPaths: ["agents.entries.main.tools.allow", "agents.entries.main.tools.deny"], + sentinel: { payload: { stats: { requiresRestart: false } } }, + }; +} + +describe("MXC in-process policy promotion", () => { + it("CAS-patches only agents and verifies the exact applied active revision", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockResolvedValueOnce(patchAck("active-hash")) + .mockResolvedValueOnce(snapshot("active", "active-hash")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).resolves.toMatchObject({ appliedHash: "active-hash-applied" }); + + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[1][0]).toBe("config.patch"); + expect(JSON.parse(request.mock.calls[1][1].raw)).toEqual({ + agents: config("active").agents, + }); + expect(request.mock.calls[1][1].replacePaths).toEqual([ + "agents.entries.main.tools.allow", + "agents.entries.main.tools.deny", + ]); + }); + + it("uses an explicit whole-array replacement for a legacy agent roster", async () => { + const lockedConfig = legacyConfig("locked"); + const activeConfig = legacyConfig("active"); + const request = vi + .fn() + .mockResolvedValueOnce({ + config: lockedConfig, + hash: "locked-hash", + configRevisionHash: "locked-applied", + appliedConfigHash: "locked-applied", + }) + .mockResolvedValueOnce({ + ...patchAck("active-hash"), + changedPaths: ["agents.list"], + }) + .mockResolvedValueOnce({ + config: activeConfig, + hash: "active-hash", + configRevisionHash: "active-applied", + appliedConfigHash: "active-applied", + }); + + await promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }); + + expect(request.mock.calls[1][1].replacePaths).toEqual(["agents.list"]); + }); + + it("rolls back to the locked policy when active read-back drifts", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockResolvedValueOnce(patchAck("active-hash")) + .mockResolvedValueOnce({ + ...snapshot("active", "active-hash"), + appliedConfigHash: "stale-runtime-hash", + }) + .mockResolvedValueOnce(snapshot("active", "active-hash")) + .mockResolvedValueOnce(patchAck("rollback-hash")) + .mockResolvedValueOnce(snapshot("locked", "rollback-hash")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/has not been applied/); + + expect(JSON.parse(request.mock.calls[4][1].raw)).toEqual({ + agents: config("locked").agents, + }); + }); + + it("fails closed when rollback cannot be proven", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockRejectedValueOnce(new Error("promotion failed")) + .mockRejectedValueOnce(new Error("rollback snapshot unavailable")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/rollback also failed/); + }); + + it("rejects a stale generation before issuing config.patch", async () => { + let currentGeneration = generation; + let requestCount = 0; + const request = async () => { + requestCount += 1; + currentGeneration = "generation-2"; + return snapshot("locked", "locked-hash") as T; + }; + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => currentGeneration, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/generation changed/); + expect(requestCount).toBe(1); + }); +}); diff --git a/desktop/src/windows-node-mxc-policy-promotion.ts b/desktop/src/windows-node-mxc-policy-promotion.ts new file mode 100644 index 0000000..50f7ba9 --- /dev/null +++ b/desktop/src/windows-node-mxc-policy-promotion.ts @@ -0,0 +1,294 @@ +import { + applyWindowsNodeMxcGatewayPolicy, + validateWindowsNodeMxcGatewayPolicy, + type AgentToolsBackup, +} from "./windows-node-mxc"; +import { isDeepStrictEqual } from "node:util"; + +export type GatewayConfigSnapshot = { + config?: unknown; + hash?: unknown; + configRevisionHash?: unknown; + appliedConfigHash?: unknown; +}; + +type GatewayConfigPatchAck = { + ok?: unknown; + hash?: unknown; + changedPaths?: unknown; + restart?: unknown; + sentinel?: { + payload?: { + stats?: { + requiresRestart?: unknown; + }; + }; + }; +}; + +export type WindowsNodeMxcPolicyPromotionOptions = { + gateway: { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + }; + nodeId: string; + backups: AgentToolsBackup; + expectedGeneration: string; + currentGeneration: () => string; + isCurrent: () => boolean; + requestTimeoutMs: number; +}; + +export type WindowsNodeMxcPolicyPromotionResult = { + backups: AgentToolsBackup; + appliedHash: string; +}; + +export function attestWindowsNodeMxcGatewaySnapshot( + snapshot: GatewayConfigSnapshot, + nodeId: string, + expectedPolicy: "locked" | "active" | "either", +): { + config: Record; + hash: string; + configRevisionHash: string; + appliedConfigHash: string; +} { + if (!snapshot.config || typeof snapshot.config !== "object" || Array.isArray(snapshot.config)) { + throw new Error("Gateway config.get did not return a configuration object"); + } + const hash = nonEmptyString(snapshot.hash, "persisted config hash"); + const configRevisionHash = nonEmptyString( + snapshot.configRevisionHash, + "resolved config revision hash", + ); + const appliedConfigHash = nonEmptyString(snapshot.appliedConfigHash, "applied config hash"); + if (configRevisionHash !== appliedConfigHash) { + throw new Error("Gateway persisted policy has not been applied to the active runtime"); + } + const policy = validateWindowsNodeMxcGatewayPolicy(snapshot.config, nodeId, expectedPolicy); + if (!policy.ready) { + throw new Error( + `Gateway ${expectedPolicy} policy attestation failed: ${policy.blockers.join("; ")}`, + ); + } + return { + config: snapshot.config as Record, + hash, + configRevisionHash, + appliedConfigHash, + }; +} + +export async function promoteWindowsNodeMxcPolicy( + options: WindowsNodeMxcPolicyPromotionOptions, +): Promise { + assertCurrent(options); + const lockedSnapshot = await readAppliedSnapshot(options, "locked"); + const active = applyWindowsNodeMxcGatewayPolicy( + lockedSnapshot.config, + options.nodeId, + options.backups, + "active", + ); + + try { + const ack = await patchAgents( + options, + lockedSnapshot.hash, + active.config.agents, + getAgentToolReplacePaths(active.config.agents), + ); + assertHotAppliedAck(ack); + const activeSnapshot = await readAppliedSnapshot(options, "active"); + assertMatchingAckHash(ack, activeSnapshot.hash); + assertExactConfig(activeSnapshot.config, active.config, "active policy"); + return { backups: active.backups, appliedHash: activeSnapshot.appliedConfigHash }; + } catch (error) { + assertCurrent(options); + try { + await rollbackWindowsNodeMxcPolicy(options, lockedSnapshot.config); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "MXC policy promotion failed and the locked-policy rollback also failed", + { cause: rollbackError }, + ); + } + throw error; + } +} + +async function rollbackWindowsNodeMxcPolicy( + options: WindowsNodeMxcPolicyPromotionOptions, + lockedConfig: Record, +): Promise { + const current = await readSnapshot(options, "either", false); + if (validateWindowsNodeMxcGatewayPolicy(current.config, options.nodeId, "locked").ready) { + const verified = await readAppliedSnapshot(options, "locked"); + assertExactConfig(verified.config, lockedConfig, "locked rollback"); + return; + } + + const ack = await patchAgents( + options, + current.hash, + lockedConfig.agents, + getAgentToolReplacePaths(lockedConfig.agents), + ); + assertHotAppliedAck(ack); + const verified = await readAppliedSnapshot(options, "locked"); + assertMatchingAckHash(ack, verified.hash); + assertExactConfig(verified.config, lockedConfig, "locked rollback"); +} + +async function readAppliedSnapshot( + options: WindowsNodeMxcPolicyPromotionOptions, + expectedPolicy: "locked" | "active" | "either", +): Promise> { + return readSnapshot(options, expectedPolicy, true); +} + +async function readSnapshot( + options: WindowsNodeMxcPolicyPromotionOptions, + expectedPolicy: "locked" | "active" | "either", + requireApplied: boolean, +): Promise<{ + config: Record; + hash: string; + configRevisionHash: string; + appliedConfigHash: string; +}> { + assertCurrent(options); + const snapshot = await options.gateway.request( + "config.get", + {}, + options.requestTimeoutMs, + ); + assertCurrent(options); + if (!requireApplied) { + if (!snapshot.config || typeof snapshot.config !== "object" || Array.isArray(snapshot.config)) { + throw new Error("Gateway config.get did not return a configuration object"); + } + const hash = nonEmptyString(snapshot.hash, "persisted config hash"); + const policy = validateWindowsNodeMxcGatewayPolicy( + snapshot.config, + options.nodeId, + expectedPolicy, + ); + if (!policy.ready) { + throw new Error( + `Gateway ${expectedPolicy} policy attestation failed: ${policy.blockers.join("; ")}`, + ); + } + return { + config: snapshot.config as Record, + hash, + configRevisionHash: + typeof snapshot.configRevisionHash === "string" ? snapshot.configRevisionHash : "", + appliedConfigHash: + typeof snapshot.appliedConfigHash === "string" ? snapshot.appliedConfigHash : "", + }; + } + return attestWindowsNodeMxcGatewaySnapshot(snapshot, options.nodeId, expectedPolicy); +} + +async function patchAgents( + options: WindowsNodeMxcPolicyPromotionOptions, + baseHash: string, + agents: unknown, + replacePaths: string[], +): Promise { + assertCurrent(options); + const ack = await options.gateway.request( + "config.patch", + { + baseHash, + raw: JSON.stringify({ agents }), + replacePaths, + note: "Promote verified MicroClaw MXC policy without restarting the Gateway", + }, + options.requestTimeoutMs, + ); + assertCurrent(options); + return ack; +} + +function assertHotAppliedAck(ack: GatewayConfigPatchAck): void { + if (ack.ok !== true) throw new Error("Gateway rejected the MXC policy promotion"); + if (ack.restart !== undefined && ack.restart !== null) { + throw new Error("Gateway attempted to restart while promoting the MXC policy"); + } + if (ack.sentinel?.payload?.stats?.requiresRestart !== false) { + throw new Error("Gateway did not prove that MXC policy promotion was hot-applied"); + } + if ( + !Array.isArray(ack.changedPaths) || + ack.changedPaths.length === 0 || + !ack.changedPaths.every( + (path) => + typeof path === "string" && + ((path.startsWith("agents.entries.") && path.includes(".tools")) || path === "agents.list"), + ) + ) { + throw new Error("Gateway MXC policy promotion changed an unexpected configuration path"); + } +} + +function assertMatchingAckHash(ack: GatewayConfigPatchAck, snapshotHash: string): void { + const ackHash = nonEmptyString(ack.hash, "config.patch acknowledgement hash"); + if (ackHash !== snapshotHash) { + throw new Error("Gateway config.patch acknowledgement did not match exact read-back"); + } +} + +function assertExactConfig( + actual: Record, + expected: Record, + label: string, +): void { + if (!isDeepStrictEqual(actual, expected)) { + throw new Error(`Gateway ${label} read-back drifted from the exact requested configuration`); + } +} + +function getAgentToolReplacePaths(agents: unknown): string[] { + if (!agents || typeof agents !== "object" || Array.isArray(agents)) { + throw new Error("MXC one-process promotion requires an explicit agent roster"); + } + const roster = agents as Record; + if (Array.isArray(roster.list)) { + if (roster.list.length === 0) { + throw new Error("MXC one-process promotion requires at least one agent entry"); + } + return ["agents.list"]; + } + const entries = roster.entries; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + throw new Error("MXC one-process promotion requires agents.entries or agents.list"); + } + const agentIds = Object.keys(entries); + if (agentIds.length === 0) { + throw new Error("MXC one-process promotion requires at least one agent entry"); + } + return agentIds.flatMap((agentId) => [ + `agents.entries.${agentId}.tools.allow`, + `agents.entries.${agentId}.tools.deny`, + ]); +} + +function assertCurrent(options: WindowsNodeMxcPolicyPromotionOptions): void { + if ( + !options.isCurrent() || + options.currentGeneration() !== options.expectedGeneration || + !options.expectedGeneration + ) { + throw new Error("Managed Gateway generation changed during MXC policy promotion"); + } +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Gateway response is missing its ${label}`); + } + return value; +} From 0b3b9a81a137ef34c7f0f33bc0f161dcea9d0a29 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:27:33 +0800 Subject: [PATCH 02/24] Fail closed on degraded MXC containment Reject DACL-only isolation, pin and revalidate the MXC runtime at launch, and disable per-command DACL fallback so approved folder roots remain a hard filesystem boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/src/main.ts | 16 +- desktop/src/windows-node-mxc-service.test.ts | 25 ++- desktop/src/windows-node-mxc-service.ts | 15 +- desktop/src/windows-node-mxc.test.ts | 29 ++- desktop/src/windows-node-mxc.ts | 27 ++- docs/experimental-windows-node-mxc.md | 16 +- windows-node-host.Tests/ApprovalProofTests.cs | 18 ++ windows-node-host.Tests/CwdPolicyTests.cs | 208 +++++++++++++++++- .../ReadinessProofTests.cs | 22 ++ windows-node-host/ApprovalProof.cs | 8 + windows-node-host/BundledSystemCapability.cs | 94 +++++++- windows-node-host/CwdPolicy.cs | 22 +- windows-node-host/MxcIsolationTierGuard.cs | 144 ++++++++++++ windows-node-host/Program.cs | 1 + 14 files changed, 605 insertions(+), 40 deletions(-) create mode 100644 windows-node-host/MxcIsolationTierGuard.cs diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 93585f2..00f8aa6 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -1363,7 +1363,7 @@ function assertWindowsNodeMxcBaseReady( failures.push("Strict MXC no-host-fallback is not effective"); if (!status.allowWindowsUiEffective) failures.push("PowerShell compatibility policy is not effective"); - if (status.probe.outcome !== "supported" || !status.probe.tier) { + if (status.probe.outcome !== "supported" || status.probe.degraded || !status.probe.tier) { failures.push(status.probe.reason ?? "MXC did not report a supported containment tier"); } if (!status.settingsFingerprint) failures.push("The bundled node security policy is unavailable"); @@ -1464,8 +1464,16 @@ async function runCurrentWindowsNodeMxcSmoke( status: WindowsNodeMxcRuntimeStatus, ): Promise { if (!gwClient?.connected) throw new Error("MicroClaw managed Gateway is not connected"); - if (!status.settingsFingerprint || !status.probe.tier) { - throw new Error("Current MXC settings and containment tier are required"); + if ( + !status.settingsFingerprint || + status.probe.outcome !== "supported" || + status.probe.degraded || + !status.probe.tier + ) { + throw new Error( + status.probe.reason ?? + "Current MXC settings and an exact filesystem isolation tier are required", + ); } const expectedGeneration = gatewayGenerationId; const transitionId = windowsNodeMxcReadinessTransitionId; @@ -1486,7 +1494,7 @@ async function runCurrentWindowsNodeMxcSmoke( expectedGeneration, status.selectedNodeId, status.settingsFingerprint, - status.probe.tier, + status.probe, { transitionId, proofContext }, ); if (gatewayGenerationId !== expectedGeneration || !gwClient.connected) { diff --git a/desktop/src/windows-node-mxc-service.test.ts b/desktop/src/windows-node-mxc-service.test.ts index 1a3a0c1..d9242a6 100644 --- a/desktop/src/windows-node-mxc-service.test.ts +++ b/desktop/src/windows-node-mxc-service.test.ts @@ -6,11 +6,13 @@ import { WINDOWS_NODE_MXC_SCRATCH_BINDING, windowsNodeMxcReadinessSessionKey, } from "./windows-node-mxc-readiness-proof"; +import { classifyMxcProbe } from "./windows-node-mxc"; import { runWindowsNodeMxcSmoke, type WindowsNodeMxcGateway } from "./windows-node-mxc-service"; const transitionId = "12345678-1234-4234-8234-123456789abc"; const generation = "generation-test-001"; const nodeId = "b".repeat(64); +const strongProbe = classifyMxcProbe(0, JSON.stringify({ tier: "base-container" }), ""); function preparedPlan(probeKind: "hostname" | "powershell") { const argv = [...WINDOWS_NODE_MXC_READINESS_PROBES[probeKind]]; @@ -74,7 +76,7 @@ describe("Windows Node MXC readiness smoke", () => { generation, nodeId, "settings-fingerprint", - "appcontainer-dacl", + strongProbe, authorization(), ); @@ -115,7 +117,7 @@ describe("Windows Node MXC readiness smoke", () => { "another-generation", nodeId, "settings", - "appcontainer-dacl", + strongProbe, authorization(), ), ).rejects.toThrow(/does not match/); @@ -125,16 +127,31 @@ describe("Windows Node MXC readiness smoke", () => { generation, "d".repeat(64), "settings", - "appcontainer-dacl", + strongProbe, authorization(), ), ).rejects.toThrow(/does not match/); await expect( - runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", "appcontainer-dacl", { + runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", strongProbe, { ...authorization(), transitionId: "12345678-1234-4234-8234-123456789abe", }), ).rejects.toThrow(/does not match/); expect(request).not.toHaveBeenCalled(); }); + + it("rejects degraded containment before invoking the node", async () => { + const request = vi.fn(); + const gateway = { connected: true, request } as unknown as WindowsNodeMxcGateway; + const degraded = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", + ); + + await expect( + runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", degraded, authorization()), + ).rejects.toThrow(/cannot enforce the configured filesystem roots/); + expect(request).not.toHaveBeenCalled(); + }); }); diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index ba8699c..8b1eed2 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -530,12 +530,16 @@ export function validateBundledCwdAttestation(value: unknown): { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, durableApprovalStoreProtected: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: true, + degradedIsolationTierRejected: true, }; const blockers = Object.entries(expected) .filter(([key, expectedValue]) => record?.[key] !== expectedValue) @@ -564,13 +568,18 @@ export async function runWindowsNodeMxcSmoke( gatewayGeneration: string, nodeId: string, settingsFingerprint: string, - probeTier: string, + probe: MxcProbeResult, authorization: WindowsNodeMxcReadinessAuthorization, ): Promise { if (!gateway.connected) throw new Error("MicroClaw managed Gateway is not connected"); if (!nodeId.trim()) throw new Error("A stable Windows node ID is required"); if (!settingsFingerprint) throw new Error("Strict Windows Companion settings are not loaded"); - if (!probeTier) throw new Error("A supported MXC tier is required"); + if (probe.outcome !== "supported" || probe.degraded || !probe.tier) { + throw new Error( + probe.reason ?? + "Exact filesystem isolation requires the base-container or appcontainer-bfs MXC tier", + ); + } if ( authorization.transitionId !== authorization.proofContext.readinessTransitionId || gatewayGeneration !== authorization.proofContext.gatewayGeneration || @@ -607,7 +616,7 @@ export async function runWindowsNodeMxcSmoke( gatewayGeneration, nodeId: nodeId.trim(), settingsFingerprint, - probeTier, + probeTier: probe.tier, checkedAt: new Date().toISOString(), hostname, powershell, diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index ba00444..2e7b273 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -314,11 +314,15 @@ describe("bundled Windows Node CWD attestation", () => { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: true, + degradedIsolationTierRejected: true, durableApprovalStoreProtected: true, durableApprovalsPresent: true, }, @@ -332,10 +336,14 @@ describe("bundled Windows Node CWD attestation", () => { "approvalProofBindsPreparedPlan", "approvalProofBindsExecutableContent", "approvalProofBindsActivation", + "launchTimeApprovalProofRevalidation", "readinessProofBindsTransition", "readinessProofBindsPreparedPlan", "readinessProofExactBuiltInsOnly", "readinessProofOneUse", + "launchTimeReadinessProofRevalidation", + "exactFilesystemIsolationRequired", + "degradedIsolationTierRejected", "durableApprovalStoreProtected", ])("fails closed when %s is not attested", (property) => { const payload = { @@ -359,11 +367,15 @@ describe("bundled Windows Node CWD attestation", () => { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: true, + degradedIsolationTierRejected: true, durableApprovalStoreProtected: true, durableApprovalsPresent: false, }; @@ -966,15 +978,26 @@ describe("Windows Node MXC global folder policy", () => { }); describe("MXC probe and smoke classification", () => { - it("accepts DACL-only machines with a prominent degraded warning", () => { + it("fails closed on DACL-only machines", () => { const probe = classifyMxcProbe( 0, JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), "", ); - expect(probe.outcome).toBe("supported"); + expect(probe.outcome).toBe("unsupported"); + expect(probe.degraded).toBe(true); + expect(probe.reason).toContain("cannot enforce the configured filesystem roots"); + expect(getMxcTierWarning(probe)).toContain("Rejected degraded MXC containment"); + }); + + it("fails closed when an otherwise strong tier requests DACL augmentation", () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ tier: "base-container", needsDaclAugmentation: true }), + "", + ); + expect(probe.outcome).toBe("unsupported"); expect(probe.degraded).toBe(true); - expect(getMxcTierWarning(probe)).toContain("requires host DACL augmentation"); }); it("treats base-container and BFS as full-strength", () => { diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index f1a9a84..af590b3 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -337,6 +337,8 @@ export interface MxcProbeResult { reason: string | null; } +const MXC_EXACT_FILESYSTEM_ISOLATION_TIERS = new Set(["base-container", "appcontainer-bfs"]); + export interface MxcSmokeResult { outcome: | "passed" @@ -925,26 +927,39 @@ export function classifyMxcProbe( const tier = typeof parsed.tier === "string" ? parsed.tier.trim() : ""; if (!tier) return probeError("wxc-exec --probe did not report an isolation tier"); const normalizedTier = tier.toLowerCase(); - if (!["base-container", "appcontainer-bfs", "appcontainer-dacl"].includes(normalizedTier)) { + if (![...MXC_EXACT_FILESYSTEM_ISOLATION_TIERS, "appcontainer-dacl"].includes(normalizedTier)) { return probeError(`wxc-exec --probe reported an unrecognized isolation tier: ${tier}`); } const needsDaclAugmentation = parsed.needsDaclAugmentation === true; + const degraded = + needsDaclAugmentation || !MXC_EXACT_FILESYSTEM_ISOLATION_TIERS.has(normalizedTier); + if (degraded) { + return { + outcome: "unsupported", + tier, + needsDaclAugmentation, + degraded: true, + warnings, + reason: + `MXC isolation tier ${tier} cannot enforce the configured filesystem roots as a hard ` + + "capability boundary; base-container or appcontainer-bfs is required", + }; + } return { outcome: "supported", tier, needsDaclAugmentation, - degraded: - needsDaclAugmentation || !["base-container", "appcontainer-bfs"].includes(normalizedTier), + degraded: false, warnings, reason: null, }; } export function getMxcTierWarning(probe: MxcProbeResult): string | null { - if (probe.outcome !== "supported" || !probe.degraded) return null; - return `Degraded MXC containment: ${probe.tier ?? "unknown tier"}${ + if (!probe.degraded) return null; + return `Rejected degraded MXC containment: ${probe.tier ?? "unknown tier"}${ probe.needsDaclAugmentation ? " requires host DACL augmentation" : "" - }. This mode remains usable but is weaker than base-container or appcontainer-bfs.`; + }. This mode cannot enforce the configured folder policy exactly.`; } export function classifyMxcSmoke(value: unknown, expectedMarker: string): MxcSmokeResult { diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index d8c6cc7..c3a0a63 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -28,6 +28,11 @@ The helper advertises only `system.run`, proof-only `system.run.readiness`, `sys when MXC is absent or fails. Network, clipboard, input injection, MCP, screen, camera, canvas, browser, location, and speech capabilities are absent. `allowWindowsUi=true` is required for PowerShell compatibility with MXC 0.7; it does not activate any UI capture or input capability. +MicroClaw accepts only the `base-container` and `appcontainer-bfs` isolation tiers. The +`appcontainer-dacl` tier is rejected before the node registers because its additive ACL grants +cannot remove ambient filesystem access and therefore cannot enforce approved folders as a hard +capability ceiling. Every command config also sets `fallback.allowDaclMutation=false`, so an +individual invocation cannot silently downgrade after readiness. Gateway-native file/process tools remain removed from the agent surface. The policy pins `exec` to the app-owned node and denies the runtime/filesystem/plugin groups. An empty OpenClaw allowlist is @@ -230,11 +235,12 @@ back to the chat session while replay normalizes the plan to `null`, which the G rejects as `approval id does not match request`. After explicit consent, the MicroClaw-built system-drive helper and the official MXC null-device -helper both completed successfully. Non-elevated probing reports `appcontainer-dacl` with no host- -preparation warning. Live locked-generation proof reached the exact bundled node and official -`wxc-exec.exe`; protected `C:\Windows` CWD denial, `cmd.exe -> hostname.exe`, and PowerShell child -execution all pass under transition-bound internal one-use proofs with no durable approval, user -prompt, or host fallback. Ordinary agent execution retains the visible approval contract. +helper both completed successfully. A host that reports `appcontainer-dacl` remains locked; only +`base-container` or `appcontainer-bfs` can proceed to live readiness. On a supported host, locked- +generation proof reaches the exact bundled node and official `wxc-exec.exe`; protected `C:\Windows` +CWD denial, `cmd.exe -> hostname.exe`, and PowerShell child execution pass under transition-bound +internal one-use proofs with no durable approval, user prompt, or host fallback. Ordinary agent +execution retains the visible approval contract. The active transaction also passed end to end: MicroClaw reported chat disconnected before release, started a new Gateway generation, re-attested the exact node and `exec`-only tool surface, repeated all three smokes, issued and renewed the generation-bound active lease, then reported chat connected. diff --git a/windows-node-host.Tests/ApprovalProofTests.cs b/windows-node-host.Tests/ApprovalProofTests.cs index 31f39d0..8e20a9c 100644 --- a/windows-node-host.Tests/ApprovalProofTests.cs +++ b/windows-node-host.Tests/ApprovalProofTests.cs @@ -57,6 +57,24 @@ public void RejectsSameApprovalIdWithAnotherValidNonce() Assert.Equal("approval-proof-replayed", replay.Code); } + [Fact] + public void RevalidationRejectsProofThatExpiresBeforeLaunch() + { + var now = IssuedAtUnixMs; + var verifier = new ApprovalProofVerifier( + Secret, + "generation-test-001", + PolicyFingerprint, + NodeId, + () => now); + var validated = verifier.ValidateAndConsume(Args(VectorProof()), CurrentIdentity); + + now = validated.ExpiresAtUnixMs; + var error = Assert.Throws(() => verifier.Revalidate(validated)); + + Assert.Equal("approval-proof-stale", error.Code); + } + [Fact] public async Task ConcurrentConsumptionAllowsExactlyOneInvocation() { diff --git a/windows-node-host.Tests/CwdPolicyTests.cs b/windows-node-host.Tests/CwdPolicyTests.cs index 89d1823..65a99ed 100644 --- a/windows-node-host.Tests/CwdPolicyTests.cs +++ b/windows-node-host.Tests/CwdPolicyTests.cs @@ -12,6 +12,8 @@ namespace MicroClaw.WindowsNodeHost.Tests; public sealed class CwdPolicyTests : IDisposable { + private static readonly Func NoopIsolationGuard = + _ => Task.CompletedTask; private readonly string _root = Path.Combine(Path.GetTempPath(), "microclaw-cwd-tests-" + Guid.NewGuid().ToString("N")); public CwdPolicyTests() @@ -249,6 +251,7 @@ public void AttestationUsesTheVersionedCamelCaseWireContract() root.GetProperty("approvalProofPlanContract").GetString()); Assert.True(root.GetProperty("approvalProofBindsExecutableContent").GetBoolean()); Assert.True(root.GetProperty("approvalProofBindsActivation").GetBoolean()); + Assert.True(root.GetProperty("launchTimeApprovalProofRevalidation").GetBoolean()); Assert.Equal( "microclaw.windows-node-readiness.v1", root.GetProperty("readinessProofContract").GetString()); @@ -256,6 +259,9 @@ public void AttestationUsesTheVersionedCamelCaseWireContract() Assert.True(root.GetProperty("readinessProofBindsPreparedPlan").GetBoolean()); Assert.True(root.GetProperty("readinessProofExactBuiltInsOnly").GetBoolean()); Assert.True(root.GetProperty("readinessProofOneUse").GetBoolean()); + Assert.True(root.GetProperty("launchTimeReadinessProofRevalidation").GetBoolean()); + Assert.True(root.GetProperty("exactFilesystemIsolationRequired").GetBoolean()); + Assert.True(root.GetProperty("degradedIsolationTierRejected").GetBoolean()); Assert.True(root.GetProperty("durableApprovalStoreProtected").GetBoolean()); Assert.False(root.GetProperty("durableApprovalsPresent").GetBoolean()); Assert.False(root.TryGetProperty("Contract", out _)); @@ -660,7 +666,8 @@ public async Task InvalidDeclarationStopsBeforePromptOrDurableApproval( Path.Combine(_root, "missing-lease.json"), Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), "unused-generation", - "unused-fingerprint")); + "unused-fingerprint"), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -742,6 +749,136 @@ public void OmittedDeclarationCannotExpandMxcFilesystemPolicy() item => string.Equals(item, outside, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void DeniedRootsOverrideApprovedRootsWhenBuildingMxcPolicy() + { + var denied = Directory.CreateDirectory(Path.Combine(_root, "approved", "sensitive")).FullName; + var approved = Directory.GetParent(denied)!.FullName; + var scratch = Directory.CreateDirectory(Path.Combine(_root, "scratch-denied")).FullName; + + var error = Assert.Throws(() => + BundledSystemCapability.BuildMxcConfig( + Policy( + [new ApprovedRoot(approved, FolderAccess.ReadWrite)], + [WindowsPathCanonicalizer.CanonicalizeDirectory(denied)]), + Path.Combine(Environment.SystemDirectory, "hostname.exe"), + [], + approved, + scratch, + 30_000)); + + Assert.Equal("approved-root-overlaps-sensitive-root", error.Code); + } + + [Fact] + public void FilesystemPolicyKeepsApprovalSeparateFromConfiguredRootCapabilities() + { + var desktop = Directory.CreateDirectory(Path.Combine(_root, "Desktop")).FullName; + var nestedDesktop = Directory.CreateDirectory(Path.Combine(desktop, "nested")).FullName; + var downloads = Directory.CreateDirectory(Path.Combine(_root, "Downloads")).FullName; + var scratch = Directory.CreateDirectory(Path.Combine(_root, "scratch-boundary")).FullName; + var executable = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + var arguments = new[] + { + "-NoProfile", + "-Command", + $"Set-Content -Path '{Path.Combine(downloads, "outside.txt")}' -Value denied", + }; + + var desktopReadWrite = BundledSystemCapability.BuildMxcConfig( + Policy([new ApprovedRoot(desktop, FolderAccess.ReadWrite)]), + executable, + arguments, + nestedDesktop, + scratch, + 30_000); + Assert.Equal([desktop, scratch], desktopReadWrite.Filesystem!.ReadwritePaths!); + Assert.Empty(desktopReadWrite.Filesystem.ReadonlyPaths!); + Assert.DoesNotContain( + downloads, + desktopReadWrite.Filesystem.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + using (var serialized = JsonDocument.Parse( + BundledSystemCapability.SerializeMxcConfig(desktopReadWrite))) + { + Assert.False( + serialized.RootElement + .GetProperty("fallback") + .GetProperty("allowDaclMutation") + .GetBoolean()); + } + + var desktopReadOnly = BundledSystemCapability.BuildMxcConfig( + Policy([new ApprovedRoot(desktop, FolderAccess.ReadOnly)]), + executable, + arguments, + desktop, + scratch, + 30_000); + Assert.Equal([desktop], desktopReadOnly.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain( + desktop, + desktopReadOnly.Filesystem.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + + var downloadsReadWrite = BundledSystemCapability.BuildMxcConfig( + Policy( + [ + new ApprovedRoot(desktop, FolderAccess.ReadWrite), + new ApprovedRoot(downloads, FolderAccess.ReadWrite), + ]), + executable, + arguments, + desktop, + scratch, + 30_000); + Assert.Contains( + downloads, + downloadsReadWrite.Filesystem!.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("base-container")] + [InlineData("appcontainer-bfs")] + public void ExactFilesystemIsolationTiersAreAccepted(string tier) + { + var actual = MxcIsolationTierGuard.ValidateProbeResult( + 0, + $$"""{"tier":{{JsonSerializer.Serialize(tier)}},"needsDaclAugmentation":false}""", + string.Empty); + + Assert.Equal(tier, actual); + } + + [Theory] + [InlineData("appcontainer-dacl", false)] + [InlineData("base-container", true)] + [InlineData("future-tier", false)] + public void DegradedFilesystemIsolationTiersFailClosed(string tier, bool needsDaclAugmentation) + { + var error = Assert.Throws(() => + MxcIsolationTierGuard.ValidateProbeResult( + 0, + $$"""{"tier":{{JsonSerializer.Serialize(tier)}},"needsDaclAugmentation":{{JsonSerializer.Serialize(needsDaclAugmentation)}}}""", + string.Empty)); + + Assert.Equal("mxc-filesystem-isolation-insufficient", error.Code); + } + + [Fact] + public void LockedExecutableContentMustMatchExpectedHash() + { + var executable = Path.Combine(_root, "fake-wxc-exec.exe"); + File.WriteAllText(executable, "not the pinned runtime"); + using var lease = ExecutableApprovalLease.Acquire(executable); + + var error = Assert.Throws(() => + lease.VerifySha256(new string('0', 64))); + + Assert.Equal("executable-hash-mismatch", error.Code); + } + [Fact] public async Task DiagnosticDeclaredCommandRetainsAttendedPromptAndDenyStopsExecution() { @@ -770,7 +907,8 @@ await WriteActivationLease( Policy([new ApprovedRoot(desktop, FolderAccess.ReadWrite)]), pipeName, approvalsPath, - new ActivationLeaseGuard(leasePath, secret, generation, fingerprint)); + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: NoopIsolationGuard); var command = new[] { @"C:\Windows\System32\cmd.exe", @@ -845,7 +983,8 @@ await WriteActivationLease( Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), pipeName, approvalsPath, - new ActivationLeaseGuard(leasePath, secret, generation, fingerprint)); + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: NoopIsolationGuard); var command = ReadinessProbeContract.Probes["hostname"]; var commandText = WindowsCommandLine.Join(command); @@ -908,7 +1047,8 @@ await WriteActivationLease( generation, fingerprint, nodeId, - transitionId)); + transitionId), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -967,7 +1107,8 @@ await WriteActivationLease( proofSecret, generation, fingerprint, - nodeId)); + nodeId), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -1011,6 +1152,60 @@ public async Task RunPrepareRejectsDeclarationAfterExecutableContent() Assert.Contains("declare-access-position-invalid", response.Error); } + [Fact] + public async Task IsolationTierDegradationAfterPrepareStopsBeforeApprovalOrExecution() + { + var leasePath = Path.Combine(_root, "degraded-tier-lease.json"); + var approvalsPath = Path.Combine(_root, "degraded-tier-approvals.json"); + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + const string generation = "degraded-tier-generation"; + const string fingerprint = "degraded-tier-policy"; + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Diagnostic, + generation, + fingerprint, + DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds()); + var strongTier = true; + Task Guard(CancellationToken _) + { + if (!strongTier) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + "MXC isolation degraded."); + return Task.CompletedTask; + } + var capability = new BundledSystemCapability( + Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), + "missing-approval-pipe-" + Guid.NewGuid().ToString("N"), + approvalsPath, + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: Guard); + var command = new[] { Path.Combine(Environment.SystemDirectory, "hostname.exe") }; + var args = Parse(JsonSerializer.Serialize(new + { + command, + rawCommand = WindowsCommandLine.Join(command), + agentId = "main", + sessionKey = "agent:main:degraded-tier", + })); + + var prepared = await capability.ExecuteAsync( + new NodeInvokeRequest { Command = "system.run.prepare", Args = args }, + TestContext.Current.CancellationToken); + Assert.True(prepared.Ok, prepared.Error); + + strongTier = false; + var response = await capability.ExecuteAsync( + new NodeInvokeRequest { Command = "system.run", Args = args }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("mxc-filesystem-isolation-insufficient", response.Error); + Assert.False(File.Exists(approvalsPath)); + } + [Fact] public async Task RunReplayRejectsDeclarationAfterExecutableContent() { @@ -1128,7 +1323,8 @@ private BundledSystemCapability Capability(HostPolicy policy, string uiLocale = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), "unused-generation", "unused-fingerprint"), - uiLocale); + uiLocale, + isolationGuard: NoopIsolationGuard); private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); diff --git a/windows-node-host.Tests/ReadinessProofTests.cs b/windows-node-host.Tests/ReadinessProofTests.cs index 786b55d..9b2618f 100644 --- a/windows-node-host.Tests/ReadinessProofTests.cs +++ b/windows-node-host.Tests/ReadinessProofTests.cs @@ -72,6 +72,28 @@ await Task.WhenAll( Assert.Equal(23, results.Count(result => result == "readiness-proof-replayed")); } + [Fact] + public void RevalidationRejectsProofThatExpiresBeforeLaunch() + { + var now = IssuedAtUnixMs; + var verifier = new ReadinessProofVerifier( + Secret, + Generation, + PolicyFingerprint, + NodeId, + TransitionId, + () => now); + var args = Args(); + var validated = verifier.ValidateAndConsume( + Args(Sign(UnsignedProof(args))), + CurrentIdentity); + + now = validated.ExpiresAtUnixMs; + var error = Assert.Throws(() => verifier.Revalidate(validated)); + + Assert.Equal("readiness-proof-stale", error.Code); + } + [Theory] [InlineData("transition", "readiness-proof-transition-mismatch")] [InlineData("generation", "readiness-proof-generation-mismatch")] diff --git a/windows-node-host/ApprovalProof.cs b/windows-node-host/ApprovalProof.cs index d85bb4d..dddd03d 100644 --- a/windows-node-host/ApprovalProof.cs +++ b/windows-node-host/ApprovalProof.cs @@ -173,6 +173,14 @@ public ValidatedApprovalProof ValidateAndConsume(JsonElement args, DurableApprov proof.ExpiresAtUnixMs); } + public void Revalidate(ValidatedApprovalProof proof) + { + if (proof.ExpiresAtUnixMs <= _nowUnixMs()) + throw new HostPolicyException( + "approval-proof-stale", + "The Gateway approval proof expired before process launch."); + } + public static string ComputePlanSha256(JsonElement args, DurableApproval currentIdentity) { if (!args.TryGetProperty("systemRunPlan", out var plan) diff --git a/windows-node-host/BundledSystemCapability.cs b/windows-node-host/BundledSystemCapability.cs index f654a1d..ab97bb8 100644 --- a/windows-node-host/BundledSystemCapability.cs +++ b/windows-node-host/BundledSystemCapability.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using OpenClaw.Shared; @@ -17,9 +18,14 @@ internal sealed class BundledSystemCapability( ActivationLeaseGuard activationLease, string uiLocale = "en-US", ApprovalProofVerifier? approvalProof = null, - ReadinessProofVerifier? readinessProof = null) : INodeCapability + ReadinessProofVerifier? readinessProof = null, + Func? isolationGuard = null) : INodeCapability { private readonly SemaphoreSlim _runGate = new(1, 1); + private static readonly JsonSerializerOptions MxcConfigJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; private static readonly string[] SupportedCommands = [ "system.run", @@ -49,7 +55,7 @@ CwdPolicyAttestation.Current with DurableApprovalsPresent = DurableApprovalIdentity.Load(approvalsPath).Count > 0, }), "system.which" => Success(new { bins = ResolveBins(request.Args) }), - "system.run.prepare" => Success(Prepare(request)), + "system.run.prepare" => Success(await PrepareAsync(request, cancellationToken)), "system.run" => await RunAsync(request, cancellationToken), ReadinessProbeContract.Command => await RunReadinessAsync(request, cancellationToken), _ => Error("Unsupported bundled node command."), @@ -69,8 +75,11 @@ CwdPolicyAttestation.Current with } } - private object Prepare(NodeInvokeRequest request) + private async Task PrepareAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) { + await EnsureExactFilesystemIsolationAsync(cancellationToken); var run = ParseRun(request.Args); var cwd = policy.ResolveCwd(run.Cwd); var commandText = WindowsCommandLine.Join(run.Argv); @@ -104,6 +113,7 @@ private object Prepare(NodeInvokeRequest request) }, cwdBinding = cwd.ApprovalBinding, cwdAccess = cwd.Access.ToString(), + matchedRoot = cwd.MatchedRoot, contract = CwdPolicyContract.Version, declaredAccess = run.DeclaredAccess, }; @@ -156,6 +166,7 @@ private async Task RunExclusiveAsync( bool readiness, CancellationToken cancellationToken) { + await EnsureExactFilesystemIsolationAsync(cancellationToken); var request = ParseRun(args); var cwd = policy.ResolveCwd(request.Cwd); var validatedActivation = activationLease.Validate(request.Argv); @@ -166,6 +177,11 @@ private async Task RunExclusiveAsync( var declaredApprovalAccess = request.DeclaredAccess .Select(entry => new DurableApprovalAccess(entry.Access, entry.Path)) .ToArray(); + var executableDirectory = Path.GetDirectoryName(executable) + ?? throw new HostPolicyException( + "executable-path-invalid", + "The requested executable has no parent directory."); + using var executableDirectoryLease = DirectoryPathLease.Acquire([executableDirectory]); using var executableLease = ExecutableApprovalLease.Acquire(executable); executable = executableLease.CanonicalPath; var approvedIdentity = executableLease.Capture( @@ -173,6 +189,7 @@ private async Task RunExclusiveAsync( cwd.ApprovalBinding, declaredApprovalAccess); ValidatedReadinessProof? validatedReadiness = null; + ValidatedApprovalProof? validatedApproval = null; if (readiness) { if (validatedActivation.Mode is not ActivationLeaseMode.Diagnostic) @@ -193,7 +210,7 @@ private async Task RunExclusiveAsync( throw new HostPolicyException( "approval-proof-unavailable", "The active Gateway approval proof verifier is unavailable."); - approvalProof.ValidateAndConsume(args, approvedIdentity); + validatedApproval = approvalProof.ValidateAndConsume(args, approvedIdentity); } var durable = !readiness && !activeGatewayApproval && DurableApprovalIdentity.Load(approvalsPath) @@ -241,9 +258,37 @@ private async Task RunExclusiveAsync( policy.ApprovedRoots.Select(root => root.Path) .Append(currentCwd.LaunchPath) .Append(scratch)); + var wxcDirectory = Path.GetDirectoryName(policy.WxcExecPath) + ?? throw new HostPolicyException( + "mxc-path-invalid", + "The pinned MXC runtime path has no parent directory."); + using var wxcDirectoryLease = DirectoryPathLease.Acquire([wxcDirectory]); + using var wxcExecutableLease = ExecutableApprovalLease.Acquire(policy.WxcExecPath); + await EnsureExactFilesystemIsolationAsync( + cancellationToken, + wxcExecutableLease); currentCwd = policy.RevalidateCwd(request.Cwd, cwd.ApprovalBinding); activationLease.Revalidate(validatedActivation, request.Argv); + currentIdentity = executableLease.Capture( + exactArgs, + currentCwd.ApprovalBinding, + declaredApprovalAccess); + if (!DurableApprovalIdentity.Matches(approvedIdentity, currentIdentity)) + throw new HostPolicyException( + "approval-changed-before-launch", + "The executable, arguments, CWD, or declared access changed before launch."); + if (validatedApproval is not null) + approvalProof!.Revalidate(validatedApproval); + if (validatedReadiness is not null) + readinessProof!.Revalidate(validatedReadiness); + if (requireDurableRecheck && !DurableApprovalIdentity.Load(approvalsPath) + .Any(entry => DurableApprovalIdentity.Matches(entry, currentIdentity))) + throw new HostPolicyException( + "approval-changed-before-launch", + "The durable approval is no longer current."); var launchCwd = string.IsNullOrEmpty(currentCwd.LaunchPath) ? scratch : currentCwd.LaunchPath; + Console.Error.WriteLine( + $"[filesystem-policy] matchedRoot={currentCwd.MatchedRoot ?? ""} effectiveAccess={currentCwd.Access}"); var config = BuildMxcConfig( policy, currentExecutable, @@ -251,6 +296,12 @@ private async Task RunExclusiveAsync( launchCwd, scratch, request.TimeoutMs); + var configPath = Path.Combine(scratch, "mxc-config.json"); + await File.WriteAllTextAsync( + configPath, + SerializeMxcConfig(config), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); using var environment = ProcessEnvironmentOverride.Apply( new Dictionary { @@ -259,10 +310,13 @@ private async Task RunExclusiveAsync( ["TMPDIR"] = scratch, }); var result = await new MxcExecutor( - policy.WxcExecPath, + wxcExecutableLease.CanonicalPath, 4 * 1024 * 1024, 4 * 1024 * 1024) - .RunAsync(config, cancellationToken, workingDirectory: launchCwd); + .RunWithConfigFileAsync( + configPath, + cancellationToken, + workingDirectory: launchCwd); if (validatedReadiness is not null) Console.Error.WriteLine( $"[readiness-probe] completed internal fixed probe kind={validatedReadiness.ProbeKind} exitCode={result.ExitCode} timedOut={result.TimedOut}"); @@ -283,6 +337,28 @@ private async Task RunExclusiveAsync( } } + internal static string SerializeMxcConfig(MxcConfig config) + { + var root = JsonSerializer.SerializeToNode(config, MxcConfigJsonOptions)?.AsObject() + ?? throw new HostPolicyException( + "mxc-config-serialization-failed", + "The MXC launch policy could not be serialized."); + root["fallback"] = new JsonObject { ["allowDaclMutation"] = false }; + return root.ToJsonString(MxcConfigJsonOptions); + } + + private Task EnsureExactFilesystemIsolationAsync( + CancellationToken cancellationToken, + ExecutableApprovalLease? wxcExecutableLease = null) => + isolationGuard?.Invoke(cancellationToken) + ?? (wxcExecutableLease is null + ? MxcIsolationTierGuard.EnsureExactFilesystemIsolationAsync( + policy.WxcExecPath, + cancellationToken) + : MxcIsolationTierGuard.EnsureExactFilesystemIsolationAsync( + wxcExecutableLease, + cancellationToken)); + internal static MxcConfig BuildMxcConfig( HostPolicy policy, string executable, @@ -291,6 +367,12 @@ internal static MxcConfig BuildMxcConfig( string scratch, int timeoutMs) { + if (policy.ApprovedRoots.Any(root => policy.DeniedRoots.Any(denied => + WindowsPathCanonicalizer.IsEqualOrNested(root.Path, denied) + || WindowsPathCanonicalizer.IsEqualOrNested(denied, root.Path)))) + throw new HostPolicyException( + "approved-root-overlaps-sensitive-root", + "Denied roots override approved filesystem roots."); var readOnly = policy.ApprovedRoots .Where(root => root.Access == FolderAccess.ReadOnly) .Select(root => root.Path) diff --git a/windows-node-host/CwdPolicy.cs b/windows-node-host/CwdPolicy.cs index 57e1c94..f8513a3 100644 --- a/windows-node-host/CwdPolicy.cs +++ b/windows-node-host/CwdPolicy.cs @@ -33,11 +33,15 @@ public sealed record CwdPolicyAttestation( [property: JsonPropertyName("approvalProofBindsExecutableContent")] bool ApprovalProofBindsExecutableContent, [property: JsonPropertyName("approvalProofBindsActivation")] bool ApprovalProofBindsActivation, [property: JsonPropertyName("approvalProofOneUse")] bool ApprovalProofOneUse, + [property: JsonPropertyName("launchTimeApprovalProofRevalidation")] bool LaunchTimeApprovalProofRevalidation, [property: JsonPropertyName("readinessProofContract")] string ReadinessProofContract, [property: JsonPropertyName("readinessProofBindsTransition")] bool ReadinessProofBindsTransition, [property: JsonPropertyName("readinessProofBindsPreparedPlan")] bool ReadinessProofBindsPreparedPlan, [property: JsonPropertyName("readinessProofExactBuiltInsOnly")] bool ReadinessProofExactBuiltInsOnly, [property: JsonPropertyName("readinessProofOneUse")] bool ReadinessProofOneUse, + [property: JsonPropertyName("launchTimeReadinessProofRevalidation")] bool LaunchTimeReadinessProofRevalidation, + [property: JsonPropertyName("exactFilesystemIsolationRequired")] bool ExactFilesystemIsolationRequired, + [property: JsonPropertyName("degradedIsolationTierRejected")] bool DegradedIsolationTierRejected, [property: JsonPropertyName("durableApprovalStoreProtected")] bool DurableApprovalStoreProtected, [property: JsonPropertyName("durableApprovalsPresent")] bool DurableApprovalsPresent) { @@ -62,11 +66,15 @@ public sealed record CwdPolicyAttestation( ApprovalProofBindsExecutableContent: true, ApprovalProofBindsActivation: true, ApprovalProofOneUse: true, + LaunchTimeApprovalProofRevalidation: true, ReadinessProofContract: global::MicroClaw.WindowsNodeHost.ReadinessProbeContract.Version, ReadinessProofBindsTransition: true, ReadinessProofBindsPreparedPlan: true, ReadinessProofExactBuiltInsOnly: true, ReadinessProofOneUse: true, + LaunchTimeReadinessProofRevalidation: true, + ExactFilesystemIsolationRequired: true, + DegradedIsolationTierRejected: true, DurableApprovalStoreProtected: true, DurableApprovalsPresent: false); } @@ -79,7 +87,11 @@ public enum FolderAccess public sealed record ApprovedRoot(string Path, FolderAccess Access); -public sealed record CanonicalCwd(string LaunchPath, string ApprovalBinding, FolderAccess Access); +public sealed record CanonicalCwd( + string LaunchPath, + string ApprovalBinding, + FolderAccess Access, + string? MatchedRoot); public sealed class HostPolicyException(string code, string message) : Exception(message) { @@ -171,7 +183,11 @@ private static HostPolicy Parse(ReadOnlySpan json) public CanonicalCwd ResolveCwd(string? requestedCwd) { if (string.IsNullOrWhiteSpace(requestedCwd)) - return new CanonicalCwd(string.Empty, CwdPolicyContract.ScratchBinding, FolderAccess.ReadWrite); + return new CanonicalCwd( + string.Empty, + CwdPolicyContract.ScratchBinding, + FolderAccess.ReadWrite, + MatchedRoot: null); var canonical = WindowsPathCanonicalizer.CanonicalizeDirectory(requestedCwd); if (DeniedRoots.Any(root => WindowsPathCanonicalizer.IsEqualOrNested(canonical, root))) @@ -183,7 +199,7 @@ public CanonicalCwd ResolveCwd(string? requestedCwd) .FirstOrDefault() ?? throw new HostPolicyException("cwd-outside-approved-roots", "The requested working directory is not within an approved folder."); - return new CanonicalCwd(canonical, canonical, grant.Access); + return new CanonicalCwd(canonical, canonical, grant.Access, grant.Path); } public CanonicalCwd RevalidateCwd(string? requestedCwd, string approvalBinding) diff --git a/windows-node-host/MxcIsolationTierGuard.cs b/windows-node-host/MxcIsolationTierGuard.cs new file mode 100644 index 0000000..3a472ee --- /dev/null +++ b/windows-node-host/MxcIsolationTierGuard.cs @@ -0,0 +1,144 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace MicroClaw.WindowsNodeHost; + +internal static class MxcIsolationTierGuard +{ + private static readonly HashSet ExactFilesystemIsolationTiers = + new(StringComparer.OrdinalIgnoreCase) { "base-container", "appcontainer-bfs" }; + private const string WxcExecX64Sha256 = + "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2"; + private const string WxcExecArm64Sha256 = + "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab"; + + public static async Task EnsureExactFilesystemIsolationAsync( + string wxcExecPath, + CancellationToken cancellationToken = default) + { + var parentDirectory = Path.GetDirectoryName(wxcExecPath) + ?? throw new HostPolicyException( + "mxc-path-invalid", + "The pinned MXC runtime path has no parent directory."); + using var directoryLease = DirectoryPathLease.Acquire([parentDirectory]); + using var executableLease = ExecutableApprovalLease.Acquire(wxcExecPath); + await EnsureExactFilesystemIsolationAsync(executableLease, cancellationToken); + } + + internal static async Task EnsureExactFilesystemIsolationAsync( + ExecutableApprovalLease executableLease, + CancellationToken cancellationToken = default) + { + executableLease.VerifySha256(ExpectedWxcExecSha256()); + var startInfo = new ProcessStartInfo + { + FileName = executableLease.CanonicalPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("--probe"); + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + throw new HostPolicyException( + "mxc-probe-launch-failed", + "The pinned MXC runtime probe could not be started."); + } + catch (HostPolicyException) + { + throw; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + throw new HostPolicyException( + "mxc-probe-launch-failed", + $"The pinned MXC runtime probe could not be started: {ex.Message}"); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + cancellationToken.ThrowIfCancellationRequested(); + throw new HostPolicyException( + "mxc-probe-timeout", + "The pinned MXC runtime probe timed out."); + } + + ValidateProbeResult( + process.ExitCode, + await stdoutTask, + await stderrTask); + } + + private static string ExpectedWxcExecSha256() => + RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => WxcExecX64Sha256, + Architecture.Arm64 => WxcExecArm64Sha256, + _ => throw new HostPolicyException( + "mxc-architecture-unsupported", + $"The pinned MXC runtime does not support {RuntimeInformation.ProcessArchitecture}."), + }; + + internal static string ValidateProbeResult(int exitCode, string stdout, string stderr) + { + if (exitCode != 0) + throw new HostPolicyException( + "mxc-probe-failed", + $"The pinned MXC runtime probe exited with code {exitCode}: {stderr.Trim()}"); + + try + { + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + if (root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String) + throw new HostPolicyException( + "mxc-probe-failed", + error.GetString() ?? "The pinned MXC runtime reported an error."); + if (root.TryGetProperty("supported", out var supported) + && supported.ValueKind == JsonValueKind.False) + throw new HostPolicyException( + "mxc-probe-unsupported", + "The pinned MXC runtime is not supported on this host."); + + var tier = root.TryGetProperty("tier", out var tierElement) + && tierElement.ValueKind == JsonValueKind.String + ? tierElement.GetString()?.Trim() + : null; + var needsDaclAugmentation = + root.TryGetProperty("needsDaclAugmentation", out var dacl) + && dacl.ValueKind == JsonValueKind.True; + if (string.IsNullOrEmpty(tier) + || needsDaclAugmentation + || !ExactFilesystemIsolationTiers.Contains(tier)) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + $"MXC isolation tier {tier ?? ""} cannot enforce configured filesystem roots exactly; base-container or appcontainer-bfs is required."); + return tier; + } + catch (HostPolicyException) + { + throw; + } + catch (JsonException) + { + throw new HostPolicyException( + "mxc-probe-invalid", + "The pinned MXC runtime probe returned invalid JSON."); + } + } +} diff --git a/windows-node-host/Program.cs b/windows-node-host/Program.cs index ef3a406..07943a6 100644 --- a/windows-node-host/Program.cs +++ b/windows-node-host/Program.cs @@ -71,6 +71,7 @@ private static async Task Main(string[] args) var policy = await HostPolicy.LoadVerifiedAsync( bootstrap.PolicyPath, bootstrap.PolicyFingerprint); + await MxcIsolationTierGuard.EnsureExactFilesystemIsolationAsync(policy.WxcExecPath); SecureStateDirectory.Ensure(bootstrap.IdentityDirectory); var approvalDirectory = Path.GetDirectoryName(bootstrap.ApprovalsPath) ?? throw new HostPolicyException( From adb113bffc3492f9e5f3bb066aadc02a9e93d075 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:28:03 +0800 Subject: [PATCH 03/24] Revalidate MXC launch dependencies Keep the pinned launcher identity and expiring readiness proof valid through process creation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- windows-node-host/Approvals.cs | 20 ++++++++++++++++++++ windows-node-host/ReadinessProof.cs | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/windows-node-host/Approvals.cs b/windows-node-host/Approvals.cs index bb2ad18..c5783d0 100644 --- a/windows-node-host/Approvals.cs +++ b/windows-node-host/Approvals.cs @@ -151,5 +151,25 @@ public DurableApproval Capture( declaredAccess ?? [], _stream); + public void VerifySha256(string expectedSha256) + { + _stream.Position = 0; + var actualSha256 = Convert.ToHexString(SHA256.HashData(_stream)).ToLowerInvariant(); + _stream.Position = 0; + if (!Regexes.Sha256().IsMatch(expectedSha256) + || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(actualSha256), + Encoding.ASCII.GetBytes(expectedSha256.ToLowerInvariant()))) + throw new HostPolicyException( + "executable-hash-mismatch", + "The locked executable does not match the pinned content hash."); + } + public void Dispose() => _stream.Dispose(); } + +internal static partial class Regexes +{ + [System.Text.RegularExpressions.GeneratedRegex("^[a-f0-9]{64}$")] + public static partial System.Text.RegularExpressions.Regex Sha256(); +} diff --git a/windows-node-host/ReadinessProof.cs b/windows-node-host/ReadinessProof.cs index 61d4c77..9f43c89 100644 --- a/windows-node-host/ReadinessProof.cs +++ b/windows-node-host/ReadinessProof.cs @@ -157,6 +157,14 @@ public ValidatedReadinessProof ValidateAndConsume( proof.ExpiresAtUnixMs); } + public void Revalidate(ValidatedReadinessProof proof) + { + if (proof.ExpiresAtUnixMs <= _nowUnixMs()) + throw new HostPolicyException( + "readiness-proof-stale", + "The readiness proof expired before process launch."); + } + public static string ComputeSignature(string secretBase64, ReadinessProofRecord proof) => ComputeSignature(DecodeSecret(secretBase64), proof); From 25c567cd9bbf48828bd9b87ac6d71524378947cc Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:43:32 +0800 Subject: [PATCH 04/24] Fail closed before unavailable MXC backends Attest the pinned MXC build contract, preserve backend probe facts, and block enablement, startup, and folder-policy transitions before launching a node when no exact backend is available. Surface host-specific remediation while keeping DACL containment rejected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/env.d.ts | 6 + desktop/renderer/src/browser-openclaw.ts | 8 ++ desktop/renderer/src/i18n/en-US.ts | 8 ++ desktop/renderer/src/i18n/zh-CN.ts | 8 ++ .../renderer/src/views/SettingsView.test.ts | 36 ++++++ desktop/renderer/src/views/SettingsView.vue | 35 +++++- .../prepare-windows-node-resources.mjs | 4 + desktop/src/bundled-windows-node-host.test.ts | 51 ++++++++ desktop/src/bundled-windows-node-host.ts | 72 +++++++---- desktop/src/main.ts | 47 +++++-- desktop/src/preload.ts | 2 + .../windows-node-mxc-auto-transition.test.ts | 54 +++++++- .../src/windows-node-mxc-auto-transition.ts | 4 + ...indows-node-mxc-folder-transaction.test.ts | 13 +- .../windows-node-mxc-folder-transaction.ts | 2 + desktop/src/windows-node-mxc-service.test.ts | 47 ++++++- desktop/src/windows-node-mxc-service.ts | 41 +++++-- desktop/src/windows-node-mxc.test.ts | 73 ++++++++++- desktop/src/windows-node-mxc.ts | 116 ++++++++++++++++++ docs/experimental-windows-node-mxc.md | 70 ++++++++--- 20 files changed, 630 insertions(+), 67 deletions(-) diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index e6f290f..c482b10 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -441,6 +441,11 @@ interface OpenClawAPI { needsDaclAugmentation: boolean; degraded: boolean; warnings: string[]; + probes: { + baseContainerApiPresent: boolean | null; + bfscfgPresent: boolean | null; + bfsCompiledIn: boolean | null; + }; reason: string | null; }; smoke: { @@ -457,6 +462,7 @@ interface OpenClawAPI { warnings: string[]; remediation: string[]; }>; + preflight(): Promise; setEnabled(params: { enabled: boolean; nodeId?: string; diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 4ecfea9..c853870 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -113,6 +113,11 @@ export function createBrowserOpenClawMock(): OpenClawAPI { needsDaclAugmentation: false, degraded: false, warnings: [], + probes: { + baseContainerApiPresent: null, + bfscfgPresent: null, + bfsCompiledIn: null, + }, reason: "Unavailable in browser development", }, smoke: null, @@ -120,6 +125,9 @@ export function createBrowserOpenClawMock(): OpenClawAPI { warnings: [], remediation: [], }), + preflight: async () => { + throw new Error("Unavailable in browser development"); + }, setEnabled: async () => { throw new Error("Unavailable in browser development"); }, diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 00db17a..15dd186 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -305,6 +305,11 @@ export default { "settings.windowsNodeMxcProtectionOff": "Off", "settings.windowsNodeMxcTechnicalDetails": "Technical details", "settings.windowsNodeMxcUnavailable": "Unavailable", + "settings.windowsNodeMxcAvailable": "Available", + "settings.windowsNodeMxcMissing": "Missing", + "settings.windowsNodeMxcUnknown": "Unknown", + "settings.windowsNodeMxcCompiledIn": "Compiled in", + "settings.windowsNodeMxcCompiledOut": "Compiled out", "settings.windowsNodeMxcVerified": "Verified", "settings.windowsNodeMxcUnverified": "Unverified", "settings.windowsNodeMxcDaclRequired": "DACL augmentation required", @@ -314,6 +319,9 @@ export default { "settings.windowsNodeMxcCwdAttestation": "CWD policy attestation", "settings.windowsNodeMxcEffectiveTools": "Effective Gateway tools", "settings.windowsNodeMxcTier": "MXC isolation tier", + "settings.windowsNodeMxcBaseContainerApi": "Base Container API", + "settings.windowsNodeMxcBfsRuntime": "BFS runtime support", + "settings.windowsNodeMxcBfsHostTool": "BFS host tool", "settings.windowsNodeMxcStrictFallback": "Strict host-fallback blocking", "settings.windowsNodeMxcEnabled": "Enabled", "settings.windowsNodeMxcDisabled": "Disabled", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index b84dd21..408f839 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -288,6 +288,11 @@ export default { "settings.windowsNodeMxcProtectionOff": "已关闭", "settings.windowsNodeMxcTechnicalDetails": "技术详情", "settings.windowsNodeMxcUnavailable": "不可用", + "settings.windowsNodeMxcAvailable": "可用", + "settings.windowsNodeMxcMissing": "缺失", + "settings.windowsNodeMxcUnknown": "未知", + "settings.windowsNodeMxcCompiledIn": "已编译", + "settings.windowsNodeMxcCompiledOut": "未编译", "settings.windowsNodeMxcVerified": "已验证", "settings.windowsNodeMxcUnverified": "未验证", "settings.windowsNodeMxcDaclRequired": "需要 DACL 增强", @@ -297,6 +302,9 @@ export default { "settings.windowsNodeMxcCwdAttestation": "工作目录策略证明", "settings.windowsNodeMxcEffectiveTools": "网关有效工具", "settings.windowsNodeMxcTier": "MXC 隔离层级", + "settings.windowsNodeMxcBaseContainerApi": "基础容器 API", + "settings.windowsNodeMxcBfsRuntime": "BFS 运行时支持", + "settings.windowsNodeMxcBfsHostTool": "BFS 主机工具", "settings.windowsNodeMxcStrictFallback": "严格禁止主机回退", "settings.windowsNodeMxcEnabled": "已启用", "settings.windowsNodeMxcDisabled": "未启用", diff --git a/desktop/renderer/src/views/SettingsView.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index 7e70b84..346df94 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -17,6 +17,7 @@ describe("SettingsView", () => { const exportGatewayLogs = vi.fn(); const openExternal = vi.fn(); const getWindowsNodeMxcStatus = vi.fn(); + const preflightWindowsNodeMxc = vi.fn(); const setWindowsNodeMxcEnabled = vi.fn(); const addUserDir = vi.fn(); const stageUserDir = vi.fn(); @@ -89,6 +90,11 @@ describe("SettingsView", () => { needsDaclAugmentation: true, degraded: true, warnings: [], + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + bfsCompiledIn: false, + }, reason: null, }, smoke: null, @@ -100,6 +106,7 @@ describe("SettingsView", () => { routeState.section = "skills"; setLocale("en-US"); getWindowsNodeMxcStatus.mockReset().mockResolvedValue(status("diagnostic-unpaired-local-node")); + preflightWindowsNodeMxc.mockReset().mockResolvedValue(undefined); setWindowsNodeMxcEnabled .mockReset() .mockImplementation(async ({ enabled }: { enabled: boolean }) => ({ @@ -139,6 +146,7 @@ describe("SettingsView", () => { }, windowsNodeMxc: { getStatus: getWindowsNodeMxcStatus, + preflight: preflightWindowsNodeMxc, setEnabled: setWindowsNodeMxcEnabled, validateFolderPolicy, applyFolderPolicy, @@ -357,6 +365,34 @@ describe("SettingsView", () => { expect(setWindowsNodeMxcEnabled).toHaveBeenCalledWith({ enabled: false }); }); + it("keeps the standard route ready when exact-backend preflight rejects enablement", async () => { + routeState.section = "security"; + preflightWindowsNodeMxc.mockRejectedValueOnce(new Error("exact backend unavailable")); + const pinia = createPinia(); + const wrapper = shallowMount(SettingsView, { + global: { + plugins: [pinia], + stubs: { + "el-switch": { + template: '